@kaeawc/auto-mobile 0.0.40 → 0.0.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +3 -3
  2. package/README.md.backup +3 -3
  3. package/dist/schemas/test-plan.schema.json +6 -2
  4. package/dist/schemas/tool-definitions.json +2549 -546
  5. package/dist/src/db/eventTables.ts +8 -0
  6. package/dist/src/db/migrations/2025_12_28_000_initial_schema.ts +3 -3
  7. package/dist/src/db/migrations/2025_12_30_000_performance_thresholds.ts +3 -3
  8. package/dist/src/db/migrations/2025_12_30_001_navigation_graph.ts +9 -9
  9. package/dist/src/db/migrations/2025_12_31_000_accessibility_baselines.ts +3 -3
  10. package/dist/src/db/migrations/2025_12_31_001_memory_audit.ts +4 -4
  11. package/dist/src/db/migrations/2026_01_01_000_recomposition_metrics.ts +2 -2
  12. package/dist/src/db/migrations/2026_01_02_000_prediction_history.ts +4 -4
  13. package/dist/src/db/migrations/2026_01_03_000_feature_flags.ts +3 -3
  14. package/dist/src/db/migrations/2026_01_03_000_test_executions.ts +2 -2
  15. package/dist/src/db/migrations/2026_01_05_000_tool_calls.ts +2 -2
  16. package/dist/src/db/migrations/2026_01_08_000_test_coverage.ts +4 -4
  17. package/dist/src/db/migrations/2026_01_09_000_video_recordings.ts +3 -3
  18. package/dist/src/db/migrations/2026_01_10_000_device_snapshots.ts +3 -3
  19. package/dist/src/db/migrations/2026_01_14_000_appearance_config.ts +3 -3
  20. package/dist/src/db/migrations/2026_01_25_001_test_run_details.ts +2 -2
  21. package/dist/src/db/migrations/2026_01_27_000_crash_anr_monitoring.ts +2 -2
  22. package/dist/src/db/migrations/2026_01_27_000_failures.ts +7 -7
  23. package/dist/src/db/migrations/2026_01_29_000_named_nodes.ts +3 -3
  24. package/dist/src/db/migrations/2026_01_30_000_performance_live_metrics.ts +1 -1
  25. package/dist/src/db/migrations/2026_03_15_000_telemetry_events.ts +5 -5
  26. package/dist/src/db/migrations/2026_03_18_000_navigation_events.ts +2 -2
  27. package/dist/src/db/migrations/2026_03_19_000_storage_events.ts +2 -2
  28. package/dist/src/db/migrations/2026_03_19_001_layout_events.ts +2 -2
  29. package/dist/src/db/migrations/2026_04_01_000_drop_custom_events.ts +2 -2
  30. package/dist/src/db/migrations/2026_04_02_000_device_sessions.ts +3 -3
  31. package/dist/src/db/migrations/2026_07_01_000_failure_groups_signature_unique.ts +162 -0
  32. package/dist/src/db/migrations/2026_07_02_000_event_composite_indexes.ts +62 -0
  33. package/dist/src/db/migrations/2026_07_03_000_drop_redundant_device_indexes.ts +66 -0
  34. package/dist/src/db/migrations/2026_07_03_000_repair_datetime_now_defaults.ts +268 -0
  35. package/dist/src/db/migrations/2026_07_04_000_storage_events_key_lookup.ts +60 -0
  36. package/dist/src/db/migrations/2026_07_05_000_repair_updated_at_defaults.ts +242 -0
  37. package/dist/src/index.js +533 -390
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/vendor/libwebp/COPYING +30 -0
  40. package/dist/vendor/libwebp/PATENTS +23 -0
  41. package/dist/vendor/libwebp/README.md +13 -0
  42. package/dist/vendor/libwebp/README.upstream.md +54 -0
  43. package/dist/vendor/libwebp/win32-x64/cwebp.exe +0 -0
  44. package/dist/vendor/libwebp/win32-x64/dwebp.exe +0 -0
  45. package/package.json +9 -4
  46. package/schemas/test-plan.schema.json +6 -2
  47. package/schemas/tool-definitions.json +2549 -546
@@ -0,0 +1,162 @@
1
+ import { Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * #2789 — make `failure_groups.signature` uniquely indexed and back-fill any
5
+ * duplicate-signature rows that the original non-unique index allowed to
6
+ * accumulate (the racy get-or-create in `recordFailure` could INSERT two groups
7
+ * for one signature).
8
+ *
9
+ * ORDERING IS LOAD-BEARING. The migration connection runs with
10
+ * `PRAGMA foreign_keys = ON` (`configureSqliteDatabase`, src/db/database.ts) and
11
+ * these SQLite migrations are NOT wrapped in a DDL transaction by the migrator
12
+ * (`SqliteAdapter.supportsTransactionalDdl === false`). `failure_occurrences`
13
+ * and `failure_notifications` carry a CASCADE FK on `failure_occurrences.id` /
14
+ * a plain `group_id`. So we MUST repoint children onto the keeper BEFORE
15
+ * deleting loser groups — delete-first would CASCADE-wipe the occurrences we
16
+ * mean to keep. We wrap the whole de-dup + index swap in ONE transaction so a
17
+ * mid-way `CREATE UNIQUE INDEX` failure cannot leave losers already deleted.
18
+ *
19
+ * Keeper selection is a total order — `ORDER BY first_occurrence ASC, id ASC` —
20
+ * so a same-millisecond burst (two groups tied on `first_occurrence`, the exact
21
+ * scenario this bug produces) still collapses deterministically to one row and
22
+ * `CREATE UNIQUE INDEX` cannot throw on a residual duplicate.
23
+ *
24
+ * Idempotent / safe on an empty DB so it survives #2785's destructive-recovery
25
+ * replay (`resetDatabaseState` drops every table and re-runs all migrations):
26
+ * with no duplicates the de-dup statements are no-ops and the index create uses
27
+ * `IF NOT EXISTS`.
28
+ */
29
+ export async function up(db: Kysely<unknown>): Promise<void> {
30
+ await db.transaction().execute(async trx => {
31
+ // 1. Repoint occurrences from loser groups onto their signature's keeper —
32
+ // BEFORE any delete, so CASCADE cannot wipe them. A "loser" is any group
33
+ // that is not the keeper (earliest first_occurrence, min id tiebreak)
34
+ // for its signature.
35
+ await sql`
36
+ UPDATE failure_occurrences
37
+ SET group_id = (
38
+ SELECT k.id FROM failure_groups k
39
+ WHERE k.signature = (
40
+ SELECT g.signature FROM failure_groups g WHERE g.id = failure_occurrences.group_id
41
+ )
42
+ ORDER BY k.first_occurrence ASC, k.id ASC
43
+ LIMIT 1
44
+ )
45
+ WHERE group_id IN (
46
+ SELECT g.id FROM failure_groups g
47
+ WHERE g.id <> (
48
+ SELECT k.id FROM failure_groups k
49
+ WHERE k.signature = g.signature
50
+ ORDER BY k.first_occurrence ASC, k.id ASC
51
+ LIMIT 1
52
+ )
53
+ )
54
+ `.execute(trx);
55
+
56
+ // 2. Repoint notifications the same way. `failure_notifications.group_id` is
57
+ // NOT a foreign key, so nothing cascades and nothing stops us — forget
58
+ // this and notifications silently strand on a deleted group id.
59
+ await sql`
60
+ UPDATE failure_notifications
61
+ SET group_id = (
62
+ SELECT k.id FROM failure_groups k
63
+ WHERE k.signature = (
64
+ SELECT g.signature FROM failure_groups g WHERE g.id = failure_notifications.group_id
65
+ )
66
+ ORDER BY k.first_occurrence ASC, k.id ASC
67
+ LIMIT 1
68
+ )
69
+ WHERE group_id IN (
70
+ SELECT g.id FROM failure_groups g
71
+ WHERE g.id <> (
72
+ SELECT k.id FROM failure_groups k
73
+ WHERE k.signature = g.signature
74
+ ORDER BY k.first_occurrence ASC, k.id ASC
75
+ LIMIT 1
76
+ )
77
+ )
78
+ `.execute(trx);
79
+
80
+ // 3. Back-fill the keeper's aggregates (only for signatures that actually
81
+ // had duplicates). total_count is the SUM of the historical counts so we
82
+ // do not lose increments that predate retention pruning; last_occurrence
83
+ // is the MAX so a collapsed group is not sorted stale on the dashboard
84
+ // (getFailureGroups orders by last_occurrence); unique_sessions is
85
+ // DERIVED from the now-repointed occurrences via COUNT(DISTINCT), the
86
+ // same definition recordFailure uses post-fix (summing would double-count
87
+ // a session present in two duplicate groups).
88
+ //
89
+ // Note: single-group signatures whose stored counts drifted from the
90
+ // lost-increment path (issue scenario #2, which does NOT create a second
91
+ // row) are intentionally left untouched. total_count deliberately
92
+ // preserves increments that predate retention pruning, so a blanket
93
+ // recompute of COUNT(*) from surviving occurrences would DESTROY
94
+ // legitimately-retained historical counts — the two cases are
95
+ // indistinguishable after the fact, so we heal only where a duplicate row
96
+ // already forces a merge.
97
+ await sql`
98
+ UPDATE failure_groups
99
+ SET
100
+ total_count = (
101
+ SELECT COALESCE(SUM(g2.total_count), 0)
102
+ FROM failure_groups g2
103
+ WHERE g2.signature = failure_groups.signature
104
+ ),
105
+ last_occurrence = (
106
+ SELECT MAX(g2.last_occurrence)
107
+ FROM failure_groups g2
108
+ WHERE g2.signature = failure_groups.signature
109
+ ),
110
+ unique_sessions = (
111
+ SELECT COUNT(DISTINCT o.session_id)
112
+ FROM failure_occurrences o
113
+ WHERE o.group_id = failure_groups.id
114
+ )
115
+ WHERE
116
+ (SELECT COUNT(*) FROM failure_groups d WHERE d.signature = failure_groups.signature) > 1
117
+ AND id = (
118
+ SELECT k.id FROM failure_groups k
119
+ WHERE k.signature = failure_groups.signature
120
+ ORDER BY k.first_occurrence ASC, k.id ASC
121
+ LIMIT 1
122
+ )
123
+ `.execute(trx);
124
+
125
+ // 4. Delete the losers. Children were repointed in steps 1-2, so CASCADE has
126
+ // nothing left to take.
127
+ await sql`
128
+ DELETE FROM failure_groups
129
+ WHERE id <> (
130
+ SELECT k.id FROM failure_groups k
131
+ WHERE k.signature = failure_groups.signature
132
+ ORDER BY k.first_occurrence ASC, k.id ASC
133
+ LIMIT 1
134
+ )
135
+ `.execute(trx);
136
+
137
+ // 5. Swap the plain signature index for a UNIQUE one, and add a composite
138
+ // index so the post-fix `COUNT(DISTINCT session_id)` recompute per event
139
+ // does not scan every occurrence in the group.
140
+ await sql`DROP INDEX IF EXISTS idx_failure_groups_signature`.execute(trx);
141
+ await sql`
142
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_failure_groups_signature
143
+ ON failure_groups (signature)
144
+ `.execute(trx);
145
+ await sql`
146
+ CREATE INDEX IF NOT EXISTS idx_failure_occurrences_group_session
147
+ ON failure_occurrences (group_id, session_id)
148
+ `.execute(trx);
149
+ });
150
+ }
151
+
152
+ export async function down(db: Kysely<unknown>): Promise<void> {
153
+ await db.transaction().execute(async trx => {
154
+ await sql`DROP INDEX IF EXISTS idx_failure_occurrences_group_session`.execute(trx);
155
+ await sql`DROP INDEX IF EXISTS idx_failure_groups_signature`.execute(trx);
156
+ // Restore the original non-unique index.
157
+ await sql`
158
+ CREATE INDEX IF NOT EXISTS idx_failure_groups_signature
159
+ ON failure_groups (signature)
160
+ `.execute(trx);
161
+ });
162
+ }
@@ -0,0 +1,62 @@
1
+ import { type Kysely, sql } from "kysely";
2
+ import { EVENT_TABLES } from "../eventTables";
3
+
4
+ async function tableExists(db: Kysely<unknown>, tableName: string): Promise<boolean> {
5
+ const result = await sql<{ name: string }>`
6
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}
7
+ `.execute(db);
8
+ return result.rows.length > 0;
9
+ }
10
+
11
+ /**
12
+ * Composite (device_id, timestamp) indexes for the telemetry event tables (#2788).
13
+ *
14
+ * Every event getter queries `WHERE device_id=? [AND timestamp>=?]
15
+ * ORDER BY timestamp DESC LIMIT n`, but each table only carries single-column
16
+ * indexes on `device_id` and `timestamp` separately. SQLite uses at most one
17
+ * index per query, so the `device_id` equality filter forces a temp B-tree
18
+ * filesort for the `ORDER BY timestamp DESC`. A composite `(device_id,
19
+ * timestamp)` index lets the planner satisfy the equality filter and produce the
20
+ * ordering from a single index — scanning it backward for the DESC order — which
21
+ * drops the sort entirely.
22
+ *
23
+ * No DESC in the index columns: SQLite scans a plain ascending index backward for
24
+ * `ORDER BY ... DESC`, so a plain composite suffices (mirrors the precedent in
25
+ * 2025_12_30_000_performance_thresholds.ts).
26
+ *
27
+ * Additive and forward-only, per #2788's explicit scope ("do not modify the
28
+ * existing single-column indexes"):
29
+ * - The standalone `timestamp` index MUST stay — the retention cutoff query
30
+ * (`ORDER BY timestamp DESC LIMIT 1 OFFSET <cap>`, no device filter) uses it
31
+ * as a covering scan, and the composite cannot substitute because `device_id`
32
+ * leads the composite.
33
+ * - The standalone `device_id` index is now functionally redundant: the
34
+ * composite's `device_id` left-prefix serves every `device_id=?` access path
35
+ * (including `COUNT(*) WHERE device_id=?`). It is deliberately RETAINED here
36
+ * to keep this migration purely additive; dropping the six redundant
37
+ * `idx_<table>_device` indexes to cut per-insert write amplification is
38
+ * deferred to a follow-up so the change is reviewed on its own (see #2788).
39
+ */
40
+ export async function up(db: Kysely<unknown>): Promise<void> {
41
+ for (const table of EVENT_TABLES) {
42
+ if (!(await tableExists(db, table))) {
43
+ continue;
44
+ }
45
+ // .ifNotExists() so the migration survives #2785's destructive-recovery
46
+ // replay (drop-all then replay every migration on an empty DB).
47
+ await db.schema
48
+ .createIndex(`idx_${table}_device_timestamp`)
49
+ .ifNotExists()
50
+ .on(table as never)
51
+ .columns(["device_id", "timestamp"] as never[])
52
+ .execute();
53
+ }
54
+ }
55
+
56
+ export async function down(db: Kysely<unknown>): Promise<void> {
57
+ for (const table of EVENT_TABLES) {
58
+ // .ifExists() so a partial-up followed by down (recovery machinery can
59
+ // invoke migrations in unusual orders) does not throw.
60
+ await db.schema.dropIndex(`idx_${table}_device_timestamp`).ifExists().execute();
61
+ }
62
+ }
@@ -0,0 +1,66 @@
1
+ import { type Kysely, sql } from "kysely";
2
+ import { EVENT_TABLES } from "../eventTables";
3
+
4
+ async function tableExists(db: Kysely<unknown>, tableName: string): Promise<boolean> {
5
+ const result = await sql<{ name: string }>`
6
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${tableName}
7
+ `.execute(db);
8
+ return result.rows.length > 0;
9
+ }
10
+
11
+ /**
12
+ * Drop the now-redundant single-column `idx_<table>_device` indexes on the six
13
+ * telemetry event tables (#2893, follow-up to #2788 / PR #2890).
14
+ *
15
+ * PR #2890 added composite `(device_id, timestamp)` indexes
16
+ * (`idx_<table>_device_timestamp`) but was deliberately kept additive-only per
17
+ * #2788's scope ("do not modify the existing single-column indexes"). This
18
+ * migration completes the optimization it deferred.
19
+ *
20
+ * A composite `(device_id, timestamp)` index serves every access path a
21
+ * standalone `(device_id)` index can, via SQLite's left-prefix rule:
22
+ * - `WHERE device_id=?` — equality on the composite's leading column.
23
+ * - `WHERE device_id=? ORDER BY timestamp DESC` — filter + order from one index.
24
+ * - `COUNT(*) WHERE device_id=?` — covering scan of the composite.
25
+ * There is no `ANALYZE`/`sqlite_stat1` in `src/db/`, so the planner chooses
26
+ * structurally and deterministically; the standalone device index is dead weight
27
+ * maintained on every insert for no read benefit. At the retention cap with a
28
+ * high insert rate this is pure write amplification.
29
+ *
30
+ * Left intact (different query shapes — out of scope):
31
+ * - The standalone `timestamp` index: the retention cutoff query
32
+ * (`ORDER BY timestamp DESC LIMIT 1 OFFSET <cap>`, no device filter) uses it
33
+ * as a covering scan, and the composite CANNOT substitute because `device_id`
34
+ * leads the composite.
35
+ * - The category/content indexes (`idx_network_events_host`,
36
+ * `idx_log_events_tag`, `idx_os_events_category`).
37
+ *
38
+ * Forward-only, replay-safe (`.ifExists()` / `.ifNotExists()`) so the migration
39
+ * survives #2785's destructive-recovery replay (drop-all then replay every
40
+ * migration on an empty DB) in unusual orders. Sorts after
41
+ * `2026_07_02_000_event_composite_indexes.ts`, so the composite that subsumes
42
+ * each device index is guaranteed present when its device index is dropped.
43
+ */
44
+ export async function up(db: Kysely<unknown>): Promise<void> {
45
+ for (const table of EVENT_TABLES) {
46
+ // .ifExists() so replay on a DB that never had the standalone device index
47
+ // (or a partial recovery order) does not throw.
48
+ await db.schema.dropIndex(`idx_${table}_device`).ifExists().execute();
49
+ }
50
+ }
51
+
52
+ export async function down(db: Kysely<unknown>): Promise<void> {
53
+ for (const table of EVENT_TABLES) {
54
+ if (!(await tableExists(db, table))) {
55
+ continue;
56
+ }
57
+ // Recreate the single-column device index exactly as the base migrations
58
+ // defined it. .ifNotExists() so a down after a partial-up is idempotent.
59
+ await db.schema
60
+ .createIndex(`idx_${table}_device`)
61
+ .ifNotExists()
62
+ .on(table as never)
63
+ .column("device_id" as never)
64
+ .execute();
65
+ }
66
+ }
@@ -0,0 +1,268 @@
1
+ import { type Kysely, sql } from "kysely";
2
+
3
+ /**
4
+ * #2895 — repair the string-literal timestamp-default bug on already-migrated
5
+ * databases.
6
+ *
7
+ * Every migration column that passed a plain SQL time-expression string to
8
+ * `col.defaultTo(...)` — `"datetime('now')"` or `"CURRENT_TIMESTAMP"` — bound the
9
+ * argument as a VALUE, not raw SQL, so its DDL emitted `DEFAULT 'datetime(''now'')'`
10
+ * / `DEFAULT 'CURRENT_TIMESTAMP'`. Any row inserted without an explicit value for
11
+ * such a column stored the useless literal text instead of a timestamp.
12
+ *
13
+ * The DDL is fixed FORWARD (all columns now use `defaultTo(sql`(datetime('now'))`)`),
14
+ * so fresh databases are already correct. But a database that already ran the
15
+ * buggy migrations keeps the broken `DEFAULT` in its stored schema — editing a
16
+ * historical migration does NOT re-run it (Kysely tracks migrations by name, no
17
+ * content checksum). So on an upgraded DB this migration must do TWO things:
18
+ *
19
+ * 1. REBUILD each column whose live default is the broken string literal, so
20
+ * FUTURE inserts that omit the column (e.g. `getOrCreateApp`, which omits
21
+ * `created_at`) stop re-poisoning it. SQLite cannot alter a column default
22
+ * in place, and bun:sqlite forbids editing `sqlite_master` directly, so we
23
+ * use the standard table-rebuild: create a twin table with the corrected
24
+ * default, copy the rows, drop the original, rename the twin back, recreate
25
+ * its indexes/triggers, and restore its `sqlite_sequence` AUTOINCREMENT
26
+ * high-water mark (so a rebuilt table cannot reuse a deleted top id). FK
27
+ * enforcement is toggled OFF around the
28
+ * rebuild (see `up`) so dropping a referenced parent cannot cascade-delete
29
+ * its children, then `PRAGMA foreign_key_check` verifies integrity before it
30
+ * is restored.
31
+ * 2. REPAIR the existing poisoned rows in those columns to an evaluated
32
+ * timestamp.
33
+ *
34
+ * TARGETING — the fix keys off `pragma_table_info(...).dflt_value`, which reports
35
+ * a column's stored default. Only the two broken string-literal forms match
36
+ * ({@link BROKEN_DEFAULTS}); a corrected `(datetime('now'))` default, a bare
37
+ * `CURRENT_TIMESTAMP` keyword, and legitimate value defaults (`'{}'`, `'success'`)
38
+ * all report a different `dflt_value` and are skipped. This is deliberately
39
+ * NARROWER than "rewrite any TEXT cell equal to the literal": columns like
40
+ * `storage_events.value` persist arbitrary app data (an app could legitimately
41
+ * store the string `datetime('now')`), so restricting the row repair to columns
42
+ * that actually carry the broken default is what keeps it from corrupting real
43
+ * data.
44
+ *
45
+ * The evaluated `datetime('now')` written for repaired rows is the best available
46
+ * timestamp for a row that never had a real one (its true creation time is
47
+ * unrecoverable). It is stored in SQLite's `YYYY-MM-DD HH:MM:SS` format — the same
48
+ * format the fixed column defaults now emit and the codebase's other
49
+ * server-evaluated-now writes use (`sql`datetime('now')`` in ThresholdManager /
50
+ * MemoryThresholdManager) — rather than the app's ISO-8601. This divergence is
51
+ * deliberate and harmless: no column is both defaulted AND written explicit ISO at
52
+ * an ordered/compared read site (audited during #2895 review), and every TTL
53
+ * comparison wraps the value in `datetime(...)`, which normalizes both formats.
54
+ *
55
+ * Safe on a fresh DB and idempotent: fresh/replayed schemas carry the corrected
56
+ * default, so no column matches {@link BROKEN_DEFAULTS} and the whole pass is a
57
+ * no-op. A second run finds nothing left to rebuild or repair.
58
+ */
59
+
60
+ /**
61
+ * A column's stored default (as reported by `pragma_table_info(...).dflt_value`)
62
+ * mapped to the corrected default expression it should carry and the literal
63
+ * value its poisoned rows hold. The `dflt_value` keys are the exact broken
64
+ * string-literal forms — a correct `datetime('now')` / `CURRENT_TIMESTAMP`
65
+ * default reports differently and never matches.
66
+ */
67
+ const BROKEN_DEFAULTS: Record<string, { corrected: string; poisonedValue: string }> = {
68
+ "'datetime(''now'')'": { corrected: "(datetime('now'))", poisonedValue: "datetime('now')" },
69
+ "'CURRENT_TIMESTAMP'": { corrected: "CURRENT_TIMESTAMP", poisonedValue: "CURRENT_TIMESTAMP" },
70
+ };
71
+
72
+ interface NamedRow {
73
+ name: string;
74
+ }
75
+
76
+ interface ColumnInfoRow {
77
+ name: string;
78
+ dflt_value: string | null;
79
+ }
80
+
81
+ interface SqlRow {
82
+ sql: string | null;
83
+ }
84
+
85
+ export async function up(db: Kysely<unknown>): Promise<void> {
86
+ // Fast path: on a fresh/already-fixed schema no column carries a broken default,
87
+ // so there is nothing to rebuild. Return WITHOUT touching PRAGMA foreign_keys so
88
+ // the common case (every existing DB and test) leaves the connection's FK state
89
+ // exactly as the caller set it. Only the rare upgraded-DB path below toggles it.
90
+ const brokenTables = await findTablesWithBrokenDefaults(db);
91
+ if (brokenTables.length === 0) {
92
+ return;
93
+ }
94
+
95
+ // Rebuilding a table that OTHER tables reference by foreign key requires FK
96
+ // enforcement OFF: `DROP TABLE` performs an implicit DELETE that would otherwise
97
+ // fire `ON DELETE CASCADE` and wipe the child rows (deferral only delays the
98
+ // constraint *check*, not the cascade *action*). `PRAGMA foreign_keys` is a
99
+ // no-op inside a transaction, so it is toggled on the connection around the
100
+ // transaction — safe because the migrator does not wrap SQLite migrations in a
101
+ // transaction and the run is serialized by the migration lock. It is restored to
102
+ // ON afterwards: this branch only runs on an upgraded production DB, whose
103
+ // connection is configured FK-ON (`configureSqliteDatabase`), which is also the
104
+ // state the rest of the migration chain expects.
105
+ await sql`PRAGMA foreign_keys = OFF`.execute(db);
106
+ try {
107
+ // One transaction so a mid-rebuild failure cannot leave a half-swapped schema.
108
+ await db.transaction().execute(async trx => {
109
+ await rebuildAndRepair(trx, brokenTables);
110
+ });
111
+ } finally {
112
+ await sql`PRAGMA foreign_keys = ON`.execute(db);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Return the tables that have at least one column whose stored default is a
118
+ * broken string literal. Uses a `SELECT`-shaped `pragma_table_info(...)` read so
119
+ * the bunSqliteDialect returns rows (a bare `PRAGMA x` value read does not).
120
+ */
121
+ async function findTablesWithBrokenDefaults(db: Kysely<unknown>): Promise<string[]> {
122
+ const tables = await sql<NamedRow>`
123
+ SELECT name FROM sqlite_master
124
+ WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
125
+ `.execute(db);
126
+
127
+ const broken: string[] = [];
128
+ for (const { name: table } of tables.rows) {
129
+ const columns = await sql<ColumnInfoRow>`
130
+ SELECT dflt_value FROM pragma_table_info(${table})
131
+ `.execute(db);
132
+ if (columns.rows.some(column => column.dflt_value !== null && column.dflt_value in BROKEN_DEFAULTS)) {
133
+ broken.push(table);
134
+ }
135
+ }
136
+ return broken;
137
+ }
138
+
139
+ async function rebuildAndRepair(trx: Kysely<unknown>, tables: string[]): Promise<void> {
140
+ for (const table of tables) {
141
+ const columns = await sql<ColumnInfoRow>`
142
+ SELECT name, dflt_value FROM pragma_table_info(${table})
143
+ `.execute(trx);
144
+
145
+ const brokenColumns = columns.rows.filter(
146
+ column => column.dflt_value !== null && column.dflt_value in BROKEN_DEFAULTS
147
+ );
148
+ if (brokenColumns.length === 0) {
149
+ continue;
150
+ }
151
+
152
+ await rebuildTableWithCorrectedDefaults(trx, table);
153
+
154
+ // Rows copied verbatim during the rebuild still hold the literal; heal them
155
+ // now, restricted to the columns that actually carried the broken default.
156
+ for (const column of brokenColumns) {
157
+ const { poisonedValue } = BROKEN_DEFAULTS[column.dflt_value as string];
158
+ await sql`
159
+ UPDATE ${sql.ref(table)}
160
+ SET ${sql.ref(column.name)} = datetime('now')
161
+ WHERE ${sql.ref(column.name)} = ${poisonedValue}
162
+ `.execute(trx);
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Rebuild `table` so every broken string-literal default becomes its corrected
169
+ * raw-SQL form, preserving data, indexes, and triggers. Only the `DEFAULT`
170
+ * clauses change; column set and order are untouched, so `INSERT ... SELECT *`
171
+ * lines the rows up 1:1.
172
+ */
173
+ async function rebuildTableWithCorrectedDefaults(
174
+ trx: Kysely<unknown>,
175
+ table: string
176
+ ): Promise<void> {
177
+ const createRow = await sql<SqlRow>`
178
+ SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${table}
179
+ `.execute(trx);
180
+ const createSql = createRow.rows[0]?.sql;
181
+ if (!createSql) {
182
+ return;
183
+ }
184
+
185
+ const tempTable = `__rebuild_${table}`;
186
+ let correctedSql = replaceFirstTableName(createSql, table, tempTable);
187
+ for (const brokenDefault of Object.keys(BROKEN_DEFAULTS)) {
188
+ // The broken literal only appears in the schema as a column default, so a
189
+ // global replace cannot touch anything else.
190
+ correctedSql = correctedSql.split(brokenDefault).join(BROKEN_DEFAULTS[brokenDefault].corrected);
191
+ }
192
+
193
+ // Capture the table's own indexes and triggers before the drop — auto-indexes
194
+ // backing PRIMARY KEY / UNIQUE constraints have a NULL `sql` and are recreated
195
+ // by the CREATE TABLE itself, so only the explicit ones need replaying.
196
+ const auxiliary = await sql<SqlRow>`
197
+ SELECT sql FROM sqlite_master
198
+ WHERE tbl_name = ${table} AND type IN ('index', 'trigger') AND sql IS NOT NULL
199
+ `.execute(trx);
200
+
201
+ // Capture the AUTOINCREMENT high-water mark. `sqlite_sequence` records the
202
+ // highest rowid EVER used so AUTOINCREMENT never reuses a deleted id; the
203
+ // copy/drop/rename below would otherwise reset it to `max(current id)` and let a
204
+ // deleted top id be handed out again — aliasing ids that other tables reference
205
+ // as plain integers. Restored after the rename.
206
+ const originalSequence = await captureSequence(trx, table);
207
+
208
+ await sql.raw(correctedSql).execute(trx);
209
+ await sql`INSERT INTO ${sql.ref(tempTable)} SELECT * FROM ${sql.ref(table)}`.execute(trx);
210
+ await sql`DROP TABLE ${sql.ref(table)}`.execute(trx);
211
+ await sql`ALTER TABLE ${sql.ref(tempTable)} RENAME TO ${sql.ref(table)}`.execute(trx);
212
+ for (const { sql: auxSql } of auxiliary.rows) {
213
+ if (auxSql) {
214
+ await sql.raw(auxSql).execute(trx);
215
+ }
216
+ }
217
+
218
+ if (originalSequence !== undefined) {
219
+ await sql`DELETE FROM sqlite_sequence WHERE name = ${table}`.execute(trx);
220
+ await sql`INSERT INTO sqlite_sequence (name, seq) VALUES (${table}, ${originalSequence})`.execute(
221
+ trx
222
+ );
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Read a table's `sqlite_sequence` high-water mark, or `undefined` when the table
228
+ * is not AUTOINCREMENT / has never been inserted into (no sequence row) or the
229
+ * `sqlite_sequence` table does not exist (no AUTOINCREMENT table in the DB).
230
+ */
231
+ async function captureSequence(
232
+ trx: Kysely<unknown>,
233
+ table: string
234
+ ): Promise<number | undefined> {
235
+ const hasSequenceTable = await sql<NamedRow>`
236
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'
237
+ `.execute(trx);
238
+ if (hasSequenceTable.rows.length === 0) {
239
+ return undefined;
240
+ }
241
+ const seqRow = await sql<{ seq: number }>`
242
+ SELECT seq FROM sqlite_sequence WHERE name = ${table}
243
+ `.execute(trx);
244
+ return seqRow.rows[0]?.seq;
245
+ }
246
+
247
+ /**
248
+ * Replace the table name in a `CREATE TABLE` statement, tolerating the double-
249
+ * quoted (Kysely) and bare forms and arbitrary whitespace after the keyword.
250
+ * Only the first occurrence — the table being declared — is rewritten; a later
251
+ * self-reference (e.g. a table-level FK) keeps pointing at the real name so the
252
+ * copy still works.
253
+ */
254
+ function replaceFirstTableName(createSql: string, from: string, to: string): string {
255
+ // Match the declared table name as either a double-quoted identifier (Kysely's
256
+ // form) or a bare word, and rewrite only when it is exactly `from` so a later
257
+ // self-reference in the body is left untouched.
258
+ return createSql.replace(
259
+ /^(\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?)(?:"([^"]+)"|(\w+))/i,
260
+ (match, prefix: string, quotedName: string | undefined, bareName: string | undefined) =>
261
+ (quotedName ?? bareName) === from ? `${prefix}"${to}"` : match
262
+ );
263
+ }
264
+
265
+ export async function down(): Promise<void> {
266
+ // Irreversible repair: the corrected defaults and healed rows carry no
267
+ // information worth reverting, so the down migration is intentionally a no-op.
268
+ }
@@ -0,0 +1,60 @@
1
+ import type { Kysely } from "kysely";
2
+
3
+ /**
4
+ * Seek-reduction index for `recordStorageEvent`'s per-insert previous-value
5
+ * lookup (#2798).
6
+ *
7
+ * On every storage telemetry insert that doesn't already carry `previousValue`,
8
+ * `storageEventRepository.recordStorageEvent` runs:
9
+ *
10
+ * SELECT value FROM storage_events
11
+ * WHERE device_id=? AND file_name=? AND key=?
12
+ * ORDER BY timestamp DESC LIMIT 1
13
+ *
14
+ * #2798 was filed against the pre-#2788 schema, where the only usable index was
15
+ * the standalone `idx_storage_events_device` and the plan showed
16
+ * `USE TEMP B-TREE FOR ORDER BY`. That temp B-tree is ALREADY gone: PR #2890
17
+ * (#2788) added `idx_storage_events_device_timestamp (device_id, timestamp)` and
18
+ * PR #2904 (#2893) dropped the standalone device index, so the composite's
19
+ * `device_id` prefix now supplies the DESC order for free.
20
+ *
21
+ * What remains — and what this index fixes — is the SEEK width. With only the
22
+ * `(device_id, timestamp)` composite the planner seeks on `device_id=?` and then
23
+ * scans every row for that device in timestamp order, applying the `file_name=?`
24
+ * and `key=?` filters row-by-row until the first match. A device with many
25
+ * distinct keys makes that scan arbitrarily long. `(device_id, file_name, key,
26
+ * timestamp)` turns the three equality predicates into a single prefix seek that
27
+ * descends straight to the target key's rows, and the trailing `timestamp` column
28
+ * still yields the newest row without a sort (SQLite walks the ascending index
29
+ * backward for `ORDER BY timestamp DESC`).
30
+ *
31
+ * NOT a covering index (per Mira Kessler's review on #2798): the query selects
32
+ * `value`, which is deliberately NOT in the index — `value` is an arbitrarily
33
+ * large TEXT blob, and widening the index to make the read index-only is a bad
34
+ * trade. The plan is a prefix seek + one rowid lookup for `value`; the name is
35
+ * `_key_lookup` (a seek-reduction index), NOT `_covering`.
36
+ *
37
+ * Distinct from `idx_storage_events_device_timestamp` (#2788), not a
38
+ * planner-redundant near-duplicate: that composite serves the device-scoped
39
+ * getter (`WHERE device_id=? ORDER BY timestamp DESC`) and cannot serve this
40
+ * three-equality predicate as a prefix seek; this index cannot serve the
41
+ * device-only getter's ordering as efficiently. The planner picks each for its
42
+ * own shape.
43
+ *
44
+ * Additive and forward-only, `.ifNotExists()` / `.ifExists()` so the migration
45
+ * survives #2785's destructive-recovery replay (drop-all then replay every
46
+ * migration on an empty DB) in unusual orders. Sorts after
47
+ * `2026_07_03_*`, so `storage_events` exists when it runs during a full replay.
48
+ */
49
+ export async function up(db: Kysely<unknown>): Promise<void> {
50
+ await db.schema
51
+ .createIndex("idx_storage_events_key_lookup")
52
+ .ifNotExists()
53
+ .on("storage_events")
54
+ .columns(["device_id", "file_name", "key", "timestamp"])
55
+ .execute();
56
+ }
57
+
58
+ export async function down(db: Kysely<unknown>): Promise<void> {
59
+ await db.schema.dropIndex("idx_storage_events_key_lookup").ifExists().execute();
60
+ }