@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.
- package/LICENSE +202 -0
- package/README.md +473 -0
- package/api/src/fetch.ts +50 -0
- package/api/src/generated/surface.gen.json +1 -0
- package/api/src/generated/ui.gen.json +1 -0
- package/api/src/index.ts +19 -0
- package/api/src/manifest.ts +136 -0
- package/api/src/screens/deploy-request.tsx +111 -0
- package/api/src/screens/service-tokens.tsx +141 -0
- package/api/src/screens/session.tsx +117 -0
- package/api/src/semantics/audit.ts +82 -0
- package/api/src/semantics/backups.ts +258 -0
- package/api/src/semantics/branches.ts +201 -0
- package/api/src/semantics/deploy-requests.ts +493 -0
- package/api/src/semantics/index.ts +371 -0
- package/api/src/semantics/shared.ts +141 -0
- package/api/src/semantics/time.ts +77 -0
- package/api/src/token-gate.ts +96 -0
- package/dist/api/src/fetch.d.ts +8 -0
- package/dist/api/src/fetch.js +51 -0
- package/dist/api/src/fetch.ts +50 -0
- package/dist/api/src/generated/surface.gen.json +1 -0
- package/dist/api/src/generated/ui.gen.json +1 -0
- package/dist/api/src/index.ts +19 -0
- package/dist/api/src/manifest.d.ts +2 -0
- package/dist/api/src/manifest.js +113 -0
- package/dist/api/src/manifest.ts +136 -0
- package/dist/api/src/screens/deploy-request.d.ts +7 -0
- package/dist/api/src/screens/deploy-request.js +106 -0
- package/dist/api/src/screens/deploy-request.tsx +111 -0
- package/dist/api/src/screens/service-tokens.d.ts +3 -0
- package/dist/api/src/screens/service-tokens.js +134 -0
- package/dist/api/src/screens/service-tokens.tsx +141 -0
- package/dist/api/src/screens/session.d.ts +11 -0
- package/dist/api/src/screens/session.js +108 -0
- package/dist/api/src/screens/session.tsx +117 -0
- package/dist/api/src/semantics/audit.d.ts +31 -0
- package/dist/api/src/semantics/audit.js +80 -0
- package/dist/api/src/semantics/audit.ts +82 -0
- package/dist/api/src/semantics/backups.d.ts +37 -0
- package/dist/api/src/semantics/backups.js +264 -0
- package/dist/api/src/semantics/backups.ts +258 -0
- package/dist/api/src/semantics/branches.d.ts +53 -0
- package/dist/api/src/semantics/branches.js +197 -0
- package/dist/api/src/semantics/branches.ts +201 -0
- package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
- package/dist/api/src/semantics/deploy-requests.js +491 -0
- package/dist/api/src/semantics/deploy-requests.ts +493 -0
- package/dist/api/src/semantics/index.d.ts +20 -0
- package/dist/api/src/semantics/index.js +381 -0
- package/dist/api/src/semantics/index.ts +371 -0
- package/dist/api/src/semantics/shared.d.ts +36 -0
- package/dist/api/src/semantics/shared.js +132 -0
- package/dist/api/src/semantics/shared.ts +141 -0
- package/dist/api/src/semantics/time.d.ts +2 -0
- package/dist/api/src/semantics/time.js +81 -0
- package/dist/api/src/semantics/time.ts +77 -0
- package/dist/api/src/token-gate.d.ts +9 -0
- package/dist/api/src/token-gate.js +97 -0
- package/dist/api/src/token-gate.ts +96 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +61 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.js +156 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +41 -0
- package/dist/src/planetscale-budget.d.ts +78 -0
- package/dist/src/planetscale-budget.js +305 -0
- package/dist/src/planetscale-capabilities.d.ts +10 -0
- package/dist/src/planetscale-capabilities.js +3977 -0
- package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
- package/dist/src/planetscale-collation-weights.gen.js +12 -0
- package/dist/src/planetscale-collation.d.ts +70 -0
- package/dist/src/planetscale-collation.js +391 -0
- package/dist/src/planetscale-conformance.d.ts +8 -0
- package/dist/src/planetscale-conformance.js +213 -0
- package/dist/src/planetscale-connector.d.ts +150 -0
- package/dist/src/planetscale-connector.js +532 -0
- package/dist/src/planetscale-deploy.d.ts +26 -0
- package/dist/src/planetscale-deploy.js +235 -0
- package/dist/src/planetscale-information-schema.d.ts +32 -0
- package/dist/src/planetscale-information-schema.js +299 -0
- package/dist/src/planetscale-mysql.d.ts +33 -0
- package/dist/src/planetscale-mysql.js +547 -0
- package/dist/src/planetscale-roles.d.ts +11 -0
- package/dist/src/planetscale-roles.js +60 -0
- package/dist/src/planetscale-row.d.ts +12 -0
- package/dist/src/planetscale-row.js +39 -0
- package/dist/src/planetscale-server.d.ts +42 -0
- package/dist/src/planetscale-server.js +137 -0
- package/dist/src/planetscale-sql.d.ts +701 -0
- package/dist/src/planetscale-sql.js +7167 -0
- package/dist/src/planetscale-store.d.ts +126 -0
- package/dist/src/planetscale-store.js +827 -0
- package/dist/src/planetscale-twin.d.ts +48 -0
- package/dist/src/planetscale-twin.js +290 -0
- package/dist/src/planetscale-values.d.ts +139 -0
- package/dist/src/planetscale-values.js +719 -0
- package/dist/src/planetscale-wire.d.ts +110 -0
- package/dist/src/planetscale-wire.js +188 -0
- package/dist/src/semantics/psdb.d.ts +18 -0
- package/dist/src/semantics/psdb.js +30 -0
- package/package.json +58 -0
- package/src/cli.ts +58 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +267 -0
- package/src/manifest.ts +60 -0
- package/src/planetscale-budget.ts +347 -0
- package/src/planetscale-capabilities.ts +3862 -0
- package/src/planetscale-collation-weights.gen.ts +13 -0
- package/src/planetscale-collation.ts +378 -0
- package/src/planetscale-conformance.ts +237 -0
- package/src/planetscale-connector.ts +571 -0
- package/src/planetscale-deploy.ts +197 -0
- package/src/planetscale-information-schema.ts +322 -0
- package/src/planetscale-mysql.ts +339 -0
- package/src/planetscale-roles.ts +71 -0
- package/src/planetscale-row.ts +43 -0
- package/src/planetscale-server.ts +162 -0
- package/src/planetscale-sql.ts +5957 -0
- package/src/planetscale-store.ts +869 -0
- package/src/planetscale-twin.ts +338 -0
- package/src/planetscale-values.ts +572 -0
- package/src/planetscale-wire.ts +274 -0
- 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
|
+
}
|