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