orez-lite 0.9.7
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/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/browser/generated/sqlite3-browser.wasm +0 -0
- package/dist/browser/generated/sync_wasm.js +922 -0
- package/dist/browser/generated/sync_wasm_bg.wasm +0 -0
- package/dist/browser/host.d.ts +10 -0
- package/dist/browser/host.d.ts.map +1 -0
- package/dist/browser/host.js +496 -0
- package/dist/browser/host.js.map +1 -0
- package/dist/browser/idb-snapshot.d.ts +11 -0
- package/dist/browser/idb-snapshot.d.ts.map +1 -0
- package/dist/browser/idb-snapshot.js +122 -0
- package/dist/browser/idb-snapshot.js.map +1 -0
- package/dist/browser/index.d.ts +5 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-port.d.ts +7 -0
- package/dist/browser/message-port.d.ts.map +1 -0
- package/dist/browser/message-port.js +188 -0
- package/dist/browser/message-port.js.map +1 -0
- package/dist/browser/sqlite-adapter.d.ts +53 -0
- package/dist/browser/sqlite-adapter.d.ts.map +1 -0
- package/dist/browser/sqlite-adapter.js +126 -0
- package/dist/browser/sqlite-adapter.js.map +1 -0
- package/dist/browser/types.d.ts +52 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/cf-deploy/bundle.d.ts +18 -0
- package/dist/cf-deploy/bundle.d.ts.map +1 -0
- package/dist/cf-deploy/bundle.js +273 -0
- package/dist/cf-deploy/bundle.js.map +1 -0
- package/dist/cf-deploy/config.d.ts +12 -0
- package/dist/cf-deploy/config.d.ts.map +1 -0
- package/dist/cf-deploy/config.js +11 -0
- package/dist/cf-deploy/config.js.map +1 -0
- package/dist/cf-deploy/index.d.ts +10 -0
- package/dist/cf-deploy/index.d.ts.map +1 -0
- package/dist/cf-deploy/index.js +12 -0
- package/dist/cf-deploy/index.js.map +1 -0
- package/dist/cf-deploy/leaves.d.ts +8 -0
- package/dist/cf-deploy/leaves.d.ts.map +1 -0
- package/dist/cf-deploy/leaves.js +66 -0
- package/dist/cf-deploy/leaves.js.map +1 -0
- package/dist/cf-deploy/migration.d.ts +27 -0
- package/dist/cf-deploy/migration.d.ts.map +1 -0
- package/dist/cf-deploy/migration.js +734 -0
- package/dist/cf-deploy/migration.js.map +1 -0
- package/dist/cf-deploy/nativeMigrations.d.ts +24 -0
- package/dist/cf-deploy/nativeMigrations.d.ts.map +1 -0
- package/dist/cf-deploy/nativeMigrations.js +185 -0
- package/dist/cf-deploy/nativeMigrations.js.map +1 -0
- package/dist/cf-deploy/prune.d.ts +14 -0
- package/dist/cf-deploy/prune.d.ts.map +1 -0
- package/dist/cf-deploy/prune.js +235 -0
- package/dist/cf-deploy/prune.js.map +1 -0
- package/dist/cf-deploy/readiness.d.ts +11 -0
- package/dist/cf-deploy/readiness.d.ts.map +1 -0
- package/dist/cf-deploy/readiness.js +56 -0
- package/dist/cf-deploy/readiness.js.map +1 -0
- package/dist/cf-deploy/sources.d.ts +3 -0
- package/dist/cf-deploy/sources.d.ts.map +1 -0
- package/dist/cf-deploy/sources.js +24 -0
- package/dist/cf-deploy/sources.js.map +1 -0
- package/dist/cf-deploy/wrangler.d.ts +9 -0
- package/dist/cf-deploy/wrangler.d.ts.map +1 -0
- package/dist/cf-deploy/wrangler.js +48 -0
- package/dist/cf-deploy/wrangler.js.map +1 -0
- package/dist/cf-do/application-sql.d.ts +49 -0
- package/dist/cf-do/application-sql.d.ts.map +1 -0
- package/dist/cf-do/application-sql.js +124 -0
- package/dist/cf-do/application-sql.js.map +1 -0
- package/dist/cf-do/cdc.d.ts +155 -0
- package/dist/cf-do/cdc.d.ts.map +1 -0
- package/dist/cf-do/cdc.js +723 -0
- package/dist/cf-do/cdc.js.map +1 -0
- package/dist/cf-do/cursor-pull.d.ts +27 -0
- package/dist/cf-do/cursor-pull.d.ts.map +1 -0
- package/dist/cf-do/cursor-pull.js +78 -0
- package/dist/cf-do/cursor-pull.js.map +1 -0
- package/dist/cf-do/lite-data-worker.d.ts +171 -0
- package/dist/cf-do/lite-data-worker.d.ts.map +1 -0
- package/dist/cf-do/lite-data-worker.js +685 -0
- package/dist/cf-do/lite-data-worker.js.map +1 -0
- package/dist/cf-do/namespace-backup.d.ts +85 -0
- package/dist/cf-do/namespace-backup.d.ts.map +1 -0
- package/dist/cf-do/namespace-backup.js +470 -0
- package/dist/cf-do/namespace-backup.js.map +1 -0
- package/dist/cf-do/row-undo.d.ts +36 -0
- package/dist/cf-do/row-undo.d.ts.map +1 -0
- package/dist/cf-do/row-undo.js +221 -0
- package/dist/cf-do/row-undo.js.map +1 -0
- package/dist/cf-do/tx-journal.d.ts +118 -0
- package/dist/cf-do/tx-journal.d.ts.map +1 -0
- package/dist/cf-do/tx-journal.js +717 -0
- package/dist/cf-do/tx-journal.js.map +1 -0
- package/dist/cf-do/watermark.d.ts +29 -0
- package/dist/cf-do/watermark.d.ts.map +1 -0
- package/dist/cf-do/watermark.js +105 -0
- package/dist/cf-do/watermark.js.map +1 -0
- package/dist/cf-do/worker.d.ts +282 -0
- package/dist/cf-do/worker.d.ts.map +1 -0
- package/dist/cf-do/worker.js +1999 -0
- package/dist/cf-do/worker.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2 -0
- package/dist/client.js.map +1 -0
- package/dist/cloudflare-build.d.ts +9 -0
- package/dist/cloudflare-build.d.ts.map +1 -0
- package/dist/cloudflare-build.js +9 -0
- package/dist/cloudflare-build.js.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-bun-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-bun-wasm-loader.js +2 -0
- package/dist/cloudflare-bun-wasm-loader.js.map +1 -0
- package/dist/cloudflare-query-compiler.d.ts +2 -0
- package/dist/cloudflare-query-compiler.d.ts.map +1 -0
- package/dist/cloudflare-query-compiler.js +2 -0
- package/dist/cloudflare-query-compiler.js.map +1 -0
- package/dist/cloudflare-runtime.d.ts +15 -0
- package/dist/cloudflare-runtime.d.ts.map +1 -0
- package/dist/cloudflare-runtime.js +11 -0
- package/dist/cloudflare-runtime.js.map +1 -0
- package/dist/cloudflare-sync.d.ts +3 -0
- package/dist/cloudflare-sync.d.ts.map +1 -0
- package/dist/cloudflare-sync.js +3 -0
- package/dist/cloudflare-sync.js.map +1 -0
- package/dist/cloudflare-transaction-query.d.ts +2 -0
- package/dist/cloudflare-transaction-query.d.ts.map +1 -0
- package/dist/cloudflare-transaction-query.js +2 -0
- package/dist/cloudflare-transaction-query.js.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts +2 -0
- package/dist/cloudflare-vite-wasm-loader.d.ts.map +1 -0
- package/dist/cloudflare-vite-wasm-loader.js +2 -0
- package/dist/cloudflare-vite-wasm-loader.js.map +1 -0
- package/dist/cloudflare.d.ts +3 -0
- package/dist/cloudflare.d.ts.map +1 -0
- package/dist/cloudflare.js +3 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/do-sql-tracking.d.ts +114 -0
- package/dist/do-sql-tracking.d.ts.map +1 -0
- package/dist/do-sql-tracking.js +280 -0
- package/dist/do-sql-tracking.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/wake.d.ts +22 -0
- package/dist/wake.d.ts.map +1 -0
- package/dist/wake.js +114 -0
- package/dist/wake.js.map +1 -0
- package/dist/worker/cf-do-shim.d.ts +53 -0
- package/dist/worker/cf-do-shim.d.ts.map +1 -0
- package/dist/worker/cf-do-shim.js +66 -0
- package/dist/worker/cf-do-shim.js.map +1 -0
- package/dist/zero-http/encrypted-column-codec.d.ts +46 -0
- package/dist/zero-http/encrypted-column-codec.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-codec.js +676 -0
- package/dist/zero-http/encrypted-column-codec.js.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts +7 -0
- package/dist/zero-http/encrypted-column-conformance.d.ts.map +1 -0
- package/dist/zero-http/encrypted-column-conformance.js +161 -0
- package/dist/zero-http/encrypted-column-conformance.js.map +1 -0
- package/dist/zero-http/fixture-schema.d.ts +127 -0
- package/dist/zero-http/fixture-schema.d.ts.map +1 -0
- package/dist/zero-http/fixture-schema.js +56 -0
- package/dist/zero-http/fixture-schema.js.map +1 -0
- package/dist/zero-http/mount.d.ts +71 -0
- package/dist/zero-http/mount.d.ts.map +1 -0
- package/dist/zero-http/mount.js +594 -0
- package/dist/zero-http/mount.js.map +1 -0
- package/dist/zero-http/payload-codec.d.ts +91 -0
- package/dist/zero-http/payload-codec.d.ts.map +1 -0
- package/dist/zero-http/payload-codec.js +10 -0
- package/dist/zero-http/payload-codec.js.map +1 -0
- package/dist/zero-http/server.d.ts +16 -0
- package/dist/zero-http/server.d.ts.map +1 -0
- package/dist/zero-http/server.js +270 -0
- package/dist/zero-http/server.js.map +1 -0
- package/dist/zero-http/test-harness.d.ts +32 -0
- package/dist/zero-http/test-harness.d.ts.map +1 -0
- package/dist/zero-http/test-harness.js +77 -0
- package/dist/zero-http/test-harness.js.map +1 -0
- package/dist/zero-http/transport.d.ts +71 -0
- package/dist/zero-http/transport.d.ts.map +1 -0
- package/dist/zero-http/transport.js +1106 -0
- package/dist/zero-http/transport.js.map +1 -0
- package/package.json +116 -0
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
const SENTINEL_LOWER = 'nspfx';
|
|
2
|
+
const SENTINEL_PASCAL = 'Nspfx';
|
|
3
|
+
function applyPrefix(template, cfg) {
|
|
4
|
+
const pascalName = cfg.name.charAt(0).toUpperCase() + cfg.name.slice(1);
|
|
5
|
+
return template
|
|
6
|
+
.split(SENTINEL_PASCAL)
|
|
7
|
+
.join(pascalName)
|
|
8
|
+
.split(SENTINEL_LOWER)
|
|
9
|
+
.join(cfg.name);
|
|
10
|
+
}
|
|
11
|
+
export function buildMigrationModuleSource(cfg, parts) {
|
|
12
|
+
const runCloudflareMigrations = applyPrefix('runNspfxCloudflareMigrations', cfg);
|
|
13
|
+
const migrationTableName = applyPrefix('__nspfx_cf_migrations', cfg);
|
|
14
|
+
if (parts.mode === 'noop') {
|
|
15
|
+
return `export const SCHEMA_VERSION = ${JSON.stringify(parts.schemaVersion)}
|
|
16
|
+
export async function ${runCloudflareMigrations}() {}
|
|
17
|
+
export { ${runCloudflareMigrations} as runCloudflareMigrations }
|
|
18
|
+
export const orezAppSchema = {
|
|
19
|
+
version: SCHEMA_VERSION,
|
|
20
|
+
schema: { tables: {}, relationships: {} },
|
|
21
|
+
publicTables: [],
|
|
22
|
+
migrate: ${runCloudflareMigrations},
|
|
23
|
+
}
|
|
24
|
+
`;
|
|
25
|
+
}
|
|
26
|
+
if (parts.mode === 'native') {
|
|
27
|
+
const applicationSqlGlobal = applyPrefix('__nspfx_cf_application_sql_client', cfg);
|
|
28
|
+
return `import { schema } from ${JSON.stringify(parts.schemaImportSpecifier)}
|
|
29
|
+
|
|
30
|
+
export const SCHEMA_VERSION = ${JSON.stringify(parts.schemaVersion)}
|
|
31
|
+
|
|
32
|
+
const nativeSqlStatements = ${JSON.stringify(parts.nativeSqlStatements)}
|
|
33
|
+
const configuredPublicTables = ${JSON.stringify(parts.publicTables ?? [])}
|
|
34
|
+
const expectedTables = ${JSON.stringify(parts.expectedTables ?? [])}
|
|
35
|
+
const migrationTable = ${JSON.stringify(migrationTableName)}
|
|
36
|
+
|
|
37
|
+
function quoteIdentifier(value) {
|
|
38
|
+
return '"' + String(value).replaceAll('"', '""') + '"'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function schemaMetadataStatements() {
|
|
42
|
+
return Object.values(schema.tables || {})
|
|
43
|
+
.filter((table) => table && typeof table.name === 'string')
|
|
44
|
+
.map((table) => {
|
|
45
|
+
const publicName = table.name.startsWith('public.')
|
|
46
|
+
? table.name
|
|
47
|
+
: 'public.' + table.name
|
|
48
|
+
const registration = resolvedPublicTables.find(
|
|
49
|
+
(candidate) => candidate.publicTable === publicName,
|
|
50
|
+
)
|
|
51
|
+
return {
|
|
52
|
+
sql: 'INSERT OR REPLACE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)',
|
|
53
|
+
params: [
|
|
54
|
+
table.name.replace(/^public\\./, ''),
|
|
55
|
+
JSON.stringify({
|
|
56
|
+
columns: table.columns,
|
|
57
|
+
primaryKey: table.primaryKey,
|
|
58
|
+
physicalName:
|
|
59
|
+
(registration && registration.table) ||
|
|
60
|
+
table.serverName ||
|
|
61
|
+
table.name.replace(/^public\\./, ''),
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function publicTables() {
|
|
69
|
+
if (configuredPublicTables.length) return configuredPublicTables
|
|
70
|
+
return Object.values(schema.tables || {})
|
|
71
|
+
.filter((table) => table && typeof table.name === 'string')
|
|
72
|
+
.map((table) => {
|
|
73
|
+
const publicTable = table.name.startsWith('public.') ? table.name : 'public.' + table.name
|
|
74
|
+
return { table: publicTable.replace(/^public\\./, ''), publicTable }
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const resolvedPublicTables = publicTables()
|
|
79
|
+
|
|
80
|
+
async function shouldSkipStatement(tx, statement) {
|
|
81
|
+
if (statement.skipIfTableMissing) {
|
|
82
|
+
const tempRows = await tx.query(
|
|
83
|
+
'PRAGMA table_info(' + quoteIdentifier(statement.skipIfTableMissing) + ')',
|
|
84
|
+
)
|
|
85
|
+
if (tempRows.length === 0) return true
|
|
86
|
+
}
|
|
87
|
+
const condition = statement.skipIfColumnExists || statement.skipIfColumnMissing
|
|
88
|
+
if (!condition) return false
|
|
89
|
+
const rows = await tx.query('PRAGMA table_info(' + quoteIdentifier(condition.table) + ')')
|
|
90
|
+
const hasColumn = rows.some((row) => row && row.name === condition.column)
|
|
91
|
+
return statement.skipIfColumnExists ? hasColumn : !hasColumn
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeSqlType(value) {
|
|
95
|
+
return String(value).trim().toLowerCase().replaceAll(/\\s+/g, ' ')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// every table's columns in ONE round trip. this used to be a PRAGMA
|
|
99
|
+
// table_info per expected table in the shape assert plus another per ledgered
|
|
100
|
+
// ADD COLUMN in the reconcile — order 125 sequential DO calls, all of them
|
|
101
|
+
// holding the namespace's exclusive application-SQL session while concurrent
|
|
102
|
+
// readers spin in the 25ms acquire retry. healing paths force this run on
|
|
103
|
+
// EVERY request for an unseeded project, so its cost lands on exactly the
|
|
104
|
+
// population it exists to repair (the 2026-07-21 outage shape).
|
|
105
|
+
//
|
|
106
|
+
// one behaviour difference from the PRAGMAs this replaced: lookups here are
|
|
107
|
+
// case-EXACT, because the keys come from sqlite_master.name, while
|
|
108
|
+
// PRAGMA table_info resolves a table name case-insensitively. No statement in
|
|
109
|
+
// the current set mismatches, but a hand-written repair migration that spelled
|
|
110
|
+
// a table name in a different case would read as a missing column, resurrect,
|
|
111
|
+
// and then die on "duplicate column name". Match sqlite_master's spelling.
|
|
112
|
+
async function readLiveColumns(tx) {
|
|
113
|
+
// exclude protected/internal tables from the scan. once a Durable Object has
|
|
114
|
+
// used its KV API, _cf_KV is listed in sqlite_master but the platform
|
|
115
|
+
// authorizer refuses pragma_table_info on it, and one denied row fails the
|
|
116
|
+
// WHOLE statement with SQLITE_AUTH (verified 2026-07-24 against a real DO:
|
|
117
|
+
// scan passes without _cf_KV, fails the moment storage.put creates it, and
|
|
118
|
+
// passes again with this filter). every prod namespace DO has _cf_KV, so the
|
|
119
|
+
// unfiltered scan broke every migration session. caret escape on purpose:
|
|
120
|
+
// this whole function body is emitted through a template literal, where a
|
|
121
|
+
// literal backslash would need quadruple escaping.
|
|
122
|
+
const rows = await tx.query(
|
|
123
|
+
'SELECT m.name AS tableName, p.name AS columnName, p.type AS columnType,' +
|
|
124
|
+
' p."notnull" AS columnNotNull, p.pk AS columnPk' +
|
|
125
|
+
" FROM sqlite_master m JOIN pragma_table_info(m.name) p WHERE m.type = 'table'" +
|
|
126
|
+
" AND m.name NOT LIKE '^_cf^_%' ESCAPE '^'" +
|
|
127
|
+
" AND m.name NOT LIKE 'sqlite^_%' ESCAPE '^'",
|
|
128
|
+
)
|
|
129
|
+
const tables = new Map()
|
|
130
|
+
for (const row of rows) {
|
|
131
|
+
let columns = tables.get(row.tableName)
|
|
132
|
+
if (!columns) {
|
|
133
|
+
columns = []
|
|
134
|
+
tables.set(row.tableName, columns)
|
|
135
|
+
}
|
|
136
|
+
columns.push({
|
|
137
|
+
name: row.columnName,
|
|
138
|
+
notnull: row.columnNotNull,
|
|
139
|
+
pk: row.columnPk,
|
|
140
|
+
type: row.columnType,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return tables
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function assertExpectedSchema(tx) {
|
|
147
|
+
const liveColumns = await readLiveColumns(tx)
|
|
148
|
+
// two passes on purpose. reporting only the FIRST missing table makes an
|
|
149
|
+
// operator fix them one deploy at a time, and a per-column mismatch later in
|
|
150
|
+
// the list would throw before the missing-table set was even known. collect
|
|
151
|
+
// every absent table first and report them together.
|
|
152
|
+
const present = []
|
|
153
|
+
const missingTables = []
|
|
154
|
+
for (const expectedTable of expectedTables) {
|
|
155
|
+
const actualColumns = liveColumns.get(expectedTable.name) || []
|
|
156
|
+
if (actualColumns.length === 0) missingTables.push(expectedTable.name)
|
|
157
|
+
else present.push({ actualColumns, expectedTable })
|
|
158
|
+
}
|
|
159
|
+
if (missingTables.length > 0) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'application SQLite schema mismatch: missing table' +
|
|
162
|
+
(missingTables.length > 1 ? 's' : '') +
|
|
163
|
+
' ' +
|
|
164
|
+
missingTables.join(', '),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const { actualColumns, expectedTable } of present) {
|
|
169
|
+
const actualByName = new Map(actualColumns.map((column) => [column.name, column]))
|
|
170
|
+
const missing = expectedTable.columns
|
|
171
|
+
.filter((column) => !actualByName.has(column.name))
|
|
172
|
+
.map((column) => column.name)
|
|
173
|
+
if (missing.length > 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
'application SQLite schema mismatch for ' + expectedTable.name +
|
|
176
|
+
': missing [' + missing.join(', ') + ']',
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const expectedPrimaryKey = expectedTable.columns
|
|
181
|
+
.filter((column) => column.primaryKeyOrder > 0)
|
|
182
|
+
.sort((left, right) => left.primaryKeyOrder - right.primaryKeyOrder)
|
|
183
|
+
.map((column) => column.name)
|
|
184
|
+
const actualPrimaryKey = actualColumns
|
|
185
|
+
.filter((column) => Number(column.pk) > 0)
|
|
186
|
+
.sort((left, right) => Number(left.pk) - Number(right.pk))
|
|
187
|
+
.map((column) => column.name)
|
|
188
|
+
let equivalentPrimaryKeyIndex = false
|
|
189
|
+
if (
|
|
190
|
+
expectedPrimaryKey.length > 0 &&
|
|
191
|
+
(actualPrimaryKey.length !== expectedPrimaryKey.length ||
|
|
192
|
+
actualPrimaryKey.some((column, index) => column !== expectedPrimaryKey[index]))
|
|
193
|
+
) {
|
|
194
|
+
const indexes = await tx.query(
|
|
195
|
+
'PRAGMA index_list(' + quoteIdentifier(expectedTable.name) + ')',
|
|
196
|
+
)
|
|
197
|
+
for (const index of indexes) {
|
|
198
|
+
if (Number(index.unique) === 0 || Number(index.partial) !== 0) continue
|
|
199
|
+
const columns = (await tx.query(
|
|
200
|
+
'PRAGMA index_info(' + quoteIdentifier(index.name) + ')',
|
|
201
|
+
))
|
|
202
|
+
.sort((left, right) => Number(left.seqno) - Number(right.seqno))
|
|
203
|
+
.map((column) => column.name)
|
|
204
|
+
if (
|
|
205
|
+
columns.length === expectedPrimaryKey.length &&
|
|
206
|
+
columns.every((column, index) => column === expectedPrimaryKey[index])
|
|
207
|
+
) {
|
|
208
|
+
equivalentPrimaryKeyIndex = true
|
|
209
|
+
break
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
for (const expected of expectedTable.columns) {
|
|
215
|
+
const actual = actualByName.get(expected.name)
|
|
216
|
+
if (!actual) continue
|
|
217
|
+
const actualType = normalizeSqlType(actual.type)
|
|
218
|
+
const compatibleLegacyTimestamp =
|
|
219
|
+
expected.sqlType === 'timestamp' &&
|
|
220
|
+
(actualType === 'text' || actualType === 'real')
|
|
221
|
+
const compatibleLegacyNumber =
|
|
222
|
+
expected.sqlType === 'integer' &&
|
|
223
|
+
(actualType === 'real' || actualType === 'text')
|
|
224
|
+
if (
|
|
225
|
+
actualType !== expected.sqlType &&
|
|
226
|
+
!compatibleLegacyTimestamp &&
|
|
227
|
+
!compatibleLegacyNumber
|
|
228
|
+
) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
'application SQLite schema mismatch for ' + expectedTable.name + '.' + expected.name +
|
|
231
|
+
': expected ' + expected.sqlType + ', found ' + (actualType || '(untyped)'),
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const actualNotNull = Number(actual.notnull) !== 0 || Number(actual.pk) !== 0
|
|
236
|
+
if (actualNotNull !== expected.notNull) {
|
|
237
|
+
if (expected.notNull && !actualNotNull) {
|
|
238
|
+
const nullRows = await tx.query(
|
|
239
|
+
'SELECT 1 FROM ' + quoteIdentifier(expectedTable.name) +
|
|
240
|
+
' WHERE ' + quoteIdentifier(expected.name) + ' IS NULL LIMIT 1',
|
|
241
|
+
)
|
|
242
|
+
if (nullRows.length === 0) continue
|
|
243
|
+
}
|
|
244
|
+
throw new Error(
|
|
245
|
+
'application SQLite schema mismatch for ' + expectedTable.name + '.' + expected.name +
|
|
246
|
+
': expected ' + (expected.notNull ? 'NOT NULL' : 'nullable') +
|
|
247
|
+
', found ' + (actualNotNull ? 'NOT NULL' : 'nullable'),
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
if (Number(actual.pk) !== expected.primaryKeyOrder && !equivalentPrimaryKeyIndex) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
'application SQLite schema mismatch for ' + expectedTable.name + '.' + expected.name +
|
|
253
|
+
': expected primary-key position ' + expected.primaryKeyOrder +
|
|
254
|
+
', found ' + actual.pk,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function hashMigrationSql(sql) {
|
|
262
|
+
let h = 2166136261
|
|
263
|
+
for (let i = 0; i < sql.length; i++) {
|
|
264
|
+
h ^= sql.charCodeAt(i)
|
|
265
|
+
h = Math.imul(h, 16777619)
|
|
266
|
+
}
|
|
267
|
+
return (h >>> 0).toString(36)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// a run killed mid-transaction (client abort, isolate eviction) rolls its DDL
|
|
271
|
+
// back but keeps its ledger rows: the ledger is not a CDC-registered table, so
|
|
272
|
+
// the DO's row undo never captures it. those phantom rows made every later run
|
|
273
|
+
// skip statements whose effects no longer exist, permanently wedging the
|
|
274
|
+
// namespace (2026-07-23 provisioning incident). before trusting the ledger,
|
|
275
|
+
// delete entries whose effect is verifiably absent so their statements re-run
|
|
276
|
+
// in order. a standalone DROP TABLE never re-runs: its effect IS absence.
|
|
277
|
+
//
|
|
278
|
+
// what this does NOT detect, stated plainly rather than as a reassurance:
|
|
279
|
+
// - DML. a backfill re-runs only when its target table is gone entirely (the
|
|
280
|
+
// same pass recreates it first). undone on a surviving table it is
|
|
281
|
+
// invisible in schema and stays skipped, unlogged. the current set holds 50
|
|
282
|
+
// UPDATE, 10 INSERT INTO and 6 DELETE FROM statements. this cannot wedge a
|
|
283
|
+
// namespace, it leaves stale data behind a correct-looking schema.
|
|
284
|
+
// - an EXTRA column. assertExpectedSchema catches a missing table, a missing
|
|
285
|
+
// column, a type change and a NOT NULL change, but not a surplus one, so a
|
|
286
|
+
// ledgered ALTER TABLE ... DROP COLUMN whose effect rolled back is neither
|
|
287
|
+
// resurrected nor flagged.
|
|
288
|
+
// - a column a later statement in the SAME file renames away. the
|
|
289
|
+
// x__rebuild scratch columns in 20260723140000_declare_epoch_ms_integer
|
|
290
|
+
// are added, copied, then renamed over the original, so the ADD COLUMN rule
|
|
291
|
+
// always reads them as missing and re-adds them on a perfectly healthy
|
|
292
|
+
// namespace. surplus columns, so assertExpectedSchema stays quiet. this
|
|
293
|
+
// predates the block work and is not fixed here.
|
|
294
|
+
// - a rebuild block that changes only nullability, defaults or foreign keys
|
|
295
|
+
// without changing the column SET. its rollback leaves a table this pass
|
|
296
|
+
// reads as landed. it does not silently pass: assertExpectedSchema fails
|
|
297
|
+
// the run on the nullability difference, so the namespace reports loudly
|
|
298
|
+
// instead of self-repairing.
|
|
299
|
+
async function reconcilePhantomLedger(tx, applied) {
|
|
300
|
+
if (applied.size === 0) return
|
|
301
|
+
const schemaRows = await tx.query(
|
|
302
|
+
"SELECT name, type FROM sqlite_master WHERE type IN ('table', 'index')",
|
|
303
|
+
)
|
|
304
|
+
const tables = new Set()
|
|
305
|
+
const indexes = new Set()
|
|
306
|
+
for (const row of schemaRows) {
|
|
307
|
+
if (row.type === 'table') tables.add(row.name)
|
|
308
|
+
else indexes.add(row.name)
|
|
309
|
+
}
|
|
310
|
+
const liveColumns = await readLiveColumns(tx)
|
|
311
|
+
// a drizzle rebuild block (CREATE __new_<t> / INSERT..SELECT / DROP <t> /
|
|
312
|
+
// RENAME) is ONE unit. its statements are individually meaningless, so they
|
|
313
|
+
// resurrect together or not at all — including the DROP, which is the only
|
|
314
|
+
// way the RENAME can land on a re-run. the honest evidence that the block
|
|
315
|
+
// landed is <t> carrying the rebuilt COLUMN SET: a rolled-back block leaves
|
|
316
|
+
// the ORIGINAL <t> in place, so judging it by table name reads every wedged
|
|
317
|
+
// namespace as healthy and silently repairs nothing.
|
|
318
|
+
const blockKey = (item) => item.id.split(':')[0] + '::' + item.rebuildTarget
|
|
319
|
+
const blockLanded = new Map()
|
|
320
|
+
for (const statement of nativeSqlStatements) {
|
|
321
|
+
if (!statement || typeof statement !== 'object') continue
|
|
322
|
+
if (!statement.rebuildTarget || !statement.rebuildColumns) continue
|
|
323
|
+
const key = blockKey(statement)
|
|
324
|
+
if (blockLanded.has(key)) continue
|
|
325
|
+
const actualNames = new Set(
|
|
326
|
+
(liveColumns.get(statement.rebuildTarget) || []).map((column) => column.name),
|
|
327
|
+
)
|
|
328
|
+
// a superset test, not equality: a later migration legitimately adds
|
|
329
|
+
// columns to <t>, and treating that as "not landed" would drop a live
|
|
330
|
+
// table and rebuild it from a shape that no longer has them.
|
|
331
|
+
blockLanded.set(
|
|
332
|
+
key,
|
|
333
|
+
actualNames.size > 0 &&
|
|
334
|
+
statement.rebuildColumns.every((column) => actualNames.has(column)),
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
// the tables a resurrecting block will DROP and recreate this pass.
|
|
338
|
+
const rebuiltTables = new Set()
|
|
339
|
+
for (const statement of nativeSqlStatements) {
|
|
340
|
+
if (!statement || typeof statement !== 'object' || !statement.rebuildTarget) continue
|
|
341
|
+
if (blockLanded.get(blockKey(statement)) === false) {
|
|
342
|
+
rebuiltTables.add(statement.rebuildTarget)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const resurrect = new Set()
|
|
346
|
+
for (const [index, statement] of nativeSqlStatements.entries()) {
|
|
347
|
+
const item = typeof statement === 'string'
|
|
348
|
+
? { id: 'statement-' + index, sql: statement }
|
|
349
|
+
: statement
|
|
350
|
+
if (!item || typeof item.sql !== 'string' || !item.sql.trim()) continue
|
|
351
|
+
const baseId = typeof item.id === 'string' && item.id ? item.id : 'statement-' + index
|
|
352
|
+
let ledgered = false
|
|
353
|
+
for (const id of applied) {
|
|
354
|
+
if (id === baseId || id.startsWith(baseId + ':')) {
|
|
355
|
+
ledgered = true
|
|
356
|
+
break
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (!ledgered) continue
|
|
360
|
+
// block members bypass the per-statement rules entirely: their effect is
|
|
361
|
+
// the block's effect, judged once above.
|
|
362
|
+
if (item.rebuildTarget) {
|
|
363
|
+
if (blockLanded.get(blockKey(item)) === false) resurrect.add(baseId)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
const sql = item.sql.trim()
|
|
367
|
+
let match
|
|
368
|
+
let missing = false
|
|
369
|
+
if ((match = /^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?[\`"]?(\\w+)/i.exec(sql))) {
|
|
370
|
+
missing = !tables.has(match[1])
|
|
371
|
+
} else if ((match = /^CREATE (?:UNIQUE )?INDEX\\s+(?:IF NOT EXISTS\\s+)?[\`"]?(\\w+)[\`"]?\\s+ON\\s+[\`"]?(\\w+)/i.exec(sql))) {
|
|
372
|
+
// the index set was sampled BEFORE this pass drops and rebuilds <t>, so
|
|
373
|
+
// "the index exists" is stale evidence for any table a resurrecting
|
|
374
|
+
// block is about to DROP: the drop takes its indexes with it and the
|
|
375
|
+
// trailing CREATE INDEXes stay ledgered, leaving the rebuilt table
|
|
376
|
+
// bare. that silently removed the UNIQUE index invite redemption's
|
|
377
|
+
// ON CONFLICT target needs.
|
|
378
|
+
missing = !indexes.has(match[1]) || rebuiltTables.has(match[2])
|
|
379
|
+
} else if ((match = /^ALTER TABLE\\s+[\`"]?(\\w+)[\`"]?\\s+RENAME TO\\s+[\`"]?(\\w+)/i.exec(sql))) {
|
|
380
|
+
missing = !tables.has(match[2])
|
|
381
|
+
} else if ((match = /^ALTER TABLE\\s+[\`"]?(\\w+)[\`"]?\\s+ADD\\s+(?:COLUMN\\s+)?[\`"]?(\\w+)/i.exec(sql))) {
|
|
382
|
+
const columns = liveColumns.get(match[1])
|
|
383
|
+
missing = !columns || !columns.some((column) => column.name === match[2])
|
|
384
|
+
} else if ((match = /^(?:INSERT INTO|UPDATE|DELETE FROM|ALTER TABLE)\\s+[\`"]?(\\w+)/i.exec(sql))) {
|
|
385
|
+
missing = !tables.has(match[1])
|
|
386
|
+
}
|
|
387
|
+
if (missing) resurrect.add(baseId)
|
|
388
|
+
}
|
|
389
|
+
if (resurrect.size === 0) return
|
|
390
|
+
// a resurrected rebuild block must re-run with its sibling PRAGMA
|
|
391
|
+
// foreign_keys toggles, or the block re-executes under FK enforcement.
|
|
392
|
+
const resurrectedFiles = new Set(
|
|
393
|
+
[...resurrect].map((baseId) => baseId.split(':')[0]),
|
|
394
|
+
)
|
|
395
|
+
for (const [index, statement] of nativeSqlStatements.entries()) {
|
|
396
|
+
const item = typeof statement === 'string'
|
|
397
|
+
? { id: 'statement-' + index, sql: statement }
|
|
398
|
+
: statement
|
|
399
|
+
if (!item || typeof item.sql !== 'string') continue
|
|
400
|
+
if (!/^\\s*PRAGMA\\b/i.test(item.sql.trim())) continue
|
|
401
|
+
const baseId = typeof item.id === 'string' && item.id ? item.id : 'statement-' + index
|
|
402
|
+
if (resurrectedFiles.has(baseId.split(':')[0])) resurrect.add(baseId)
|
|
403
|
+
}
|
|
404
|
+
console.warn(
|
|
405
|
+
'[orez-migrations] resurrecting ' + resurrect.size +
|
|
406
|
+
' phantom ledger entries: ' + [...resurrect].join(', '),
|
|
407
|
+
)
|
|
408
|
+
// clear a leftover __new_<t> before the block replays, but ONLY when <t> is
|
|
409
|
+
// also present. both present means the previous run died before its DROP, so
|
|
410
|
+
// __new_<t> is a stale partial copy and the resurrected INSERT..SELECT would
|
|
411
|
+
// fail on rows already in it ("UNIQUE constraint failed: __new_<t>.id").
|
|
412
|
+
// <t> ABSENT is the opposite case and must not be touched: there the DROP did
|
|
413
|
+
// land, __new_<t> holds the only copy of the rows, and the replay recreates an
|
|
414
|
+
// empty <t>, copies nothing out of it, drops it and renames __new_<t> over it.
|
|
415
|
+
for (const table of rebuiltTables) {
|
|
416
|
+
if (!tables.has(table) || !tables.has('__new_' + table)) continue
|
|
417
|
+
await tx.exec('DROP TABLE IF EXISTS ' + quoteIdentifier('__new_' + table))
|
|
418
|
+
}
|
|
419
|
+
for (const baseId of resurrect) {
|
|
420
|
+
for (const id of [...applied]) {
|
|
421
|
+
if (id !== baseId && !id.startsWith(baseId + ':')) continue
|
|
422
|
+
await tx.exec(
|
|
423
|
+
'DELETE FROM ' + quoteIdentifier(migrationTable) + ' WHERE id = ?',
|
|
424
|
+
[id],
|
|
425
|
+
)
|
|
426
|
+
applied.delete(id)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function applyNativeSchema(tx, instance) {
|
|
432
|
+
await tx.exec(
|
|
433
|
+
'CREATE TABLE IF NOT EXISTS ' + quoteIdentifier(migrationTable) +
|
|
434
|
+
' (id TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)',
|
|
435
|
+
)
|
|
436
|
+
const appliedRows = await tx.query('SELECT id FROM ' + quoteIdentifier(migrationTable))
|
|
437
|
+
const applied = new Set(appliedRows.map((row) => row.id))
|
|
438
|
+
await reconcilePhantomLedger(tx, applied)
|
|
439
|
+
// register BEFORE the statements, not only after them. a cdc trigger whose
|
|
440
|
+
// _orez_cdc_tables row is missing is invisible to beginSchemaChange, which
|
|
441
|
+
// skips unregistered tables, so it never suspends capture and the next
|
|
442
|
+
// DROP/RENAME COLUMN on that table dies on the surviving trigger. ensureTable
|
|
443
|
+
// reads that orphan as changed (its registration lookup returns nothing),
|
|
444
|
+
// drops the stale triggers and rewrites the row, so registering first is what
|
|
445
|
+
// lets the DDL below suspend capture at all. the pass after the statements
|
|
446
|
+
// still runs and still owns publication; this one only heals. on a fresh
|
|
447
|
+
// namespace every table is absent, ensureTable returns false for each, and
|
|
448
|
+
// this is a no-op beyond creating the cdc bookkeeping tables early.
|
|
449
|
+
await tx.registerTables(publicTables())
|
|
450
|
+
const appliedIds = [...applied]
|
|
451
|
+
const appliedStatementIds = new Set()
|
|
452
|
+
const supersededStatementIds = new Set()
|
|
453
|
+
for (const [index, statement] of nativeSqlStatements.entries()) {
|
|
454
|
+
const item = typeof statement === 'string'
|
|
455
|
+
? { id: 'statement-' + index, sql: statement }
|
|
456
|
+
: statement
|
|
457
|
+
if (!item || typeof item.sql !== 'string' || !item.sql.trim()) continue
|
|
458
|
+
const baseId = typeof item.id === 'string' && item.id ? item.id : 'statement-' + index
|
|
459
|
+
if (
|
|
460
|
+
applied.has(baseId) ||
|
|
461
|
+
appliedIds.some((appliedId) => appliedId.startsWith(baseId + ':'))
|
|
462
|
+
) {
|
|
463
|
+
appliedStatementIds.add(baseId)
|
|
464
|
+
}
|
|
465
|
+
for (const id of Array.isArray(item.supersedes) ? item.supersedes : []) {
|
|
466
|
+
supersededStatementIds.add(id)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// CREATE TABLE IF NOT EXISTS is a SILENT NO-OP against a table that already
|
|
470
|
+
// exists in an older shape, so every column that only ever appears inside a
|
|
471
|
+
// CREATE definition is unreachable on a namespace whose table predates it,
|
|
472
|
+
// and the first index over such a column fails the whole run forever (prod
|
|
473
|
+
// 2026-07-26: 217 namespaces, ~2k billed rows per retry, for two days).
|
|
474
|
+
//
|
|
475
|
+
// this belongs here and not in the phantom-ledger pass, which is where it was
|
|
476
|
+
// tried first and did nothing: that pass only considers LEDGERED statements,
|
|
477
|
+
// and the ledger entry for a failing run's CREATE is deleted again by the
|
|
478
|
+
// failure compensation below, so at the start of every retry the CREATE is
|
|
479
|
+
// unledgered and the pass skipped it. converging here is independent of
|
|
480
|
+
// ledger state, which is the only thing that made it reachable at all.
|
|
481
|
+
for (const [index, statement] of nativeSqlStatements.entries()) {
|
|
482
|
+
const item = typeof statement === 'string' ? null : statement
|
|
483
|
+
if (!item || !Array.isArray(item.declaredColumns)) continue
|
|
484
|
+
if (typeof item.sql !== 'string') continue
|
|
485
|
+
const created = /^CREATE TABLE\\s+(?:IF NOT EXISTS\\s+)?[\`"]?(\\w+)/i.exec(item.sql.trim())
|
|
486
|
+
if (!created) continue
|
|
487
|
+
const baseId = typeof item.id === 'string' && item.id ? item.id : 'statement-' + index
|
|
488
|
+
if (supersededStatementIds.has(baseId)) continue
|
|
489
|
+
// PRAGMA, not the bulk sqlite_master scan: it resolves the table the same
|
|
490
|
+
// way the statements do, and no rows means the table is genuinely absent,
|
|
491
|
+
// so the CREATE below builds it correctly and there is nothing to converge.
|
|
492
|
+
const liveRows = await tx.query(
|
|
493
|
+
'PRAGMA table_info(' + quoteIdentifier(created[1]) + ')',
|
|
494
|
+
)
|
|
495
|
+
if (liveRows.length === 0) continue
|
|
496
|
+
const live = new Set(liveRows.map((column) => String((column && column.name) ?? '')))
|
|
497
|
+
for (const column of item.declaredColumns) {
|
|
498
|
+
if (!column || typeof column.name !== 'string') continue
|
|
499
|
+
if (live.has(column.name)) continue
|
|
500
|
+
let definition = String(column.definition || '').trim()
|
|
501
|
+
if (!definition) continue
|
|
502
|
+
// sqlite refuses ADD COLUMN for these shapes; leave them to
|
|
503
|
+
// assertExpectedSchema, which reports the namespace loudly.
|
|
504
|
+
if (/\\bPRIMARY KEY\\b|\\bUNIQUE\\b/i.test(definition)) continue
|
|
505
|
+
if (/\\bNOT NULL\\b/i.test(definition) && /\\bREFERENCES\\b/i.test(definition)) continue
|
|
506
|
+
// a NOT NULL column with no default is skipped rather than given a
|
|
507
|
+
// synthesised one. sqlite would accept ADD COLUMN ... NOT NULL DEFAULT '',
|
|
508
|
+
// and every existing row would silently take that placeholder — inventing
|
|
509
|
+
// data the migration author never wrote, on a table that already holds
|
|
510
|
+
// real rows. the honest outcome is the loud one: assertExpectedSchema
|
|
511
|
+
// names the column and the namespace reports instead of self-repairing.
|
|
512
|
+
// no column that wedged production is of this shape.
|
|
513
|
+
if (/\\bNOT NULL\\b/i.test(definition) && !/\\bDEFAULT\\b/i.test(definition)) continue
|
|
514
|
+
console.warn(
|
|
515
|
+
'[orez-migrations] converging ' + created[1] + '.' + column.name +
|
|
516
|
+
' declared by ' + baseId,
|
|
517
|
+
)
|
|
518
|
+
await tx.exec(
|
|
519
|
+
'ALTER TABLE ' + quoteIdentifier(created[1]) + ' ADD COLUMN ' + definition,
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// the ledger table is treated as replication bookkeeping by the DO backend,
|
|
524
|
+
// so its writes are NOT covered by the transaction rollback: an aborted run
|
|
525
|
+
// used to leave every already-recorded statement marked applied while its
|
|
526
|
+
// data and DDL effects were undone, and later runs then skipped work that
|
|
527
|
+
// never happened. compensate explicitly: on failure, delete the rows this
|
|
528
|
+
// run inserted before rethrowing.
|
|
529
|
+
const insertedThisRun = []
|
|
530
|
+
try {
|
|
531
|
+
for (const [index, statement] of nativeSqlStatements.entries()) {
|
|
532
|
+
const item = typeof statement === 'string'
|
|
533
|
+
? { id: 'statement-' + index, sql: statement }
|
|
534
|
+
: statement
|
|
535
|
+
if (!item || typeof item.sql !== 'string' || !item.sql.trim()) continue
|
|
536
|
+
// a statement that is only sql comments (a supersession-anchor
|
|
537
|
+
// migration) has nothing to execute.
|
|
538
|
+
if (!item.sql.split('\\n').some((line) => {
|
|
539
|
+
const trimmed = line.trim()
|
|
540
|
+
return trimmed.length > 0 && !trimmed.startsWith('--')
|
|
541
|
+
})) continue
|
|
542
|
+
const baseId = typeof item.id === 'string' && item.id ? item.id : 'statement-' + index
|
|
543
|
+
if (supersededStatementIds.has(baseId)) continue
|
|
544
|
+
const id = baseId + ':' + hashMigrationSql(item.sql)
|
|
545
|
+
// migration statement ids are immutable execution identities. the SQL hash
|
|
546
|
+
// records which source version ran, but editing an old migration must never
|
|
547
|
+
// make destructive SQL run again under the same id.
|
|
548
|
+
if (appliedStatementIds.has(baseId)) continue
|
|
549
|
+
if (!(await shouldSkipStatement(tx, item))) {
|
|
550
|
+
try {
|
|
551
|
+
await tx.exec(item.sql, Array.isArray(item.params) ? item.params : [])
|
|
552
|
+
} catch (error) {
|
|
553
|
+
// a bare sqlite message ("no such table: x") names neither the failing
|
|
554
|
+
// statement nor which of its siblings the ledger already recorded, and
|
|
555
|
+
// that ledger state is the only thing that explains a half-applied
|
|
556
|
+
// table-recreate block. the live column list of the target table is
|
|
557
|
+
// the other half of that story for DDL chains.
|
|
558
|
+
const file = baseId.split(':')[0]
|
|
559
|
+
const siblings = [...applied].filter((appliedId) => appliedId.startsWith(file))
|
|
560
|
+
let tableInfo = ''
|
|
561
|
+
// CREATE INDEX belongs here as much as the DML shapes: "no such
|
|
562
|
+
// column" on an index names a column the target table is missing,
|
|
563
|
+
// and the table's real column list is the whole diagnosis. it also
|
|
564
|
+
// has to come BEFORE the ledger list, which runs to hundreds of ids
|
|
565
|
+
// and truncated every trailing byte of this off the stored error.
|
|
566
|
+
const target =
|
|
567
|
+
/(?:UPDATE|ALTER TABLE|DELETE FROM|INSERT INTO)\\s+[\`"]?(\\w+)/i.exec(item.sql) ||
|
|
568
|
+
/^CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:IF NOT EXISTS\\s+)?[\`"]?\\w+[\`"]?\\s+ON\\s+[\`"]?(\\w+)/i.exec(item.sql)
|
|
569
|
+
if (target) {
|
|
570
|
+
try {
|
|
571
|
+
const columns = await tx.query('PRAGMA table_info(' + quoteIdentifier(target[1]) + ')')
|
|
572
|
+
tableInfo = ' | table_info(' + target[1] + '): ' +
|
|
573
|
+
(columns.map((column) => column.name + ':' + column.type).join(', ') ||
|
|
574
|
+
'(no columns)')
|
|
575
|
+
} catch (infoError) {
|
|
576
|
+
tableInfo = ' | table_info(' + target[1] + ') failed: ' +
|
|
577
|
+
((infoError && infoError.message) || String(infoError))
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
throw new Error(
|
|
581
|
+
'migration statement ' + baseId + ' failed on instance ' + instance + ': ' +
|
|
582
|
+
(error && error.message ? error.message : String(error)) +
|
|
583
|
+
' | sql: ' + item.sql.replace(/\\s+/g, ' ').slice(0, 160) +
|
|
584
|
+
tableInfo +
|
|
585
|
+
' | ledger for ' + file + ': ' + (siblings.join(', ') || '(none)'),
|
|
586
|
+
{ cause: error },
|
|
587
|
+
)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
if (!applied.has(id)) {
|
|
591
|
+
await tx.exec(
|
|
592
|
+
'INSERT INTO ' + quoteIdentifier(migrationTable) + ' (id, applied_at) VALUES (?, ?)',
|
|
593
|
+
[id, Date.now()],
|
|
594
|
+
)
|
|
595
|
+
applied.add(id)
|
|
596
|
+
appliedStatementIds.add(baseId)
|
|
597
|
+
insertedThisRun.push(id)
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
} catch (error) {
|
|
601
|
+
for (const insertedId of insertedThisRun) {
|
|
602
|
+
try {
|
|
603
|
+
await tx.exec(
|
|
604
|
+
'DELETE FROM ' + quoteIdentifier(migrationTable) + ' WHERE id = ?',
|
|
605
|
+
[insertedId],
|
|
606
|
+
)
|
|
607
|
+
} catch {}
|
|
608
|
+
}
|
|
609
|
+
throw error
|
|
610
|
+
}
|
|
611
|
+
await assertExpectedSchema(tx)
|
|
612
|
+
await tx.exec(
|
|
613
|
+
'CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)',
|
|
614
|
+
)
|
|
615
|
+
try {
|
|
616
|
+
for (const statement of schemaMetadataStatements()) {
|
|
617
|
+
await tx.exec(statement.sql, statement.params)
|
|
618
|
+
}
|
|
619
|
+
await tx.registerTables(publicTables())
|
|
620
|
+
} catch (error) {
|
|
621
|
+
// publication runs against every registered table, so a table this schema
|
|
622
|
+
// no longer has still gets queried here. without the label the error is
|
|
623
|
+
// indistinguishable from a migration statement failing.
|
|
624
|
+
throw new Error(
|
|
625
|
+
'schema publication failed on instance ' + instance + ': ' +
|
|
626
|
+
(error && error.message ? error.message : String(error)),
|
|
627
|
+
{ cause: error },
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// what the namespace actually looks like, for a failure message. the single
|
|
633
|
+
// most expensive missing fact when a namespace is wedged is whether the table
|
|
634
|
+
// the error names is genuinely absent or merely unreachable — a whole day went
|
|
635
|
+
// into guessing that on 2026-07-23 because nothing ever said.
|
|
636
|
+
//
|
|
637
|
+
// runs on the error path only, in its own session, and must never throw: a
|
|
638
|
+
// namespace too broken to answer is exactly the case it exists to describe.
|
|
639
|
+
async function liveSchemaSummary(client) {
|
|
640
|
+
try {
|
|
641
|
+
let summary = ''
|
|
642
|
+
await client.transaction(() => {
|
|
643
|
+
throw new Error('native schema migration does not use queryAst')
|
|
644
|
+
}, async (tx) => {
|
|
645
|
+
const rows = await tx.query(
|
|
646
|
+
"SELECT name FROM sqlite_master WHERE type = 'table'" +
|
|
647
|
+
" AND name NOT LIKE '^_cf^_%' ESCAPE '^'" +
|
|
648
|
+
" AND name NOT LIKE 'sqlite^_%' ESCAPE '^' ORDER BY name",
|
|
649
|
+
)
|
|
650
|
+
const present = new Set(rows.map((row) => row.name))
|
|
651
|
+
const missing = expectedTables
|
|
652
|
+
.map((table) => table.name)
|
|
653
|
+
.filter((name) => !present.has(name))
|
|
654
|
+
summary =
|
|
655
|
+
' | live tables: ' + present.size +
|
|
656
|
+
(missing.length ? ', MISSING expected: ' + missing.join(', ') : ', none missing')
|
|
657
|
+
})
|
|
658
|
+
return summary
|
|
659
|
+
} catch (error) {
|
|
660
|
+
return ' | live schema unreadable: ' + (error && error.message ? error.message : String(error))
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// \`client\` lets a caller that is ALREADY inside the durable object hand in an
|
|
665
|
+
// in-process client (ZeroDO#applicationSqlLocalClient) instead of one that
|
|
666
|
+
// round-trips per statement. it is a parameter rather than another global
|
|
667
|
+
// because durable object instances share an isolate, so a global bound to one
|
|
668
|
+
// instance would be read by another.
|
|
669
|
+
export async function ${runCloudflareMigrations}({
|
|
670
|
+
schemaOnly = false,
|
|
671
|
+
registrationOnly = false,
|
|
672
|
+
instance = 'singleton',
|
|
673
|
+
client: providedClient,
|
|
674
|
+
} = {}) {
|
|
675
|
+
if (schemaOnly && registrationOnly) {
|
|
676
|
+
throw new Error('schemaOnly and registrationOnly are mutually exclusive')
|
|
677
|
+
}
|
|
678
|
+
let client = providedClient
|
|
679
|
+
if (!client) {
|
|
680
|
+
const createClient = globalThis.${applicationSqlGlobal}
|
|
681
|
+
if (typeof createClient !== 'function') {
|
|
682
|
+
throw new Error('Cloudflare application SQLite client is not initialized')
|
|
683
|
+
}
|
|
684
|
+
client = createClient(instance)
|
|
685
|
+
}
|
|
686
|
+
if (registrationOnly) {
|
|
687
|
+
await client.registerTables(resolvedPublicTables)
|
|
688
|
+
return { tables: resolvedPublicTables.map((table) => table.publicTable) }
|
|
689
|
+
}
|
|
690
|
+
// the statement loop labels its own failures; acquiring the session and
|
|
691
|
+
// committing sit OUTSIDE every try/catch in applyNativeSchema, so a Durable
|
|
692
|
+
// Object SQL error raised there arrives bare. the schema barrier hands that
|
|
693
|
+
// string straight back as a 503 body, which is the entire diagnosis budget a
|
|
694
|
+
// wedged namespace gets — prod has answered \`no such table: main.user:
|
|
695
|
+
// SQLITE_ERROR\` since 2026-07-23 and it names neither the phase that raised
|
|
696
|
+
// it nor whether that table is really gone. the phantom-ledger reconcile
|
|
697
|
+
// heals a dropped table correctly (verified against a real durable object),
|
|
698
|
+
// so what is left is only reachable through a message that says more.
|
|
699
|
+
let phase = 'session-acquire'
|
|
700
|
+
try {
|
|
701
|
+
await client.transaction(() => {
|
|
702
|
+
throw new Error('native schema migration does not use queryAst')
|
|
703
|
+
}, async (tx) => {
|
|
704
|
+
phase = 'reconcile'
|
|
705
|
+
await applyNativeSchema(tx, instance)
|
|
706
|
+
phase = 'commit'
|
|
707
|
+
})
|
|
708
|
+
} catch (error) {
|
|
709
|
+
throw new Error(
|
|
710
|
+
'schema migration failed on instance ' + instance + ' during ' + phase + ': ' +
|
|
711
|
+
(error && error.message ? error.message : String(error)) +
|
|
712
|
+
(await liveSchemaSummary(client)),
|
|
713
|
+
{ cause: error },
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
tables: resolvedPublicTables.map((table) => table.publicTable),
|
|
718
|
+
...(schemaOnly ? { schemaOnly: true } : null),
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export { ${runCloudflareMigrations} as runCloudflareMigrations }
|
|
723
|
+
export const orezAppSchema = {
|
|
724
|
+
version: SCHEMA_VERSION,
|
|
725
|
+
schema,
|
|
726
|
+
publicTables: resolvedPublicTables,
|
|
727
|
+
migrate: ${runCloudflareMigrations},
|
|
728
|
+
}
|
|
729
|
+
`;
|
|
730
|
+
}
|
|
731
|
+
const exhaustive = parts;
|
|
732
|
+
throw new TypeError(`unsupported Cloudflare migration mode: ${String(exhaustive)}`);
|
|
733
|
+
}
|
|
734
|
+
//# sourceMappingURL=migration.js.map
|