@volter/twin-planetscale 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +473 -0
  3. package/api/src/fetch.ts +50 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/manifest.ts +136 -0
  8. package/api/src/screens/deploy-request.tsx +111 -0
  9. package/api/src/screens/service-tokens.tsx +141 -0
  10. package/api/src/screens/session.tsx +117 -0
  11. package/api/src/semantics/audit.ts +82 -0
  12. package/api/src/semantics/backups.ts +258 -0
  13. package/api/src/semantics/branches.ts +201 -0
  14. package/api/src/semantics/deploy-requests.ts +493 -0
  15. package/api/src/semantics/index.ts +371 -0
  16. package/api/src/semantics/shared.ts +141 -0
  17. package/api/src/semantics/time.ts +77 -0
  18. package/api/src/token-gate.ts +96 -0
  19. package/dist/api/src/fetch.d.ts +8 -0
  20. package/dist/api/src/fetch.js +51 -0
  21. package/dist/api/src/fetch.ts +50 -0
  22. package/dist/api/src/generated/surface.gen.json +1 -0
  23. package/dist/api/src/generated/ui.gen.json +1 -0
  24. package/dist/api/src/index.ts +19 -0
  25. package/dist/api/src/manifest.d.ts +2 -0
  26. package/dist/api/src/manifest.js +113 -0
  27. package/dist/api/src/manifest.ts +136 -0
  28. package/dist/api/src/screens/deploy-request.d.ts +7 -0
  29. package/dist/api/src/screens/deploy-request.js +106 -0
  30. package/dist/api/src/screens/deploy-request.tsx +111 -0
  31. package/dist/api/src/screens/service-tokens.d.ts +3 -0
  32. package/dist/api/src/screens/service-tokens.js +134 -0
  33. package/dist/api/src/screens/service-tokens.tsx +141 -0
  34. package/dist/api/src/screens/session.d.ts +11 -0
  35. package/dist/api/src/screens/session.js +108 -0
  36. package/dist/api/src/screens/session.tsx +117 -0
  37. package/dist/api/src/semantics/audit.d.ts +31 -0
  38. package/dist/api/src/semantics/audit.js +80 -0
  39. package/dist/api/src/semantics/audit.ts +82 -0
  40. package/dist/api/src/semantics/backups.d.ts +37 -0
  41. package/dist/api/src/semantics/backups.js +264 -0
  42. package/dist/api/src/semantics/backups.ts +258 -0
  43. package/dist/api/src/semantics/branches.d.ts +53 -0
  44. package/dist/api/src/semantics/branches.js +197 -0
  45. package/dist/api/src/semantics/branches.ts +201 -0
  46. package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
  47. package/dist/api/src/semantics/deploy-requests.js +491 -0
  48. package/dist/api/src/semantics/deploy-requests.ts +493 -0
  49. package/dist/api/src/semantics/index.d.ts +20 -0
  50. package/dist/api/src/semantics/index.js +381 -0
  51. package/dist/api/src/semantics/index.ts +371 -0
  52. package/dist/api/src/semantics/shared.d.ts +36 -0
  53. package/dist/api/src/semantics/shared.js +132 -0
  54. package/dist/api/src/semantics/shared.ts +141 -0
  55. package/dist/api/src/semantics/time.d.ts +2 -0
  56. package/dist/api/src/semantics/time.js +81 -0
  57. package/dist/api/src/semantics/time.ts +77 -0
  58. package/dist/api/src/token-gate.d.ts +9 -0
  59. package/dist/api/src/token-gate.js +97 -0
  60. package/dist/api/src/token-gate.ts +96 -0
  61. package/dist/src/cli.d.ts +2 -0
  62. package/dist/src/cli.js +61 -0
  63. package/dist/src/generated/surface.gen.json +1 -0
  64. package/dist/src/generated/ui.gen.json +1 -0
  65. package/dist/src/index.d.ts +26 -0
  66. package/dist/src/index.js +156 -0
  67. package/dist/src/manifest.d.ts +2 -0
  68. package/dist/src/manifest.js +41 -0
  69. package/dist/src/planetscale-budget.d.ts +78 -0
  70. package/dist/src/planetscale-budget.js +305 -0
  71. package/dist/src/planetscale-capabilities.d.ts +10 -0
  72. package/dist/src/planetscale-capabilities.js +3977 -0
  73. package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
  74. package/dist/src/planetscale-collation-weights.gen.js +12 -0
  75. package/dist/src/planetscale-collation.d.ts +70 -0
  76. package/dist/src/planetscale-collation.js +391 -0
  77. package/dist/src/planetscale-conformance.d.ts +8 -0
  78. package/dist/src/planetscale-conformance.js +213 -0
  79. package/dist/src/planetscale-connector.d.ts +150 -0
  80. package/dist/src/planetscale-connector.js +532 -0
  81. package/dist/src/planetscale-deploy.d.ts +26 -0
  82. package/dist/src/planetscale-deploy.js +235 -0
  83. package/dist/src/planetscale-information-schema.d.ts +32 -0
  84. package/dist/src/planetscale-information-schema.js +299 -0
  85. package/dist/src/planetscale-mysql.d.ts +33 -0
  86. package/dist/src/planetscale-mysql.js +547 -0
  87. package/dist/src/planetscale-roles.d.ts +11 -0
  88. package/dist/src/planetscale-roles.js +60 -0
  89. package/dist/src/planetscale-row.d.ts +12 -0
  90. package/dist/src/planetscale-row.js +39 -0
  91. package/dist/src/planetscale-server.d.ts +42 -0
  92. package/dist/src/planetscale-server.js +137 -0
  93. package/dist/src/planetscale-sql.d.ts +701 -0
  94. package/dist/src/planetscale-sql.js +7167 -0
  95. package/dist/src/planetscale-store.d.ts +126 -0
  96. package/dist/src/planetscale-store.js +827 -0
  97. package/dist/src/planetscale-twin.d.ts +48 -0
  98. package/dist/src/planetscale-twin.js +290 -0
  99. package/dist/src/planetscale-values.d.ts +139 -0
  100. package/dist/src/planetscale-values.js +719 -0
  101. package/dist/src/planetscale-wire.d.ts +110 -0
  102. package/dist/src/planetscale-wire.js +188 -0
  103. package/dist/src/semantics/psdb.d.ts +18 -0
  104. package/dist/src/semantics/psdb.js +30 -0
  105. package/package.json +58 -0
  106. package/src/cli.ts +58 -0
  107. package/src/generated/surface.gen.json +1 -0
  108. package/src/generated/ui.gen.json +1 -0
  109. package/src/index.ts +267 -0
  110. package/src/manifest.ts +60 -0
  111. package/src/planetscale-budget.ts +347 -0
  112. package/src/planetscale-capabilities.ts +3862 -0
  113. package/src/planetscale-collation-weights.gen.ts +13 -0
  114. package/src/planetscale-collation.ts +378 -0
  115. package/src/planetscale-conformance.ts +237 -0
  116. package/src/planetscale-connector.ts +571 -0
  117. package/src/planetscale-deploy.ts +197 -0
  118. package/src/planetscale-information-schema.ts +322 -0
  119. package/src/planetscale-mysql.ts +339 -0
  120. package/src/planetscale-roles.ts +71 -0
  121. package/src/planetscale-row.ts +43 -0
  122. package/src/planetscale-server.ts +162 -0
  123. package/src/planetscale-sql.ts +5957 -0
  124. package/src/planetscale-store.ts +869 -0
  125. package/src/planetscale-twin.ts +338 -0
  126. package/src/planetscale-values.ts +572 -0
  127. package/src/planetscale-wire.ts +274 -0
  128. package/src/semantics/psdb.ts +57 -0
@@ -0,0 +1,827 @@
1
+ // PlanetScale SQL state lives in the kernel tree. One statement or committed transaction
2
+ // appends one action with its final resource projection and ordered row changes for deployment.
3
+ // Session buffers are _session bookkeeping and never enter the deployable set. Row cells remain
4
+ // nested so SQL column names cannot collide with the kernel's subject metadata.
5
+ import { applyTwinWrite, getActiveWorldStore, twinResources, ownFields, readParentTreeMap, treeChangesSince, treeStamp } from '@volter/world-core';
6
+ import { emptyDatabase, execStatement, parseStatement, SqlError, tableKey, unsupported, uniqueKeyPart, withColumnCollations, } from "./planetscale-sql.js";
7
+ import { keyedRowId, keyedRowIdentity } from "./planetscale-row.js";
8
+ import { prepareSql, combinePlans } from "./planetscale-deploy.js";
9
+ export const SERVICE = 'planetscale';
10
+ export const PLANETSCALE_RESOURCE_TYPES = ['table', 'row', '_session', '_backup', '_branch_table', '_branch_row'];
11
+ /** The default schema name a twin branch presents. Overridable per server, like the branch. */
12
+ export const DEFAULT_DATABASE = 'twin';
13
+ export const DEFAULT_BRANCH = 'main';
14
+ const accessOf = (v) => (v === 'READ WRITE' || v === 'READ ONLY' ? v : undefined);
15
+ const sessionOf = (id, rec) => ({
16
+ id,
17
+ inTransaction: rec.in_transaction === true,
18
+ pending: (rec.pending ?? []),
19
+ pendingPlans: (rec.pending_plans ?? []),
20
+ ...(Array.isArray(rec.locks) && rec.locks.length ? { locks: rec.locks } : {}),
21
+ ...(rec.read_only === true ? { readOnly: true } : {}),
22
+ ...(accessOf(rec.session_access) !== undefined ? { sessionAccess: accessOf(rec.session_access) } : {}),
23
+ ...(accessOf(rec.next_access) !== undefined ? { nextAccess: accessOf(rec.next_access) } : {}),
24
+ closed: rec.closed === true,
25
+ ...(typeof rec.scope === 'string' ? { scope: rec.scope } : {}),
26
+ });
27
+ /** A statement's own `_rev` counters over the image's: a flush's bumps land here, reads fall through (a copy of every
28
+ * subject's counter per statement grew with the database). Only `get` and `set` are used; it is not a snapshot: a
29
+ * later patch of the image raises the counters it reads through to, which only ever grow. */
30
+ class RevBook extends Map {
31
+ base;
32
+ constructor(base) { super(); this.base = base; }
33
+ get(key) { return super.has(key) ? super.get(key) : this.base.get(key); }
34
+ }
35
+ const rowId = (table, rowid, scope) => `${scope ? `${scope}:` : ''}row:${tableKey(table)}:${rowid}`;
36
+ const tableId = (table, scope) => `${scope ? `${scope}:` : ''}table:${tableKey(table)}`;
37
+ /** A scope that is `main` (or none) is the World's image; any other names a branch's own subjects. */
38
+ export const scopeOf = (database, branch) => (branch === DEFAULT_BRANCH ? undefined : `${database}/${branch}`);
39
+ const sessionId = (id) => `session:${id}`;
40
+ // The image is folded once per World state, not once per statement: every statement read the whole tree and rebuilt
41
+ // every row of every table, so a statement's cost grew with the whole database (measured through the MySQL frontend,
42
+ // a select by email: 1.9 ms over 200 rows, 29 ms over 5,000). The memo is keyed by the tree's stamp (what the fold
43
+ // reads; any write moves it), as GitHub's projection is. Each caller gets its own maps, since a transaction overlays its buffer onto the image
44
+ // and a flush bumps `revs`; the row arrays, row records and table definitions are shared, and nothing changes them in place.
45
+ const loadMemos = new WeakMap();
46
+ /** The action log folded into a `Database` image plus the session book. The maps (tables, rows, sessions) are the
47
+ * caller's own; the row arrays, row records, table definitions and `sessionIds` are shared and must not be changed in
48
+ * place (replace an array to change it, as `applyWritesToImage` does). */
49
+ export function loadState(root, databaseName, scope) {
50
+ const store = getActiveWorldStore();
51
+ const memos = loadMemos.get(store) ?? loadMemos.set(store, new Map()).get(store);
52
+ const key = `${root ?? ''}\u0000${databaseName}\u0000${scope ?? ''}`;
53
+ // the stamp is read before the fold: a write between them leaves a newer fold under an older stamp, which the next
54
+ // read refolds; never an older fold under a newer stamp
55
+ let held = memos.get(key);
56
+ // a write moves the stamp; the image follows it by the rows and sessions the write touched (a refold per
57
+ // write made each statement's cost, and its garbage, grow with the whole database: 2.4 GB after 6,000 rows)
58
+ const delta = held ? treeChangesSince(SERVICE, root, held.stamp) : undefined;
59
+ if (held && delta && patchState(held.loaded, delta, scope))
60
+ held.stamp = delta.stamp;
61
+ else {
62
+ const stamp = treeStamp(SERVICE, root);
63
+ if (!held || held.stamp !== stamp) {
64
+ held = { stamp, loaded: foldState(root, databaseName, scope) };
65
+ memos.set(key, held);
66
+ }
67
+ }
68
+ const { db, sessions, sessionIds, revs } = held.loaded;
69
+ return {
70
+ // the maps are the caller's own; the row arrays are shared, and every writer replaces an array rather than changing
71
+ // it (copying each table's rows for every statement made a statement's cost, and its garbage, grow with the database)
72
+ db: { name: db.name, tables: new Map(db.tables), rows: new Map(db.rows) },
73
+ sessions: new Map(sessions),
74
+ sessionIds, // shared: a new session replaces the array (patchState), and a caller only reads it
75
+ revs: new RevBook(revs),
76
+ };
77
+ }
78
+ /** Bring a folded image forward by the subjects written since it was folded, as `foldState` would fold them.
79
+ * False (the caller refolds) when the change is one only a whole fold decides: a table's (the orphan-row
80
+ * rule reads the final table set) or a subject gone from the tree (a refold would drop its `_rev`). */
81
+ function patchState(loaded, delta, scope) {
82
+ if (delta.removed.length)
83
+ return false;
84
+ if (delta.changed.some((r) => r.type === 'table' || r.type === '_branch_table'))
85
+ return false;
86
+ const rowType = scope ? '_branch_row' : 'row';
87
+ const { db, sessions, revs } = loaded;
88
+ const copied = new Set(); // a table's row array is replaced, never changed: readers may hold the old one
89
+ for (const r of delta.changed) {
90
+ const rec = ownFields(r);
91
+ if (typeof rec._rev === 'number')
92
+ revs.set(r.id, Math.max(revs.get(r.id) ?? 0, rec._rev));
93
+ const gone = rec.gone === true || rec.deleted === true;
94
+ if (r.type === rowType || r.type === 'row' || r.type === '_branch_row') {
95
+ if (r.type !== rowType || (r.type === '_branch_row' && rec.scope !== scope))
96
+ continue;
97
+ const name = String(rec.table_name ?? '');
98
+ const rid = typeof rec.rowid === 'number' ? rec.rowid : Number(rec.rowid);
99
+ if (name === '' || !Number.isFinite(rid))
100
+ return false;
101
+ if (!db.tables.has(tableKey(name)))
102
+ continue; // an orphan, as the fold drops it
103
+ const key = tableKey(name);
104
+ if (!copied.has(key)) {
105
+ db.rows.set(key, [...(db.rows.get(key) ?? [])]);
106
+ copied.add(key);
107
+ }
108
+ const rows = db.rows.get(key);
109
+ let lo = 0;
110
+ let hi = rows.length;
111
+ while (lo < hi) {
112
+ const mid = (lo + hi) >> 1;
113
+ if (rows[mid].rowid < rid)
114
+ lo = mid + 1;
115
+ else
116
+ hi = mid;
117
+ }
118
+ const found = rows[lo]?.rowid === rid;
119
+ if (gone) {
120
+ if (found)
121
+ rows.splice(lo, 1);
122
+ }
123
+ else {
124
+ const row = { rowid: rid, cells: (rec.cells ?? {}) };
125
+ if (found)
126
+ rows[lo] = row;
127
+ else
128
+ rows.splice(lo, 0, row);
129
+ }
130
+ continue;
131
+ }
132
+ if (r.type === '_session') {
133
+ const id = String(rec.sid ?? '');
134
+ if (id === '')
135
+ continue;
136
+ // a session's id and scope are fixed at creation; only what it holds changes
137
+ if (gone)
138
+ return false;
139
+ if (!sessions.has(id) && !loaded.sessionIds.includes(id))
140
+ loaded.sessionIds = [...loaded.sessionIds, id];
141
+ if ((typeof rec.scope === 'string' ? rec.scope : undefined) !== scope)
142
+ continue;
143
+ sessions.set(id, sessionOf(id, rec));
144
+ }
145
+ }
146
+ // an emptied table holds no array, as the fold leaves it
147
+ for (const key of copied)
148
+ if (db.rows.get(key).length === 0)
149
+ db.rows.delete(key);
150
+ return true;
151
+ }
152
+ /** Fold the action log into a `Database` image plus the session book. */
153
+ function foldState(root, databaseName, scope) {
154
+ const db = emptyDatabase(databaseName);
155
+ const sessions = new Map();
156
+ const sessionIds = [];
157
+ const revs = new Map();
158
+ const tableType = scope ? '_branch_table' : 'table';
159
+ const rowType = scope ? '_branch_row' : 'row';
160
+ for (const r of twinResources(SERVICE, root)) {
161
+ const rec = ownFields(r);
162
+ // Record the ordinal FIRST, for deleted subjects too: a dropped table or a deleted row that is
163
+ // later recreated must CONTINUE the sequence rather than restart it, or the same content could
164
+ // collide with its own earlier action all over again.
165
+ if (typeof rec._rev === 'number')
166
+ revs.set(r.id, Math.max(revs.get(r.id) ?? 0, rec._rev));
167
+ if (rec.gone === true || rec.deleted === true)
168
+ continue;
169
+ if ((r.type === '_branch_table' || r.type === '_branch_row') && rec.scope !== scope)
170
+ continue;
171
+ if (r.type === tableType) {
172
+ const table = {
173
+ name: String(rec.name ?? ''),
174
+ columns: (rec.columns ?? []),
175
+ primaryKey: (rec.primary_key ?? []),
176
+ uniques: (rec.uniques ?? []),
177
+ autoIncrement: typeof rec.auto_increment === 'number' ? rec.auto_increment : 1,
178
+ ...(Array.isArray(rec.indexes) && rec.indexes.length ? { indexes: rec.indexes } : {}),
179
+ ...(typeof rec.charset === 'string' ? { charset: rec.charset } : {}),
180
+ ...(typeof rec.collation === 'string' ? { collation: rec.collation } : {}),
181
+ };
182
+ if (table.name === '')
183
+ continue;
184
+ db.tables.set(tableKey(table.name), withColumnCollations(table));
185
+ continue;
186
+ }
187
+ if (r.type === rowType) {
188
+ const name = String(rec.table_name ?? '');
189
+ const rid = typeof rec.rowid === 'number' ? rec.rowid : Number(rec.rowid);
190
+ if (name === '' || !Number.isFinite(rid))
191
+ continue;
192
+ const bucket = db.rows.get(tableKey(name)) ?? [];
193
+ bucket.push({ rowid: rid, cells: (rec.cells ?? {}) });
194
+ db.rows.set(tableKey(name), bucket);
195
+ continue;
196
+ }
197
+ if (r.type === '_session') {
198
+ const id = String(rec.sid ?? '');
199
+ if (id === '')
200
+ continue;
201
+ sessionIds.push(id);
202
+ if ((typeof rec.scope === 'string' ? rec.scope : undefined) !== scope)
203
+ continue;
204
+ sessions.set(id, sessionOf(id, rec));
205
+ }
206
+ }
207
+ // Rows project in log order, which is insertion order for a table nobody has updated — but an
208
+ // UPDATE re-appends its subject, so the projection order is "last write wins" order, not row
209
+ // order. MySQL without an ORDER BY makes no ordering promise either, but a twin that reshuffles
210
+ // rows on every update is undebuggable, so rows are presented in stable rowid order.
211
+ for (const bucket of db.rows.values())
212
+ bucket.sort((a, b) => a.rowid - b.rowid);
213
+ // A table with no `table` subject cannot exist; drop orphan rows so a dropped-and-recreated table
214
+ // can never inherit the previous incarnation's rows.
215
+ for (const key of [...db.rows.keys()])
216
+ if (!db.tables.has(key))
217
+ db.rows.delete(key);
218
+ return { db, sessions, sessionIds, revs };
219
+ }
220
+ /** Apply a buffered write list to an in-memory image (the transaction overlay). */
221
+ export function applyWritesToImage(db, writes) {
222
+ // a table's array is replaced, never changed (the image's arrays are shared with other readers): copied once per
223
+ // call with a rowid index, and put back in rowid order once at the end (a search and a sort per write made a bulk
224
+ // write list quadratic)
225
+ const owned = new Map();
226
+ const own = (key) => {
227
+ let held = owned.get(key);
228
+ if (!held) {
229
+ const rows = [...(db.rows.get(key) ?? [])];
230
+ held = { rows, at: new Map(rows.map((r, i) => [r.rowid, i])) };
231
+ owned.set(key, held);
232
+ db.rows.set(key, rows);
233
+ }
234
+ return held;
235
+ };
236
+ for (const w of writes) {
237
+ if (w.kind === 'table') {
238
+ db.tables.set(tableKey(w.table.name), w.table);
239
+ if (!db.rows.has(tableKey(w.table.name)))
240
+ db.rows.set(tableKey(w.table.name), []);
241
+ continue;
242
+ }
243
+ if (w.kind === 'drop-table') {
244
+ db.tables.delete(tableKey(w.table));
245
+ db.rows.delete(tableKey(w.table));
246
+ owned.delete(tableKey(w.table));
247
+ continue;
248
+ }
249
+ const held = own(tableKey(w.table));
250
+ if (w.kind === 'row') {
251
+ const i = held.at.get(w.row.rowid);
252
+ if (i !== undefined)
253
+ held.rows[i] = w.row;
254
+ else {
255
+ held.at.set(w.row.rowid, held.rows.length);
256
+ held.rows.push(w.row);
257
+ }
258
+ continue;
259
+ }
260
+ const i = held.at.get(w.rowid);
261
+ if (i === undefined)
262
+ continue;
263
+ // the last row takes the deleted one's place; the order is restored at the end
264
+ const last = held.rows.pop();
265
+ held.at.delete(w.rowid);
266
+ if (i < held.rows.length) {
267
+ held.rows[i] = last;
268
+ held.at.set(last.rowid, i);
269
+ }
270
+ }
271
+ for (const { rows } of owned.values())
272
+ rows.sort((a, b) => a.rowid - b.rowid);
273
+ }
274
+ function bumpRev(ctx, subject) {
275
+ const next = (ctx.revs.get(subject) ?? 0) + 1;
276
+ ctx.revs.set(subject, next);
277
+ return next;
278
+ }
279
+ function sessionResource(ctx, s) {
280
+ const id = sessionId(s.id);
281
+ return { type: '_session', id, fields: {
282
+ sid: s.id, in_transaction: s.inTransaction, pending: s.pending,
283
+ pending_plans: s.pendingPlans, locks: (s.locks ?? []), read_only: s.readOnly === true,
284
+ session_access: s.sessionAccess ?? null, next_access: s.nextAccess ?? null, closed: s.closed, gone: false, _rev: bumpRev(ctx, id),
285
+ ...(s.scope ? { scope: s.scope } : {}),
286
+ } };
287
+ }
288
+ function assertUpstreamWriteIdentities(root, db, writes, scope) {
289
+ // a branch scope has no upstream: its subjects are never pushed or observed
290
+ if (scope)
291
+ return;
292
+ // A local tombstone cannot release an address still occupied upstream. Check that view
293
+ // separately before minting rows, including writes buffered inside a transaction.
294
+ if (writes.some(w => w.kind === 'row')) {
295
+ const upstream = readParentTreeMap(SERVICE, root);
296
+ for (const write of writes) {
297
+ if (write.kind !== 'row')
298
+ continue;
299
+ const table = db.tables.get(tableKey(write.table));
300
+ if (!table?.primaryKey.length)
301
+ continue;
302
+ const id = keyedRowId(table, write.row.cells);
303
+ const prior = upstream.get(`row:${rowId(table.name, id)}`);
304
+ if (prior && prior.fields.deleted !== true && prior.fields.gone !== true && JSON.stringify(keyedRowIdentity(table, prior.fields.cells)) !== JSON.stringify(keyedRowIdentity(table, write.row.cells))) {
305
+ throw unsupported(`row identity hash collision in ${table.name}`);
306
+ }
307
+ }
308
+ }
309
+ }
310
+ async function flushWrites(ctx, writes, plans, session) {
311
+ // Refresh may have changed upstream while this transaction was buffered. Both COMMIT and
312
+ // BEGIN's implicit commit pass here and must revalidate before projecting any buffered row.
313
+ if (writes.some(w => w.kind === 'row')) {
314
+ const current = loadState(ctx.root, DEFAULT_DATABASE, ctx.scope).db;
315
+ // A sibling local session can occupy the address without changing upstream. Validate
316
+ // the held image before replaying the buffer, which would otherwise overwrite that row.
317
+ for (const write of writes) {
318
+ if (write.kind !== 'row')
319
+ continue;
320
+ const table = current.tables.get(tableKey(write.table));
321
+ const held = current.rows.get(tableKey(write.table))?.find(r => r.rowid === write.row.rowid);
322
+ if (table?.primaryKey.length && held && JSON.stringify(keyedRowIdentity(table, held.cells)) !== JSON.stringify(keyedRowIdentity(table, write.row.cells))) {
323
+ throw unsupported(`row identity hash collision in ${table.name}`);
324
+ }
325
+ }
326
+ applyWritesToImage(current, writes);
327
+ assertUpstreamWriteIdentities(ctx.root, current, writes, ctx.scope);
328
+ }
329
+ // One logical SQL change is one action. Coalescing also preserves delete→reinsert within a
330
+ // transaction: its last image wins, rather than a projection delete erasing the reinsert.
331
+ const resources = new Map();
332
+ const scope = ctx.scope;
333
+ const tableType = scope ? '_branch_table' : 'table';
334
+ const rowType = scope ? '_branch_row' : 'row';
335
+ const scoped = scope ? { scope } : {};
336
+ for (const w of writes) {
337
+ let resource;
338
+ if (w.kind === 'table') {
339
+ const id = tableId(w.table.name, scope);
340
+ resource = { type: tableType, id, fields: { name: w.table.name, columns: w.table.columns,
341
+ primary_key: w.table.primaryKey, uniques: w.table.uniques,
342
+ indexes: (w.table.indexes ?? []), charset: w.table.charset ?? null, collation: w.table.collation ?? null,
343
+ auto_increment: w.table.autoIncrement, gone: false, _rev: bumpRev(ctx, id), ...scoped } };
344
+ }
345
+ else if (w.kind === 'drop-table') {
346
+ const id = tableId(w.table, scope);
347
+ resource = { type: tableType, id, fields: { name: w.table, columns: [], primary_key: [], uniques: [],
348
+ auto_increment: 1, gone: true, _rev: bumpRev(ctx, id), ...scoped } };
349
+ }
350
+ else {
351
+ const rid = w.kind === 'row' ? w.row.rowid : w.rowid;
352
+ const id = rowId(w.table, rid, scope);
353
+ resource = { type: rowType, id, fields: { table_name: w.table, rowid: rid,
354
+ cells: w.kind === 'row' ? w.row.cells : {}, gone: w.kind === 'delete-row', _rev: bumpRev(ctx, id), ...scoped } };
355
+ }
356
+ resources.set(`${resource.type}:${resource.id}`, resource);
357
+ }
358
+ if (!resources.size) {
359
+ if (session)
360
+ await writeSession(ctx, session);
361
+ return;
362
+ }
363
+ const [subject, ...rest] = [...resources.values()];
364
+ if (session)
365
+ rest.push(sessionResource(ctx, session));
366
+ await applyTwinWrite(SERVICE, {
367
+ operation: scope ? 'branch.sql.apply' : 'sql.apply', subjectType: subject.type, subjectId: subject.id, fields: subject.fields,
368
+ ...(scope ? {} : { input: { plan: combinePlans(plans) } }), projection: { updates: rest },
369
+ occurredAt: ctx.occurredAt, actor: { kind: 'agent' },
370
+ }, ctx.root);
371
+ }
372
+ async function writeSession(ctx, s) {
373
+ const resource = sessionResource(ctx, s);
374
+ await applyTwinWrite(SERVICE, { operation: 'session.write', subjectType: resource.type,
375
+ subjectId: resource.id, fields: resource.fields, occurredAt: ctx.occurredAt, actor: { kind: 'agent' },
376
+ }, ctx.root);
377
+ }
378
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
379
+ // SERIALIZATION
380
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
381
+ /**
382
+ * One in-process promise chain per state root, so every mutating entry point runs to completion
383
+ * before the next one starts.
384
+ *
385
+ * This is NOT belt-and-braces. `executeSql` reads the whole projection (`loadState`, which snapshots
386
+ * the per-subject `_rev` watermarks and the row ids) and only later writes it back — with `await`
387
+ * points in between. `Bun.serve` dispatches concurrently, so two overlapping `Execute` requests on
388
+ * one root would compute the SAME next `_rev` and the SAME `nextRowId` from the same stale snapshot,
389
+ * and the writes could land on the same subject.
390
+ *
391
+ * §9 round one, finding 11: three documents already ASSERTED this ("the twin is single-writer and
392
+ * serialized") while nothing in the code provided it. The honest options were to drop the claim or
393
+ * to make it true; this makes it true, which is also the behaviour a caller expects from a database.
394
+ *
395
+ * WHAT IT DOES NOT COVER, stated rather than implied (§9 round two, finding 7): the gate wraps the
396
+ * PSDB ENTRY POINTS — `executeSql`, `createSession`, `closeSession`. The CONNECTOR's write paths
397
+ * (`syncPlanetscaleFromReal` → `observeResources`, `pushPlanetscaleAction` → `confirmAction`) go straight to
398
+ * the kernel and take its own file locks instead; they are safe, but they are not on this chain. And
399
+ * the gate is in-process only, so two SEPARATE processes over one root are ordered solely by that
400
+ * same kernel locking.
401
+ */
402
+ const gates = new Map(); // cache: active in-process writer queues, never database state
403
+ function serialized(root, run) {
404
+ const key = root ?? '<default-root>';
405
+ const prior = gates.get(key) ?? Promise.resolve();
406
+ // `catch` on the CHAIN (not on `run`): a failed statement must not poison every later one, and
407
+ // the caller still receives the original rejection from `next`.
408
+ const next = prior.then(run, run);
409
+ const settled = next.then(() => undefined, () => undefined);
410
+ gates.set(key, settled);
411
+ // Drop the entry once this root goes quiet, so a process that opens many roots (every verify
412
+ // uses a fresh temp dir) does not retain one closure per root for its lifetime.
413
+ void settled.then(() => { if (gates.get(key) === settled)
414
+ gates.delete(key); });
415
+ return next;
416
+ }
417
+ /** Mint a session id deterministically from the state that already exists — never entropy, never a
418
+ * row count. The serve path must be a pure function of (request, stored state), so a session id is
419
+ * derived from the ids already present rather than from `crypto.randomUUID()`. */
420
+ export function mintSessionId(existing, occurredAt) {
421
+ let max = 0;
422
+ for (const id of existing) {
423
+ const m = /^tws-(\d+)-/.exec(id);
424
+ if (m !== null)
425
+ max = Math.max(max, Number(m[1]));
426
+ }
427
+ // The instant is part of the id so two sessions minted from different requests differ even when
428
+ // the projection they were minted from is identical — but the COUNTER is what guarantees
429
+ // uniqueness, since two requests can share a millisecond.
430
+ return `tws-${max + 1}-${Date.parse(occurredAt) || 0}`;
431
+ }
432
+ /** Open a new session. */
433
+ async function createSessionUnlocked(ctx) {
434
+ const { sessionIds, revs } = loadState(ctx.root, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
435
+ const state = { id: mintSessionId(sessionIds, ctx.occurredAt), inTransaction: false, pending: [], pendingPlans: [], closed: false, ...(ctx.scope ? { scope: ctx.scope } : {}) };
436
+ await writeSession({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, revs }, state);
437
+ return state;
438
+ }
439
+ /** Close a session, discarding any uncommitted transaction — exactly what a dropped connection does. */
440
+ async function closeSessionUnlocked(ctx, id) {
441
+ const { sessions, revs } = loadState(ctx.root, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
442
+ const existing = sessions.get(id);
443
+ if (existing === undefined || existing.closed)
444
+ return false;
445
+ await writeSession({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, revs }, { ...existing, inTransaction: false, pending: [], pendingPlans: [], locks: [], closed: true });
446
+ transactionEnded(ctx.root, id);
447
+ return true;
448
+ }
449
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
450
+ // ROW LOCKS
451
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
452
+ //
453
+ // An open transaction holds InnoDB-style exclusive row locks until it ends: on every row it has
454
+ // written (its buffered writes) and on every row a locking read (`FOR UPDATE`/`FOR SHARE`)
455
+ // returned. A statement in ANOTHER session whose writes or locking read touch one of those rows,
456
+ // or would claim a PRIMARY/UNIQUE key value the transaction has claimed, waits for that
457
+ // transaction to end and then runs against what it committed, exactly as a blocked InnoDB
458
+ // statement re-reads the latest version. Plain reads never wait (consistent nonlocking reads).
459
+ // A wait that would close a cycle is MySQL's deadlock (errno 1213, the waiter's transaction rolled
460
+ // back); a wait longer than innodb_lock_wait_timeout (50 s) is errno 1205.
461
+ //
462
+ // FOR SHARE is held exclusively here, which is stricter than InnoDB's shared lock: two readers of
463
+ // one row serialize instead of both proceeding. The rows a locking read locks are the rows it
464
+ // returned; InnoDB's gap and next-key locks on the ranges it scanned are not modeled.
465
+ /** The lock keys of one row: its PRIMARY KEY value and every UNIQUE value it holds
466
+ * (`table:u:name:[values]`), or, in a table with no primary key, its hidden row id. Keys are VALUES,
467
+ * never the engine's hashed row identities, so two rows whose identities collide never share a lock. */
468
+ function rowKeys(db, table, rowid, cells, into) {
469
+ const def = db.tables.get(tableKey(table));
470
+ if (def === undefined || def.primaryKey.length === 0)
471
+ into.add(`${tableKey(table)}:row:${rowid}`);
472
+ if (def === undefined || cells === undefined)
473
+ return;
474
+ const uniques = [
475
+ ...(def.primaryKey.length ? [{ name: 'PRIMARY', columns: def.primaryKey }] : []),
476
+ ...def.uniques,
477
+ ...def.columns.filter((c) => c.unique).map((c) => ({ name: c.name, columns: [c.name] })),
478
+ ];
479
+ for (const u of uniques) {
480
+ const values = u.columns.map((n) => cells[Object.keys(cells).find((k) => k.toLowerCase() === n.toLowerCase()) ?? n] ?? null);
481
+ if (values.some((v) => v === null))
482
+ continue;
483
+ // Keyed by the column's collation, as the index is: two sessions claiming 'A@x' and 'a@x' in a
484
+ // case-insensitive UNIQUE column contend for one key.
485
+ const parts = values.map((v, i) => uniqueKeyPart(def.columns.find((c) => c.name.toLowerCase() === u.columns[i].toLowerCase()), v));
486
+ into.add(`${tableKey(table)}:u:${u.name.toLowerCase()}:${JSON.stringify(parts)}`);
487
+ }
488
+ }
489
+ /** The keys a write list (and a locking read's rows) lock. A deleted or locked row's values are read
490
+ * from `db`, the image the rows are still in. */
491
+ function rowLockKeys(db, writes, locked = []) {
492
+ const keys = new Set();
493
+ const held = (table, rowid) => db.rows.get(tableKey(table))?.find((r) => r.rowid === rowid)?.cells;
494
+ for (const w of writes) {
495
+ if (w.kind === 'row') {
496
+ rowKeys(db, w.table, w.row.rowid, w.row.cells, keys);
497
+ // An update also locks the unique values it replaces. The prior image is the same ROW only
498
+ // when its primary key matches: a row id is a hash of the key, and a colliding id names a
499
+ // different row (which the write refuses elsewhere) that this statement does not lock.
500
+ const def = db.tables.get(tableKey(w.table));
501
+ const before = held(w.table, w.row.rowid);
502
+ if (before !== undefined && def !== undefined && JSON.stringify(keyedRowIdentity(def, before)) === JSON.stringify(keyedRowIdentity(def, w.row.cells)))
503
+ rowKeys(db, w.table, w.row.rowid, before, keys);
504
+ }
505
+ else if (w.kind === 'delete-row')
506
+ rowKeys(db, w.table, w.rowid, held(w.table, w.rowid), keys);
507
+ else
508
+ keys.add(`${tableKey(w.kind === 'table' ? w.table.name : w.table)}:ddl`);
509
+ }
510
+ for (const l of locked)
511
+ rowKeys(db, l.table, l.rowid, held(l.table, l.rowid), keys);
512
+ return keys;
513
+ }
514
+ /** A statement that must wait for `holder`'s transaction to end before it can run. */
515
+ class LockWait {
516
+ holder;
517
+ constructor(holder) {
518
+ this.holder = holder;
519
+ }
520
+ }
521
+ const liveTransactions = new Map(); // cache: in-process lock waits
522
+ function lockBook(root) {
523
+ const key = root ?? '<default-root>';
524
+ let book = liveTransactions.get(key);
525
+ if (book === undefined) {
526
+ book = { live: new Map(), waitsFor: new Map() };
527
+ liveTransactions.set(key, book);
528
+ }
529
+ return book;
530
+ }
531
+ function transactionStarted(root, id) {
532
+ const book = lockBook(root);
533
+ if (book.live.has(id))
534
+ return;
535
+ let end = () => { };
536
+ const ended = new Promise((resolve) => { end = resolve; });
537
+ book.live.set(id, { ended, end });
538
+ }
539
+ function transactionEnded(root, id) {
540
+ const book = lockBook(root);
541
+ const entry = book.live.get(id);
542
+ if (entry === undefined)
543
+ return;
544
+ book.live.delete(id);
545
+ entry.end();
546
+ }
547
+ /** Throw `LockWait` when another live transaction holds a key this statement needs. */
548
+ function assertUnlocked(root, db, sessions, self, keys) {
549
+ if (keys.size === 0)
550
+ return;
551
+ const book = lockBook(root);
552
+ for (const other of sessions.values()) {
553
+ if (other.id === self || !other.inTransaction || !book.live.has(other.id))
554
+ continue;
555
+ const theirs = rowLockKeys(db, other.pending);
556
+ for (const k of other.locks ?? [])
557
+ theirs.add(k);
558
+ for (const k of keys)
559
+ if (theirs.has(k))
560
+ throw new LockWait(other.id);
561
+ }
562
+ }
563
+ // innodb_lock_wait_timeout's default. The wait is measured in elapsed real time, not on the World
564
+ // clock: a blocked statement is a request in flight, and the World clock only advances between
565
+ // requests, so no World-clock reading can end a wait that nothing else ends.
566
+ const LOCK_WAIT_TIMEOUT_MS = 50_000;
567
+ /** Wait for `holder`'s transaction to end, or fail with MySQL's deadlock / lock-wait-timeout. */
568
+ async function waitForLock(ctx, waiter, holder) {
569
+ const book = lockBook(ctx.root);
570
+ const entry = book.live.get(holder);
571
+ if (entry === undefined)
572
+ return; // it ended between the check and now
573
+ if (waiter !== null) {
574
+ const seen = new Set();
575
+ for (let t = holder; t !== undefined && !seen.has(t); t = book.waitsFor.get(t)) {
576
+ seen.add(t);
577
+ if (t !== waiter)
578
+ continue;
579
+ // A cycle: MySQL rolls back the transaction that would close it and reports errno 1213.
580
+ await serialized(ctx.root, async () => {
581
+ const { sessions, revs } = loadState(ctx.root, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
582
+ const mine = sessions.get(waiter);
583
+ if (mine !== undefined && mine.inTransaction) {
584
+ await writeSession({ ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, revs }, { ...mine, inTransaction: false, pending: [], pendingPlans: [], locks: [] });
585
+ }
586
+ transactionEnded(ctx.root, waiter);
587
+ });
588
+ throw new SqlError('Deadlock found when trying to get lock; try restarting transaction', 'ABORTED', 1213, '40001');
589
+ }
590
+ book.waitsFor.set(waiter, holder);
591
+ }
592
+ let timer;
593
+ try {
594
+ const timedOut = await Promise.race([
595
+ entry.ended.then(() => false),
596
+ new Promise((resolve) => { timer = setTimeout(() => resolve(true), LOCK_WAIT_TIMEOUT_MS); }),
597
+ ]);
598
+ if (timedOut)
599
+ throw new SqlError('Lock wait timeout exceeded; try restarting transaction', 'DEADLINE_EXCEEDED', 1205, 'HY000');
600
+ }
601
+ finally {
602
+ if (timer !== undefined)
603
+ clearTimeout(timer);
604
+ if (waiter !== null && book.waitsFor.get(waiter) === holder)
605
+ book.waitsFor.delete(waiter);
606
+ }
607
+ }
608
+ /**
609
+ * Execute ONE statement in the named session (creating the session when the client sent none —
610
+ * which is exactly what a first `conn.execute()` does: the client's `session` starts null).
611
+ */
612
+ async function executeSqlUnlocked(ctx, sql, incomingSessionId) {
613
+ const databaseName = ctx.database ?? DEFAULT_DATABASE;
614
+ const { db, sessions, sessionIds, revs } = loadState(ctx.root, databaseName, ctx.scope);
615
+ const flushCtx = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, revs, ...(ctx.scope ? { scope: ctx.scope } : {}) };
616
+ let session = incomingSessionId === null ? undefined : sessions.get(incomingSessionId);
617
+ if (session !== undefined && session.closed)
618
+ session = undefined;
619
+ let sessionIsNew = false;
620
+ if (session === undefined) {
621
+ session = { id: mintSessionId(sessionIds, ctx.occurredAt), inTransaction: false, pending: [], pendingPlans: [], closed: false, ...(ctx.scope ? { scope: ctx.scope } : {}) };
622
+ sessionIsNew = true;
623
+ }
624
+ const stmt = parseStatement(sql, ctx.vars?.sqlMode);
625
+ // A transaction this process did not open was opened by one that is gone (the server restarted
626
+ // under it). Its row locks died with that process, so other sessions may already have written
627
+ // the rows it buffered: committing the buffer now would overwrite them. MySQL rolls back the
628
+ // transaction of a connection that dies; so does this, and the session learns it on its next
629
+ // statement (a ROLLBACK simply succeeds).
630
+ if (session.inTransaction && !lockBook(ctx.root).live.has(session.id)) {
631
+ session = { ...session, inTransaction: false, pending: [], pendingPlans: [], locks: [], readOnly: false };
632
+ await writeSession(flushCtx, session);
633
+ if (stmt.kind === 'rollback')
634
+ return { outcome: { fields: [], rows: [], rowsAffected: 0, insertId: 0, writes: [], transaction: 'rollback' }, session };
635
+ throw new SqlError('Lost connection to MySQL server during query: the twin restarted while this transaction was open, so it was rolled back', 'ABORTED', 2013, 'HY000');
636
+ }
637
+ // SET TRANSACTION holds the access mode for the next transaction (or, with SESSION, for every
638
+ // later one); the next-transaction form is refused inside an open transaction (errno 1568).
639
+ if (stmt.kind === 'set-transaction') {
640
+ if (stmt.scope === 'next' && session.inTransaction) {
641
+ throw new SqlError("Transaction characteristics can't be changed while a transaction is in progress", 'FAILED_PRECONDITION', 1568, '25001');
642
+ }
643
+ if (stmt.access !== undefined) {
644
+ session = stmt.scope === 'next' ? { ...session, nextAccess: stmt.access } : { ...session, sessionAccess: stmt.access };
645
+ await writeSession(flushCtx, session);
646
+ }
647
+ else if (sessionIsNew)
648
+ await writeSession(flushCtx, session);
649
+ return { outcome: { fields: [], rows: [], rowsAffected: 0, insertId: 0, writes: [] }, session };
650
+ }
651
+ // The access mode this statement runs under: the open transaction's, else (an autocommit
652
+ // statement is a transaction of its own, and so is the one BEGIN opens) the one SET TRANSACTION
653
+ // named for the next transaction, else the session's.
654
+ const startsTransaction = !session.inTransaction || stmt.kind === 'begin';
655
+ const nextAccess = startsTransaction ? (stmt.kind === 'begin' ? stmt.access : undefined) ?? session.nextAccess ?? session.sessionAccess : undefined;
656
+ const readOnlyTransaction = startsTransaction ? nextAccess === 'READ ONLY' : session.readOnly === true;
657
+ const consumedNext = startsTransaction && session.nextAccess !== undefined;
658
+ if (consumedNext) {
659
+ const { nextAccess: _consumed, ...rest } = session;
660
+ session = rest;
661
+ }
662
+ // Other transactions' locks are judged against committed state, before this session's own
663
+ // buffer is overlaid (their buffered deletes name rows by their committed images).
664
+ const committed = session.inTransaction && session.pending.length > 0
665
+ ? { name: db.name, tables: new Map(db.tables), rows: new Map(db.rows) } // the arrays are shared: the overlay replaces them
666
+ : db;
667
+ // Inside an open transaction the statement runs against committed state PLUS the buffer, so it
668
+ // reads its own uncommitted writes.
669
+ if (session.inTransaction && session.pending.length > 0)
670
+ applyWritesToImage(db, session.pending);
671
+ let outcome;
672
+ try {
673
+ outcome = execStatement(db, stmt, {
674
+ now: ctx.occurredAt, ...(ctx.readOnly === true ? { readOnly: true } : {}), ...(ctx.vars !== undefined ? { vars: ctx.vars } : {}),
675
+ ...(readOnlyTransaction && stmt.kind !== 'begin' ? { transactionReadOnly: true } : {}),
676
+ });
677
+ }
678
+ catch (error) {
679
+ // The failed statement was the transaction SET TRANSACTION named: its characteristics are spent.
680
+ if (consumedNext)
681
+ await writeSession(flushCtx, session);
682
+ throw error;
683
+ }
684
+ if (outcome.transaction === 'begin') {
685
+ const next = { ...session, inTransaction: true, pending: [], pendingPlans: [], locks: [], readOnly: readOnlyTransaction };
686
+ await flushWrites(flushCtx, session.inTransaction ? session.pending : [], session.pendingPlans, next);
687
+ transactionEnded(ctx.root, session.id);
688
+ transactionStarted(ctx.root, session.id);
689
+ return { outcome, session: next };
690
+ }
691
+ if (outcome.transaction === 'commit') {
692
+ const next = { ...session, inTransaction: false, pending: [], pendingPlans: [], locks: [], readOnly: false };
693
+ await flushWrites(flushCtx, session.inTransaction ? session.pending : [], session.pendingPlans, next);
694
+ transactionEnded(ctx.root, session.id);
695
+ return { outcome, session: next };
696
+ }
697
+ if (outcome.transaction === 'rollback') {
698
+ session = { ...session, inTransaction: false, pending: [], pendingPlans: [], locks: [], readOnly: false };
699
+ await writeSession(flushCtx, session);
700
+ transactionEnded(ctx.root, session.id);
701
+ return { outcome, session };
702
+ }
703
+ assertUpstreamWriteIdentities(ctx.root, db, outcome.writes, ctx.scope);
704
+ const prepared = prepareSql(db, outcome.writes, stmt.kind, sql);
705
+ const lockedRows = outcome.locked ?? [];
706
+ assertUnlocked(ctx.root, committed, sessions, session.id, rowLockKeys(db, prepared.writes, lockedRows));
707
+ if (session.inTransaction) {
708
+ const locks = lockedRows.length ? [...new Set([...(session.locks ?? []), ...rowLockKeys(db, [], lockedRows)])] : session.locks;
709
+ const changed = prepared.writes.length > 0 || locks !== session.locks || consumedNext;
710
+ session = { ...session, pending: [...session.pending, ...prepared.writes],
711
+ pendingPlans: [...session.pendingPlans, ...(prepared.writes.length ? [prepared.plan] : [])], ...(locks !== undefined ? { locks } : {}) };
712
+ // A read inside a transaction changes nothing the session records, so it writes nothing.
713
+ if (changed || sessionIsNew)
714
+ await writeSession(flushCtx, session);
715
+ return { outcome, session };
716
+ }
717
+ const sessionChanged = sessionIsNew || consumedNext;
718
+ if (prepared.writes.length > 0)
719
+ await flushWrites(flushCtx, prepared.writes, [prepared.plan], sessionChanged ? session : undefined);
720
+ else if (sessionChanged)
721
+ await writeSession(flushCtx, session);
722
+ return { outcome, session };
723
+ }
724
+ // The PUBLIC entry points: every one runs inside its root's serialization gate (see `serialized`).
725
+ /** Open a new session. */
726
+ export const createSession = (ctx) => serialized(ctx.root, () => createSessionUnlocked(ctx));
727
+ /** Close a session, discarding any uncommitted transaction. */
728
+ export const closeSession = (ctx, id) => serialized(ctx.root, () => closeSessionUnlocked(ctx, id));
729
+ /** Execute ONE statement in the named session. A statement blocked by another transaction's row
730
+ * locks waits OUTSIDE the serialization gate, so the holder's COMMIT can run, then re-executes. */
731
+ export async function executeSql(ctx, sql, incomingSessionId) {
732
+ for (;;) {
733
+ try {
734
+ return await serialized(ctx.root, () => executeSqlUnlocked(ctx, sql, incomingSessionId));
735
+ }
736
+ catch (error) {
737
+ if (!(error instanceof LockWait))
738
+ throw error;
739
+ await waitForLock(ctx, incomingSessionId, error.holder);
740
+ }
741
+ }
742
+ }
743
+ /**
744
+ * The CURRENT state of one session, or undefined. Read by the request handler's error path: a
745
+ * statement that FAILS inside an open transaction must still echo `inTransaction: true`, because
746
+ * MySQL does not end a transaction on a failed statement (§9 round one, finding 10 — the handler
747
+ * used to answer a flat `false` and tell the caller the transaction had ended when it had not).
748
+ */
749
+ export function sessionState(root, id, databaseName = DEFAULT_DATABASE, scope) {
750
+ return loadState(root, databaseName, scope).sessions.get(id);
751
+ }
752
+ /** Read-only projection helper — used by the connector's push-free inspection and by tests. */
753
+ export function readDatabase(root, databaseName = DEFAULT_DATABASE, scope) {
754
+ return loadState(root, databaseName, scope).db;
755
+ }
756
+ /** The named projections this pack serves through the store door (planetscale-server.ts). */
757
+ export const STORE_NAMES = ['tables', 'rows'];
758
+ /**
759
+ * THE STORE DOOR projection (`GET /twin/store/<tables|rows>`, the twin programming model's named
760
+ * read-only read over stored state).
761
+ *
762
+ * The psdb wire is Connect-JSON: EVERY call, `Execute` included, is a POST, and the handler
763
+ * refuses a GET on the RPC path before it authenticates (Connect unary is POST-only, which is
764
+ * vendor-faithful). So a `SELECT` — the vendor's own read — cannot be a GET, and nothing in the
765
+ * vendor surface observes stored state with one. This is that observation: a pure, ordered fold
766
+ * of the same image `Execute` runs against, with the engine's internal `rowid` included because
767
+ * it is the row identity the projection is keyed on.
768
+ *
769
+ * `Map` iteration order is insertion order, which is a function of the log rather than of the
770
+ * state, so both projections are SORTED — by table key, and within a table by rowid.
771
+ */
772
+ export function planetscaleStore(name, root, databaseName = DEFAULT_DATABASE) {
773
+ const db = readDatabase(root, databaseName);
774
+ const keys = [...db.tables.keys()].sort();
775
+ if (name === 'tables')
776
+ return { database: db.name, tables: keys.map((k) => db.tables.get(k)) };
777
+ return {
778
+ database: db.name,
779
+ rows: keys.map((k) => ({ table: k, rows: [...(db.rows.get(k) ?? [])].sort((a, b) => a.rowid - b.rowid) })),
780
+ };
781
+ }
782
+ export { SqlError };
783
+ /** Drop every table of the branch, each in a session of its own that is closed after it: what deleting the database
784
+ * does to its data (the management lane's delete_database; "Remove all data and backups permanently"). */
785
+ export async function dropAllTables(ctx) {
786
+ for (const table of [...readDatabase(ctx.root, ctx.database ?? DEFAULT_DATABASE, ctx.scope).tables.values()]) {
787
+ const { session } = await executeSql(ctx, `DROP TABLE \`${table.name.replace(/`/g, '``')}\``, null);
788
+ await closeSession(ctx, session.id);
789
+ }
790
+ }
791
+ /** The image of a scope (`main` when none), sorted as the store door sorts it. */
792
+ export function scopeImage(root, scope) {
793
+ const db = readDatabase(root, DEFAULT_DATABASE, scope);
794
+ const keys = [...db.tables.keys()].sort();
795
+ return { tables: keys.map((k) => db.tables.get(k)), rows: keys.map((k) => ({ table: db.tables.get(k).name, rows: [...(db.rows.get(k) ?? [])] })) };
796
+ }
797
+ /**
798
+ * Make a branch scope hold `image` (its tables, and its rows when a backup is restored): what creating a branch does
799
+ * ("the schema of the source branch is copied to the new branch", planetscale.com/docs/vitess/schema-changes/branching)
800
+ * and what restoring a backup into a new branch does (the spec's `create_branch.backup_id`: "restores the backup's schema
801
+ * and data"). One `branch.seed` action; nothing of `main` is touched.
802
+ */
803
+ export async function seedScope(ctx, scope, image) {
804
+ await serialized(ctx.root, async () => {
805
+ const { revs } = loadState(ctx.root, DEFAULT_DATABASE, scope);
806
+ const flush = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, revs, scope };
807
+ const writes = [
808
+ ...image.tables.map((table) => ({ kind: 'table', table })),
809
+ ...image.rows.flatMap(({ table, rows }) => rows.map((row) => ({ kind: 'row', table, row }))),
810
+ ];
811
+ if (writes.length)
812
+ await flushWrites(flush, writes, []);
813
+ });
814
+ }
815
+ /** Drop every table of a branch scope with its rows, in one action: what deleting the branch does to its data. */
816
+ export async function dropScope(ctx, scope) {
817
+ await serialized(ctx.root, async () => {
818
+ const { db, revs } = loadState(ctx.root, DEFAULT_DATABASE, scope);
819
+ const flush = { ...(ctx.root !== undefined ? { root: ctx.root } : {}), occurredAt: ctx.occurredAt, revs, scope };
820
+ const writes = [
821
+ ...[...db.rows].flatMap(([key, rows]) => rows.map((r) => ({ kind: 'delete-row', table: db.tables.get(key)?.name ?? key, rowid: r.rowid }))),
822
+ ...[...db.tables.values()].map((t) => ({ kind: 'drop-table', table: t.name })),
823
+ ];
824
+ if (writes.length)
825
+ await flushWrites(flush, writes, []);
826
+ });
827
+ }