@kaeawc/auto-mobile 0.0.40 → 0.0.42
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.
- package/README.md +3 -3
- package/README.md.backup +3 -3
- package/dist/schemas/test-plan.schema.json +6 -2
- package/dist/schemas/tool-definitions.json +2549 -546
- package/dist/src/db/eventTables.ts +8 -0
- package/dist/src/db/migrations/2025_12_28_000_initial_schema.ts +3 -3
- package/dist/src/db/migrations/2025_12_30_000_performance_thresholds.ts +3 -3
- package/dist/src/db/migrations/2025_12_30_001_navigation_graph.ts +9 -9
- package/dist/src/db/migrations/2025_12_31_000_accessibility_baselines.ts +3 -3
- package/dist/src/db/migrations/2025_12_31_001_memory_audit.ts +4 -4
- package/dist/src/db/migrations/2026_01_01_000_recomposition_metrics.ts +2 -2
- package/dist/src/db/migrations/2026_01_02_000_prediction_history.ts +4 -4
- package/dist/src/db/migrations/2026_01_03_000_feature_flags.ts +3 -3
- package/dist/src/db/migrations/2026_01_03_000_test_executions.ts +2 -2
- package/dist/src/db/migrations/2026_01_05_000_tool_calls.ts +2 -2
- package/dist/src/db/migrations/2026_01_08_000_test_coverage.ts +4 -4
- package/dist/src/db/migrations/2026_01_09_000_video_recordings.ts +3 -3
- package/dist/src/db/migrations/2026_01_10_000_device_snapshots.ts +3 -3
- package/dist/src/db/migrations/2026_01_14_000_appearance_config.ts +3 -3
- package/dist/src/db/migrations/2026_01_25_001_test_run_details.ts +2 -2
- package/dist/src/db/migrations/2026_01_27_000_crash_anr_monitoring.ts +2 -2
- package/dist/src/db/migrations/2026_01_27_000_failures.ts +7 -7
- package/dist/src/db/migrations/2026_01_29_000_named_nodes.ts +3 -3
- package/dist/src/db/migrations/2026_01_30_000_performance_live_metrics.ts +1 -1
- package/dist/src/db/migrations/2026_03_15_000_telemetry_events.ts +5 -5
- package/dist/src/db/migrations/2026_03_18_000_navigation_events.ts +2 -2
- package/dist/src/db/migrations/2026_03_19_000_storage_events.ts +2 -2
- package/dist/src/db/migrations/2026_03_19_001_layout_events.ts +2 -2
- package/dist/src/db/migrations/2026_04_01_000_drop_custom_events.ts +2 -2
- package/dist/src/db/migrations/2026_04_02_000_device_sessions.ts +3 -3
- package/dist/src/db/migrations/2026_07_01_000_failure_groups_signature_unique.ts +162 -0
- package/dist/src/db/migrations/2026_07_02_000_event_composite_indexes.ts +62 -0
- package/dist/src/db/migrations/2026_07_03_000_drop_redundant_device_indexes.ts +66 -0
- package/dist/src/db/migrations/2026_07_03_000_repair_datetime_now_defaults.ts +268 -0
- package/dist/src/db/migrations/2026_07_04_000_storage_events_key_lookup.ts +60 -0
- package/dist/src/db/migrations/2026_07_05_000_repair_updated_at_defaults.ts +242 -0
- package/dist/src/index.js +533 -390
- package/dist/src/index.js.map +1 -1
- package/dist/vendor/libwebp/COPYING +30 -0
- package/dist/vendor/libwebp/PATENTS +23 -0
- package/dist/vendor/libwebp/README.md +13 -0
- package/dist/vendor/libwebp/README.upstream.md +54 -0
- package/dist/vendor/libwebp/win32-x64/cwebp.exe +0 -0
- package/dist/vendor/libwebp/win32-x64/dwebp.exe +0 -0
- package/package.json +9 -4
- package/schemas/test-plan.schema.json +6 -2
- 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
|
+
}
|