@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,571 @@
1
+ // planetscale CONNECTOR — the live-vendor pull/push path that gives this twin the "git for SaaS"
2
+ // lifecycle over an INJECTED client (the auth boundary). The pack imports NO SDK and holds NO
3
+ // credential; a consumer injects something structurally satisfying `PlanetscaleLikeClient` — a real
4
+ // `@planetscale/database` `Client` or `Connection` is assignable as-is, since its `execute` has
5
+ // exactly this shape.
6
+ //
7
+ // Pulls use bounded keyset pages, like an incremental database scan. Finishing a scan is not
8
+ // a cross-shard snapshot: refresh rechecks missing keyed rows before observing their deletion.
9
+ // Any query failure aborts observation. LIMIT bounds returned rows, NOT storage-engine rows
10
+ // examined or billed; every query also passes through the mandatory request budget.
11
+ //
12
+ // ── THE RATE BUDGET IS NOT OPTIONAL HERE ──────────────────────────────────────────────────────
13
+ // Every entrypoint GUARDS the injected client before touching it (`guardPlanetscaleClient`, which
14
+ // is idempotent). There is deliberately no option that turns the guard off.
15
+ import { confirmAction, deployableEntries, observeResources, readParentTreeMap, twinResources, ownFields } from '@volter/world-core';
16
+ import type { PerformContext, PushOutcome, RemoteExecute, SyncResource, ObservedResource, TwinAction } from '@volter/world-core';
17
+ import { runPlanetscaleTransaction, guardPlanetscaleClient, planetscaleBudgetOf, type PlanetscaleBudgetedOptions } from './planetscale-budget.ts';
18
+ import { tableKey, type Cell, type ColumnDef, type TableDef } from './planetscale-sql.ts';
19
+
20
+ import { deploymentStatements } from './planetscale-deploy.ts';
21
+ import { pulledRowId, keyedRowId, keyedRowIdentity, quoteIdent, quoteSqlLiteral } from './planetscale-row.ts';
22
+ export { pulledRowId, quoteIdent, quoteSqlLiteral } from './planetscale-row.ts';
23
+
24
+ const SERVICE = 'planetscale';
25
+
26
+ export type { PlanetscaleBudgetedOptions };
27
+
28
+ /**
29
+ * The subset of a real PlanetScale client this connector calls.
30
+ *
31
+ * A `@planetscale/database` `Client`/`Connection` satisfies it structurally. Pulls require
32
+ * execute; a missing executor is a failure, never evidence of an empty database.
33
+ */
34
+ export interface PlanetscaleLikeClient {
35
+ execute?: (query: string, args?: unknown, options?: unknown) => Promise<PlanetscaleExecutedQuery>;
36
+ transaction?: <T>(fn: (tx: unknown) => Promise<T>) => Promise<T>;
37
+ refresh?: () => Promise<void>;
38
+ }
39
+
40
+ /** `ExecutedQuery` as the real client returns it (dist/index.d.ts). Only what this connector reads. */
41
+ export type PlanetscaleExecutedQuery = {
42
+ rows: Array<Record<string, unknown>>;
43
+ fields?: Array<{ name: string; type?: string; columnType?: string | null; flags?: number | null; charset?: number | null; columnLength?: number | null }>;
44
+ rowsAffected?: number;
45
+ insertId?: string;
46
+ };
47
+
48
+ /** One table observed on the real database, already in this twin's storage shape. */
49
+ export type PlanetscaleRealTable = {
50
+ name: string;
51
+ columns: ColumnDef[];
52
+ primaryKey: string[];
53
+ uniques: Array<{ name: string; columns: string[] }>;
54
+ autoIncrement: number;
55
+ rows: Array<{ rowid: number; cells: Record<string, Cell> }>;
56
+ };
57
+
58
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
59
+ // PURE MAPPERS
60
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
61
+ //
62
+ // Both are PURE (they never touch a client), so the mutation harness's connector-seam sweep — which
63
+ // sabotages exports matching /^(sync|push|pull|fullSync)/ — leaves them real. That is the pack
64
+ // convention (qstash's `mapMessage`, upstash's `mapKey`).
65
+
66
+ /**
67
+ * A `DESCRIBE <table>` row → a `ColumnDef`.
68
+ *
69
+ * MySQL's `DESCRIBE` output is the schema as the SERVER renders it: `Type` is the full column type
70
+ * (`varchar(255)`, `bigint unsigned`), `Null` is `YES`/`NO`, `Key` is `PRI`/`UNI`/`MUL`/``, `Default`
71
+ * is the literal or NULL, and `Extra` carries `auto_increment`. Every field of `ColumnDef` is
72
+ * derivable from those five, which is why the pull needs no information_schema query.
73
+ */
74
+ export function mapDescribeRow(row: Record<string, unknown>): ColumnDef {
75
+ const name = String(row.Field ?? row.field ?? '');
76
+ const rendered = String(row.Type ?? row.type ?? 'varchar(255)');
77
+ const base = /^([a-zA-Z_]+)/.exec(rendered);
78
+ const lengthMatch = /\((\d+)/.exec(rendered);
79
+ const key = String(row.Key ?? row.key ?? '');
80
+ const extra = String(row.Extra ?? row.extra ?? '');
81
+ const rawDefault = row.Default ?? row.default ?? null;
82
+ const def: ColumnDef = {
83
+ name,
84
+ dataType: (base?.[1] ?? 'varchar').toUpperCase(),
85
+ columnType: rendered,
86
+ length: lengthMatch === null ? null : Number(lengthMatch[1]),
87
+ unsigned: /\bunsigned\b/i.test(rendered),
88
+ nullable: String(row.Null ?? row.null ?? 'YES').toUpperCase() !== 'NO',
89
+ autoIncrement: /auto_increment/i.test(extra),
90
+ primaryKey: key.toUpperCase() === 'PRI',
91
+ unique: key.toUpperCase() === 'UNI',
92
+ };
93
+ // A column with NO default and a column defaulting to NULL are DIFFERENT (errno 1364 vs. a
94
+ // silent NULL), so the absence is preserved rather than flattened to `null`.
95
+ if (rawDefault !== null && rawDefault !== undefined) return { ...def, defaultValue: String(rawDefault) };
96
+ if (def.nullable && key.toUpperCase() !== 'PRI' && !def.autoIncrement) return { ...def, defaultValue: null };
97
+ return def;
98
+ }
99
+
100
+ /** A real table → the kernel `table` resource. */
101
+ export function mapTable(real: PlanetscaleRealTable): SyncResource {
102
+ return {
103
+ type: 'table',
104
+ id: `table:${tableKey(real.name)}`,
105
+ // The kernel MERGES fields (never a deep merge, never an omission-clear), so every field a
106
+ // later local write could overwrite is written explicitly — a pulled table can never inherit a
107
+ // stale `gone` flag or a previous incarnation's columns.
108
+ fields: {
109
+ name: real.name,
110
+ columns: real.columns as never,
111
+ primary_key: real.primaryKey as never,
112
+ uniques: real.uniques as never,
113
+ auto_increment: real.autoIncrement,
114
+ gone: false,
115
+ },
116
+ };
117
+ }
118
+
119
+ /** Vendor cells are nested to avoid reserved resource keys. Local write ordinals are
120
+ * deliberately absent: observation must compare vendor facts, not local bookkeeping. */
121
+ export function mapRow(table: string, rowid: number, cells: Record<string, Cell>): SyncResource {
122
+ return {
123
+ type: 'row',
124
+ id: `row:${tableKey(table)}:${rowid}`,
125
+ fields: { table_name: table, rowid, cells: cells as never, gone: false },
126
+ };
127
+ }
128
+
129
+ /** Normalize a value the real client handed back into this twin's text-cell encoding. */
130
+ export function encodeCellValue(value: unknown): Cell {
131
+ if (value === null || value === undefined) return null;
132
+ if (typeof value === 'string') return value;
133
+ if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value);
134
+ if (value instanceof Uint8Array) {
135
+ // A BLOB/BINARY column: `cast` returns bytes, and this twin stores bytes as one char per byte
136
+ // (planetscale-wire.ts packs them back out the same way).
137
+ let out = '';
138
+ for (const b of value) out += String.fromCharCode(b);
139
+ return out;
140
+ }
141
+ // A JSON column: `cast` already parsed it, so re-serialising is what puts it back on the wire the
142
+ // way the vendor sent it.
143
+ return JSON.stringify(value);
144
+ }
145
+
146
+ /**
147
+ * MySQL string-literal quoting, for the statements this connector BUILDS.
148
+ *
149
+ * Deliberately a local implementation rather than importing the SDK's `format`: this pack must not
150
+ * depend on the vendor SDK at runtime (it is a devDependency), and the escape set here is the same
151
+ * one `@planetscale/database`'s `dist/sanitization.js` uses — \0 \b \n \r \t \x1a \\ " '.
152
+ */
153
+
154
+ /** MySQL identifier quoting — a backtick inside an identifier is doubled. */
155
+
156
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
157
+ // PULL
158
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
159
+
160
+ export type PlanetscalePullOptions = {
161
+ /** Only these tables. Omit to discover them with `SHOW TABLES`. */
162
+ tables?: string[];
163
+ /** Maximum returned rows per page (1–10,000), default 500. Not a billed-rows guarantee. */
164
+ rowLimit?: number;
165
+ /** Maximum pages per table (1–100), default 10. A full last page remains partial. */
166
+ maxPages?: number;
167
+ /** Maximum missing-row point checks per refresh (1–1,000), default 100. */
168
+ maxMissingChecks?: number;
169
+ } & PlanetscaleBudgetedOptions;
170
+
171
+ const DEFAULT_ROW_LIMIT = 500;
172
+ export type PlanetscalePullReport = {
173
+ resources: SyncResource[];
174
+ completeTables: string[];
175
+ partialTables: string[];
176
+ };
177
+ export type PlanetscaleRefreshReport = Omit<PlanetscalePullReport, 'resources'> & {
178
+ observed: number; deltasAppended: number; removed: number;
179
+ };
180
+
181
+ function bound(value: number | undefined, fallback: number, max: number, name: string): number {
182
+ const n = value === undefined ? fallback : value;
183
+ if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new Error(`Invalid PlanetScale ${name}: expected 1..${max}`);
184
+ return n;
185
+ }
186
+ function queryRows(result: PlanetscaleExecutedQuery): Array<Record<string, unknown>> {
187
+ if (result?.fields !== undefined && (!Array.isArray(result.fields) || !result.fields.length || result.fields.some(f => !f || typeof f.name !== 'string' || !f.name))) throw new Error('PlanetScale read returned no valid field metadata');
188
+ if (!result || !Array.isArray(result.rows) || result.rows.some(r => !r || typeof r !== 'object' || Array.isArray(r))) throw new Error('Malformed PlanetScale rows');
189
+ return result.rows;
190
+ }
191
+ function tableNames(names: string[]): string[] {
192
+ if (!Array.isArray(names) || names.some(n => typeof n !== 'string' || !n) || new Set(names.map(tableKey)).size !== names.length) throw new Error('Invalid or colliding PlanetScale table names');
193
+ return names;
194
+ }
195
+ /** Use the column's own ordering. Numeric literals stay exact, including BIGINTs above 2^53. */
196
+ function keyLiteral(column: ColumnDef, value: Cell): string {
197
+ if (typeof value !== 'string') throw new Error(`Missing PlanetScale key: ${column.name}`);
198
+ if (/^(?:TINYINT|SMALLINT|MEDIUMINT|INT|INTEGER|BIGINT|DECIMAL|NUMERIC)$/.test(column.dataType)) {
199
+ if (!/^[+-]?[0-9]+(?:\.[0-9]+)?$/.test(value)) throw new Error(`Invalid PlanetScale numeric key: ${column.name}`);
200
+ return value;
201
+ }
202
+ if (/^(?:VARBINARY|BINARY)$/.test(column.dataType)) return `X'${Buffer.from(value, 'latin1').toString('hex')}'`;
203
+ if (/^(?:VARCHAR|CHAR|TEXT|TINYTEXT|MEDIUMTEXT|LONGTEXT)$/.test(column.dataType)) return quoteSqlLiteral(value);
204
+ throw new Error(`Unsupported PlanetScale paging key: ${column.name} (${column.dataType})`);
205
+ }
206
+ function keyPredicate(columns: ColumnDef[], cells: Record<string, Cell>, after: boolean): string {
207
+ const parts = columns.map(c => ({ name: quoteIdent(c.name), literal: keyLiteral(c, cells[Object.keys(cells).find(k => k.toLowerCase() === c.name.toLowerCase()) ?? c.name]!) }));
208
+ const equal = (p: typeof parts[number]) => `${p.name} = ${p.literal}`;
209
+ if (!after) return parts.map(equal).join(' AND ');
210
+ return parts.map((p, i) => `(${[...parts.slice(0, i).map(equal), `${p.name} > ${p.literal}`].join(' AND ')})`).join(' OR ');
211
+ }
212
+
213
+ /** Discover the real database's table names. */
214
+ export async function pullPlanetscaleTables(
215
+ rawClient: PlanetscaleLikeClient,
216
+ opts: PlanetscaleBudgetedOptions = {},
217
+ ): Promise<string[]> {
218
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
219
+ if (!client.execute) throw new Error('PlanetScale pull requires execute');
220
+ const res = await client.execute('SHOW TABLES');
221
+ // `SHOW TABLES` names its single column `Tables_in_<database>`, so the column NAME is not knowable
222
+ // in advance — the first value of each row is.
223
+ return tableNames(queryRows(res).map(r => {
224
+ const values = Object.values(r);
225
+ if (values.length !== 1 || typeof values[0] !== 'string') throw new Error('Malformed PlanetScale table listing');
226
+ return values[0];
227
+ }));
228
+ }
229
+
230
+ /** Compatibility resource-only pull. Use pullPlanetscaleSnapshot for completion metadata. */
231
+ export async function pullPlanetscaleDatabase(rawClient: PlanetscaleLikeClient, opts: PlanetscalePullOptions = {}): Promise<SyncResource[]> {
232
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
233
+ return (await pullPlanetscaleSnapshot(client, opts)).resources;
234
+ }
235
+
236
+ /** Bounded scan. A short page proves that this traversal ended; it is not a global snapshot. */
237
+ export async function pullPlanetscaleSnapshot(
238
+ rawClient: PlanetscaleLikeClient,
239
+ opts: PlanetscalePullOptions = {},
240
+ ): Promise<PlanetscalePullReport> {
241
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
242
+ if (!client.execute) throw new Error('PlanetScale pull requires execute');
243
+ const rowLimit = bound(opts.rowLimit, DEFAULT_ROW_LIMIT, 10_000, 'rowLimit');
244
+ const maxPages = bound(opts.maxPages, 10, 100, 'maxPages');
245
+ bound(opts.maxMissingChecks, 100, 1_000, 'maxMissingChecks');
246
+ const names = tableNames(opts.tables ?? (await pullPlanetscaleTables(client, planetscaleBudgetOf(opts))));
247
+ const out: SyncResource[] = [];
248
+ const completeTables: string[] = []; const partialTables: string[] = [];
249
+ for (const name of names) {
250
+ const described = await client.execute(`DESCRIBE ${quoteIdent(name)}`);
251
+ const rawColumns = queryRows(described);
252
+ if (!rawColumns.length || rawColumns.some(c => typeof (c.Field ?? c.field) !== 'string' || !(c.Field ?? c.field) || typeof (c.Type ?? c.type) !== 'string' || !(c.Type ?? c.type) || !['YES', 'NO'].includes(String(c.Null ?? c.null)) || !['', 'PRI', 'UNI', 'MUL'].includes(String(c.Key ?? c.key)) || typeof (c.Extra ?? c.extra) !== 'string')) throw new Error(`Malformed PlanetScale schema: ${name}`);
253
+ const columns = rawColumns.map(mapDescribeRow);
254
+ if (new Set(columns.map(c => c.name.toLowerCase())).size !== columns.length) throw new Error(`Duplicate PlanetScale columns: ${name}`);
255
+ const primaryKey = columns.filter(c => c.primaryKey).map(c => c.name);
256
+ const keyColumns = columns.filter(c => c.primaryKey);
257
+ const uniques = columns.filter(c => c.unique).map(c => ({ name: c.name, columns: [c.name] }));
258
+ const selected: Array<Record<string, unknown>> = [];
259
+ const order = primaryKey.length ? ` ORDER BY ${primaryKey.map(quoteIdent).join(', ')}` : '';
260
+ let cursor: Record<string, Cell> | undefined;
261
+ let complete = false;
262
+ for (let page = 0; page < maxPages; page++) {
263
+ const where = cursor ? ` WHERE ${keyPredicate(keyColumns, cursor, true)}` : '';
264
+ const rows = queryRows(await client.execute(`SELECT * FROM ${quoteIdent(name)}${where}${order} LIMIT ${rowLimit}`));
265
+ if (rows.length > rowLimit || rows.some(r => Object.keys(r).length !== columns.length || columns.some(c => !Object.hasOwn(r, c.name) || r[c.name] === undefined || (c.primaryKey && r[c.name] === null)))) throw new Error(`Malformed PlanetScale page: ${name}`);
266
+ selected.push(...rows);
267
+ if (rows.length < rowLimit) { complete = true; break; }
268
+ if (!primaryKey.length) break; // no unique key means no safe continuation
269
+ cursor = Object.fromEntries(columns.map(c => [c.name, encodeCellValue(rows.at(-1)![c.name])]));
270
+ }
271
+ (complete ? completeTables : partialTables).push(name);
272
+ // Identity: the PK's values, or every cell when the table has no PK. See `pulledRowId` for
273
+ // why this must not be the row's POSITION — and why identical identities still need an ordinal.
274
+ const seen = new Map<string, number>();
275
+ const rowIds = new Set<number>();
276
+ const rows = selected.map((raw) => {
277
+ const cells: Record<string, Cell> = {};
278
+ for (const col of columns) cells[col.name] = encodeCellValue(raw[col.name]);
279
+ const identityColumns = primaryKey.length > 0 ? primaryKey : columns.map((c) => c.name);
280
+ const identity = identityColumns.map((n) => `${n}=${cells[n] ?? '\u0000NULL'}`);
281
+ const key = identity.join('\u0000');
282
+ const ordinal = seen.get(key) ?? 0;
283
+ seen.set(key, ordinal + 1);
284
+ const rowid = primaryKey.length ? keyedRowId({ name, primaryKey }, cells) : pulledRowId(name, identity, ordinal);
285
+ if (rowIds.has(rowid)) throw new Error(`PlanetScale row identity hash collision in ${name}`);
286
+ rowIds.add(rowid);
287
+ return { rowid, cells };
288
+ });
289
+ // The AUTO_INCREMENT watermark is derived from the rows actually observed, pushed one past the
290
+ // largest id seen. That is deliberately CONSERVATIVE relative to the real server's counter: a
291
+ // twin that guessed lower would mint an id colliding with a pulled row, which is the exact
292
+ // "create after connector pull" collision class. `planetscale.connector.auto_increment_watermark`
293
+ // files the residual gap (rows beyond `rowLimit` are unobserved, so the watermark is a floor).
294
+ const autoColumn = columns.find((c) => c.autoIncrement);
295
+ let autoIncrement = 1;
296
+ if (autoColumn !== undefined) {
297
+ for (const r of rows) {
298
+ const n = Number(r.cells[autoColumn.name] ?? 0);
299
+ if (Number.isFinite(n) && n >= autoIncrement) autoIncrement = Math.floor(n) + 1;
300
+ }
301
+ }
302
+ out.push(mapTable({ name, columns, primaryKey, uniques, autoIncrement, rows }));
303
+ for (const r of rows) out.push(mapRow(name, r.rowid, r.cells));
304
+ }
305
+ return { resources: out, completeTables, partialTables };
306
+ }
307
+
308
+ /** Compare the incoming batch with BOTH views: a pending local edit/deletion cannot hide
309
+ * an upstream identity that occupies the same hashed subject. The kernel owns both folds. */
310
+ function assertObservationIdentities(resources: SyncResource[], root?: string): void {
311
+ const projected = twinResources(SERVICE, root).map(r => ({ type: r.type, id: r.id, fields: ownFields(r) }));
312
+ const upstream = [...readParentTreeMap(SERVICE, root).values()];
313
+ const incomingTables = new Map(resources.filter(r => r.type === 'table').map(r => [tableKey(String(r.fields.name)), r.fields]));
314
+ for (const view of [projected, upstream]) {
315
+ const held = new Map(view.filter(r => r.type === 'row' && r.fields.gone !== true).map(r => [r.id, r.fields]));
316
+ for (const r of resources) {
317
+ if (r.type !== 'row') continue;
318
+ const prior = held.get(r.id); if (!prior) continue;
319
+ const name = String(r.fields.table_name); const table = incomingTables.get(tableKey(name));
320
+ const primaryKey = (table?.primary_key ?? []) as string[];
321
+ const identity = (cells: unknown) => primaryKey.length
322
+ ? keyedRowIdentity({ name, primaryKey }, cells as Record<string, Cell>)
323
+ : Object.entries(cells as Record<string, Cell>).sort(([a], [b]) => a.localeCompare(b));
324
+ if (JSON.stringify(identity(prior.cells)) !== JSON.stringify(identity(r.fields.cells))) throw new Error(`PlanetScale row identity hash collision in ${name}`);
325
+ }
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Pull the real database and fold it into the twin through the kernel observation path. Returns `{observed, deltasAppended}` — a re-pull of identical state appends ZERO deltas,
331
+ * which is what makes the pull idempotent.
332
+ */
333
+ export async function syncPlanetscaleFromReal(
334
+ rawClient: PlanetscaleLikeClient,
335
+ opts: { root?: string; occurredAt?: string } & PlanetscalePullOptions = {},
336
+ ): Promise<PlanetscaleRefreshReport> {
337
+ // Guard ONCE here and hand the guarded client down: the per-table loop is unbounded in table
338
+ // count, so this is the entrypoint that must be unable to run unbudgeted.
339
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
340
+ // Distinct polling batches get distinct identities even when calls share a millisecond.
341
+ const occurredAt = opts.occurredAt ?? pollTimestamp();
342
+ const scan = await pullPlanetscaleSnapshot(client, opts);
343
+ const { resources } = scan;
344
+ const upstream = readParentTreeMap(SERVICE, opts.root);
345
+ const keysEqual = (keys: string[]) => JSON.stringify(keys.map(k => k.toLowerCase()).sort());
346
+ for (const table of resources.filter(r => r.type === 'table')) {
347
+ const prior = upstream.get(`table:${table.id}`);
348
+ if (prior && prior.fields.deleted !== true && scan.partialTables.includes(String(table.fields.name)) && keysEqual((prior.fields.primary_key ?? []) as string[]) !== keysEqual((table.fields.primary_key ?? []) as string[])) {
349
+ throw new Error(`Incomplete PlanetScale primary-key change: ${table.fields.name}; increase the bounded scan before refreshing`);
350
+ }
351
+ }
352
+ assertObservationIdentities(resources, opts.root);
353
+ const tables = new Map(resources.filter(r => r.type === 'table').map(r => [tableKey(String(r.fields.name)), r.fields]));
354
+ const complete = new Set(scan.completeTables.map(tableKey));
355
+ const seen = new Set(resources.filter(r => r.type === 'row').map(r => r.id));
356
+ const absent: ObservedResource[] = [];
357
+ let checks = 0;
358
+ const observedRows = new Map(resources.filter(r => r.type === 'row').map(r => [r.id, r]));
359
+ for (const prior of upstream.values()) {
360
+ if (prior.type !== 'row' || prior.fields.deleted === true || prior.fields.gone === true || seen.has(prior.id)) continue;
361
+ const name = String(prior.fields.table_name); const table = tables.get(tableKey(name));
362
+ if (!table) {
363
+ // Only an unfiltered, successful SHOW TABLES can establish a dropped table's absence.
364
+ if (opts.tables === undefined) absent.push({ type: 'row', id: prior.id, fields: {}, deleted: true });
365
+ continue;
366
+ }
367
+ if (!complete.has(tableKey(name))) continue;
368
+ const keyColumns = (table.columns as unknown as ColumnDef[]).filter(c => c.primaryKey);
369
+ const oldTable = upstream.get(`table:table:${tableKey(name)}`);
370
+ const oldKeys = (oldTable?.fields.primary_key ?? []) as string[];
371
+ const identityChanged = keysEqual(oldKeys) !== keysEqual(keyColumns.map(c => c.name));
372
+ // A changed key definition retires the old addressing scheme. Only a completed table
373
+ // can replace those subjects; old rows need not contain newly added key columns.
374
+ if (keyColumns.length && !identityChanged) {
375
+ if (++checks > bound(opts.maxMissingChecks, 100, 1_000, 'maxMissingChecks')) throw new Error('PlanetScale missing-row check limit exceeded');
376
+ const predicate = keyPredicate(keyColumns, prior.fields.cells as Record<string, Cell>, false);
377
+ const rows = queryRows(await client.execute!(`SELECT * FROM ${quoteIdent(name)} WHERE ${predicate} LIMIT 2`));
378
+ if (rows.length > 1) throw new Error(`Ambiguous PlanetScale primary key: ${name}`);
379
+ if (rows.length) {
380
+ const columns = table.columns as unknown as ColumnDef[];
381
+ const raw = rows[0]!;
382
+ if (Object.keys(raw).length !== columns.length || columns.some(c => !Object.hasOwn(raw, c.name) || raw[c.name] === undefined || (c.primaryKey && raw[c.name] === null))) throw new Error(`Malformed PlanetScale point read: ${name}`);
383
+ const cells = Object.fromEntries(columns.map(c => [c.name, encodeCellValue(raw[c.name])]));
384
+ const id = `row:${tableKey(name)}:${keyedRowId({ name, primaryKey: keyColumns.map(c => c.name) }, cells)}`;
385
+ const scanned = observedRows.get(id);
386
+ // Collation can resolve the old key spelling to an already-scanned new identity.
387
+ // Retire the old subject only when this point read agrees with that complete scan.
388
+ if (!scanned || id === prior.id || JSON.stringify(scanned.fields.cells) !== JSON.stringify(cells)) throw new Error(`PlanetScale rows changed during refresh: ${name}; retry the refresh`);
389
+ }
390
+ }
391
+ // Keyless tables only complete in one short page; no paged absence inference is made.
392
+ absent.push({ type: 'row', id: prior.id, fields: {}, deleted: true });
393
+ }
394
+ const result = observeResources(SERVICE, [...resources, ...absent], {
395
+ ...(opts.root !== undefined ? { root: opts.root } : {}), at: occurredAt, batch: `obs:${SERVICE}:${occurredAt}`,
396
+ ...(opts.tables === undefined ? { complete: ['table'] } : {}),
397
+ });
398
+ return { observed: resources.length, deltasAppended: result.appended + result.removed,
399
+ removed: result.removed, completeTables: scan.completeTables, partialTables: scan.partialTables };
400
+ }
401
+
402
+ let lastPollMs = 0;
403
+ /** `now`, forced strictly increasing within this process — see `syncPlanetscaleFromReal`. */
404
+ export function pollTimestamp(): string {
405
+ const now = Date.now();
406
+ lastPollMs = now > lastPollMs ? now : lastPollMs + 1;
407
+ return new Date(lastPollMs).toISOString();
408
+ }
409
+
410
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
411
+ // PUSH
412
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
413
+
414
+ /** Apply captured row changes as one guarded vendor transaction. The transaction reservation
415
+ * covers BEGIN, its callback, COMMIT and rollback, so exhausted budget cannot strand an open
416
+ * transaction halfway through cleanup. Known row plans reserve all row and lifecycle calls. */
417
+ export async function applyPlanetscaleRows(rawClient: PlanetscaleLikeClient, plan: unknown, opts: PlanetscaleBudgetedOptions = {}): Promise<string[]> {
418
+ const statements = deploymentStatements(plan);
419
+ if (!statements.length) return statements;
420
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
421
+ if (!client.transaction) throw new Error('PlanetScale deployment requires a transactional client');
422
+ await runPlanetscaleTransaction(client, statements.length, async value => {
423
+ const tx = value as PlanetscaleLikeClient;
424
+ if (typeof tx?.execute !== 'function') throw new Error('PlanetScale transaction cannot execute');
425
+ for (const statement of statements) {
426
+ const result = await tx.execute(statement);
427
+ if (result.rowsAffected !== 1) throw new Error(`PlanetScale row conflict: expected one affected row, received ${result.rowsAffected ?? 'no count'}`);
428
+ }
429
+ }, opts);
430
+ return statements;
431
+ }
432
+
433
+ /** Compatibility entry point over the same captured plan as the head's performer. */
434
+ export async function pushPlanetscaleAction(
435
+ rawClient: PlanetscaleLikeClient,
436
+ action: { id: string; operation?: string; subjectType?: string; subjectId?: string; fields?: Record<string, unknown>; input?: Record<string, unknown> },
437
+ opts: { root?: string; occurredAt?: string } & PlanetscaleBudgetedOptions = {},
438
+ ): Promise<{ pushed: boolean; statement: string }> {
439
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
440
+ if (!action.subjectType || !action.subjectId) throw new Error('PlanetScale action has no subject');
441
+ const statements = await applyPlanetscaleRows(client, action.input?.plan, opts);
442
+ confirmAction({ service: SERVICE, actionId: action.id,
443
+ subject: { type: action.subjectType, id: action.subjectId }, fields: action.fields as never ?? {},
444
+ occurredAt: opts.occurredAt ?? pollTimestamp(), ...(opts.root !== undefined ? { root: opts.root } : {}) });
445
+ return { pushed: true, statement: statements.join('\n') };
446
+ }
447
+
448
+ export async function pushPlanetscaleActions(rawClient: PlanetscaleLikeClient,
449
+ opts: { root?: string; occurredAt?: string } & PlanetscaleBudgetedOptions = {},
450
+ ): Promise<{ pushed: number }> {
451
+ const client = guardPlanetscaleClient(rawClient, planetscaleBudgetOf(opts));
452
+ let pushed = 0;
453
+ for (const action of deployableEntries(SERVICE, opts.root)) {
454
+ await pushPlanetscaleAction(client, { ...action, subjectType: action.subject.type, subjectId: action.subject.id }, opts);
455
+ pushed++;
456
+ }
457
+ return { pushed };
458
+ }
459
+
460
+ export type { TableDef };
461
+
462
+ // ── PROTOCOL 2: the pack's half of the real state system ────────────────────────────────────
463
+
464
+ /**
465
+ * A `PlanetscaleLikeClient` over the kernel's executor.
466
+ *
467
+ * PlanetScale's serverless driver speaks CONNECT RPC over unary JSON POSTs to
468
+ * `psdb.v1alpha1.Database` — `CreateSession` then `Execute` — so a session is established once and
469
+ * carried on every query after it, exactly as `@planetscale/database` does. At a REAL boundary the
470
+ * kernel sets the sealed credential over these headers (executor.ts); at the twin's own wire any
471
+ * credential is one.
472
+ */
473
+ export function planetscaleClientOver(execute: RemoteExecute): PlanetscaleLikeClient {
474
+ // The session is the driver's own, established lazily and reused — a `CreateSession` per query
475
+ // would be a real round trip per statement against a vendor that bills by connection time.
476
+ let session: unknown = null;
477
+ const rpc = async (method: string, body: Record<string, unknown>): Promise<any> => {
478
+ const res = await execute({
479
+ method: 'POST', path: `/psdb.v1alpha1.Database/${method}`,
480
+ // A WELL-FORMED Basic header: base64('twin:twin'). The wire decodes it the way the real
481
+ // driver builds it (`btoa(user + ':' + pass)`) and answers anything else with the vendor's own
482
+ // 401 `unauthenticated` — a bare word where a base64 pair belongs is not a credential.
483
+ headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Basic dHdpbjp0d2lu' },
484
+ body: JSON.stringify(body),
485
+ });
486
+ const failure = (message: string): Error => Object.assign(new Error(message), { status: res.status, headers: res.headers });
487
+ if (res.status < 200 || res.status >= 300) throw failure(`PlanetScale ${method} refused: HTTP ${res.status}`);
488
+ let parsed: any;
489
+ try { parsed = JSON.parse(res.body); } catch { throw failure(`PlanetScale ${method} returned malformed JSON`); }
490
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw failure(`PlanetScale ${method} returned an invalid envelope`);
491
+ return parsed;
492
+ };
493
+ const client: PlanetscaleLikeClient = {
494
+ execute: async (query: string) => {
495
+ if (session === null) {
496
+ session = (await rpc('CreateSession', {})).session;
497
+ if (!session || typeof session !== 'object') throw new Error('PlanetScale CreateSession returned no session');
498
+ }
499
+ const answered = await rpc('Execute', { query, ...(session === null ? {} : { session }) });
500
+ if (answered?.session !== undefined) session = answered.session;
501
+ // A vendor ERROR arrives inside a 200 body — the SQL analogue of an error envelope — and must
502
+ // never be read as an empty result set, which would fold a table's rows away as if they had
503
+ // been deleted.
504
+ if (answered?.error !== undefined && answered.error !== null) {
505
+ throw new Error(`planetscale query refused: ${JSON.stringify(answered.error).slice(0, 200)}`);
506
+ }
507
+ if (!answered.result || typeof answered.result !== 'object' || Array.isArray(answered.result)) throw new Error('PlanetScale Execute returned no result');
508
+ const result = answered.result as Record<string, any>;
509
+ if ((result.fields !== undefined && !Array.isArray(result.fields)) || (result.rows !== undefined && !Array.isArray(result.rows))) throw new Error('PlanetScale returned malformed result arrays');
510
+ const fields = result.fields ?? [];
511
+ if (!fields.every((f: any) => f && typeof f.name === 'string' && f.name.length)) throw new Error('PlanetScale returned malformed field metadata');
512
+ const names = fields.map((f: any) => f.name);
513
+ // Protojson omits an empty values payload, including rows containing only NULLs.
514
+ const rows = (result.rows ?? []).map((r: any) => {
515
+ if (!r || !Array.isArray(r.lengths) || r.lengths.length !== fields.length || (r.values !== undefined && typeof r.values !== 'string')) throw new Error('PlanetScale returned a malformed packed row');
516
+ const decoded = Buffer.from(r.values ?? '', 'base64');
517
+ const out: Record<string, unknown> = {};
518
+ let offset = 0;
519
+ r.lengths.forEach((len: unknown, i: number) => {
520
+ const n = typeof len === 'string' && /^-?\d+$/.test(len) ? Number(len) : len;
521
+ if (typeof n !== 'number' || !Number.isSafeInteger(n) || n < -1 || offset + Math.max(n, 0) > decoded.length) throw new Error('PlanetScale returned invalid packed row lengths');
522
+ if (n === -1) { out[names[i]] = null; return; }
523
+ out[names[i]] = decoded.subarray(offset, offset + n).toString(Number(fields[i]?.charset) === 63 ? 'latin1' : 'utf8');
524
+ offset += n;
525
+ });
526
+ if (offset !== decoded.length) throw new Error('PlanetScale returned trailing packed row bytes');
527
+ return out;
528
+ });
529
+ if (!Number.isSafeInteger(Number(result.rowsAffected ?? 0)) || Number(result.rowsAffected ?? 0) < 0) throw new Error('PlanetScale returned invalid rowsAffected');
530
+ return { rows, fields, rowsAffected: Number(result.rowsAffected ?? 0), insertId: String(result.insertId ?? '0') };
531
+ },
532
+ transaction: async <T>(fn: (tx: unknown) => Promise<T>): Promise<T> => {
533
+ await client.execute!('BEGIN');
534
+ try {
535
+ const result = await fn(client);
536
+ await client.execute!('COMMIT');
537
+ return result;
538
+ } catch (error) {
539
+ try { await client.execute!('ROLLBACK'); }
540
+ catch (rollback) { throw new AggregateError([error, rollback], 'PlanetScale transaction failed and rollback could not be confirmed'); }
541
+ throw error;
542
+ }
543
+ },
544
+ };
545
+ return client;
546
+ }
547
+
548
+ /** The refresh adapter: PlanetScale enumerates through SQL itself (`SHOW TABLES`, then each table),
549
+ * with explicit per-table completion from the bounded scan. */
550
+ export async function syncPlanetscaleFromRemote(
551
+ execute: RemoteExecute,
552
+ opts: { root?: string; origin?: string; occurredAt?: string } & PlanetscalePullOptions = {},
553
+ ): Promise<PlanetscaleRefreshReport> {
554
+ // Guarded here as well as inside the sync: the guard is idempotent, and stating it at every
555
+ // entrypoint is what this pack's source tooth holds the connector to.
556
+ const client = guardPlanetscaleClient(planetscaleClientOver(execute), planetscaleBudgetOf(opts));
557
+ return syncPlanetscaleFromReal(client, {
558
+ ...opts,
559
+ ...(opts.root !== undefined ? { root: opts.root } : {}),
560
+ occurredAt: opts.occurredAt ?? pollTimestamp(),
561
+ });
562
+ }
563
+
564
+ /** The head performs the captured row plan; query results never become resource fields. */
565
+ export async function performPlanetscaleAction(execute: RemoteExecute, action: TwinAction, _ctx: PerformContext,
566
+ opts: PlanetscaleBudgetedOptions = {},
567
+ ): Promise<PushOutcome> {
568
+ const client = guardPlanetscaleClient(planetscaleClientOver(execute), planetscaleBudgetOf(opts));
569
+ await applyPlanetscaleRows(client, action.input?.plan, opts);
570
+ return { externalId: action.subject.id };
571
+ }