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,1999 @@
|
|
|
1
|
+
// @ts-nocheck — cloudflare:workers types not available in orez
|
|
2
|
+
import { DurableObject, RpcTarget } from 'cloudflare:workers';
|
|
3
|
+
import { executeTransactionQueryPlan, } from 'orez-sync-cf-host/transaction-query';
|
|
4
|
+
import { classifySql, isSqlMutation, isSqlRowMutation, RollingRowWriteBudget, stripPublicPrefix, trackSqlCursorRowsWritten, trackedChangeRow, WriteBudgetExceededError, } from '../do-sql-tracking.js';
|
|
5
|
+
import { TransactionalCdc, schemaChangeTargets, } from './cdc.js';
|
|
6
|
+
import { appendPendingChange, deletePendingChanges, ensurePendingChangesTable, rollbackPendingChanges, } from './row-undo.js';
|
|
7
|
+
import { beginTxJournal, commitTxJournal, recoverTxJournal, rollbackTxJournal, snapshotSideEffectWriteTables, snapshotTxSchema, upgradeToTableSnapshot, } from './tx-journal.js';
|
|
8
|
+
import { DurableWatermarkState } from './watermark.js';
|
|
9
|
+
export { createApplicationSqlClient } from './application-sql.js';
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const SQL_ERROR_SNIPPET_RADIUS = 1600;
|
|
12
|
+
const SQL_ERROR_FALLBACK_LIMIT = 4000;
|
|
13
|
+
const DEFAULT_WRITE_BUDGET_ROWS = 150_000;
|
|
14
|
+
const DEFAULT_WRITE_BUDGET_WINDOW_MS = 5 * 60 * 1000;
|
|
15
|
+
const WRITE_BUDGET_TRIPPED_KEY = '_orez_write_budget_tripped_at';
|
|
16
|
+
const SCHEMA_PROVISIONING_WAIT_MS = 20_000;
|
|
17
|
+
const SCHEMA_PROVISIONING_MAX_DELAY_MS = 500;
|
|
18
|
+
const APPLICATION_SQL_TURN_WAIT_MS = 30_000;
|
|
19
|
+
const DEFAULT_SNAPSHOT_PAGE_ROWS = 2_000;
|
|
20
|
+
const MAX_SNAPSHOT_PAGE_ROWS = 10_000;
|
|
21
|
+
const TRANSACTION_CONTROL_SQL = /^\s*(?:BEGIN|COMMIT|ROLLBACK|SAVEPOINT|RELEASE)(?=\s|;|$)/i;
|
|
22
|
+
function positiveEnvInteger(value, fallback) {
|
|
23
|
+
const parsed = Number(value);
|
|
24
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
25
|
+
}
|
|
26
|
+
function quoteIdent(name) {
|
|
27
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
28
|
+
}
|
|
29
|
+
function sqliteTypeForSchemaColumn(type) {
|
|
30
|
+
const types = {
|
|
31
|
+
string: 'TEXT',
|
|
32
|
+
number: 'REAL',
|
|
33
|
+
boolean: 'INTEGER',
|
|
34
|
+
json: 'TEXT',
|
|
35
|
+
bigint: 'TEXT',
|
|
36
|
+
};
|
|
37
|
+
return types[type] || 'TEXT';
|
|
38
|
+
}
|
|
39
|
+
function sqliteErrorOffset(message) {
|
|
40
|
+
const marker = 'offset ';
|
|
41
|
+
const start = message.indexOf(marker);
|
|
42
|
+
if (start < 0)
|
|
43
|
+
return null;
|
|
44
|
+
let index = start + marker.length;
|
|
45
|
+
let digits = '';
|
|
46
|
+
while (index < message.length) {
|
|
47
|
+
const code = message.charCodeAt(index);
|
|
48
|
+
if (code < 48 || code > 57)
|
|
49
|
+
break;
|
|
50
|
+
digits += message[index];
|
|
51
|
+
index++;
|
|
52
|
+
}
|
|
53
|
+
if (!digits)
|
|
54
|
+
return null;
|
|
55
|
+
const offset = Number(digits);
|
|
56
|
+
return Number.isFinite(offset) ? offset : null;
|
|
57
|
+
}
|
|
58
|
+
function sqlErrorSnippet(sql, message) {
|
|
59
|
+
const offset = sqliteErrorOffset(message);
|
|
60
|
+
if (offset !== null) {
|
|
61
|
+
const start = Math.max(0, offset - SQL_ERROR_SNIPPET_RADIUS);
|
|
62
|
+
const end = Math.min(sql.length, offset + SQL_ERROR_SNIPPET_RADIUS);
|
|
63
|
+
return `${start > 0 ? '...' : ''}${sql.slice(start, end)}${end < sql.length ? '...' : ''}`;
|
|
64
|
+
}
|
|
65
|
+
if (sql.length <= SQL_ERROR_FALLBACK_LIMIT)
|
|
66
|
+
return sql;
|
|
67
|
+
return `${sql.slice(0, SQL_ERROR_FALLBACK_LIMIT)}...`;
|
|
68
|
+
}
|
|
69
|
+
function assertApplicationTransactionSQL(sql) {
|
|
70
|
+
if (TRANSACTION_CONTROL_SQL.test(sql)) {
|
|
71
|
+
throw new TypeError('transaction SQL is owned by ZeroDO');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function applicationSqlTrack(metadata) {
|
|
75
|
+
if (!metadata)
|
|
76
|
+
return undefined;
|
|
77
|
+
const operations = {
|
|
78
|
+
insert: 'INSERT',
|
|
79
|
+
update: 'UPDATE',
|
|
80
|
+
delete: 'DELETE',
|
|
81
|
+
upsert: 'UPSERT',
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
tableName: metadata.publicTable,
|
|
85
|
+
physicalTableName: metadata.table,
|
|
86
|
+
operation: operations[metadata.kind],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const APPLICATION_SQL_ACQUIRE = Symbol('applicationSqlAcquire');
|
|
90
|
+
const APPLICATION_SQL_QUERY = Symbol('applicationSqlQuery');
|
|
91
|
+
const APPLICATION_SQL_EXEC = Symbol('applicationSqlExec');
|
|
92
|
+
const APPLICATION_SQL_QUERY_PLAN = Symbol('applicationSqlQueryPlan');
|
|
93
|
+
const APPLICATION_SQL_REGISTER_TABLES = Symbol('applicationSqlRegisterTables');
|
|
94
|
+
const APPLICATION_SQL_COMMIT = Symbol('applicationSqlCommit');
|
|
95
|
+
const APPLICATION_SQL_ROLLBACK = Symbol('applicationSqlRollback');
|
|
96
|
+
const APPLICATION_SQL_DISPOSE = Symbol('applicationSqlDispose');
|
|
97
|
+
class ApplicationSqlSessionTarget extends RpcTarget {
|
|
98
|
+
owner;
|
|
99
|
+
sessionID;
|
|
100
|
+
state = 'created';
|
|
101
|
+
changed = false;
|
|
102
|
+
mutated = false;
|
|
103
|
+
constructor(owner, sessionID) {
|
|
104
|
+
super();
|
|
105
|
+
this.owner = owner;
|
|
106
|
+
this.sessionID = sessionID;
|
|
107
|
+
}
|
|
108
|
+
async begin() {
|
|
109
|
+
return this.owner[APPLICATION_SQL_ACQUIRE](this);
|
|
110
|
+
}
|
|
111
|
+
query(sql, params = []) {
|
|
112
|
+
return this.owner[APPLICATION_SQL_QUERY](this, sql, params);
|
|
113
|
+
}
|
|
114
|
+
exec(sql, params = [], metadata) {
|
|
115
|
+
return this.owner[APPLICATION_SQL_EXEC](this, sql, params, metadata);
|
|
116
|
+
}
|
|
117
|
+
queryPlan(plan, queryName, queryBudget) {
|
|
118
|
+
return this.owner[APPLICATION_SQL_QUERY_PLAN](this, plan, queryName, queryBudget);
|
|
119
|
+
}
|
|
120
|
+
registerTables(tables) {
|
|
121
|
+
return this.owner[APPLICATION_SQL_REGISTER_TABLES](this, tables);
|
|
122
|
+
}
|
|
123
|
+
commit() {
|
|
124
|
+
return this.owner[APPLICATION_SQL_COMMIT](this);
|
|
125
|
+
}
|
|
126
|
+
rollback() {
|
|
127
|
+
return this.owner[APPLICATION_SQL_ROLLBACK](this);
|
|
128
|
+
}
|
|
129
|
+
[Symbol.dispose]() {
|
|
130
|
+
this.owner[APPLICATION_SQL_DISPOSE](this);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
export class ZeroDO extends DurableObject {
|
|
134
|
+
sql;
|
|
135
|
+
watermarks;
|
|
136
|
+
cdc;
|
|
137
|
+
schemaTables = new Set();
|
|
138
|
+
tableSchemas = new Map();
|
|
139
|
+
writeBudget;
|
|
140
|
+
writeBudgetDisabled;
|
|
141
|
+
writeBudgetAdminToken;
|
|
142
|
+
activeWriteMeasurements = null;
|
|
143
|
+
writeBudgetTripStatement;
|
|
144
|
+
pendingChangesSchemaReady = false;
|
|
145
|
+
activeApplicationSqlSession = null;
|
|
146
|
+
applicationSqlDidCommit(_changed, _mutated) { }
|
|
147
|
+
recordWriteBudgetRows(rows, statement) {
|
|
148
|
+
const wasTripped = this.writeBudget.status().tripped;
|
|
149
|
+
try {
|
|
150
|
+
this.writeBudget.recordBillable(rows);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof WriteBudgetExceededError && !wasTripped) {
|
|
154
|
+
this.writeBudgetTripStatement = statement
|
|
155
|
+
? {
|
|
156
|
+
sql: statement.sql
|
|
157
|
+
.replace(/'(?:''|[^'])*'/g, '?')
|
|
158
|
+
.replace(/\b\d+(?:\.\d+)?\b/g, '?')
|
|
159
|
+
.replace(/\s+/g, ' ')
|
|
160
|
+
.trim()
|
|
161
|
+
.slice(0, 1_000),
|
|
162
|
+
rowsWritten: statement.rowsWritten,
|
|
163
|
+
}
|
|
164
|
+
: undefined;
|
|
165
|
+
const status = this.writeBudget.status();
|
|
166
|
+
console.error(JSON.stringify({
|
|
167
|
+
event: 'orez_do_write_budget_tripped',
|
|
168
|
+
windowRows: status.windowRows,
|
|
169
|
+
billableRows: status.billableRows,
|
|
170
|
+
logicalRows: status.logicalRows,
|
|
171
|
+
budget: status.budget,
|
|
172
|
+
windowMs: status.windowMs,
|
|
173
|
+
trippedAt: status.trippedAt,
|
|
174
|
+
statement: this.writeBudgetTripStatement,
|
|
175
|
+
}));
|
|
176
|
+
this.persistWriteBudgetTrip();
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Make the trip sticky, from outside the transaction that is aborting.
|
|
183
|
+
*
|
|
184
|
+
* The trip fires during cursor consumption, which is almost always inside
|
|
185
|
+
* ctx.storage.transaction(). A put in that scope is rolled back with the
|
|
186
|
+
* write (prod booted un-tripped this way on 2026-07-11), and code after the
|
|
187
|
+
* transaction does not reliably get to run either: measured under workerd,
|
|
188
|
+
* a write budget tripped on the application SQL path resolved neither the
|
|
189
|
+
* transaction's success nor its failure path, so a namespace tripped by
|
|
190
|
+
* soot's real write path persisted nothing and came back OPEN on the next
|
|
191
|
+
* eviction. blockConcurrencyWhile is the seam that survives both: the
|
|
192
|
+
* runtime defers this until the in-flight storage work is done, so it lands
|
|
193
|
+
* after the rollback rather than inside it.
|
|
194
|
+
*
|
|
195
|
+
* The count rides along with the timestamp. The rolling window keeps decaying
|
|
196
|
+
* while the circuit stays sticky and a restored object starts with none of
|
|
197
|
+
* it, so a status read that consults the live meter reports 0/budget and
|
|
198
|
+
* erases how far over the namespace actually went.
|
|
199
|
+
*/
|
|
200
|
+
persistWriteBudgetTrip() {
|
|
201
|
+
const trip = this.writeBudget.trip();
|
|
202
|
+
if (!trip)
|
|
203
|
+
return;
|
|
204
|
+
void this.ctx
|
|
205
|
+
.blockConcurrencyWhile(async () => {
|
|
206
|
+
await this.ctx.storage.put(WRITE_BUDGET_TRIPPED_KEY, {
|
|
207
|
+
...trip,
|
|
208
|
+
statement: this.writeBudgetTripStatement,
|
|
209
|
+
});
|
|
210
|
+
})
|
|
211
|
+
.catch((error) => {
|
|
212
|
+
console.error(JSON.stringify({
|
|
213
|
+
event: 'orez_do_write_budget_persist_failed',
|
|
214
|
+
message: error instanceof Error ? error.message : String(error),
|
|
215
|
+
}));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// The trip itself is persisted by persistWriteBudgetTrip at the moment it
|
|
219
|
+
// fires, which is the one place every path passes through, so this only has
|
|
220
|
+
// to shape the response.
|
|
221
|
+
writeBudgetErrorResponse(error) {
|
|
222
|
+
if (!(error instanceof WriteBudgetExceededError))
|
|
223
|
+
return null;
|
|
224
|
+
return Response.json(error.toJSON(), { status: 429 });
|
|
225
|
+
}
|
|
226
|
+
constructor(ctx, env) {
|
|
227
|
+
super(ctx, env);
|
|
228
|
+
this.sql = ctx.storage.sql;
|
|
229
|
+
this.writeBudgetDisabled = /^(?:1|true)$/i.test(env.OREZ_DO_WRITE_BUDGET_DISABLED ?? '');
|
|
230
|
+
this.writeBudgetAdminToken = env.OREZ_DO_WRITE_BUDGET_ADMIN_TOKEN;
|
|
231
|
+
this.writeBudget = new RollingRowWriteBudget({
|
|
232
|
+
budgetRows: positiveEnvInteger(env.OREZ_DO_WRITE_BUDGET_ROWS, DEFAULT_WRITE_BUDGET_ROWS),
|
|
233
|
+
windowMs: positiveEnvInteger(env.OREZ_DO_WRITE_BUDGET_WINDOW_MS, DEFAULT_WRITE_BUDGET_WINDOW_MS),
|
|
234
|
+
now: () => Date.now(),
|
|
235
|
+
});
|
|
236
|
+
if (this.writeBudgetDisabled) {
|
|
237
|
+
console.error(JSON.stringify({
|
|
238
|
+
event: 'orez_do_write_budget_disabled',
|
|
239
|
+
warning: 'row write circuit breaker explicitly disabled',
|
|
240
|
+
}));
|
|
241
|
+
}
|
|
242
|
+
const rawExec = this.sql.exec.bind(this.sql);
|
|
243
|
+
this.sql.exec = (statement, ...params) => {
|
|
244
|
+
const mutation = isSqlMutation(statement);
|
|
245
|
+
if (mutation && !this.writeBudgetDisabled)
|
|
246
|
+
this.writeBudget.assertOpen();
|
|
247
|
+
const cursor = rawExec(statement, ...params);
|
|
248
|
+
if (!mutation)
|
|
249
|
+
return cursor;
|
|
250
|
+
const measurement = {
|
|
251
|
+
sql: statement,
|
|
252
|
+
rowsWritten: 0,
|
|
253
|
+
};
|
|
254
|
+
if (this.activeWriteMeasurements)
|
|
255
|
+
this.activeWriteMeasurements.push(measurement);
|
|
256
|
+
return trackSqlCursorRowsWritten(cursor, (rows) => {
|
|
257
|
+
measurement.rowsWritten += rows;
|
|
258
|
+
if (!this.writeBudgetDisabled)
|
|
259
|
+
this.recordWriteBudgetRows(rows, measurement);
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
this.cdc = new TransactionalCdc(this.sql);
|
|
263
|
+
this.watermarks = new DurableWatermarkState(this.sql);
|
|
264
|
+
ctx.blockConcurrencyWhile(async () => {
|
|
265
|
+
const recovered = this.rollbackAtomicallyWithoutForeignKeys(() => {
|
|
266
|
+
const transactionIDs = recoverTxJournal(this.sql, 'application', (transactionID) => {
|
|
267
|
+
this.rollbackPendingTrackedChanges(transactionID);
|
|
268
|
+
});
|
|
269
|
+
for (const transactionID of transactionIDs)
|
|
270
|
+
this.deletePendingTrackedChanges(transactionID);
|
|
271
|
+
return transactionIDs;
|
|
272
|
+
});
|
|
273
|
+
if (recovered.length)
|
|
274
|
+
this.invalidateSchemaCaches();
|
|
275
|
+
if (!this.writeBudgetDisabled) {
|
|
276
|
+
const persisted = await ctx.storage.get(WRITE_BUDGET_TRIPPED_KEY);
|
|
277
|
+
if (persisted) {
|
|
278
|
+
this.writeBudget.restoreTrip(persisted);
|
|
279
|
+
if (typeof persisted !== 'number')
|
|
280
|
+
this.writeBudgetTripStatement = persisted.statement;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
async fetch(request) {
|
|
286
|
+
const url = new URL(request.url);
|
|
287
|
+
if (request.method === 'OPTIONS') {
|
|
288
|
+
return new Response(null, {
|
|
289
|
+
headers: {
|
|
290
|
+
'Access-Control-Allow-Origin': '*',
|
|
291
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
292
|
+
'Access-Control-Allow-Headers': '*',
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
if (url.pathname.startsWith('/sync/v') && url.pathname.endsWith('/connect'))
|
|
297
|
+
return this.handleSyncConnect(request, url);
|
|
298
|
+
if (url.pathname === '/_orez/write-budget' && request.method === 'GET')
|
|
299
|
+
return Response.json({
|
|
300
|
+
enabled: !this.writeBudgetDisabled,
|
|
301
|
+
...this.writeBudget.status(),
|
|
302
|
+
trippedStatement: this.writeBudgetTripStatement,
|
|
303
|
+
});
|
|
304
|
+
if (url.pathname === '/_orez/write-budget/trip' && request.method === 'POST')
|
|
305
|
+
return this.handleWriteBudgetTrip(request);
|
|
306
|
+
if (url.pathname === '/_orez/write-budget/reopen' && request.method === 'POST')
|
|
307
|
+
return this.handleWriteBudgetReopen(request);
|
|
308
|
+
if ((url.pathname === '/zero/push' || url.pathname === '/api/zero/push') &&
|
|
309
|
+
request.method === 'POST')
|
|
310
|
+
return this.handleHttpPush(request);
|
|
311
|
+
if (url.pathname === '/exec' && request.method === 'POST')
|
|
312
|
+
return this.handleExec(request);
|
|
313
|
+
if (url.pathname === '/batch' && request.method === 'POST')
|
|
314
|
+
return this.handleBatch(request);
|
|
315
|
+
if (url.pathname === '/snapshot-tx-schema' && request.method === 'POST')
|
|
316
|
+
return this.handleSnapshotTransactionSchema(request);
|
|
317
|
+
if (url.pathname === '/commit-tx' && request.method === 'POST')
|
|
318
|
+
return this.handleCommitTransaction(request);
|
|
319
|
+
if (url.pathname === '/rollback-tx' && request.method === 'POST')
|
|
320
|
+
return this.handleRollbackTransaction(request);
|
|
321
|
+
if (url.pathname === '/recover-txs' && request.method === 'POST')
|
|
322
|
+
return this.handleRecoverTransactions(request);
|
|
323
|
+
if (url.pathname === '/changes' &&
|
|
324
|
+
(request.method === 'GET' || request.method === 'POST'))
|
|
325
|
+
return this.handleChanges(request, url);
|
|
326
|
+
if (url.pathname === '/snapshot' && request.method === 'GET')
|
|
327
|
+
return this.withApplicationSqlTurn(() => this.handleSnapshot(url), request.signal);
|
|
328
|
+
if (url.pathname === '/notify' && request.method === 'POST')
|
|
329
|
+
return Response.json({ ok: true, cookie: this.cookie() });
|
|
330
|
+
return new Response('not found', { status: 404 });
|
|
331
|
+
}
|
|
332
|
+
async handleWriteBudgetReopen(request) {
|
|
333
|
+
if (!this.hasWriteBudgetAdminToken(request))
|
|
334
|
+
return Response.json({ error: 'forbidden' }, { status: 403 });
|
|
335
|
+
await this.ctx.storage.delete(WRITE_BUDGET_TRIPPED_KEY);
|
|
336
|
+
this.writeBudgetTripStatement = undefined;
|
|
337
|
+
const status = this.writeBudget.reopen();
|
|
338
|
+
console.log(JSON.stringify({ event: 'orez_do_write_budget_reopened', reopenedAt: Date.now() }));
|
|
339
|
+
return Response.json({ ok: true, enabled: !this.writeBudgetDisabled, ...status });
|
|
340
|
+
}
|
|
341
|
+
async handleWriteBudgetTrip(request) {
|
|
342
|
+
if (!this.hasWriteBudgetAdminToken(request))
|
|
343
|
+
return Response.json({ error: 'forbidden' }, { status: 403 });
|
|
344
|
+
const status = this.writeBudget.forceTrip();
|
|
345
|
+
this.writeBudgetTripStatement = {
|
|
346
|
+
sql: 'operator force-trip',
|
|
347
|
+
rowsWritten: 0,
|
|
348
|
+
};
|
|
349
|
+
await this.ctx.storage.put(WRITE_BUDGET_TRIPPED_KEY, {
|
|
350
|
+
...this.writeBudget.trip(),
|
|
351
|
+
statement: this.writeBudgetTripStatement,
|
|
352
|
+
});
|
|
353
|
+
console.error(JSON.stringify({
|
|
354
|
+
event: 'orez_do_write_budget_force_tripped',
|
|
355
|
+
trippedAt: Date.now(),
|
|
356
|
+
}));
|
|
357
|
+
return Response.json({
|
|
358
|
+
ok: true,
|
|
359
|
+
enabled: !this.writeBudgetDisabled,
|
|
360
|
+
...status,
|
|
361
|
+
trippedStatement: this.writeBudgetTripStatement,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
hasWriteBudgetAdminToken(request) {
|
|
365
|
+
const supplied = request.headers.get('x-orez-admin-token') ??
|
|
366
|
+
request.headers.get('authorization')?.replace(/^Bearer\s+/i, '');
|
|
367
|
+
return Boolean(this.writeBudgetAdminToken && supplied === this.writeBudgetAdminToken);
|
|
368
|
+
}
|
|
369
|
+
// ── Zero sync protocol ──────────────────────────────────────────────────
|
|
370
|
+
handleSyncConnect(request, url) {
|
|
371
|
+
if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
|
|
372
|
+
return new Response('expected websocket upgrade', { status: 426 });
|
|
373
|
+
}
|
|
374
|
+
const pair = new WebSocketPair();
|
|
375
|
+
const client = pair[0];
|
|
376
|
+
const server = pair[1];
|
|
377
|
+
const clientID = url.searchParams.get('clientID') || 'anon';
|
|
378
|
+
const clientGroupID = url.searchParams.get('clientGroupID') || 'default';
|
|
379
|
+
const userID = url.searchParams.get('userID') || 'anon';
|
|
380
|
+
const wsid = url.searchParams.get('wsid') || crypto.randomUUID();
|
|
381
|
+
const baseCookie = url.searchParams.get('baseCookie');
|
|
382
|
+
this.ctx.acceptWebSocket(server);
|
|
383
|
+
server.serializeAttachment({
|
|
384
|
+
clientID,
|
|
385
|
+
clientGroupID,
|
|
386
|
+
userID,
|
|
387
|
+
cookie: baseCookie ? baseCookie : null,
|
|
388
|
+
initialized: false,
|
|
389
|
+
desiredTableNames: [],
|
|
390
|
+
desiredQueries: [],
|
|
391
|
+
});
|
|
392
|
+
this.sendJSON(server, ['connected', { wsid, timestamp: Date.now() }]);
|
|
393
|
+
const secProtocol = request.headers.get('sec-websocket-protocol');
|
|
394
|
+
if (secProtocol) {
|
|
395
|
+
const initData = decodeInitConnection(secProtocol);
|
|
396
|
+
if (initData) {
|
|
397
|
+
const clientSchema = initData[1]?.clientSchema;
|
|
398
|
+
const patch = (initData[1]?.desiredQueriesPatch || []);
|
|
399
|
+
this.applyDesiredQueries(server, patch, clientSchema);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return new Response(null, {
|
|
403
|
+
status: 101,
|
|
404
|
+
headers: secProtocol ? { 'Sec-WebSocket-Protocol': secProtocol } : undefined,
|
|
405
|
+
webSocket: client,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
async webSocketMessage(socket, messageData) {
|
|
409
|
+
this.watermarks.ensureTables();
|
|
410
|
+
const ws = socket;
|
|
411
|
+
const attachment = this.readSocketAttachment(ws);
|
|
412
|
+
if (!attachment)
|
|
413
|
+
return;
|
|
414
|
+
const message = this.parseMessage(messageData);
|
|
415
|
+
if (!message)
|
|
416
|
+
return;
|
|
417
|
+
const body = message[1] || {};
|
|
418
|
+
switch (message[0]) {
|
|
419
|
+
case 'initConnection':
|
|
420
|
+
case 'changeDesiredQueries':
|
|
421
|
+
this.applyDesiredQueries(ws, (body.desiredQueriesPatch || []), body.clientSchema);
|
|
422
|
+
break;
|
|
423
|
+
case 'push':
|
|
424
|
+
this.handlePush(ws, attachment, message[1]);
|
|
425
|
+
break;
|
|
426
|
+
case 'pull':
|
|
427
|
+
this.handlePull(ws, message[1]);
|
|
428
|
+
break;
|
|
429
|
+
case 'ping':
|
|
430
|
+
this.sendJSON(ws, ['pong', {}]);
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
webSocketClose(_socket, _code, _reason, _wasClean) { }
|
|
435
|
+
applyDesiredQueries(socket, patch, clientSchema) {
|
|
436
|
+
const attachment = this.readSocketAttachment(socket);
|
|
437
|
+
if (!attachment)
|
|
438
|
+
return;
|
|
439
|
+
if (clientSchema)
|
|
440
|
+
this.ensureSchemaTables(clientSchema);
|
|
441
|
+
let nextAttachment = this.applyDesiredQueryPatch(attachment, patch);
|
|
442
|
+
socket.serializeAttachment(nextAttachment);
|
|
443
|
+
if (!nextAttachment.initialized) {
|
|
444
|
+
nextAttachment = this.sendSyncPoke(socket, { ...nextAttachment, initialized: true }, { lastMutationIDChanges: {}, rowsPatch: [] });
|
|
445
|
+
}
|
|
446
|
+
if (patch.length === 0)
|
|
447
|
+
return;
|
|
448
|
+
const rowsPatch = [
|
|
449
|
+
{ op: 'clear' },
|
|
450
|
+
...this.rowsPatchForTables(nextAttachment.desiredTableNames),
|
|
451
|
+
];
|
|
452
|
+
this.sendSyncPoke(socket, nextAttachment, {
|
|
453
|
+
gotQueriesPatch: this.gotQueriesPatch(patch),
|
|
454
|
+
rowsPatch,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
applyDesiredQueryPatch(attachment, patch) {
|
|
458
|
+
const desiredQueries = new Map();
|
|
459
|
+
for (const query of attachment.desiredQueries || [])
|
|
460
|
+
desiredQueries.set(query.hash, query.tableNames);
|
|
461
|
+
for (const op of patch) {
|
|
462
|
+
if (op.op === 'clear') {
|
|
463
|
+
desiredQueries.clear();
|
|
464
|
+
}
|
|
465
|
+
else if (op.op === 'put' && op.hash) {
|
|
466
|
+
desiredQueries.set(op.hash, this.resolveTablesFromPatch([op]));
|
|
467
|
+
}
|
|
468
|
+
else if (op.op === 'del' && op.hash) {
|
|
469
|
+
desiredQueries.delete(op.hash);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const queries = [...desiredQueries.entries()].map(([hash, tableNames]) => ({
|
|
473
|
+
hash,
|
|
474
|
+
tableNames,
|
|
475
|
+
}));
|
|
476
|
+
return {
|
|
477
|
+
...attachment,
|
|
478
|
+
desiredQueries: queries,
|
|
479
|
+
desiredTableNames: [...new Set(queries.flatMap((query) => query.tableNames))],
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
gotQueriesPatch(patch) {
|
|
483
|
+
const got = [];
|
|
484
|
+
for (const op of patch) {
|
|
485
|
+
if (op.op === 'clear')
|
|
486
|
+
got.push({ op: 'clear' });
|
|
487
|
+
else if (op.hash)
|
|
488
|
+
got.push({ op: op.op, hash: op.hash });
|
|
489
|
+
}
|
|
490
|
+
return got;
|
|
491
|
+
}
|
|
492
|
+
rowsPatchForTables(tableNames) {
|
|
493
|
+
const rowsPatch = [];
|
|
494
|
+
for (const tn of tableNames) {
|
|
495
|
+
if (!this.tableExists(tn))
|
|
496
|
+
continue;
|
|
497
|
+
for (const row of this.readAllRows(tn))
|
|
498
|
+
rowsPatch.push({ op: 'put', tableName: tn, value: row });
|
|
499
|
+
}
|
|
500
|
+
return rowsPatch;
|
|
501
|
+
}
|
|
502
|
+
resolveTablesFromPatch(patch) {
|
|
503
|
+
const tables = [];
|
|
504
|
+
for (const op of patch) {
|
|
505
|
+
const tableFromName = this.tableNameFromOperationName(op.name);
|
|
506
|
+
if (tableFromName)
|
|
507
|
+
tables.push(tableFromName);
|
|
508
|
+
if (op.ast)
|
|
509
|
+
this.extractTableFromAST(op.ast, tables);
|
|
510
|
+
}
|
|
511
|
+
return tables;
|
|
512
|
+
}
|
|
513
|
+
extractTableFromAST(ast, tables) {
|
|
514
|
+
if (ast?.table)
|
|
515
|
+
tables.push(ast.table);
|
|
516
|
+
if (ast?.related)
|
|
517
|
+
for (const rel of ast.related) {
|
|
518
|
+
if (rel?.subquery?.table)
|
|
519
|
+
tables.push(rel.subquery.table);
|
|
520
|
+
if (rel?.subquery?.related)
|
|
521
|
+
this.extractTableFromAST(rel.subquery, tables);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
handlePush(socket, attachment, body) {
|
|
525
|
+
const mutations = Array.isArray(body?.mutations) ? body.mutations : [];
|
|
526
|
+
const before = this.watermark();
|
|
527
|
+
const mutationResults = [];
|
|
528
|
+
const lastMutationIDChanges = {};
|
|
529
|
+
for (const m of mutations) {
|
|
530
|
+
const result = this.applyMutation(m);
|
|
531
|
+
mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result });
|
|
532
|
+
lastMutationIDChanges[m.clientID] = m.id;
|
|
533
|
+
}
|
|
534
|
+
this.sendJSON(socket, ['pushResponse', { mutations: mutationResults }]);
|
|
535
|
+
const after = this.watermark();
|
|
536
|
+
const changes = after > before ? this.readChangesSince(before) : [];
|
|
537
|
+
const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c));
|
|
538
|
+
if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
|
|
539
|
+
this.broadcastMutationPoke(attachment, {
|
|
540
|
+
lastMutationIDChanges,
|
|
541
|
+
rowsPatch,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
async handleHttpPush(request) {
|
|
545
|
+
try {
|
|
546
|
+
const body = (await request.json());
|
|
547
|
+
const before = this.watermark();
|
|
548
|
+
const mutations = Array.isArray(body?.mutations) ? body.mutations : [];
|
|
549
|
+
const mutationResults = [];
|
|
550
|
+
const lastMutationIDChanges = {};
|
|
551
|
+
for (const m of mutations) {
|
|
552
|
+
const result = this.applyMutation(m);
|
|
553
|
+
mutationResults.push({ id: { clientID: m.clientID, id: m.id }, result });
|
|
554
|
+
lastMutationIDChanges[m.clientID] = m.id;
|
|
555
|
+
}
|
|
556
|
+
const after = this.watermark();
|
|
557
|
+
const changes = after > before ? this.readChangesSince(before) : [];
|
|
558
|
+
const rowsPatch = changes.map((c) => this.syncRowPatchFromChange(c));
|
|
559
|
+
if (Object.keys(lastMutationIDChanges).length > 0 || rowsPatch.length > 0)
|
|
560
|
+
this.broadcastPoke(body?.clientGroupID || 'default', {
|
|
561
|
+
lastMutationIDChanges,
|
|
562
|
+
rowsPatch,
|
|
563
|
+
});
|
|
564
|
+
return Response.json({ mutations: mutationResults });
|
|
565
|
+
}
|
|
566
|
+
catch (err) {
|
|
567
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
568
|
+
if (budgetResponse)
|
|
569
|
+
return budgetResponse;
|
|
570
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
handlePull(socket, body) {
|
|
574
|
+
this.sendJSON(socket, [
|
|
575
|
+
'pull',
|
|
576
|
+
{
|
|
577
|
+
requestID: body?.requestID || crypto.randomUUID(),
|
|
578
|
+
cookie: this.cookie(),
|
|
579
|
+
lastMutationIDChanges: {},
|
|
580
|
+
patch: [],
|
|
581
|
+
},
|
|
582
|
+
]);
|
|
583
|
+
}
|
|
584
|
+
// ── SQL execution endpoints ─────────────────────────────────────────────
|
|
585
|
+
async handleExec(request) {
|
|
586
|
+
let sql = '';
|
|
587
|
+
const measurements = this.startWriteMeasurement(request);
|
|
588
|
+
try {
|
|
589
|
+
const body = (await request.json());
|
|
590
|
+
sql = body.sql;
|
|
591
|
+
const params = Array.isArray(body.params) ? body.params : [];
|
|
592
|
+
const transactionID = String(body.transactionID || body.track?.transactionID || '');
|
|
593
|
+
// CDC rows are drained into the durable change log before the storage
|
|
594
|
+
// transaction returns. Keep unrelated DDL/read calls on the fast path:
|
|
595
|
+
// wrapping every /exec adds ~2-5ms and materially slows large schemas.
|
|
596
|
+
const needsAtomicCapture = !!body.track ||
|
|
597
|
+
(this.cdc.active && (isSqlRowMutation(sql) || this.cdc.capturesSchemaChange(sql)));
|
|
598
|
+
const result = await this.withSchemaProvisioningWait(() => needsAtomicCapture
|
|
599
|
+
? this.atomically(() => this.executeSQL(sql, params, body.track, transactionID || undefined))
|
|
600
|
+
: this.executeSQL(sql, params, undefined, transactionID || undefined));
|
|
601
|
+
return Response.json(measurements ? { ...result, writeMeasurements: measurements } : result);
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
605
|
+
if (budgetResponse)
|
|
606
|
+
return budgetResponse;
|
|
607
|
+
const suffix = sql ? ` while executing: ${sqlErrorSnippet(sql, err.message)}` : '';
|
|
608
|
+
console.error(`[exec-500] ${err.message} :: SQL=${sql.slice(0, 800)}`);
|
|
609
|
+
return Response.json({ error: `${err.message}${suffix}` }, { status: 500 });
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
if (measurements)
|
|
613
|
+
this.activeWriteMeasurements = null;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
/** Execute multiple statements atomically via ctx.storage.transaction() */
|
|
617
|
+
async handleBatch(request) {
|
|
618
|
+
const measurements = this.startWriteMeasurement(request);
|
|
619
|
+
try {
|
|
620
|
+
const { statements, cdcTables } = (await request.json());
|
|
621
|
+
const allRows = await this.withSchemaProvisioningWait(() => this.atomically(() => {
|
|
622
|
+
const results = [];
|
|
623
|
+
if (Array.isArray(cdcTables))
|
|
624
|
+
this.cdc.syncTables(cdcTables);
|
|
625
|
+
for (const statement of statements) {
|
|
626
|
+
const item = typeof statement === 'string' ? { sql: statement } : statement;
|
|
627
|
+
if (!item?.sql?.trim())
|
|
628
|
+
continue;
|
|
629
|
+
if (item.skipIfColumnExists &&
|
|
630
|
+
this.tableHasColumn(item.skipIfColumnExists.table, item.skipIfColumnExists.column)) {
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (item.skipIfColumnMissing &&
|
|
634
|
+
!this.tableHasColumn(item.skipIfColumnMissing.table, item.skipIfColumnMissing.column)) {
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
try {
|
|
638
|
+
results.push(this.executeSQL(item.sql, Array.isArray(item.params) ? item.params : [], item.track, item.transactionID || item.track?.transactionID));
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
if (err instanceof WriteBudgetExceededError)
|
|
642
|
+
throw err;
|
|
643
|
+
throw new Error(`${err.message} while executing: ${sqlErrorSnippet(item.sql, err.message)}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return results;
|
|
647
|
+
}));
|
|
648
|
+
return Response.json({
|
|
649
|
+
results: allRows,
|
|
650
|
+
capturedChanges: allRows.reduce((total, result) => total + Number(result.capturedChanges ?? 0), 0),
|
|
651
|
+
...(measurements ? { writeMeasurements: measurements } : null),
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
656
|
+
if (budgetResponse)
|
|
657
|
+
return budgetResponse;
|
|
658
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
if (measurements)
|
|
662
|
+
this.activeWriteMeasurements = null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// Fresh project traffic can arrive while the deploy shim is still applying
|
|
666
|
+
// its schema through a separate request to this same DO. Yield on SQLite's
|
|
667
|
+
// undefined-table error so that migration request can commit, then retry the
|
|
668
|
+
// read/batch. The operation that failed did not execute, and a failed batch
|
|
669
|
+
// transaction has rolled back, so replay is safe.
|
|
670
|
+
async withSchemaProvisioningWait(operation) {
|
|
671
|
+
const deadline = Date.now() + SCHEMA_PROVISIONING_WAIT_MS;
|
|
672
|
+
let delayMs = 25;
|
|
673
|
+
for (;;) {
|
|
674
|
+
try {
|
|
675
|
+
return await operation();
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
if (!/no such table:/i.test(String(error?.message ?? error)))
|
|
679
|
+
throw error;
|
|
680
|
+
if (Date.now() >= deadline)
|
|
681
|
+
throw error;
|
|
682
|
+
await scheduler.wait(delayMs);
|
|
683
|
+
delayMs = Math.min(SCHEMA_PROVISIONING_MAX_DELAY_MS, delayMs * 2);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
startWriteMeasurement(request) {
|
|
688
|
+
if (request.headers.get('x-orez-measure-writes') !== '1')
|
|
689
|
+
return null;
|
|
690
|
+
const measurements = [];
|
|
691
|
+
this.activeWriteMeasurements = measurements;
|
|
692
|
+
return measurements;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Atomic commit point for an application-SQL transaction. Promotes the
|
|
696
|
+
* tx's pending tracked changes into _zero_changes (allocating watermarks)
|
|
697
|
+
* and clears its journal (drops snapshots + manifest rows) in ONE storage
|
|
698
|
+
* transaction, so a DO kill can never leave a tx half-committed: either the
|
|
699
|
+
* manifest rows are gone (committed) or recovery rolls the tx back.
|
|
700
|
+
*/
|
|
701
|
+
async handleCommitTransaction(request) {
|
|
702
|
+
const measurements = this.startWriteMeasurement(request);
|
|
703
|
+
try {
|
|
704
|
+
const body = (await request.json());
|
|
705
|
+
const transactionID = String(body.transactionID || '');
|
|
706
|
+
if (!transactionID)
|
|
707
|
+
throw new Error('missing transactionID');
|
|
708
|
+
const count = await this.atomically(() => {
|
|
709
|
+
const committed = this.commitPendingTrackedChanges(transactionID);
|
|
710
|
+
commitTxJournal(this.sql, transactionID);
|
|
711
|
+
return committed;
|
|
712
|
+
});
|
|
713
|
+
return Response.json({
|
|
714
|
+
ok: true,
|
|
715
|
+
count,
|
|
716
|
+
...(measurements ? { writeMeasurements: measurements } : null),
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
721
|
+
if (budgetResponse)
|
|
722
|
+
return budgetResponse;
|
|
723
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
724
|
+
}
|
|
725
|
+
finally {
|
|
726
|
+
if (measurements)
|
|
727
|
+
this.activeWriteMeasurements = null;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
async handleSnapshotTransactionSchema(request) {
|
|
731
|
+
try {
|
|
732
|
+
const body = (await request.json());
|
|
733
|
+
const transactionID = String(body.transactionID || '');
|
|
734
|
+
if (!transactionID)
|
|
735
|
+
throw new Error('missing transactionID');
|
|
736
|
+
const owner = body.owner === undefined ? 'default' : String(body.owner);
|
|
737
|
+
const affectedTables = Array.isArray(body.affectedTables)
|
|
738
|
+
? body.affectedTables.map(String)
|
|
739
|
+
: [];
|
|
740
|
+
await this.atomically(() => snapshotTxSchema(this.sql, transactionID, owner, affectedTables));
|
|
741
|
+
return Response.json({ ok: true });
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
745
|
+
if (budgetResponse)
|
|
746
|
+
return budgetResponse;
|
|
747
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async handleRollbackTransaction(request) {
|
|
751
|
+
const measurements = this.startWriteMeasurement(request);
|
|
752
|
+
try {
|
|
753
|
+
const body = (await request.json());
|
|
754
|
+
const transactionID = String(body.transactionID || '');
|
|
755
|
+
if (!transactionID)
|
|
756
|
+
throw new Error('missing transactionID');
|
|
757
|
+
const count = this.rollbackAtomicallyWithoutForeignKeys(() => {
|
|
758
|
+
this.rollbackPendingTrackedChanges(transactionID);
|
|
759
|
+
rollbackTxJournal(this.sql, transactionID);
|
|
760
|
+
return this.deletePendingTrackedChanges(transactionID);
|
|
761
|
+
});
|
|
762
|
+
this.invalidateSchemaCaches();
|
|
763
|
+
return Response.json({
|
|
764
|
+
ok: true,
|
|
765
|
+
count,
|
|
766
|
+
...(measurements ? { writeMeasurements: measurements } : null),
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
771
|
+
if (budgetResponse)
|
|
772
|
+
return budgetResponse;
|
|
773
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
774
|
+
}
|
|
775
|
+
finally {
|
|
776
|
+
if (measurements)
|
|
777
|
+
this.activeWriteMeasurements = null;
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* roll back orphaned transactions for a dead process generation. callers
|
|
782
|
+
* (e.g. the zero-cache embed at boot, before opening pg sessions) own the
|
|
783
|
+
* liveness guarantee: every journaled tx for `owner` is dead.
|
|
784
|
+
*/
|
|
785
|
+
async handleRecoverTransactions(request) {
|
|
786
|
+
try {
|
|
787
|
+
const body = (await request.json().catch(() => ({})));
|
|
788
|
+
const owner = body.owner === undefined ? undefined : String(body.owner);
|
|
789
|
+
const transactionIDs = this.rollbackAtomicallyWithoutForeignKeys(() => {
|
|
790
|
+
const recovered = recoverTxJournal(this.sql, owner, (txID) => {
|
|
791
|
+
this.rollbackPendingTrackedChanges(txID);
|
|
792
|
+
});
|
|
793
|
+
for (const txID of recovered)
|
|
794
|
+
this.deletePendingTrackedChanges(txID);
|
|
795
|
+
return recovered;
|
|
796
|
+
});
|
|
797
|
+
this.invalidateSchemaCaches();
|
|
798
|
+
return Response.json({ ok: true, transactionIDs });
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
802
|
+
if (budgetResponse)
|
|
803
|
+
return budgetResponse;
|
|
804
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
async handleChanges(request, url) {
|
|
808
|
+
try {
|
|
809
|
+
let watermark = Number(url.searchParams.get('watermark') ?? url.searchParams.get('since') ?? 0);
|
|
810
|
+
let limit = Number(url.searchParams.get('limit') ?? 1000);
|
|
811
|
+
if (request.method === 'POST') {
|
|
812
|
+
const body = (await request.json().catch(() => ({})));
|
|
813
|
+
watermark = Number(body.watermark ?? body.since ?? watermark);
|
|
814
|
+
limit = Number(body.limit ?? limit);
|
|
815
|
+
}
|
|
816
|
+
if (!Number.isFinite(watermark) || watermark < 0)
|
|
817
|
+
watermark = 0;
|
|
818
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
819
|
+
limit = 1000;
|
|
820
|
+
const changeLimit = Math.trunc(Math.min(limit, 10_000));
|
|
821
|
+
const head = this.watermark();
|
|
822
|
+
const first = this.sql
|
|
823
|
+
.exec('SELECT MIN(watermark) AS watermark FROM _zero_changes')
|
|
824
|
+
.one();
|
|
825
|
+
const oldest = first?.watermark == null ? null : Number(first.watermark);
|
|
826
|
+
if (watermark < head && (oldest === null || oldest > watermark + 1)) {
|
|
827
|
+
return Response.json({ error: 'watermarkTooOld', watermark: head, oldestWatermark: oldest }, { status: 410 });
|
|
828
|
+
}
|
|
829
|
+
return Response.json({
|
|
830
|
+
watermark: head,
|
|
831
|
+
changes: this.readChangesSince(watermark, changeLimit),
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
catch (err) {
|
|
835
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
836
|
+
if (budgetResponse)
|
|
837
|
+
return budgetResponse;
|
|
838
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async handleSnapshot(url) {
|
|
842
|
+
try {
|
|
843
|
+
const paged = url &&
|
|
844
|
+
['table', 'cursor', 'limit'].some((parameter) => url.searchParams.has(parameter));
|
|
845
|
+
if (paged) {
|
|
846
|
+
const table = url.searchParams.get('table');
|
|
847
|
+
if (!table)
|
|
848
|
+
return Response.json({ error: 'paged snapshot requires a table parameter' }, { status: 400 });
|
|
849
|
+
const limitValue = url.searchParams.get('limit');
|
|
850
|
+
const limit = limitValue === null ? DEFAULT_SNAPSHOT_PAGE_ROWS : Number(limitValue);
|
|
851
|
+
if (!Number.isSafeInteger(limit) ||
|
|
852
|
+
limit <= 0 ||
|
|
853
|
+
limit > MAX_SNAPSHOT_PAGE_ROWS) {
|
|
854
|
+
return Response.json({
|
|
855
|
+
error: `snapshot limit must be an integer from 1 to ${MAX_SNAPSHOT_PAGE_ROWS}`,
|
|
856
|
+
}, { status: 400 });
|
|
857
|
+
}
|
|
858
|
+
return this.atomicallySync(() => {
|
|
859
|
+
this.ensureSchemaMetadataTable();
|
|
860
|
+
const schemaRow = this.sql
|
|
861
|
+
.exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', table)
|
|
862
|
+
.one();
|
|
863
|
+
if (!schemaRow?.schema_json)
|
|
864
|
+
return Response.json({ error: `snapshot table ${JSON.stringify(table)} is not modeled` }, { status: 400 });
|
|
865
|
+
const schema = JSON.parse(String(schemaRow.schema_json));
|
|
866
|
+
if (!Array.isArray(schema.primaryKey) ||
|
|
867
|
+
!schema.primaryKey.length ||
|
|
868
|
+
schema.primaryKey.some((column) => typeof column !== 'string' || !column)) {
|
|
869
|
+
throw new Error(`snapshot table ${JSON.stringify(table)} has no valid primary key`);
|
|
870
|
+
}
|
|
871
|
+
this.tableSchemas.set(table, schema);
|
|
872
|
+
const physicalTable = schema.physicalName || table;
|
|
873
|
+
const physicalPrimaryKey = schema.primaryKey.map((column) => schema.columns[column]?.serverName || column);
|
|
874
|
+
const cursor = url.searchParams.get('cursor');
|
|
875
|
+
let cursorValues = null;
|
|
876
|
+
if (cursor !== null) {
|
|
877
|
+
try {
|
|
878
|
+
const decoded = JSON.parse(cursor);
|
|
879
|
+
if (!Array.isArray(decoded) ||
|
|
880
|
+
decoded.length !== schema.primaryKey.length ||
|
|
881
|
+
decoded.some((value) => !['string', 'number', 'boolean'].includes(typeof value) ||
|
|
882
|
+
(typeof value === 'number' && !Number.isFinite(value)))) {
|
|
883
|
+
return Response.json({ error: 'snapshot cursor does not match the table primary key' }, { status: 400 });
|
|
884
|
+
}
|
|
885
|
+
cursorValues = decoded;
|
|
886
|
+
}
|
|
887
|
+
catch {
|
|
888
|
+
return Response.json({ error: 'snapshot cursor is invalid' }, { status: 400 });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
const primaryKey = physicalPrimaryKey.map(quoteIdent);
|
|
892
|
+
const keyColumns = primaryKey.length === 1 ? primaryKey[0] : `(${primaryKey.join(', ')})`;
|
|
893
|
+
const keyParams = primaryKey.length === 1 ? '?' : `(${primaryKey.map(() => '?').join(', ')})`;
|
|
894
|
+
const where = cursorValues ? ` WHERE ${keyColumns} > ${keyParams}` : '';
|
|
895
|
+
// one look-ahead row distinguishes an exact final page from a page
|
|
896
|
+
// with more data without issuing an unbounded count or second read.
|
|
897
|
+
const page = this.sql
|
|
898
|
+
.exec(`SELECT * FROM ${quoteIdent(physicalTable)}${where} ORDER BY ${primaryKey.join(', ')} LIMIT ?`, ...(cursorValues ?? []), limit + 1)
|
|
899
|
+
.toArray();
|
|
900
|
+
const hasMore = page.length > limit;
|
|
901
|
+
const rawRows = hasMore ? page.slice(0, limit) : page;
|
|
902
|
+
let nextCursor = null;
|
|
903
|
+
if (hasMore) {
|
|
904
|
+
const last = rawRows[rawRows.length - 1];
|
|
905
|
+
const values = physicalPrimaryKey.map((column) => last[column]);
|
|
906
|
+
if (values.some((value) => !['string', 'number', 'boolean'].includes(typeof value) ||
|
|
907
|
+
(typeof value === 'number' && !Number.isFinite(value)))) {
|
|
908
|
+
throw new Error(`snapshot table ${JSON.stringify(table)} returned an invalid primary key`);
|
|
909
|
+
}
|
|
910
|
+
nextCursor = JSON.stringify(values);
|
|
911
|
+
}
|
|
912
|
+
return Response.json({
|
|
913
|
+
watermark: this.watermark(),
|
|
914
|
+
rows: rawRows.map((row) => this.normalizeRow(table, row)),
|
|
915
|
+
nextCursor,
|
|
916
|
+
});
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
return this.atomicallySync(() => {
|
|
920
|
+
this.ensureSchemaMetadataTable();
|
|
921
|
+
const names = this.sql
|
|
922
|
+
.exec('SELECT name FROM _zero_schema_tables ORDER BY name')
|
|
923
|
+
.toArray()
|
|
924
|
+
.map((row) => String(row.name));
|
|
925
|
+
const tables = {};
|
|
926
|
+
for (const name of names) {
|
|
927
|
+
const schema = this.schemaForTable(name);
|
|
928
|
+
const physicalName = schema?.physicalName || name;
|
|
929
|
+
tables[name] = this.sql
|
|
930
|
+
.exec(`SELECT * FROM ${quoteIdent(physicalName)}`)
|
|
931
|
+
.toArray()
|
|
932
|
+
.map((row) => this.normalizeRow(name, row));
|
|
933
|
+
}
|
|
934
|
+
return Response.json({ watermark: this.watermark(), tables });
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
const budgetResponse = this.writeBudgetErrorResponse(err);
|
|
939
|
+
if (budgetResponse)
|
|
940
|
+
return budgetResponse;
|
|
941
|
+
return Response.json({ error: err.message }, { status: 500 });
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Run work in a storage transaction, re-deriving every in-memory schema cache
|
|
946
|
+
* from SQLite if it aborts.
|
|
947
|
+
*
|
|
948
|
+
* ctx.storage.transaction() rolls the SQLite side back on throw, but the
|
|
949
|
+
* caches are plain fields that keep asserting state SQLite no longer has: a
|
|
950
|
+
* CDC table stays "registered and verified" with no trigger left on disk, so
|
|
951
|
+
* ensureTable short-circuits and every later write to it goes silently
|
|
952
|
+
* uncaptured. The readiness flags are the same class: their CREATE TABLE is
|
|
953
|
+
* rolled back while the flag still says the table exists.
|
|
954
|
+
*/
|
|
955
|
+
async atomically(work) {
|
|
956
|
+
try {
|
|
957
|
+
return await this.ctx.storage.transaction(work);
|
|
958
|
+
}
|
|
959
|
+
catch (error) {
|
|
960
|
+
this.invalidateSchemaCaches();
|
|
961
|
+
throw error;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* execute trusted subclass work in this object's SQLite transaction.
|
|
966
|
+
*
|
|
967
|
+
* the method is protected so the base public fetch surface cannot invoke it.
|
|
968
|
+
* every SQL cursor is consumed before an executor promise is returned.
|
|
969
|
+
*/
|
|
970
|
+
async runApplicationTransaction(compileQuery, work, queryBudget) {
|
|
971
|
+
const execute = (sql, params = [], metadata) => {
|
|
972
|
+
assertApplicationTransactionSQL(sql);
|
|
973
|
+
return this.executeSQL(sql, [...params], applicationSqlTrack(metadata));
|
|
974
|
+
};
|
|
975
|
+
const tx = {
|
|
976
|
+
async exec(sql, params = [], metadata) {
|
|
977
|
+
const result = execute(sql, params, metadata);
|
|
978
|
+
return { changes: result.changes };
|
|
979
|
+
},
|
|
980
|
+
async query(sql, params = []) {
|
|
981
|
+
return execute(sql, params).rows;
|
|
982
|
+
},
|
|
983
|
+
async queryAst(ast, format, queryName) {
|
|
984
|
+
const compiled = await compileQuery(ast, format);
|
|
985
|
+
return executeTransactionQueryPlan(compiled, (sql, params) => execute(sql, params).rows, { queryName, budget: queryBudget });
|
|
986
|
+
},
|
|
987
|
+
};
|
|
988
|
+
return this.withSchemaProvisioningWait(() => this.atomically(async () => {
|
|
989
|
+
return work(tx);
|
|
990
|
+
}));
|
|
991
|
+
}
|
|
992
|
+
assertApplicationSqlSession(session) {
|
|
993
|
+
if (session.state !== 'active' || this.activeApplicationSqlSession !== session) {
|
|
994
|
+
throw new Error('application SQLite session is not active');
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
[APPLICATION_SQL_ACQUIRE](session) {
|
|
998
|
+
if (session.state === 'active' && this.activeApplicationSqlSession === session) {
|
|
999
|
+
return true;
|
|
1000
|
+
}
|
|
1001
|
+
if (session.state !== 'created') {
|
|
1002
|
+
throw new Error('application SQLite session cannot begin again');
|
|
1003
|
+
}
|
|
1004
|
+
if (this.activeApplicationSqlSession)
|
|
1005
|
+
return false;
|
|
1006
|
+
session.state = 'active';
|
|
1007
|
+
this.activeApplicationSqlSession = session;
|
|
1008
|
+
return true;
|
|
1009
|
+
}
|
|
1010
|
+
releaseApplicationSqlTurn(session) {
|
|
1011
|
+
this.assertApplicationSqlSession(session);
|
|
1012
|
+
session.state = 'closed';
|
|
1013
|
+
this.activeApplicationSqlSession = null;
|
|
1014
|
+
}
|
|
1015
|
+
async withApplicationSqlTurn(work, signal) {
|
|
1016
|
+
const session = await this.applicationSqlSession(crypto.randomUUID());
|
|
1017
|
+
const dispose = () => session[Symbol.dispose]();
|
|
1018
|
+
signal?.addEventListener('abort', dispose, { once: true });
|
|
1019
|
+
try {
|
|
1020
|
+
while (!(await session.begin())) {
|
|
1021
|
+
if (signal?.aborted)
|
|
1022
|
+
throw new Error('application SQLite request was canceled');
|
|
1023
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1024
|
+
}
|
|
1025
|
+
return await work();
|
|
1026
|
+
}
|
|
1027
|
+
finally {
|
|
1028
|
+
signal?.removeEventListener('abort', dispose);
|
|
1029
|
+
if (session.state === 'active')
|
|
1030
|
+
this.releaseApplicationSqlTurn(session);
|
|
1031
|
+
else if (session.state !== 'closed')
|
|
1032
|
+
dispose();
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* drive one application SQLite session from INSIDE this durable object.
|
|
1037
|
+
*
|
|
1038
|
+
* identical to the client-side session helper in application-sql.ts — same
|
|
1039
|
+
* ownership acquisition, same per-statement journal, same commit/rollback —
|
|
1040
|
+
* except the begin poll and every statement are local calls instead of RPCs.
|
|
1041
|
+
* the caller must already be running in this object.
|
|
1042
|
+
*/
|
|
1043
|
+
async withLocalApplicationSqlSession(work) {
|
|
1044
|
+
const session = await this.applicationSqlSession(crypto.randomUUID());
|
|
1045
|
+
try {
|
|
1046
|
+
// bounded, unlike the client-side poll. a remote caller that goes away
|
|
1047
|
+
// stops polling and frees the turn; a local caller has no signal to
|
|
1048
|
+
// observe, so an unbounded loop would spin for the life of the object
|
|
1049
|
+
// every time somebody abandoned a request mid-reconcile.
|
|
1050
|
+
const deadline = Date.now() + APPLICATION_SQL_TURN_WAIT_MS;
|
|
1051
|
+
while (!(await session.begin())) {
|
|
1052
|
+
if (Date.now() >= deadline) {
|
|
1053
|
+
throw new Error('timed out acquiring the application SQLite session');
|
|
1054
|
+
}
|
|
1055
|
+
await scheduler.wait(25);
|
|
1056
|
+
}
|
|
1057
|
+
const value = await work(session);
|
|
1058
|
+
await session.commit();
|
|
1059
|
+
return value;
|
|
1060
|
+
}
|
|
1061
|
+
catch (error) {
|
|
1062
|
+
await session.rollback().catch(() => { });
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
finally {
|
|
1066
|
+
if (session.state !== 'closed')
|
|
1067
|
+
session[Symbol.dispose]();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* an ApplicationSqlClient bound to THIS object, for subclass work that would
|
|
1072
|
+
* otherwise call back into itself over RPC.
|
|
1073
|
+
*
|
|
1074
|
+
* a schema migration is the motivating case: driven from a worker it costs one
|
|
1075
|
+
* round trip per statement, so replaying a full migration history into a fresh
|
|
1076
|
+
* namespace ran ~1000 sequential round trips and measured 35.9s wall against
|
|
1077
|
+
* 2.8s of durable-object cpu — 92% of it spent idle between statements. the
|
|
1078
|
+
* statements, their order and their transaction semantics are unchanged; only
|
|
1079
|
+
* the transport is.
|
|
1080
|
+
*/
|
|
1081
|
+
applicationSqlLocalClient(namespace) {
|
|
1082
|
+
const run = (work) => this.withLocalApplicationSqlSession(work);
|
|
1083
|
+
return {
|
|
1084
|
+
namespace,
|
|
1085
|
+
query: (sql, params = []) => run((session) => session.query(sql, params)),
|
|
1086
|
+
exec: (sql, params = [], metadata) => run((session) => session.exec(sql, params, metadata)),
|
|
1087
|
+
registerTables: (tables) => run((session) => session.registerTables(tables)),
|
|
1088
|
+
transaction: (compileQuery, work, queryBudget) => run(async (session) => work({
|
|
1089
|
+
exec: (sql, params = [], metadata) => session.exec(sql, params, metadata),
|
|
1090
|
+
query: (sql, params = []) => session.query(sql, params),
|
|
1091
|
+
registerTables: (tables) => session.registerTables(tables),
|
|
1092
|
+
async queryAst(ast, format, queryName) {
|
|
1093
|
+
return session.queryPlan(await compileQuery(ast, format), queryName, queryBudget);
|
|
1094
|
+
},
|
|
1095
|
+
})),
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
registerApplicationSqlTables(tables) {
|
|
1099
|
+
for (const table of tables) {
|
|
1100
|
+
this.cdc.ensureTable({
|
|
1101
|
+
physicalTableName: table.table,
|
|
1102
|
+
tableName: table.publicTable,
|
|
1103
|
+
...(table.publish === false ? { publish: false } : null),
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* private durable object RPC surface for the application SQLite client. the
|
|
1109
|
+
* disposable target exists before ownership acquisition, so waiting callers
|
|
1110
|
+
* hold no server state and active cancellation rolls back the transaction.
|
|
1111
|
+
* this method is intentionally absent from fetch().
|
|
1112
|
+
*/
|
|
1113
|
+
async applicationSqlSession(sessionID) {
|
|
1114
|
+
if (!sessionID)
|
|
1115
|
+
throw new TypeError('application SQLite session id is required');
|
|
1116
|
+
return new ApplicationSqlSessionTarget(this, sessionID);
|
|
1117
|
+
}
|
|
1118
|
+
prepareApplicationSqlMutation(sessionID, sql) {
|
|
1119
|
+
if (!isSqlMutation(sql))
|
|
1120
|
+
return false;
|
|
1121
|
+
if (!isSqlRowMutation(sql)) {
|
|
1122
|
+
const targets = schemaChangeTargets(sql);
|
|
1123
|
+
if (/^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(sql) &&
|
|
1124
|
+
targets.length > 0 &&
|
|
1125
|
+
targets.every((table) => this.tableExists(table))) {
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
beginTxJournal(this.sql, sessionID, 'application');
|
|
1129
|
+
snapshotTxSchema(this.sql, sessionID, 'application', targets);
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
beginTxJournal(this.sql, sessionID, 'application');
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
async [APPLICATION_SQL_QUERY](session, sql, params = []) {
|
|
1136
|
+
this.assertApplicationSqlSession(session);
|
|
1137
|
+
return this.atomically(() => {
|
|
1138
|
+
if (this.prepareApplicationSqlMutation(session.sessionID, sql)) {
|
|
1139
|
+
session.mutated = true;
|
|
1140
|
+
}
|
|
1141
|
+
return this.executeSQL(sql, [...params], undefined, session.sessionID).rows;
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
async [APPLICATION_SQL_EXEC](session, sql, params = [], metadata) {
|
|
1145
|
+
this.assertApplicationSqlSession(session);
|
|
1146
|
+
return this.atomically(() => {
|
|
1147
|
+
if (this.prepareApplicationSqlMutation(session.sessionID, sql)) {
|
|
1148
|
+
session.mutated = true;
|
|
1149
|
+
}
|
|
1150
|
+
const result = this.executeSQL(sql, [...params], applicationSqlTrack(metadata), session.sessionID);
|
|
1151
|
+
if (result.changes > 0)
|
|
1152
|
+
session.changed = true;
|
|
1153
|
+
return { changes: result.changes };
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
async [APPLICATION_SQL_QUERY_PLAN](session, plan, queryName, queryBudget) {
|
|
1157
|
+
this.assertApplicationSqlSession(session);
|
|
1158
|
+
return this.atomically(() => executeTransactionQueryPlan(plan, (sql, params) => this.executeSQL(sql, params, undefined, session.sessionID).rows, { queryName, budget: queryBudget }));
|
|
1159
|
+
}
|
|
1160
|
+
async [APPLICATION_SQL_REGISTER_TABLES](session, tables) {
|
|
1161
|
+
this.assertApplicationSqlSession(session);
|
|
1162
|
+
await this.atomically(() => this.registerApplicationSqlTables(tables));
|
|
1163
|
+
}
|
|
1164
|
+
async [APPLICATION_SQL_COMMIT](session) {
|
|
1165
|
+
this.assertApplicationSqlSession(session);
|
|
1166
|
+
try {
|
|
1167
|
+
if (session.mutated) {
|
|
1168
|
+
await this.atomically(() => {
|
|
1169
|
+
this.commitPendingTrackedChanges(session.sessionID);
|
|
1170
|
+
commitTxJournal(this.sql, session.sessionID);
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
finally {
|
|
1175
|
+
this.releaseApplicationSqlTurn(session);
|
|
1176
|
+
}
|
|
1177
|
+
this.applicationSqlDidCommit(session.changed, session.mutated);
|
|
1178
|
+
}
|
|
1179
|
+
async [APPLICATION_SQL_ROLLBACK](session) {
|
|
1180
|
+
if (session.state === 'created') {
|
|
1181
|
+
session.state = 'closed';
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
this.assertApplicationSqlSession(session);
|
|
1185
|
+
try {
|
|
1186
|
+
if (session.mutated) {
|
|
1187
|
+
this.rollbackAtomicallyWithoutForeignKeys(() => {
|
|
1188
|
+
this.rollbackPendingTrackedChanges(session.sessionID);
|
|
1189
|
+
rollbackTxJournal(this.sql, session.sessionID);
|
|
1190
|
+
this.deletePendingTrackedChanges(session.sessionID);
|
|
1191
|
+
});
|
|
1192
|
+
this.invalidateSchemaCaches();
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
finally {
|
|
1196
|
+
this.releaseApplicationSqlTurn(session);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
[APPLICATION_SQL_DISPOSE](session) {
|
|
1200
|
+
if (session.state === 'closed')
|
|
1201
|
+
return;
|
|
1202
|
+
if (session.state === 'created') {
|
|
1203
|
+
session.state = 'closed';
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
this.assertApplicationSqlSession(session);
|
|
1207
|
+
try {
|
|
1208
|
+
if (session.mutated) {
|
|
1209
|
+
this.rollbackAtomicallyWithoutForeignKeys(() => {
|
|
1210
|
+
this.rollbackPendingTrackedChanges(session.sessionID);
|
|
1211
|
+
rollbackTxJournal(this.sql, session.sessionID);
|
|
1212
|
+
this.deletePendingTrackedChanges(session.sessionID);
|
|
1213
|
+
});
|
|
1214
|
+
this.invalidateSchemaCaches();
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
finally {
|
|
1218
|
+
this.releaseApplicationSqlTurn(session);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
atomicallySync(work) {
|
|
1222
|
+
try {
|
|
1223
|
+
return this.ctx.storage.transactionSync(work);
|
|
1224
|
+
}
|
|
1225
|
+
catch (error) {
|
|
1226
|
+
this.invalidateSchemaCaches();
|
|
1227
|
+
throw error;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Run journal rollback/recovery with foreign-key enforcement off.
|
|
1232
|
+
*
|
|
1233
|
+
* Rollback restores pre-transaction images verbatim, so the target state is
|
|
1234
|
+
* the pre-tx reality whatever its referential health. Enforcement is also
|
|
1235
|
+
* fatal here: workerd always runs with foreign_keys on, and in a namespace
|
|
1236
|
+
* whose parent table is missing (an interrupted table rebuild) EVERY
|
|
1237
|
+
* statement on a referencing table fails at compile with "no such table:
|
|
1238
|
+
* main.<parent>". That made recovery unable to ever finish, and because the
|
|
1239
|
+
* storage transaction rolled the cleanup back while Cloudflare still billed
|
|
1240
|
+
* the rows written, every wake re-ran the same doomed replay forever (the
|
|
1241
|
+
* 2026-07-25 rows-written runaway). The pragma cannot change inside a
|
|
1242
|
+
* transaction, so it brackets one synchronous transaction; the whole helper
|
|
1243
|
+
* is synchronous, so no other event can execute while enforcement is off.
|
|
1244
|
+
* The cursors must be consumed: workerd executes a statement only as its
|
|
1245
|
+
* cursor is read, so a discarded PRAGMA cursor silently never runs.
|
|
1246
|
+
*/
|
|
1247
|
+
rollbackAtomicallyWithoutForeignKeys(work) {
|
|
1248
|
+
this.sql.exec('PRAGMA foreign_keys = OFF').toArray();
|
|
1249
|
+
try {
|
|
1250
|
+
return this.atomicallySync(work);
|
|
1251
|
+
}
|
|
1252
|
+
finally {
|
|
1253
|
+
this.restoreForeignKeyEnforcement();
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Measured under workerd: only the first foreign_keys change in an event
|
|
1258
|
+
* applies, so the OFF above latches and an inline reset here is silently
|
|
1259
|
+
* ignored — the object would then serve every later write unenforced.
|
|
1260
|
+
* Verify, and when the inline reset did not stick, re-run it from
|
|
1261
|
+
* blockConcurrencyWhile: the runtime defers that until the in-flight
|
|
1262
|
+
* storage work is done, the same seam persistWriteBudgetTrip relies on.
|
|
1263
|
+
*/
|
|
1264
|
+
restoreForeignKeyEnforcement() {
|
|
1265
|
+
this.sql.exec('PRAGMA foreign_keys = ON').toArray();
|
|
1266
|
+
const state = this.sql.exec('PRAGMA foreign_keys').one();
|
|
1267
|
+
if (Number(state?.foreign_keys ?? 0) === 1)
|
|
1268
|
+
return;
|
|
1269
|
+
void this.ctx
|
|
1270
|
+
.blockConcurrencyWhile(async () => {
|
|
1271
|
+
this.sql.exec('PRAGMA foreign_keys = ON').toArray();
|
|
1272
|
+
})
|
|
1273
|
+
.catch((error) => {
|
|
1274
|
+
console.error(JSON.stringify({
|
|
1275
|
+
event: 'orez_do_foreign_keys_restore_failed',
|
|
1276
|
+
message: error instanceof Error ? error.message : String(error),
|
|
1277
|
+
}));
|
|
1278
|
+
});
|
|
1279
|
+
}
|
|
1280
|
+
invalidateSchemaCaches() {
|
|
1281
|
+
this.watermarks.invalidateCache();
|
|
1282
|
+
this.pendingChangesSchemaReady = false;
|
|
1283
|
+
// Reload is intentionally last because corrupt persisted CDC metadata must
|
|
1284
|
+
// throw (fail closed), without preventing the other caches from invalidating.
|
|
1285
|
+
this.cdc.reload();
|
|
1286
|
+
}
|
|
1287
|
+
// sync (storage.transaction-safe) column presence check for the /batch
|
|
1288
|
+
// skipIfColumnExists/skipIfColumnMissing conditions. a missing table reads
|
|
1289
|
+
// as "no columns", which makes ADD COLUMN skips behave like pg's
|
|
1290
|
+
// IF NOT EXISTS on a table the same batch is about to create.
|
|
1291
|
+
tableHasColumn(table, column) {
|
|
1292
|
+
try {
|
|
1293
|
+
const cursor = this.sql.exec('SELECT 1 FROM pragma_table_info(?) WHERE name = ? LIMIT 1', table, column);
|
|
1294
|
+
return this.cursorRows(cursor).length > 0;
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
return false;
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
executeSQL(sql, params = [], track, transactionID) {
|
|
1301
|
+
let capturesTrackedTable = false;
|
|
1302
|
+
if (track?.physicalTableName) {
|
|
1303
|
+
capturesTrackedTable = this.cdc.ensureTable({
|
|
1304
|
+
physicalTableName: track.physicalTableName,
|
|
1305
|
+
tableName: track.tableName,
|
|
1306
|
+
...(track.publish === false ? { publish: false } : null),
|
|
1307
|
+
...(track.rowColumns?.length ? { columns: track.rowColumns } : null),
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
else if (track) {
|
|
1311
|
+
capturesTrackedTable = this.cdc.capturesTable(track.tableName);
|
|
1312
|
+
}
|
|
1313
|
+
// SQLite decides whether an INSERT ... ON CONFLICT write inserted or
|
|
1314
|
+
// updated. Only the installed trigger sees that result. Falling back to a
|
|
1315
|
+
// caller-declared operation would publish a false change shape.
|
|
1316
|
+
if (track?.operation === 'UPSERT' && !capturesTrackedTable) {
|
|
1317
|
+
throw new Error(`upsert requires CDC registration for ${track.tableName}`);
|
|
1318
|
+
}
|
|
1319
|
+
// The transaction owner marked this table row-journaled, expecting the DO to
|
|
1320
|
+
// capture before/after images for it. When it cannot, that marker promises
|
|
1321
|
+
// a rollback nothing can perform, so take the table snapshot the journal
|
|
1322
|
+
// would otherwise have taken. It has to happen before the DML, while the
|
|
1323
|
+
// table still holds its pre-transaction contents.
|
|
1324
|
+
const trackedTransactionID = track ? transactionID || track.transactionID : undefined;
|
|
1325
|
+
let snapshotsOwnStatement = false;
|
|
1326
|
+
if (track && !capturesTrackedTable && trackedTransactionID) {
|
|
1327
|
+
const physicalTableName = track.physicalTableName || stripPublicPrefix(track.tableName);
|
|
1328
|
+
if (this.tableExists(physicalTableName)) {
|
|
1329
|
+
upgradeToTableSnapshot(this.sql, trackedTransactionID, physicalTableName);
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (track && trackedTransactionID) {
|
|
1333
|
+
const physicalTableName = track.physicalTableName || stripPublicPrefix(track.tableName);
|
|
1334
|
+
snapshotsOwnStatement = snapshotSideEffectWriteTables(this.sql, trackedTransactionID, physicalTableName, track.operation);
|
|
1335
|
+
}
|
|
1336
|
+
const suspendedCdc = this.cdc.beginSchemaChange(sql);
|
|
1337
|
+
let cursor;
|
|
1338
|
+
try {
|
|
1339
|
+
cursor = this.sql.exec(sql, ...params);
|
|
1340
|
+
}
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
// Restore capture against the unchanged schema before propagating a DDL
|
|
1343
|
+
// failure. In normal /exec and /batch paths this is also protected by the
|
|
1344
|
+
// surrounding storage transaction.
|
|
1345
|
+
this.cdc.finishSchemaChange(suspendedCdc);
|
|
1346
|
+
throw error;
|
|
1347
|
+
}
|
|
1348
|
+
this.cdc.finishSchemaChange(suspendedCdc);
|
|
1349
|
+
const columns = Array.isArray(cursor.columnNames) ? cursor.columnNames : [];
|
|
1350
|
+
const rows = this.cursorRows(cursor, columns);
|
|
1351
|
+
const { mutation, rowMutation } = classifySql(sql);
|
|
1352
|
+
const changes = rowMutation
|
|
1353
|
+
? Number(this.sql.exec('SELECT changes() AS changes').one()?.changes ?? 0)
|
|
1354
|
+
: 0;
|
|
1355
|
+
if (mutation)
|
|
1356
|
+
this.writeBudget.recordLogical(rows.length);
|
|
1357
|
+
if (mutation && !rowMutation)
|
|
1358
|
+
this.cdc.invalidateSchema();
|
|
1359
|
+
const captured = track || (this.cdc.active && rowMutation) ? this.cdc.drain() : [];
|
|
1360
|
+
for (const change of captured) {
|
|
1361
|
+
this.appendCapturedChange(change, transactionID || track?.transactionID, !snapshotsOwnStatement);
|
|
1362
|
+
}
|
|
1363
|
+
// Backward compatibility for callers that have not yet supplied the
|
|
1364
|
+
// physical SQLite table identity needed to install a trigger. Once a
|
|
1365
|
+
// table is registered, trigger capture is the sole source of truth and
|
|
1366
|
+
// includes arbitrary business-trigger side effects.
|
|
1367
|
+
//
|
|
1368
|
+
// These rows carry no before-image, so they only feed the changefeed. Their
|
|
1369
|
+
// rollback is owned by the table snapshot taken above, and `undoable: false`
|
|
1370
|
+
// keeps the row-undo pass from trying to restore them from a wire image that
|
|
1371
|
+
// cannot round-trip a blob or an int64.
|
|
1372
|
+
if (track && !capturesTrackedTable) {
|
|
1373
|
+
const physicalTableName = track.physicalTableName || stripPublicPrefix(track.tableName);
|
|
1374
|
+
for (const row of rows) {
|
|
1375
|
+
const trackedRow = trackedChangeRow(row, track);
|
|
1376
|
+
const isDelete = track.operation === 'DELETE';
|
|
1377
|
+
this.appendTrackedChange({
|
|
1378
|
+
tableName: track.tableName,
|
|
1379
|
+
op: isDelete ? 'DELETE' : track.operation,
|
|
1380
|
+
rowData: isDelete ? null : trackedRow,
|
|
1381
|
+
oldData: isDelete ? trackedRow : null,
|
|
1382
|
+
transactionID: transactionID || track.transactionID,
|
|
1383
|
+
physicalTableName,
|
|
1384
|
+
publish: track.publish !== false,
|
|
1385
|
+
undoable: false,
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
if (track.publish !== false)
|
|
1389
|
+
this.appendDerivedTrackedChanges(track, rows);
|
|
1390
|
+
}
|
|
1391
|
+
const publishedCaptured = captured.filter((change) => change.publish !== false).length;
|
|
1392
|
+
if (!track)
|
|
1393
|
+
return { rows, columns, changes, capturedChanges: publishedCaptured };
|
|
1394
|
+
return {
|
|
1395
|
+
rows: track.returnRows ? rows : [],
|
|
1396
|
+
columns: track.returnRows ? columns : [],
|
|
1397
|
+
changes,
|
|
1398
|
+
affectedRows: rows.length,
|
|
1399
|
+
capturedChanges: publishedCaptured ||
|
|
1400
|
+
(capturesTrackedTable || track.publish === false ? 0 : rows.length),
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
cursorRows(cursor, columns) {
|
|
1404
|
+
const cols = Array.isArray(columns) && columns.length > 0 ? columns : null;
|
|
1405
|
+
return cursor.toArray().map((row) => {
|
|
1406
|
+
const obj = {};
|
|
1407
|
+
if (cols) {
|
|
1408
|
+
// include EVERY selected column, even SQL NULLs the DO cursor omits from
|
|
1409
|
+
// the row object — pg/drizzle consumers index results positionally, so a
|
|
1410
|
+
// dropped null column shifts every later value (e.g. trailing nullable
|
|
1411
|
+
// timestamps read back undefined and crash the type decoder).
|
|
1412
|
+
for (const k of cols)
|
|
1413
|
+
obj[k] = k in row ? row[k] : null;
|
|
1414
|
+
}
|
|
1415
|
+
else {
|
|
1416
|
+
for (const k of Object.keys(row))
|
|
1417
|
+
obj[k] = row[k];
|
|
1418
|
+
}
|
|
1419
|
+
return obj;
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
appendDerivedTrackedChanges(track, rows) {
|
|
1423
|
+
if (!rows.length)
|
|
1424
|
+
return;
|
|
1425
|
+
const table = stripPublicPrefix(track.tableName);
|
|
1426
|
+
if (table !== 'message')
|
|
1427
|
+
return;
|
|
1428
|
+
const channelIds = new Set();
|
|
1429
|
+
const threadIds = new Set();
|
|
1430
|
+
for (const row of rows) {
|
|
1431
|
+
const channelId = String(row.channelId || '');
|
|
1432
|
+
const threadId = String(row.threadId || '');
|
|
1433
|
+
if (this.messageRowUpdatesChannelLatestOrder(row) && channelId) {
|
|
1434
|
+
channelIds.add(channelId);
|
|
1435
|
+
}
|
|
1436
|
+
if (this.messageRowUpdatesThreadReplyCount(row) && threadId) {
|
|
1437
|
+
threadIds.add(threadId);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
this.appendRowsAsUpdates('public.channel', 'channel', 'id', channelIds, track.transactionID);
|
|
1441
|
+
this.appendRowsAsUpdates('public.thread', 'thread', 'id', threadIds, track.transactionID);
|
|
1442
|
+
}
|
|
1443
|
+
appendRowsAsUpdates(publicTableName, sqliteTableName, keyColumn, keys, transactionID) {
|
|
1444
|
+
if (keys.size === 0)
|
|
1445
|
+
return;
|
|
1446
|
+
const values = [...keys];
|
|
1447
|
+
const placeholders = values.map(() => '?').join(', ');
|
|
1448
|
+
const rows = this.sql
|
|
1449
|
+
.exec(`SELECT * FROM ${quoteIdent(sqliteTableName)} WHERE ${quoteIdent(keyColumn)} IN (${placeholders})`, ...values)
|
|
1450
|
+
.toArray();
|
|
1451
|
+
// Derived notifications: the rows were written by business triggers and are
|
|
1452
|
+
// captured for the changefeed only. They carry no physical table name, so
|
|
1453
|
+
// the row-undo pass never sees them, and their real writes are rolled back
|
|
1454
|
+
// by whichever journal entry owns the table.
|
|
1455
|
+
for (const row of rows) {
|
|
1456
|
+
this.appendTrackedChange({
|
|
1457
|
+
tableName: publicTableName,
|
|
1458
|
+
op: 'UPDATE',
|
|
1459
|
+
rowData: row,
|
|
1460
|
+
oldData: null,
|
|
1461
|
+
transactionID,
|
|
1462
|
+
undoable: false,
|
|
1463
|
+
});
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
messageRowUpdatesChannelLatestOrder(row) {
|
|
1467
|
+
return (row.type !== 'draft' &&
|
|
1468
|
+
row.type !== 'hidden' &&
|
|
1469
|
+
!row.deleted &&
|
|
1470
|
+
!row.isThreadReply &&
|
|
1471
|
+
row.order !== null &&
|
|
1472
|
+
row.order !== undefined);
|
|
1473
|
+
}
|
|
1474
|
+
messageRowUpdatesThreadReplyCount(row) {
|
|
1475
|
+
return !!row.threadId && row.type !== 'draft' && !row.deleted && row.isThreadReply;
|
|
1476
|
+
}
|
|
1477
|
+
// ── CRUD operations ──────────────────────────────────────────────────────
|
|
1478
|
+
applyMutation(mutation) {
|
|
1479
|
+
if (mutation.type === 'crud' && mutation.name === '_zero_crud') {
|
|
1480
|
+
return this.applyCrudMutation(mutation);
|
|
1481
|
+
}
|
|
1482
|
+
if (mutation.name === '_zero_cleanupResults')
|
|
1483
|
+
return {};
|
|
1484
|
+
if (mutation.type === 'custom')
|
|
1485
|
+
return this.applyTableMutation(mutation);
|
|
1486
|
+
return {
|
|
1487
|
+
error: 'app',
|
|
1488
|
+
message: `unsupported mutation ${mutation.type}:${mutation.name}`,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
applyTableMutation(mutation) {
|
|
1492
|
+
const [tableName, action] = this.tableActionFromMutationName(mutation.name);
|
|
1493
|
+
if (!tableName || !action)
|
|
1494
|
+
return { error: 'app', message: `invalid mutation name ${mutation.name}` };
|
|
1495
|
+
if (!this.tableExists(tableName))
|
|
1496
|
+
return { error: 'app', message: `unknown table ${tableName}` };
|
|
1497
|
+
const value = (mutation.args[0] || {});
|
|
1498
|
+
const primaryKey = this.primaryKeyForTable(tableName, []);
|
|
1499
|
+
if (action === 'insert')
|
|
1500
|
+
this.insertRow(tableName, value, primaryKey);
|
|
1501
|
+
else if (action === 'upsert')
|
|
1502
|
+
this.upsertRow(tableName, value, primaryKey);
|
|
1503
|
+
else if (action === 'delete')
|
|
1504
|
+
this.deleteRow(tableName, value, primaryKey);
|
|
1505
|
+
else
|
|
1506
|
+
this.updateRow(tableName, value, primaryKey);
|
|
1507
|
+
return {};
|
|
1508
|
+
}
|
|
1509
|
+
tableActionFromMutationName(name) {
|
|
1510
|
+
if (name.includes('|'))
|
|
1511
|
+
return name.split('|', 2);
|
|
1512
|
+
return name.split('.', 2);
|
|
1513
|
+
}
|
|
1514
|
+
tableNameFromOperationName(name) {
|
|
1515
|
+
if (!name)
|
|
1516
|
+
return null;
|
|
1517
|
+
return name.split(/[.|]/, 1)[0] || null;
|
|
1518
|
+
}
|
|
1519
|
+
applyCrudMutation(mutation) {
|
|
1520
|
+
const arg = mutation.args[0];
|
|
1521
|
+
const ops = Array.isArray(arg?.ops) ? arg.ops : [];
|
|
1522
|
+
for (const crud of ops) {
|
|
1523
|
+
if (!crud?.tableName)
|
|
1524
|
+
return { error: 'app', message: 'invalid crud mutation' };
|
|
1525
|
+
if (!this.tableExists(crud.tableName))
|
|
1526
|
+
return { error: 'app', message: `unknown table ${crud.tableName}` };
|
|
1527
|
+
const value = crud.value || {};
|
|
1528
|
+
const primaryKey = this.primaryKeyForTable(crud.tableName, crud.primaryKey || []);
|
|
1529
|
+
if (crud.op === 'insert')
|
|
1530
|
+
this.insertRow(crud.tableName, value, primaryKey);
|
|
1531
|
+
else if (crud.op === 'upsert')
|
|
1532
|
+
this.upsertRow(crud.tableName, value, primaryKey);
|
|
1533
|
+
else if (crud.op === 'update')
|
|
1534
|
+
this.updateRow(crud.tableName, value, primaryKey);
|
|
1535
|
+
else if (crud.op === 'delete')
|
|
1536
|
+
this.deleteRow(crud.tableName, value, primaryKey);
|
|
1537
|
+
}
|
|
1538
|
+
return {};
|
|
1539
|
+
}
|
|
1540
|
+
insertRow(tn, value, pk) {
|
|
1541
|
+
if (this.readRowByPrimaryKey(tn, value, pk))
|
|
1542
|
+
return;
|
|
1543
|
+
const row = this.storageRow(tn, value, true);
|
|
1544
|
+
const cols = Object.keys(row);
|
|
1545
|
+
if (!cols.length)
|
|
1546
|
+
return;
|
|
1547
|
+
const qc = cols.map((c) => quoteIdent(c)).join(', ');
|
|
1548
|
+
const ph = cols.map(() => '?').join(', ');
|
|
1549
|
+
this.sql.exec(`INSERT INTO ${quoteIdent(tn)} (${qc}) VALUES (${ph})`, ...cols.map((c) => row[c]));
|
|
1550
|
+
this.writeBudget.recordLogical(1);
|
|
1551
|
+
const next = this.readRowByPrimaryKey(tn, value, pk) || this.normalizeRow(tn, row);
|
|
1552
|
+
this.appendChange(tn, 'INSERT', next, null);
|
|
1553
|
+
}
|
|
1554
|
+
upsertRow(tn, value, pk) {
|
|
1555
|
+
const existing = this.readRowByPrimaryKey(tn, value, pk);
|
|
1556
|
+
if (existing) {
|
|
1557
|
+
this.updateRow(tn, value, pk);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
this.insertRow(tn, value, pk);
|
|
1561
|
+
}
|
|
1562
|
+
updateRow(tn, value, pk) {
|
|
1563
|
+
if (!pk.length)
|
|
1564
|
+
return;
|
|
1565
|
+
const existing = this.readRowByPrimaryKey(tn, value, pk);
|
|
1566
|
+
if (!existing)
|
|
1567
|
+
return;
|
|
1568
|
+
const nk = Object.keys(value).filter((c) => !pk.includes(c));
|
|
1569
|
+
if (!nk.length)
|
|
1570
|
+
return;
|
|
1571
|
+
const storage = this.storageRow(tn, value, false);
|
|
1572
|
+
this.sql.exec(`UPDATE ${quoteIdent(tn)} SET ${nk.map((c) => `${quoteIdent(c)} = ?`).join(', ')} WHERE ${this.primaryKeyWhere(pk)}`, ...nk.map((c) => storage[c]), ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
|
|
1573
|
+
this.writeBudget.recordLogical(1);
|
|
1574
|
+
const next = this.readRowByPrimaryKey(tn, value, pk);
|
|
1575
|
+
if (next)
|
|
1576
|
+
this.appendChange(tn, 'UPDATE', next, existing);
|
|
1577
|
+
}
|
|
1578
|
+
deleteRow(tn, value, pk) {
|
|
1579
|
+
if (!pk.length)
|
|
1580
|
+
return;
|
|
1581
|
+
const existing = this.readRowByPrimaryKey(tn, value, pk);
|
|
1582
|
+
if (!existing)
|
|
1583
|
+
return;
|
|
1584
|
+
this.sql.exec(`DELETE FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])));
|
|
1585
|
+
this.writeBudget.recordLogical(1);
|
|
1586
|
+
this.appendChange(tn, 'DELETE', null, existing);
|
|
1587
|
+
}
|
|
1588
|
+
appendChange(tn, op, rowData, oldData) {
|
|
1589
|
+
this.appendTrackedChange({ tableName: tn, op, rowData, oldData });
|
|
1590
|
+
}
|
|
1591
|
+
/** Record a trigger-captured change, before-images and row identity included. */
|
|
1592
|
+
appendCapturedChange(change, transactionID, undoable = true) {
|
|
1593
|
+
this.appendTrackedChange({
|
|
1594
|
+
tableName: change.tableName,
|
|
1595
|
+
op: change.op,
|
|
1596
|
+
rowData: change.rowData,
|
|
1597
|
+
oldData: change.oldData,
|
|
1598
|
+
transactionID,
|
|
1599
|
+
physicalTableName: change.physicalTableName,
|
|
1600
|
+
publish: change.publish !== false,
|
|
1601
|
+
rowJournal: change.rowJournal,
|
|
1602
|
+
oldJournal: change.oldJournal,
|
|
1603
|
+
newRowid: change.newRowid,
|
|
1604
|
+
oldRowid: change.oldRowid,
|
|
1605
|
+
undoable,
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
appendTrackedChange(change) {
|
|
1609
|
+
const publish = change.publish !== false;
|
|
1610
|
+
if (!publish && !change.transactionID)
|
|
1611
|
+
return;
|
|
1612
|
+
if (change.transactionID) {
|
|
1613
|
+
this.ensurePendingTrackedChangesTable();
|
|
1614
|
+
appendPendingChange(this.sql, {
|
|
1615
|
+
transactionID: change.transactionID,
|
|
1616
|
+
physicalTableName: change.physicalTableName,
|
|
1617
|
+
tableName: change.tableName,
|
|
1618
|
+
publish,
|
|
1619
|
+
op: change.op,
|
|
1620
|
+
rowData: change.rowData,
|
|
1621
|
+
oldData: change.oldData,
|
|
1622
|
+
rowJournal: change.rowJournal ?? null,
|
|
1623
|
+
oldJournal: change.oldJournal ?? null,
|
|
1624
|
+
newRowid: change.newRowid ?? null,
|
|
1625
|
+
oldRowid: change.oldRowid ?? null,
|
|
1626
|
+
undoable: change.undoable === true,
|
|
1627
|
+
});
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
this.appendCommittedTrackedChange(change.tableName, change.op, change.rowData, change.oldData);
|
|
1631
|
+
}
|
|
1632
|
+
appendCommittedTrackedChange(tableName, op, rowData, oldData) {
|
|
1633
|
+
this.watermarks.ensureTables();
|
|
1634
|
+
const watermark = this.watermarks.next();
|
|
1635
|
+
this.sql.exec('INSERT INTO _zero_changes (watermark, table_name, op, row_data, old_data) VALUES (?, ?, ?, ?, ?)', watermark, tableName, op, rowData ? JSON.stringify(rowData) : null, oldData ? JSON.stringify(oldData) : null);
|
|
1636
|
+
this.watermarks.mark(watermark);
|
|
1637
|
+
}
|
|
1638
|
+
ensurePendingTrackedChangesTable() {
|
|
1639
|
+
if (this.pendingChangesSchemaReady)
|
|
1640
|
+
return;
|
|
1641
|
+
ensurePendingChangesTable(this.sql);
|
|
1642
|
+
this.pendingChangesSchemaReady = true;
|
|
1643
|
+
}
|
|
1644
|
+
rollbackPendingTrackedChanges(transactionID) {
|
|
1645
|
+
this.ensurePendingTrackedChangesTable();
|
|
1646
|
+
return rollbackPendingChanges(this.sql, transactionID);
|
|
1647
|
+
}
|
|
1648
|
+
commitPendingTrackedChanges(transactionID) {
|
|
1649
|
+
this.ensurePendingTrackedChangesTable();
|
|
1650
|
+
this.watermarks.ensureTables();
|
|
1651
|
+
const rows = this.sql
|
|
1652
|
+
.exec(`INSERT INTO _zero_changes (table_name, op, row_data, old_data)
|
|
1653
|
+
SELECT table_name, op, row_data, old_data
|
|
1654
|
+
FROM _zero_pending_changes
|
|
1655
|
+
WHERE transaction_id = ? AND publish != 0
|
|
1656
|
+
ORDER BY id
|
|
1657
|
+
RETURNING watermark`, transactionID)
|
|
1658
|
+
.toArray();
|
|
1659
|
+
let watermark = 0;
|
|
1660
|
+
for (const row of rows)
|
|
1661
|
+
watermark = Math.max(watermark, Number(row.watermark ?? 0));
|
|
1662
|
+
if (watermark > 0) {
|
|
1663
|
+
this.watermarks.mark(watermark);
|
|
1664
|
+
}
|
|
1665
|
+
this.deletePendingTrackedChanges(transactionID);
|
|
1666
|
+
return rows.length;
|
|
1667
|
+
}
|
|
1668
|
+
deletePendingTrackedChanges(transactionID) {
|
|
1669
|
+
this.ensurePendingTrackedChangesTable();
|
|
1670
|
+
return deletePendingChanges(this.sql, transactionID);
|
|
1671
|
+
}
|
|
1672
|
+
readChangesSince(watermark, limit) {
|
|
1673
|
+
this.watermarks.ensureTables();
|
|
1674
|
+
const statement = 'SELECT watermark, table_name, op, row_data, old_data FROM _zero_changes WHERE watermark > ? ORDER BY watermark' +
|
|
1675
|
+
(limit === undefined ? '' : ' LIMIT ?');
|
|
1676
|
+
const params = limit === undefined ? [watermark] : [watermark, limit];
|
|
1677
|
+
return this.sql
|
|
1678
|
+
.exec(statement, ...params)
|
|
1679
|
+
.toArray()
|
|
1680
|
+
.map((row) => {
|
|
1681
|
+
const tableName = String(row.table_name);
|
|
1682
|
+
const rowData = row.row_data ? JSON.parse(String(row.row_data)) : null;
|
|
1683
|
+
const oldData = row.old_data ? JSON.parse(String(row.old_data)) : null;
|
|
1684
|
+
return {
|
|
1685
|
+
watermark: Number(row.watermark),
|
|
1686
|
+
tableName,
|
|
1687
|
+
op: String(row.op),
|
|
1688
|
+
rowData: rowData ? this.normalizeRow(tableName, rowData) : null,
|
|
1689
|
+
oldData: oldData ? this.normalizeRow(tableName, oldData) : null,
|
|
1690
|
+
};
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
watermark() {
|
|
1694
|
+
return this.watermarks.current();
|
|
1695
|
+
}
|
|
1696
|
+
ensureSchemaTables(clientSchema) {
|
|
1697
|
+
this.ensureSchemaMetadataTable();
|
|
1698
|
+
for (const [name, def] of Object.entries(clientSchema.tables)) {
|
|
1699
|
+
this.tableSchemas.set(name, def);
|
|
1700
|
+
this.createSchemaTable(name, def);
|
|
1701
|
+
this.ensureSchemaColumns(name, def);
|
|
1702
|
+
this.sql.exec('INSERT OR REPLACE INTO _zero_schema_tables (name, schema_json) VALUES (?, ?)', name, JSON.stringify(def));
|
|
1703
|
+
this.schemaTables.add(name);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
createSchemaTable(name, def) {
|
|
1707
|
+
const pk = def.primaryKey.map((c) => quoteIdent(c));
|
|
1708
|
+
const pkClause = pk.length ? `, PRIMARY KEY (${pk.join(', ')})` : '';
|
|
1709
|
+
const colDefs = Object.entries(def.columns).map(([cn, cd]) => `${quoteIdent(cn)} ${sqliteTypeForSchemaColumn(cd.type)}`);
|
|
1710
|
+
this.sql.exec(`CREATE TABLE IF NOT EXISTS ${quoteIdent(name)} (${colDefs.join(', ')}${pkClause})`);
|
|
1711
|
+
}
|
|
1712
|
+
ensureSchemaColumns(name, def) {
|
|
1713
|
+
const existing = this.columnNamesForTable(name);
|
|
1714
|
+
for (const [columnName, column] of Object.entries(def.columns)) {
|
|
1715
|
+
if (existing.has(columnName))
|
|
1716
|
+
continue;
|
|
1717
|
+
this.sql.exec(`ALTER TABLE ${quoteIdent(name)} ADD COLUMN ${quoteIdent(columnName)} ${sqliteTypeForSchemaColumn(column.type)}`);
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
columnNamesForTable(name) {
|
|
1721
|
+
try {
|
|
1722
|
+
return new Set(this.sql
|
|
1723
|
+
.exec(`PRAGMA table_info(${quoteIdent(name)})`)
|
|
1724
|
+
.toArray()
|
|
1725
|
+
.map((row) => String(row.name)));
|
|
1726
|
+
}
|
|
1727
|
+
catch {
|
|
1728
|
+
return new Set();
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
ensureSchemaMetadataTable() {
|
|
1732
|
+
this.sql.exec('CREATE TABLE IF NOT EXISTS _zero_schema_tables (name TEXT PRIMARY KEY, schema_json TEXT NOT NULL)');
|
|
1733
|
+
}
|
|
1734
|
+
schemaForTable(tableName) {
|
|
1735
|
+
const schemaTableName = stripPublicPrefix(tableName);
|
|
1736
|
+
const tableSchemas = (this.tableSchemas ??= new Map());
|
|
1737
|
+
const cached = tableSchemas.get(schemaTableName);
|
|
1738
|
+
if (cached)
|
|
1739
|
+
return cached;
|
|
1740
|
+
try {
|
|
1741
|
+
this.ensureSchemaMetadataTable();
|
|
1742
|
+
const row = this.sql
|
|
1743
|
+
.exec('SELECT schema_json FROM _zero_schema_tables WHERE name = ?', schemaTableName)
|
|
1744
|
+
.one();
|
|
1745
|
+
if (!row?.schema_json)
|
|
1746
|
+
return undefined;
|
|
1747
|
+
const schema = JSON.parse(String(row.schema_json));
|
|
1748
|
+
tableSchemas.set(schemaTableName, schema);
|
|
1749
|
+
return schema;
|
|
1750
|
+
}
|
|
1751
|
+
catch {
|
|
1752
|
+
return undefined;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
tableExists(n) {
|
|
1756
|
+
try {
|
|
1757
|
+
return !!this.sql
|
|
1758
|
+
.exec("SELECT name FROM sqlite_master WHERE type='table' AND name=?", n)
|
|
1759
|
+
.one();
|
|
1760
|
+
}
|
|
1761
|
+
catch {
|
|
1762
|
+
return false;
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
readAllRows(tn) {
|
|
1766
|
+
return this.sql
|
|
1767
|
+
.exec(`SELECT * FROM ${quoteIdent(tn)}`)
|
|
1768
|
+
.toArray()
|
|
1769
|
+
.map((row) => this.normalizeRow(tn, row));
|
|
1770
|
+
}
|
|
1771
|
+
readRowByPrimaryKey(tn, value, pk) {
|
|
1772
|
+
if (!pk.length)
|
|
1773
|
+
return null;
|
|
1774
|
+
try {
|
|
1775
|
+
const row = this.sql
|
|
1776
|
+
.exec(`SELECT * FROM ${quoteIdent(tn)} WHERE ${this.primaryKeyWhere(pk)}`, ...pk.map((c) => this.storageColumnValue(tn, c, value[c])))
|
|
1777
|
+
.one();
|
|
1778
|
+
return row ? this.normalizeRow(tn, row) : null;
|
|
1779
|
+
}
|
|
1780
|
+
catch {
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
primaryKeyWhere(pk) {
|
|
1785
|
+
return pk.map((c) => `${quoteIdent(c)} = ?`).join(' AND ');
|
|
1786
|
+
}
|
|
1787
|
+
primaryKeyForTable(tn, fallback) {
|
|
1788
|
+
const schema = this.schemaForTable(tn);
|
|
1789
|
+
if (schema?.primaryKey?.length)
|
|
1790
|
+
return schema.primaryKey;
|
|
1791
|
+
return fallback;
|
|
1792
|
+
}
|
|
1793
|
+
storageRow(tn, value, includeMissingSchemaColumns) {
|
|
1794
|
+
const schema = this.schemaForTable(tn);
|
|
1795
|
+
const row = {};
|
|
1796
|
+
if (schema && includeMissingSchemaColumns) {
|
|
1797
|
+
for (const column of Object.keys(schema.columns))
|
|
1798
|
+
row[column] = this.storageColumnValue(tn, column, value[column] ?? null);
|
|
1799
|
+
}
|
|
1800
|
+
for (const column of Object.keys(value)) {
|
|
1801
|
+
if (value[column] !== undefined)
|
|
1802
|
+
row[column] = this.storageColumnValue(tn, column, value[column]);
|
|
1803
|
+
}
|
|
1804
|
+
return row;
|
|
1805
|
+
}
|
|
1806
|
+
storageColumnValue(tn, column, value) {
|
|
1807
|
+
if (value === undefined || value === null)
|
|
1808
|
+
return null;
|
|
1809
|
+
const schema = this.schemaForTable(tn);
|
|
1810
|
+
const type = schema?.columns?.[column]?.type ??
|
|
1811
|
+
Object.values(schema?.columns ?? {}).find((candidate) => candidate.serverName === column)?.type;
|
|
1812
|
+
if (type === 'boolean')
|
|
1813
|
+
return value ? 1 : 0;
|
|
1814
|
+
if (type === 'json')
|
|
1815
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
1816
|
+
if (type === 'number')
|
|
1817
|
+
return Number(value);
|
|
1818
|
+
if (type === 'bigint')
|
|
1819
|
+
return String(value);
|
|
1820
|
+
return value;
|
|
1821
|
+
}
|
|
1822
|
+
normalizeRow(tn, row) {
|
|
1823
|
+
const schema = this.schemaForTable(tn);
|
|
1824
|
+
const normalized = {};
|
|
1825
|
+
for (const key of Object.keys(row)) {
|
|
1826
|
+
const type = schema?.columns?.[key]?.type ??
|
|
1827
|
+
Object.values(schema?.columns ?? {}).find((candidate) => candidate.serverName === key)?.type;
|
|
1828
|
+
const value = row[key];
|
|
1829
|
+
if (value === null || value === undefined) {
|
|
1830
|
+
normalized[key] = null;
|
|
1831
|
+
}
|
|
1832
|
+
else if (type === 'boolean') {
|
|
1833
|
+
normalized[key] =
|
|
1834
|
+
value === true || value === 1 || value === '1' || value === 'true';
|
|
1835
|
+
}
|
|
1836
|
+
else if (type === 'number') {
|
|
1837
|
+
// timestamp/timestamptz columns are declared `number` in the zero
|
|
1838
|
+
// schema but may be stored as timestamp text (for example
|
|
1839
|
+
// "2026-07-11 13:34:46.000+00"). Coercing
|
|
1840
|
+
// that text with Number() yields NaN, which JSON serializes as null and
|
|
1841
|
+
// silently wipes every timestamp reaching the sync-cf-host snapshot
|
|
1842
|
+
// feed. Forward a non-numeric value untouched so the engine's
|
|
1843
|
+
// timestamp_text_to_epoch_ms decodes it, matching the /changes feed
|
|
1844
|
+
// which forwards the raw text.
|
|
1845
|
+
const numeric = Number(value);
|
|
1846
|
+
normalized[key] = Number.isFinite(numeric) ? numeric : value;
|
|
1847
|
+
}
|
|
1848
|
+
else if (type === 'json' && typeof value === 'string') {
|
|
1849
|
+
try {
|
|
1850
|
+
normalized[key] = JSON.parse(value);
|
|
1851
|
+
}
|
|
1852
|
+
catch {
|
|
1853
|
+
normalized[key] = value;
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
normalized[key] = value;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return normalized;
|
|
1861
|
+
}
|
|
1862
|
+
sendSyncPoke(socket, attachment, part) {
|
|
1863
|
+
const cookie = this.nextCookie();
|
|
1864
|
+
const pokeID = crypto.randomUUID();
|
|
1865
|
+
this.sendJSON(socket, [
|
|
1866
|
+
'pokeStart',
|
|
1867
|
+
{
|
|
1868
|
+
pokeID,
|
|
1869
|
+
baseCookie: attachment.cookie,
|
|
1870
|
+
schemaVersions: {
|
|
1871
|
+
minSupportedVersion: SCHEMA_VERSION,
|
|
1872
|
+
maxSupportedVersion: SCHEMA_VERSION,
|
|
1873
|
+
},
|
|
1874
|
+
timestamp: Date.now(),
|
|
1875
|
+
},
|
|
1876
|
+
]);
|
|
1877
|
+
this.sendJSON(socket, ['pokePart', { pokeID, ...part }]);
|
|
1878
|
+
this.sendJSON(socket, ['pokeEnd', { pokeID, cookie }]);
|
|
1879
|
+
const nextAttachment = { ...attachment, cookie };
|
|
1880
|
+
socket.serializeAttachment(nextAttachment);
|
|
1881
|
+
return nextAttachment;
|
|
1882
|
+
}
|
|
1883
|
+
broadcastPoke(clientGroupID, part) {
|
|
1884
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
1885
|
+
const ws = socket;
|
|
1886
|
+
const attachment = this.readSocketAttachment(ws);
|
|
1887
|
+
if (!attachment)
|
|
1888
|
+
continue;
|
|
1889
|
+
if (attachment.clientGroupID !== clientGroupID)
|
|
1890
|
+
continue;
|
|
1891
|
+
this.sendSyncPoke(ws, attachment, part);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
broadcastMutationPoke(sourceAttachment, part) {
|
|
1895
|
+
const rowsPatch = part.rowsPatch || [];
|
|
1896
|
+
const changedTables = new Set(rowsPatch
|
|
1897
|
+
.map((op) => op?.tableName)
|
|
1898
|
+
.filter((tableName) => !!tableName));
|
|
1899
|
+
const hasLastMutationIDChanges = Object.keys(part.lastMutationIDChanges || {}).length > 0;
|
|
1900
|
+
for (const socket of this.ctx.getWebSockets()) {
|
|
1901
|
+
const ws = socket;
|
|
1902
|
+
const attachment = this.readSocketAttachment(ws);
|
|
1903
|
+
if (!attachment)
|
|
1904
|
+
continue;
|
|
1905
|
+
if (attachment.userID !== sourceAttachment.userID)
|
|
1906
|
+
continue;
|
|
1907
|
+
const isSourceClientGroup = attachment.clientGroupID === sourceAttachment.clientGroupID;
|
|
1908
|
+
const wantsChangedRows = changedTables.size > 0 &&
|
|
1909
|
+
attachment.desiredTableNames.some((tableName) => changedTables.has(tableName));
|
|
1910
|
+
const nextPart = {};
|
|
1911
|
+
if (wantsChangedRows)
|
|
1912
|
+
nextPart.rowsPatch = rowsPatch;
|
|
1913
|
+
if (isSourceClientGroup && hasLastMutationIDChanges)
|
|
1914
|
+
nextPart.lastMutationIDChanges = part.lastMutationIDChanges;
|
|
1915
|
+
if (!nextPart.rowsPatch && !nextPart.lastMutationIDChanges)
|
|
1916
|
+
continue;
|
|
1917
|
+
this.sendSyncPoke(ws, attachment, nextPart);
|
|
1918
|
+
}
|
|
1919
|
+
}
|
|
1920
|
+
syncRowPatchFromChange(change) {
|
|
1921
|
+
if (change.op === 'DELETE')
|
|
1922
|
+
return {
|
|
1923
|
+
op: 'del',
|
|
1924
|
+
tableName: change.tableName,
|
|
1925
|
+
id: this.primaryKeyValue(change.tableName, change.oldData || {}),
|
|
1926
|
+
};
|
|
1927
|
+
return {
|
|
1928
|
+
op: 'put',
|
|
1929
|
+
tableName: change.tableName,
|
|
1930
|
+
value: this.normalizeRow(change.tableName, change.rowData || {}),
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
primaryKeyValue(tableName, row) {
|
|
1934
|
+
const pk = this.primaryKeyForTable(tableName, []);
|
|
1935
|
+
if (pk.length)
|
|
1936
|
+
return Object.fromEntries(pk.map((column) => [column, row[column]]));
|
|
1937
|
+
if ('id' in row)
|
|
1938
|
+
return { id: row.id };
|
|
1939
|
+
return row;
|
|
1940
|
+
}
|
|
1941
|
+
cookie() {
|
|
1942
|
+
return String(this.watermark()).padStart(20, '0');
|
|
1943
|
+
}
|
|
1944
|
+
nextCookie() {
|
|
1945
|
+
const watermark = this.watermarks.next();
|
|
1946
|
+
this.watermarks.mark(watermark);
|
|
1947
|
+
return String(watermark).padStart(20, '0');
|
|
1948
|
+
}
|
|
1949
|
+
readSocketAttachment(socket) {
|
|
1950
|
+
const attachment = socket.deserializeAttachment();
|
|
1951
|
+
if (!attachment)
|
|
1952
|
+
return null;
|
|
1953
|
+
return {
|
|
1954
|
+
...attachment,
|
|
1955
|
+
initialized: attachment.initialized === true,
|
|
1956
|
+
desiredTableNames: attachment.desiredTableNames || [],
|
|
1957
|
+
desiredQueries: attachment.desiredQueries || [],
|
|
1958
|
+
};
|
|
1959
|
+
}
|
|
1960
|
+
sendJSON(socket, msg) {
|
|
1961
|
+
try {
|
|
1962
|
+
socket.send(JSON.stringify(msg));
|
|
1963
|
+
}
|
|
1964
|
+
catch { }
|
|
1965
|
+
}
|
|
1966
|
+
parseMessage(data) {
|
|
1967
|
+
try {
|
|
1968
|
+
return JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data));
|
|
1969
|
+
}
|
|
1970
|
+
catch {
|
|
1971
|
+
return null;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
export default {
|
|
1976
|
+
async fetch(request, env) {
|
|
1977
|
+
// every endpoint is served by the one singleton ZeroDO (its fetch() does the
|
|
1978
|
+
// routing, CORS preflight, and 404s). forward unconditionally rather than
|
|
1979
|
+
// re-listing each path here — a second route table only drifts from the DO's.
|
|
1980
|
+
const id = env.ZERO_DO.idFromName('singleton');
|
|
1981
|
+
return env.ZERO_DO.get(id).fetch(request);
|
|
1982
|
+
},
|
|
1983
|
+
};
|
|
1984
|
+
function decodeInitConnection(secProtocol) {
|
|
1985
|
+
try {
|
|
1986
|
+
const decoded = decodeURIComponent(secProtocol);
|
|
1987
|
+
const bytes = Uint8Array.from(atob(decoded), (char) => char.charCodeAt(0));
|
|
1988
|
+
const protocols = JSON.parse(new TextDecoder().decode(bytes));
|
|
1989
|
+
const message = protocols.initConnectionMessage;
|
|
1990
|
+
if (Array.isArray(message) && message[0] === 'initConnection') {
|
|
1991
|
+
return message;
|
|
1992
|
+
}
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
catch {
|
|
1996
|
+
return null;
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
//# sourceMappingURL=worker.js.map
|