2026-06-15 21:38:22 -05:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
Build the Room-loadable SQLite asset database from the question JSON packs.
|
2026-06-15 21:38:22 -05:00
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
The app loads this file via Room's `createFromAsset("database/app.db")` with **no
|
|
|
|
|
migrations and no destructive fallback**, so the output must satisfy Room exactly:
|
|
|
|
|
|
|
|
|
|
* every entity table declared by `AppDatabase` must exist, with Room's exact SQL
|
|
|
|
|
* `room_master_table` must carry Room's identity hash for the schema version
|
|
|
|
|
|
|
|
|
|
Both of those come from Room's own exported schema
|
|
|
|
|
(`app/schemas/app.closer.data.local.AppDatabase/<version>.json`, produced by
|
|
|
|
|
`room.schemaLocation` in app/build.gradle.kts). That file is the single source of
|
|
|
|
|
truth here — when the Room schema changes, re-run the app build to re-export it and
|
|
|
|
|
this script follows automatically. Nothing about the schema is hardcoded.
|
|
|
|
|
|
|
|
|
|
`answer_config` is written in the shape the app's parser actually reads
|
|
|
|
|
(`data/local/mapper/QuestionMapper.kt` -> `parseAnswerConfig`), which uses camelCase
|
|
|
|
|
keys. The authoring JSON uses snake_case (see seed/questions/QUESTION_SCHEMA.md);
|
|
|
|
|
translating between the two is this script's job.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
python3 seed/build_db.py # build + replace the asset db (backs up first)
|
|
|
|
|
python3 seed/build_db.py --out /tmp/x.db # build somewhere else (no backup, no replace)
|
|
|
|
|
python3 seed/build_db.py --check # validate the JSON only, build nothing
|
|
|
|
|
|
|
|
|
|
Any content problem is a hard failure. This script never writes a partial database.
|
2026-06-15 21:38:22 -05:00
|
|
|
"""
|
|
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
import argparse
|
2026-06-15 21:38:22 -05:00
|
|
|
import json
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
import shutil
|
2026-06-15 21:38:22 -05:00
|
|
|
import sqlite3
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
import sys
|
|
|
|
|
import tempfile
|
feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.
Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.
Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.
Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
|
|
|
from datetime import datetime
|
2026-06-15 21:38:22 -05:00
|
|
|
from pathlib import Path
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
from typing import Any, Dict, List
|
2026-06-15 21:38:22 -05:00
|
|
|
|
2026-06-19 00:35:00 -05:00
|
|
|
from validate_question_variety import load_json_records, validate_records
|
|
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
JSON_DIR = ROOT / "seed" / "questions"
|
|
|
|
|
SCHEMA_DIR = ROOT / "app" / "schemas" / "app.closer.data.local.AppDatabase"
|
|
|
|
|
ASSET_DB = ROOT / "app" / "src" / "main" / "assets" / "database" / "app.db"
|
feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.
Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.
Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.
Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
|
|
|
# Everything under assets/ is packaged into the APK, so backups must NOT live there —
|
|
|
|
|
# a stray .bak beside the db silently ships to every user. /build is gitignored.
|
|
|
|
|
BACKUP_DIR = ROOT / "build" / "db-backups"
|
2026-06-15 21:38:22 -05:00
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
VALID_TYPES = {"written", "single_choice", "multi_choice", "scale", "this_or_that"}
|
|
|
|
|
VALID_ACCESS = {"free", "premium"}
|
|
|
|
|
CHOICE_TYPES = {"single_choice", "multi_choice"}
|
2026-06-15 21:38:22 -05:00
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
# Room writes its identity row with this fixed id; see RoomOpenHelper.
|
|
|
|
|
ROOM_MASTER_TABLE_ID = 42
|
|
|
|
|
ROOM_MASTER_DDL = (
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS room_master_table "
|
|
|
|
|
"(id INTEGER PRIMARY KEY, identity_hash TEXT)"
|
|
|
|
|
)
|
2026-06-15 21:38:22 -05:00
|
|
|
|
|
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
class ContentError(Exception):
|
|
|
|
|
"""A problem in the seed JSON. Always fatal — never import partial content."""
|
2026-06-15 21:38:22 -05:00
|
|
|
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Room schema
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def load_room_schema() -> Dict[str, Any]:
|
|
|
|
|
"""Load Room's exported schema (highest version present)."""
|
|
|
|
|
if not SCHEMA_DIR.is_dir():
|
|
|
|
|
raise ContentError(
|
|
|
|
|
f"Room schema export not found at {SCHEMA_DIR}.\n"
|
|
|
|
|
"Build the app once (room.schemaLocation is configured) so Room exports it."
|
2026-06-15 21:38:22 -05:00
|
|
|
)
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
versions = []
|
|
|
|
|
for path in SCHEMA_DIR.glob("*.json"):
|
2026-06-15 21:38:22 -05:00
|
|
|
try:
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
versions.append((int(path.stem), path))
|
|
|
|
|
except ValueError:
|
2026-06-15 21:38:22 -05:00
|
|
|
continue
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
if not versions:
|
|
|
|
|
raise ContentError(f"No exported schema json in {SCHEMA_DIR}")
|
|
|
|
|
_, path = max(versions)
|
|
|
|
|
return json.loads(path.read_text(encoding="utf-8"))["database"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_schema(cursor: sqlite3.Cursor, db_schema: Dict[str, Any]) -> None:
|
|
|
|
|
"""Create every Room entity table + the identity row, exactly as Room expects."""
|
|
|
|
|
for entity in db_schema["entities"]:
|
|
|
|
|
table = entity["tableName"]
|
|
|
|
|
cursor.execute(entity["createSql"].replace("${TABLE_NAME}", table))
|
|
|
|
|
for index in entity.get("indices", []):
|
|
|
|
|
cursor.execute(index["createSql"].replace("${TABLE_NAME}", table))
|
|
|
|
|
|
|
|
|
|
for view in db_schema.get("views", []):
|
|
|
|
|
cursor.execute(view["createSql"].replace("${VIEW_NAME}", view["viewName"]))
|
|
|
|
|
|
|
|
|
|
cursor.execute(ROOM_MASTER_DDL)
|
|
|
|
|
cursor.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO room_master_table (id, identity_hash) VALUES (?, ?)",
|
|
|
|
|
(ROOM_MASTER_TABLE_ID, db_schema["identityHash"]),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# answer_config: authoring shape (snake_case) -> app parser shape (camelCase)
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _options(question: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
|
|
"""Options live top-level per the schema guide, mirrored into answer_config."""
|
|
|
|
|
opts = question.get("options") or (question.get("answer_config") or {}).get("options")
|
|
|
|
|
return opts or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_answer_config(question: Dict[str, Any], where: str) -> Dict[str, Any]:
|
|
|
|
|
"""Produce the answer_config the app's QuestionMapper can actually read."""
|
|
|
|
|
qtype = question["type"]
|
|
|
|
|
src = question.get("answer_config") or {}
|
|
|
|
|
|
|
|
|
|
if qtype == "written":
|
|
|
|
|
return {
|
|
|
|
|
"type": "written",
|
|
|
|
|
"config": {
|
|
|
|
|
"minLength": src.get("min_length", 1),
|
|
|
|
|
"maxLength": src.get("max_length", 1000),
|
|
|
|
|
"placeholder": src.get("placeholder", "Write your answer..."),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if qtype in CHOICE_TYPES:
|
|
|
|
|
opts = _options(question)
|
|
|
|
|
if not opts:
|
|
|
|
|
raise ContentError(f"{where}: {qtype} has no options")
|
|
|
|
|
config: Dict[str, Any] = {"options": [{"id": o["id"], "text": o["text"]} for o in opts]}
|
|
|
|
|
if qtype == "multi_choice":
|
|
|
|
|
config["maxSelections"] = src.get("max_selections", 0)
|
|
|
|
|
return {"type": qtype, "config": config}
|
|
|
|
|
|
|
|
|
|
if qtype == "scale":
|
|
|
|
|
return {
|
|
|
|
|
"type": "scale",
|
|
|
|
|
"config": {
|
|
|
|
|
"minScale": src.get("min", 1),
|
|
|
|
|
"maxScale": src.get("max", 5),
|
|
|
|
|
"minLabel": src.get("min_label", ""),
|
|
|
|
|
"maxLabel": src.get("max_label", ""),
|
|
|
|
|
"scaleStep": src.get("scale_step", 1),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if qtype == "this_or_that":
|
|
|
|
|
opts = _options(question)
|
|
|
|
|
if len(opts) != 2:
|
|
|
|
|
raise ContentError(f"{where}: this_or_that needs exactly 2 options, found {len(opts)}")
|
|
|
|
|
return {
|
|
|
|
|
"type": "this_or_that",
|
|
|
|
|
"config": {
|
|
|
|
|
"optionA": {"id": opts[0]["id"], "text": opts[0]["text"]},
|
|
|
|
|
"optionB": {"id": opts[1]["id"], "text": opts[1]["text"]},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
raise ContentError(f"{where}: unsupported type {qtype!r}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Validation — every problem is fatal
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def load_packs() -> List[Dict[str, Any]]:
|
|
|
|
|
"""Load and hard-validate every pack. Raises on the first structural problem."""
|
|
|
|
|
files = sorted(JSON_DIR.glob("*.json"))
|
|
|
|
|
if not files:
|
|
|
|
|
raise ContentError(f"No question JSON found in {JSON_DIR}")
|
|
|
|
|
|
|
|
|
|
packs, seen_ids = [], {}
|
|
|
|
|
for path in files:
|
|
|
|
|
name = path.name
|
|
|
|
|
try:
|
|
|
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
raise ContentError(f"{name}: invalid JSON: {e}") from e
|
|
|
|
|
|
|
|
|
|
category = data.get("category")
|
|
|
|
|
if not isinstance(category, dict):
|
|
|
|
|
raise ContentError(
|
|
|
|
|
f"{name}: missing top-level 'category' object. "
|
|
|
|
|
"A patch manifest or partial batch must never sit under a production filename."
|
|
|
|
|
)
|
|
|
|
|
for field in ("id", "display_name", "description", "access", "icon_name"):
|
|
|
|
|
if not category.get(field):
|
|
|
|
|
raise ContentError(f"{name}: category.{field} is missing or empty")
|
|
|
|
|
|
|
|
|
|
questions = data.get("questions")
|
|
|
|
|
if not isinstance(questions, list) or not questions:
|
|
|
|
|
raise ContentError(f"{name}: 'questions' is missing or empty")
|
|
|
|
|
|
|
|
|
|
cid = category["id"]
|
|
|
|
|
for i, q in enumerate(questions):
|
|
|
|
|
qid = q.get("id")
|
|
|
|
|
where = f"{name}:{qid or f'#{i}'}"
|
|
|
|
|
if not qid:
|
|
|
|
|
raise ContentError(f"{where}: question has no id")
|
|
|
|
|
if qid in seen_ids:
|
|
|
|
|
raise ContentError(f"{where}: duplicate id (also in {seen_ids[qid]})")
|
|
|
|
|
seen_ids[qid] = name
|
|
|
|
|
if not q.get("text"):
|
|
|
|
|
raise ContentError(f"{where}: empty text")
|
|
|
|
|
if q.get("type") not in VALID_TYPES:
|
|
|
|
|
raise ContentError(f"{where}: invalid type {q.get('type')!r}")
|
|
|
|
|
if q.get("access") not in VALID_ACCESS:
|
|
|
|
|
raise ContentError(f"{where}: invalid access {q.get('access')!r}")
|
|
|
|
|
if q.get("category_id") != cid:
|
|
|
|
|
raise ContentError(
|
|
|
|
|
f"{where}: category_id {q.get('category_id')!r} != category.id {cid!r}"
|
|
|
|
|
)
|
|
|
|
|
depth = q.get("depth", q.get("depth_level"))
|
|
|
|
|
if not isinstance(depth, int) or isinstance(depth, bool):
|
|
|
|
|
raise ContentError(
|
|
|
|
|
f"{where}: depth {depth!r} is not an integer. "
|
|
|
|
|
"String depth is a future migration and is not production-ready "
|
|
|
|
|
"(see QUESTION_SCHEMA.md); Room reads depth_level as an integer."
|
|
|
|
|
)
|
|
|
|
|
if not isinstance(q.get("tags"), list):
|
|
|
|
|
raise ContentError(f"{where}: tags must be an array")
|
|
|
|
|
# Surfaces option problems now rather than as an unusable question later.
|
|
|
|
|
build_answer_config(q, where)
|
|
|
|
|
|
|
|
|
|
packs.append({"name": name, "category": category, "questions": questions,
|
|
|
|
|
"mtime": int(path.stat().st_mtime)})
|
|
|
|
|
|
|
|
|
|
errors = validate_records(load_json_records(JSON_DIR), "JSON")
|
|
|
|
|
if errors:
|
|
|
|
|
raise ContentError("Catalog variety gate failed:\n" + "\n".join(errors))
|
|
|
|
|
|
|
|
|
|
return packs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
# Build
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def build(packs: List[Dict[str, Any]], db_schema: Dict[str, Any], out: Path) -> Dict[str, int]:
|
|
|
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
if out.exists():
|
|
|
|
|
out.unlink()
|
|
|
|
|
|
|
|
|
|
conn = sqlite3.connect(out)
|
|
|
|
|
try:
|
|
|
|
|
cur = conn.cursor()
|
|
|
|
|
create_schema(cur, db_schema)
|
|
|
|
|
|
|
|
|
|
total = 0
|
|
|
|
|
for pack in packs:
|
|
|
|
|
c = pack["category"]
|
|
|
|
|
cur.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO question_category "
|
|
|
|
|
"(id, display_name, description, access, icon_name) VALUES (?, ?, ?, ?, ?)",
|
|
|
|
|
(c["id"], c["display_name"], c["description"], c["access"], c["icon_name"]),
|
|
|
|
|
)
|
|
|
|
|
for q in pack["questions"]:
|
|
|
|
|
where = f"{pack['name']}:{q['id']}"
|
|
|
|
|
cur.execute(
|
|
|
|
|
"INSERT OR REPLACE INTO question "
|
|
|
|
|
"(id, text, category_id, depth_level, is_premium, type, tags, answer_config,"
|
|
|
|
|
" pack_id, created_at, status, sex) "
|
|
|
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
|
|
|
(
|
|
|
|
|
q["id"],
|
|
|
|
|
q["text"],
|
|
|
|
|
q["category_id"],
|
|
|
|
|
q.get("depth", q.get("depth_level")),
|
|
|
|
|
1 if q["access"] == "premium" else 0,
|
|
|
|
|
q["type"],
|
|
|
|
|
json.dumps(q.get("tags", []), separators=(",", ":")),
|
|
|
|
|
json.dumps(build_answer_config(q, where), separators=(",", ":")),
|
|
|
|
|
None,
|
|
|
|
|
pack["mtime"],
|
|
|
|
|
"active",
|
|
|
|
|
q.get("sex"),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
total += 1
|
|
|
|
|
conn.commit()
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
return {"categories": len(packs), "questions": total}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify(out: Path, db_schema: Dict[str, Any]) -> None:
|
|
|
|
|
"""Re-open the built file and prove it satisfies Room before it is shipped."""
|
|
|
|
|
conn = sqlite3.connect(out)
|
|
|
|
|
try:
|
|
|
|
|
tables = {r[0] for r in conn.execute(
|
|
|
|
|
"SELECT name FROM sqlite_master WHERE type='table'")}
|
|
|
|
|
expected = {e["tableName"] for e in db_schema["entities"]} | {"room_master_table"}
|
|
|
|
|
missing = expected - tables
|
|
|
|
|
if missing:
|
|
|
|
|
raise ContentError(f"built db is missing Room tables: {sorted(missing)}")
|
|
|
|
|
|
|
|
|
|
row = conn.execute(
|
|
|
|
|
"SELECT id, identity_hash FROM room_master_table").fetchone()
|
|
|
|
|
if not row or row[0] != ROOM_MASTER_TABLE_ID or row[1] != db_schema["identityHash"]:
|
|
|
|
|
raise ContentError(f"identity hash mismatch: {row} != {db_schema['identityHash']}")
|
|
|
|
|
|
|
|
|
|
empty = [t for t in ("question", "question_category")
|
|
|
|
|
if conn.execute(f"SELECT COUNT(*) FROM {t}").fetchone()[0] == 0]
|
|
|
|
|
if empty:
|
|
|
|
|
raise ContentError(f"built db has empty tables: {empty}")
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> int:
|
|
|
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
|
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
|
|
|
ap.add_argument("--out", type=Path, default=ASSET_DB,
|
|
|
|
|
help=f"output path (default: {ASSET_DB})")
|
|
|
|
|
ap.add_argument("--check", action="store_true",
|
|
|
|
|
help="validate the JSON and exit without building")
|
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
db_schema = load_room_schema()
|
|
|
|
|
packs = load_packs()
|
|
|
|
|
print(f"Validated {len(packs)} packs, "
|
|
|
|
|
f"{sum(len(p['questions']) for p in packs)} questions — catalog gate passed.")
|
|
|
|
|
if args.check:
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
replacing_asset = args.out.resolve() == ASSET_DB.resolve()
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
staged = Path(tmp) / "app.db"
|
|
|
|
|
stats = build(packs, db_schema, staged)
|
|
|
|
|
verify(staged, db_schema)
|
|
|
|
|
|
|
|
|
|
if replacing_asset and args.out.exists():
|
feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.
Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.
Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.
Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
|
|
|
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
|
|
|
backup = BACKUP_DIR / f"app.db.{stamp}.bak"
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
shutil.copy2(args.out, backup)
|
feat(seed): rebuild asset db from JSON; verify Room loads it on-device
Regenerates app/src/main/assets/database/app.db with the fixed build_db.py, so
the app finally ships the current question catalog. The db had drifted far from
the JSON source of truth: 6103 -> 3811 questions (the intentional 150-cap trim),
+150 quality_time (a category the app could not show at all), and daily 500 ->
511 — the 11 wildcards, whose feature (DailyModeResolver) was built but had zero
content in the db, so wildcard days were dark. Identity hash is unchanged
(7e7d78fc...), schema untouched, so no migration is involved.
Scale labels now actually render. The db stored snake_case answer_config while
the app's only parser (QuestionMapper.parseAnswerConfig) reads camelCase, so
optString("minLabel","") resolved to "" and the scale UI fell back to bare
numbers. The rebuild emits the shape the parser reads.
Adds AssetDatabaseVerifyTest (androidTest): Room opens the asset lazily, so an
app that launches proves nothing about it — the bundled db is only validated on
first DB touch, and a bad one crashes every user on a fresh install. The test
forces a real open via openHelper.readableDatabase (triggering the copy +
identity/schema check) and asserts content, no orphan category_ids, integer
depth, and the camelCase scale contract. Verified 4/4 green on a clean install
on throwaway emulator 5558; the 5554/5556 fixtures were never touched.
Also stops shipping ~6MB of dead weight: app.db.bak_q4 and app.db.bak_q5 were
tracked inside assets/, and everything under assets/ is packaged into the APK.
build_db.py was making it worse by writing its backup next to the db; backups
now go to build/db-backups/ (gitignored, outside the packaged tree) and the
stale ones are removed. APK now contains only assets/database/app.db.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:49:20 -05:00
|
|
|
print(f"Backed up existing asset db -> {backup.relative_to(ROOT)}")
|
fix(seed): make build_db.py produce a Room-loadable asset database
build_db.py could not produce a shippable app.db and would have crashed the
app on first launch. It created only 2 tables (Question, QuestionCategory)
while AppDatabase declares 4 entities, and it never wrote room_master_table.
Since createFromAsset runs with no migrations and no destructive fallback,
Room validates strictly against identity hash 7e7d78fc... — so the output was
guaranteed to fail. Hence the standing "never run build_db.py" rule; the tool
had drifted from the schema it feeds.
Schema is now taken from Room's own exported schema
(app/schemas/app.closer.data.local.AppDatabase/1.json, via room.schemaLocation)
rather than hand-written DDL: all 4 entity tables + indices + the identity row
are created from it, so the script follows future schema changes automatically.
Verified the output's PRAGMA table_info/index_list is identical to the shipped
known-good db for all 5 tables, with a matching identity hash.
Also fixes the answer_config contract. The app's only parser
(QuestionMapper.parseAnswerConfig) reads camelCase keys, but the shipped db
stores snake_case and the old script emitted a third shape again — so scale
labels silently resolved to "" (the UI then shows bare numbers) and written
max_length was ignored. The authoring JSON stays snake_case per
QUESTION_SCHEMA.md; translating to the parser's shape is now this script's job.
Content problems are now hard failures instead of silent corruption. Previously
a JSON parse error or missing category made the script skip/mislabel a whole
pack ("unknown"). Now rejected with a precise message: parse errors, missing
category object (patch manifest under a production filename), zero questions,
missing id, duplicate id/text, invalid type/access, category_id mismatch,
non-integer depth, choice without options, this_or_that without exactly 2.
All 12 guards verified against mutated fixtures; a valid pack still builds.
Adds --check (validate only) and --out (build elsewhere). Builds are staged to
a temp file, verified, and only then moved into place, backing up the existing
asset db first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:37:59 -05:00
|
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
shutil.copy2(staged, args.out)
|
|
|
|
|
except ContentError as e:
|
|
|
|
|
print(f"\nBUILD FAILED\n{e}", file=sys.stderr)
|
|
|
|
|
return 1
|
|
|
|
|
|
|
|
|
|
print(f"Built {args.out}")
|
|
|
|
|
print(f" schema version {db_schema['version']} · identity {db_schema['identityHash']}")
|
|
|
|
|
print(f" {stats['categories']} categories · {stats['questions']} questions")
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
sys.exit(main())
|