@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,3977 @@
|
|
|
1
|
+
// planetscale capability manifest — the EXPECTED REAL-PRODUCT SURFACE (the target), authored
|
|
2
|
+
// top-down from what PlanetScale actually does, NOT from what this twin has built.
|
|
3
|
+
//
|
|
4
|
+
// GROUNDED (2026-08-31), all read-only — see spec-sources.json:
|
|
5
|
+
// (a) the ACTUALLY-INSTALLED `@planetscale/database@1.20.1` package source (npm pack) — the
|
|
6
|
+
// authoritative wire contract: dist/index.js (URL construction, the Basic header, the two
|
|
7
|
+
// error channels, `decodeRow`'s base64+lengths packing, the omitted-zero-field defaults),
|
|
8
|
+
// dist/cast.js (the field-type → JavaScript-type table), dist/sanitization.js (the client's
|
|
9
|
+
// own `?`/`:name` interpolation, which is why this wire carries no bind parameters), plus its
|
|
10
|
+
// own test fixtures for the 401 and query-error envelopes;
|
|
11
|
+
// (b) `github.com/mattrobenolt/ps-http-sim` and the `psdb.v1alpha1.Database` Connect service it
|
|
12
|
+
// implements — CreateSession / Execute / StreamExecute / CloseSession / Prepare, with Prepare
|
|
13
|
+
// answering Unimplemented;
|
|
14
|
+
// (c) planetscale.com/docs/reference/planetscale-system-limits — the published scalar limits.
|
|
15
|
+
//
|
|
16
|
+
// THE DENOMINATOR is enumerated from MySQL's own statement families (DDL, INSERT, SELECT, UPDATE,
|
|
17
|
+
// DELETE, transactions, functions, error codes) crossed with the psdb protocol's own surface AND
|
|
18
|
+
// with PlanetScale's MANAGEMENT API (api.planetscale.com: organizations, databases, branches,
|
|
19
|
+
// deploy requests, backups, passwords, insights) — of which this pack models the slice its `api` lane
|
|
20
|
+
// serves (api/src/: databases, branches, passwords, deploy requests, backups and policies, the audit log,
|
|
21
|
+
// service tokens). The rest appear as `todo`s rather than being quietly left out, because a
|
|
22
|
+
// reader deciding whether this twin covers their PlanetScale integration needs to see that the control
|
|
23
|
+
// plane is missing, not infer it.
|
|
24
|
+
//
|
|
25
|
+
// `verify()` (required to count as done) is ground truth: every API verify drives the REAL
|
|
26
|
+
// `handlePlanetscaleTwinRequest` over a FRESH temp root with a PINNED clock, and asserts VALUES —
|
|
27
|
+
// never a bare status code. Nothing here is a mock.
|
|
28
|
+
//
|
|
29
|
+
// ── THE HONEST CARVE-OUTS ─────────────────────────────────────────────────────────────────────
|
|
30
|
+
// A local twin cannot make a wall clock pass and cannot run a storage engine. `NOW()` and the
|
|
31
|
+
// other clock functions read the World clock's instant for the statement, never the wall clock;
|
|
32
|
+
// `RAND()` and `UUID()` are REFUSED rather than frozen (a served response must
|
|
33
|
+
// be a pure function of (request, stored state)); elapsed query `timing` is never emitted; and real
|
|
34
|
+
// Vitess sharding, real storage-engine physics and real replication lag are out of reach. Those are
|
|
35
|
+
//
|
|
36
|
+
// (PlanetScale is an API-first vendor — docs/contributing/architecture.md C1b: an integrator TALKS TO A DATABASE from
|
|
37
|
+
// code, and PlanetScale's console is a provisioning/insights dashboard, not where the work happens.
|
|
38
|
+
// So this pack ships NO mirror and there are no UI capabilities. README ## Coverage § No UI mirror.)
|
|
39
|
+
import assert from 'node:assert/strict';
|
|
40
|
+
import { deployableEntries, parentEntries } from '@volter/world-core';
|
|
41
|
+
import { cpSync, mkdtempSync, rmSync } from 'node:fs';
|
|
42
|
+
import { tmpdir } from 'node:os';
|
|
43
|
+
import { join } from 'node:path';
|
|
44
|
+
import { checkCapabilities, verifyBoundary } from '@volter/world-tooling';
|
|
45
|
+
import { handlePlanetscaleTwinRequest, PSDB_SERVICE } from "./planetscale-twin.js";
|
|
46
|
+
import { createPlanetscaleTwinFetch } from "./planetscale-server.js";
|
|
47
|
+
import { packRow, unpackRow } from "./planetscale-wire.js";
|
|
48
|
+
import { checkPlanetscaleConformance } from "./planetscale-conformance.js";
|
|
49
|
+
import { applyPlanetscaleRows, planetscaleClientOver, mapDescribeRow, pullPlanetscaleDatabase, pullPlanetscaleSnapshot, pullPlanetscaleTables, pushPlanetscaleAction, syncPlanetscaleFromReal, } from "./planetscale-connector.js";
|
|
50
|
+
import { guardPlanetscaleClient, PlanetscaleBudget, PLANETSCALE_BUDGET_CEILING, PLANETSCALE_CALL_WEIGHTS } from "./planetscale-budget.js";
|
|
51
|
+
import { applyWritesToImage, executeSql, readDatabase } from "./planetscale-store.js";
|
|
52
|
+
import { keyedRowId } from "./planetscale-row.js";
|
|
53
|
+
import { emptyDatabase, runSql, SqlError, PLANETSCALE_LIMITS } from "./planetscale-sql.js";
|
|
54
|
+
/** The pinned clock every verify runs against, so session ids and action ordinals are deterministic. */
|
|
55
|
+
const AT = '2026-01-01T00:00:00.000Z';
|
|
56
|
+
const AT_MS = Date.parse(AT);
|
|
57
|
+
/** A later instant, for verifies that need the clock to ADVANCE (a second request, a second pull). */
|
|
58
|
+
const laterAt = (ms) => new Date(AT_MS + ms).toISOString();
|
|
59
|
+
const AUTH = { authorization: `Basic ${btoa('twinuser:twinpass')}` };
|
|
60
|
+
const EXECUTE = `/${PSDB_SERVICE}/Execute`;
|
|
61
|
+
const CREATE_SESSION = `/${PSDB_SERVICE}/CreateSession`;
|
|
62
|
+
const CLOSE_SESSION = `/${PSDB_SERVICE}/CloseSession`;
|
|
63
|
+
function decode(res) {
|
|
64
|
+
const body = (res.body ?? {});
|
|
65
|
+
const result = body.result;
|
|
66
|
+
return {
|
|
67
|
+
status: res.status,
|
|
68
|
+
fields: result?.fields ?? [],
|
|
69
|
+
rows: (result?.rows ?? []).map(unpackRow),
|
|
70
|
+
rowsAffected: result?.rowsAffected === undefined ? 0 : Number(result.rowsAffected),
|
|
71
|
+
insertId: result?.insertId ?? '0',
|
|
72
|
+
...(body.error !== undefined ? { error: body.error } : {}),
|
|
73
|
+
session: body.session,
|
|
74
|
+
raw: res,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
async function withRoot(steps) {
|
|
78
|
+
const root = mkdtempSync(join(tmpdir(), 'planetscale-cap-'));
|
|
79
|
+
let carried = null;
|
|
80
|
+
const raw = (req) => handlePlanetscaleTwinRequest({
|
|
81
|
+
method: req.method ?? 'POST',
|
|
82
|
+
path: req.path ?? EXECUTE,
|
|
83
|
+
root,
|
|
84
|
+
occurredAt: req.at ?? AT,
|
|
85
|
+
headers: req.headers === undefined ? AUTH : req.headers,
|
|
86
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
87
|
+
...(req.readOnly !== undefined ? { readOnly: req.readOnly } : {}),
|
|
88
|
+
...(req.username !== undefined ? { username: req.username } : {}),
|
|
89
|
+
...(req.password !== undefined ? { password: req.password } : {}),
|
|
90
|
+
...(req.database !== undefined ? { database: req.database } : {}),
|
|
91
|
+
});
|
|
92
|
+
const run = (async (sql, opts = {}) => {
|
|
93
|
+
const session = opts.session !== undefined ? opts.session : carried;
|
|
94
|
+
const res = await raw({
|
|
95
|
+
body: JSON.stringify({ query: sql, session }),
|
|
96
|
+
...(opts.at !== undefined ? { at: opts.at } : {}),
|
|
97
|
+
...(opts.readOnly !== undefined ? { readOnly: opts.readOnly } : {}),
|
|
98
|
+
});
|
|
99
|
+
const q = decode(res);
|
|
100
|
+
if (q.session !== undefined && opts.session === undefined)
|
|
101
|
+
carried = q.session;
|
|
102
|
+
return q;
|
|
103
|
+
});
|
|
104
|
+
run.raw = raw;
|
|
105
|
+
run.newSession = () => { carried = null; };
|
|
106
|
+
run.root = root;
|
|
107
|
+
try {
|
|
108
|
+
return await verifyBoundary('planetscale.withRoot', () => steps(run, root));
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
rmSync(root, { recursive: true, force: true });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** The schema every DML verify seeds — deliberately dub-shaped, since dub is the demand source. */
|
|
115
|
+
const LINK_DDL = 'CREATE TABLE Link (id BIGINT NOT NULL AUTO_INCREMENT, `key` VARCHAR(255) NOT NULL, url TEXT NOT NULL, '
|
|
116
|
+
+ 'clicks INT NOT NULL DEFAULT 0, archived BOOLEAN NULL, PRIMARY KEY (id), UNIQUE KEY key_unique (`key`))';
|
|
117
|
+
const ok = (q) => q.status === 200 && q.error === undefined;
|
|
118
|
+
const failed = (q, code, fragment) => q.status === 200 && q.error?.code === code && q.error.message.includes(fragment);
|
|
119
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
// CONNECTOR FAKES — an injected client, never a real one. Offline by construction.
|
|
121
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
/**
|
|
123
|
+
* A fake PlanetScale client shaped EXACTLY like what the real one emits: `execute` returns an
|
|
124
|
+
* `ExecutedQuery` whose `rows` are OBJECTS already cast by the client (`cast.js` has run), which is
|
|
125
|
+
* what the connector consumes. A fixture returning packed wire rows here would be green over an
|
|
126
|
+
* input the real SDK never produces.
|
|
127
|
+
*/
|
|
128
|
+
function fakeClient(script, opts = {}) {
|
|
129
|
+
const calls = [];
|
|
130
|
+
const client = {
|
|
131
|
+
transaction: async (fn) => fn(client),
|
|
132
|
+
async execute(query) {
|
|
133
|
+
calls.push(query);
|
|
134
|
+
if (opts.fail?.has(query))
|
|
135
|
+
throw new Error(`vendor refused: ${query}`);
|
|
136
|
+
const hit = script[query];
|
|
137
|
+
if (hit === undefined)
|
|
138
|
+
throw new Error(`unscripted query: ${query}`);
|
|
139
|
+
return hit;
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
return { client, calls };
|
|
143
|
+
}
|
|
144
|
+
const describeRows = (rows) => ({
|
|
145
|
+
rows: rows.map(([Field, Type, Null, Key, Default, Extra]) => ({ Field, Type, Null, Key, Default, Extra })),
|
|
146
|
+
});
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// THE MANIFEST
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
/**
|
|
151
|
+
* The AREA CENSUS — enumerated TOP-DOWN from MySQL's own statement families and PlanetScale's own
|
|
152
|
+
* product surface (the psdb protocol, the branch credential model, the management API), NOT derived
|
|
153
|
+
* from the manifest below. Deriving it from the manifest would make `assertAreaCensus` a tautology;
|
|
154
|
+
* enumerating it independently is what gives the check teeth.
|
|
155
|
+
*/
|
|
156
|
+
export const PLANETSCALE_AREAS = [
|
|
157
|
+
'auth',
|
|
158
|
+
'collation',
|
|
159
|
+
'conformance',
|
|
160
|
+
'connector',
|
|
161
|
+
'ddl',
|
|
162
|
+
'delete',
|
|
163
|
+
'errors',
|
|
164
|
+
'functions',
|
|
165
|
+
'insert',
|
|
166
|
+
'limits',
|
|
167
|
+
'management',
|
|
168
|
+
'native',
|
|
169
|
+
'protocol',
|
|
170
|
+
'readonly',
|
|
171
|
+
'select',
|
|
172
|
+
'session',
|
|
173
|
+
'transactions',
|
|
174
|
+
'update',
|
|
175
|
+
'wire',
|
|
176
|
+
];
|
|
177
|
+
const todo = (id, area, title, tier) => ({ id, area, title, dimension: 'api', tier, expected: 'todo' });
|
|
178
|
+
/** A minimal MySQL text-protocol client over node:net for the manifest's native verify: the
|
|
179
|
+
* handshake response (protocol 41, secure auth, no password: the frontend takes any non-empty
|
|
180
|
+
* auth bytes when started without --password) and COM_QUERY with text result-set decoding. */
|
|
181
|
+
async function rawMysqlClient(port) {
|
|
182
|
+
const { connect } = await import('node:net');
|
|
183
|
+
const socket = connect({ host: '127.0.0.1', port });
|
|
184
|
+
let buffer = Buffer.alloc(0);
|
|
185
|
+
const packets = [];
|
|
186
|
+
const waiters = [];
|
|
187
|
+
const pump = () => {
|
|
188
|
+
while (buffer.length >= 4) {
|
|
189
|
+
const size = buffer.readUIntLE(0, 3);
|
|
190
|
+
if (buffer.length < size + 4)
|
|
191
|
+
break;
|
|
192
|
+
packets.push(Buffer.from(buffer.subarray(4, size + 4)));
|
|
193
|
+
buffer = buffer.subarray(size + 4);
|
|
194
|
+
}
|
|
195
|
+
while (packets.length > 0 && waiters.length > 0)
|
|
196
|
+
waiters.shift().resolve(packets.shift());
|
|
197
|
+
};
|
|
198
|
+
socket.on('data', (chunk) => { buffer = Buffer.concat([buffer, chunk]); pump(); });
|
|
199
|
+
socket.on('close', () => { for (const w of waiters.splice(0))
|
|
200
|
+
w.reject(new Error('native frontend closed the connection')); });
|
|
201
|
+
const next = () => new Promise((resolve, reject) => { waiters.push({ resolve, reject }); pump(); });
|
|
202
|
+
const send = (payload, seq) => { const head = Buffer.alloc(4); head.writeUIntLE(payload.length, 0, 3); head[3] = seq; socket.write(Buffer.concat([head, payload])); };
|
|
203
|
+
const lenenc = (b, at) => {
|
|
204
|
+
const first = b[at.i++];
|
|
205
|
+
if (first < 251)
|
|
206
|
+
return first;
|
|
207
|
+
if (first === 251)
|
|
208
|
+
return null;
|
|
209
|
+
if (first === 252) {
|
|
210
|
+
const v = b.readUIntLE(at.i, 2);
|
|
211
|
+
at.i += 2;
|
|
212
|
+
return v;
|
|
213
|
+
}
|
|
214
|
+
if (first === 253) {
|
|
215
|
+
const v = b.readUIntLE(at.i, 3);
|
|
216
|
+
at.i += 3;
|
|
217
|
+
return v;
|
|
218
|
+
}
|
|
219
|
+
const v = Number(b.readBigUInt64LE(at.i));
|
|
220
|
+
at.i += 8;
|
|
221
|
+
return v;
|
|
222
|
+
};
|
|
223
|
+
await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); });
|
|
224
|
+
await next(); // the server handshake
|
|
225
|
+
const capabilities = 512 | 8 | 32768; // PROTOCOL_41 | CONNECT_WITH_DB | SECURE_CONNECTION
|
|
226
|
+
const head = Buffer.alloc(32);
|
|
227
|
+
head.writeUInt32LE(capabilities, 0);
|
|
228
|
+
head.writeUInt32LE(16777215, 4);
|
|
229
|
+
head[8] = 45;
|
|
230
|
+
send(Buffer.concat([head, Buffer.from('twin\0'), Buffer.from([20]), Buffer.alloc(20, 1), Buffer.from('twin\0')]), 1);
|
|
231
|
+
const ok = await next();
|
|
232
|
+
if (ok[0] !== 0)
|
|
233
|
+
throw new Error(`handshake refused: ${ok.subarray(1).toString()}`);
|
|
234
|
+
const isEof = (p) => p[0] === 0xfe && p.length < 9;
|
|
235
|
+
const query = async (sql) => {
|
|
236
|
+
send(Buffer.concat([Buffer.from([3]), Buffer.from(sql)]), 0);
|
|
237
|
+
const first = await next();
|
|
238
|
+
if (first[0] === 0xff)
|
|
239
|
+
throw new Error(`query failed: ${first.subarray(3).toString()}`);
|
|
240
|
+
if (first[0] === 0)
|
|
241
|
+
return [];
|
|
242
|
+
const columns = lenenc(first, { i: 0 }) ?? 0;
|
|
243
|
+
for (let i = 0; i < columns; i++)
|
|
244
|
+
await next();
|
|
245
|
+
if (!isEof(await next()))
|
|
246
|
+
throw new Error('expected EOF after columns');
|
|
247
|
+
const rows = [];
|
|
248
|
+
for (;;) {
|
|
249
|
+
const p = await next();
|
|
250
|
+
if (isEof(p))
|
|
251
|
+
return rows;
|
|
252
|
+
const at = { i: 0 };
|
|
253
|
+
const row = [];
|
|
254
|
+
for (let i = 0; i < columns; i++) {
|
|
255
|
+
const n = lenenc(p, at);
|
|
256
|
+
if (n === null)
|
|
257
|
+
row.push(null);
|
|
258
|
+
else {
|
|
259
|
+
row.push(p.subarray(at.i, at.i + n).toString());
|
|
260
|
+
at.i += n;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
rows.push(row);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
return { query, end: () => { send(Buffer.from([1]), 0); socket.end(); } };
|
|
267
|
+
}
|
|
268
|
+
export const PLANETSCALE_CAPABILITIES = [
|
|
269
|
+
// ── protocol ────────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
{
|
|
271
|
+
id: 'planetscale.protocol.create_session',
|
|
272
|
+
area: 'protocol', title: 'POST /psdb.v1alpha1.Database/CreateSession mints a session, branch and user',
|
|
273
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
274
|
+
verify: () => withRoot(async (q) => {
|
|
275
|
+
const res = await q.raw({ path: CREATE_SESSION, body: '{}' });
|
|
276
|
+
const body = res.body;
|
|
277
|
+
// The user echoed back is the AUTHENTICATED one, not a constant — a handler returning a fixed
|
|
278
|
+
// envelope would pass a shape check and fail this.
|
|
279
|
+
const other = await q.raw({ path: CREATE_SESSION, body: '{}', headers: { authorization: `Basic ${btoa('someone-else:pw')}` } });
|
|
280
|
+
const otherBody = other.body;
|
|
281
|
+
return res.status === 200
|
|
282
|
+
&& body.branch === 'main'
|
|
283
|
+
&& body.user?.username === 'twinuser'
|
|
284
|
+
&& body.session?.vitessSession?.autocommit === true
|
|
285
|
+
&& body.session.vitessSession.targetString === 'main@primary'
|
|
286
|
+
&& typeof body.session.vitessSession.sessionUUID === 'string' && body.session.vitessSession.sessionUUID.startsWith('tws-')
|
|
287
|
+
&& typeof body.session.signature === 'string' && body.session.signature.length > 0
|
|
288
|
+
&& otherBody.user?.username === 'someone-else'
|
|
289
|
+
// …and two CreateSession calls mint DIFFERENT sessions, so the id is minted from state.
|
|
290
|
+
&& otherBody.session?.vitessSession?.sessionUUID !== body.session.vitessSession.sessionUUID;
|
|
291
|
+
}),
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
id: 'planetscale.protocol.execute',
|
|
295
|
+
area: 'protocol', title: 'POST /psdb.v1alpha1.Database/Execute runs a statement and returns a QueryResult',
|
|
296
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
297
|
+
verify: () => withRoot(async (q) => {
|
|
298
|
+
const res = await q('SELECT 7 AS answer FROM dual');
|
|
299
|
+
return ok(res) && res.fields.length === 1 && res.fields[0].name === 'answer' && res.rows.length === 1 && res.rows[0][0] === '7';
|
|
300
|
+
}),
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'planetscale.protocol.session_round_trip',
|
|
304
|
+
area: 'session', title: 'the client-echoed session is honoured — the same session id comes back',
|
|
305
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
306
|
+
verify: () => withRoot(async (q) => {
|
|
307
|
+
const first = await q('SELECT 1 FROM dual');
|
|
308
|
+
const id = first.session.vitessSession?.sessionUUID;
|
|
309
|
+
const second = await q('SELECT 2 FROM dual');
|
|
310
|
+
const secondId = second.session.vitessSession?.sessionUUID;
|
|
311
|
+
// A DIFFERENT session, explicitly echoed, must come back as itself rather than being reissued.
|
|
312
|
+
q.newSession();
|
|
313
|
+
const third = await q('SELECT 3 FROM dual');
|
|
314
|
+
const thirdId = third.session.vitessSession?.sessionUUID;
|
|
315
|
+
return typeof id === 'string' && id !== '' && secondId === id && typeof thirdId === 'string' && thirdId !== id;
|
|
316
|
+
}),
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
id: 'planetscale.protocol.close_session',
|
|
320
|
+
area: 'session', title: 'POST /CloseSession closes the session and discards its open transaction',
|
|
321
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
322
|
+
verify: () => withRoot(async (q) => {
|
|
323
|
+
await q(LINK_DDL);
|
|
324
|
+
await q('BEGIN');
|
|
325
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('gone', 'https://g.test')");
|
|
326
|
+
const session = (await q('SELECT COUNT(*) FROM Link')).session;
|
|
327
|
+
const inside = (await q('SELECT COUNT(*) FROM Link')).rows[0][0];
|
|
328
|
+
const closed = await q.raw({ path: CLOSE_SESSION, body: JSON.stringify({ session }) });
|
|
329
|
+
const closedBody = closed.body;
|
|
330
|
+
// The buffered INSERT must be GONE — a close that flushed it would be data the caller never
|
|
331
|
+
// committed, and a close that left the session open would let the next request commit it.
|
|
332
|
+
q.newSession();
|
|
333
|
+
const after = await q('SELECT COUNT(*) FROM Link');
|
|
334
|
+
const reuse = await q('SELECT COUNT(*) FROM Link', { session });
|
|
335
|
+
const reusedId = reuse.session.vitessSession?.sessionUUID;
|
|
336
|
+
const oldId = session.vitessSession?.sessionUUID;
|
|
337
|
+
return inside === '1' && closed.status === 200
|
|
338
|
+
&& closedBody.session?.vitessSession?.sessionUUID === oldId
|
|
339
|
+
&& after.rows[0][0] === '0'
|
|
340
|
+
&& reusedId !== oldId; // a closed session is not resurrected
|
|
341
|
+
}),
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: 'planetscale.protocol.prepare_unimplemented',
|
|
345
|
+
area: 'protocol', title: 'Prepare answers Connect `unimplemented` — the vendor\'s own behaviour, reproduced',
|
|
346
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
347
|
+
verify: () => withRoot(async (q) => {
|
|
348
|
+
const res = await q.raw({ path: `/${PSDB_SERVICE}/Prepare`, body: '{}' });
|
|
349
|
+
const body = res.body;
|
|
350
|
+
// NOT dressed up in the `{error}` query envelope: a transport-level refusal must reach the
|
|
351
|
+
// client as `UnknownError`, not as a database error.
|
|
352
|
+
return res.status === 501 && body.code === 'unimplemented' && body.error === undefined
|
|
353
|
+
&& String(body.message).includes('Prepare');
|
|
354
|
+
}),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: 'planetscale.protocol.unknown_method',
|
|
358
|
+
area: 'protocol', title: 'an unknown service method answers Connect `unimplemented`, never a fabricated result',
|
|
359
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
360
|
+
verify: () => withRoot(async (q) => {
|
|
361
|
+
const unknown = await q.raw({ path: `/${PSDB_SERVICE}/DropEverything`, body: '{}' });
|
|
362
|
+
const other = await q.raw({ path: '/v1/query', body: '{}' });
|
|
363
|
+
return unknown.status === 404 && unknown.body.code === 'unimplemented'
|
|
364
|
+
&& other.status === 404 && other.body.code === 'not_found';
|
|
365
|
+
}),
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
id: 'planetscale.protocol.post_only',
|
|
369
|
+
area: 'protocol', title: 'a non-POST verb is refused — Connect unary RPCs are POST-only',
|
|
370
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
371
|
+
verify: () => withRoot(async (q) => {
|
|
372
|
+
const res = await q.raw({ method: 'GET', path: EXECUTE });
|
|
373
|
+
// Refused BEFORE authentication: a GET with a perfect credential is still refused.
|
|
374
|
+
const authed = await q.raw({ method: 'GET', path: EXECUTE, headers: AUTH });
|
|
375
|
+
return res.status === 405 && res.body.code === 'unimplemented' && authed.status === 405;
|
|
376
|
+
}),
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: 'planetscale.protocol.malformed_body',
|
|
380
|
+
area: 'protocol', title: 'a malformed JSON body and a non-string query are both refused',
|
|
381
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
382
|
+
verify: () => withRoot(async (q) => {
|
|
383
|
+
const bad = await q.raw({ body: '{not json' });
|
|
384
|
+
const array = await q.raw({ body: '[1,2,3]' });
|
|
385
|
+
const noQuery = await q.raw({ body: JSON.stringify({ query: 42 }) });
|
|
386
|
+
return bad.status === 400 && bad.body.code === 'invalid_argument'
|
|
387
|
+
&& array.status === 400
|
|
388
|
+
&& noQuery.status === 400 && String(noQuery.body.message).includes('query must be a string');
|
|
389
|
+
}),
|
|
390
|
+
},
|
|
391
|
+
todo('planetscale.protocol.stream_execute', 'protocol', 'StreamExecute (Connect server-streaming envelope framing)', 'niche'),
|
|
392
|
+
todo('planetscale.protocol.connect_binary_codecs', 'protocol', 'the Connect/gRPC binary protobuf codecs (this twin serves the JSON codec only)', 'niche'),
|
|
393
|
+
todo('planetscale.protocol.connect_status_mapping', 'protocol', "the exact HTTP status connect-go gives an unknown method on a registered service (this twin answers 404 {code:'unimplemented'}; connect-go maps the `unimplemented` code to 501, and an unregistered mux path to a bodiless 404) — UNVERIFIED, because this build read ps-http-sim's behaviour through documentation rather than its compiled source", 'niche'),
|
|
394
|
+
todo('planetscale.protocol.allow_header', 'protocol', 'the `Allow: POST` header a 405 should carry', 'niche'),
|
|
395
|
+
todo('planetscale.protocol.compression', 'protocol', 'Connect content-encoding negotiation (gzip/br request and response compression)', 'niche'),
|
|
396
|
+
{
|
|
397
|
+
id: 'planetscale.protocol.timing',
|
|
398
|
+
area: 'protocol', title: 'ExecuteResponse.timing: emit the field as a deterministic value (today it is omitted)',
|
|
399
|
+
dimension: 'api', tier: 'niche', expected: 'todo',
|
|
400
|
+
},
|
|
401
|
+
// ── auth ────────────────────────────────────────────────────────────────────────────────────
|
|
402
|
+
{
|
|
403
|
+
id: 'planetscale.auth.basic_accepted',
|
|
404
|
+
area: 'auth', title: 'HTTP Basic credentials are accepted and the username is echoed back',
|
|
405
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
406
|
+
verify: () => withRoot(async (q) => {
|
|
407
|
+
// A password containing a colon must survive: PlanetScale connection-URL passwords are
|
|
408
|
+
// arbitrary, and splitting on the LAST colon (or on every colon) truncates them.
|
|
409
|
+
const res = await q.raw({ path: CREATE_SESSION, body: '{}', headers: { authorization: `Basic ${btoa('user:pa:ss:word')}` }, username: 'user', password: 'pa:ss:word' });
|
|
410
|
+
return res.status === 200 && res.body.user?.username === 'user';
|
|
411
|
+
}),
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: 'planetscale.auth.missing_credential_401',
|
|
415
|
+
area: 'auth', title: 'a missing credential answers 401 in the {error:{code,message}} envelope',
|
|
416
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
417
|
+
verify: () => withRoot(async (q) => {
|
|
418
|
+
const res = await q.raw({ body: JSON.stringify({ query: 'SELECT 1' }), headers: {} });
|
|
419
|
+
const body = res.body;
|
|
420
|
+
// The envelope matters as much as the status: the client only propagates the REAL 401 when
|
|
421
|
+
// the body carries `error`. A bare Connect body would surface as UnknownError instead.
|
|
422
|
+
return res.status === 401 && body.error?.code === 'unauthenticated' && body.error.message === 'invalid auth credentials';
|
|
423
|
+
}),
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: 'planetscale.auth.wrong_credential_401',
|
|
427
|
+
area: 'auth', title: 'a wrong username or password answers the SAME 401 — the vendor draws no distinction',
|
|
428
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
429
|
+
verify: () => withRoot(async (q) => {
|
|
430
|
+
const wrongPw = await q.raw({ body: JSON.stringify({ query: 'SELECT 1' }), headers: { authorization: `Basic ${btoa('right:WRONG')}` }, username: 'right', password: 'secret' });
|
|
431
|
+
const wrongUser = await q.raw({ body: JSON.stringify({ query: 'SELECT 1' }), headers: { authorization: `Basic ${btoa('WRONG:secret')}` }, username: 'right', password: 'secret' });
|
|
432
|
+
const right = await q.raw({ body: JSON.stringify({ query: 'SELECT 9 AS n FROM dual' }), headers: { authorization: `Basic ${btoa('right:secret')}` }, username: 'right', password: 'secret' });
|
|
433
|
+
const good = decode(right);
|
|
434
|
+
return wrongPw.status === 401 && wrongUser.status === 401
|
|
435
|
+
&& wrongPw.body.error?.code === 'unauthenticated'
|
|
436
|
+
&& wrongUser.body.error?.code === 'unauthenticated'
|
|
437
|
+
&& ok(good) && good.rows[0][0] === '9';
|
|
438
|
+
}),
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
id: 'planetscale.auth.malformed_header_401',
|
|
442
|
+
area: 'auth', title: 'a malformed Authorization header never falls through as a bare credential',
|
|
443
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
444
|
+
verify: () => withRoot(async (q) => {
|
|
445
|
+
const body = JSON.stringify({ query: 'SELECT 1' });
|
|
446
|
+
const cases = [
|
|
447
|
+
{ authorization: 'Bearer sometoken' }, // wrong scheme
|
|
448
|
+
{ authorization: 'Basic' }, // scheme with no payload
|
|
449
|
+
{ authorization: `Basic ${btoa('nocolon')}` }, // no colon in the decoded pair
|
|
450
|
+
{ authorization: `Basic ${btoa(':pw')}` }, // empty username
|
|
451
|
+
{ authorization: `Basic ${btoa('user:')}` }, // empty password
|
|
452
|
+
{ authorization: 'Basic !!!not-base64!!!' },
|
|
453
|
+
];
|
|
454
|
+
for (const headers of cases) {
|
|
455
|
+
const res = await q.raw({ body, headers });
|
|
456
|
+
if (res.status !== 401 || res.body.error?.code !== 'unauthenticated')
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
return true;
|
|
460
|
+
}),
|
|
461
|
+
},
|
|
462
|
+
todo('planetscale.session.vitess_session_fields', 'session', "the inner psdb Session shape beyond the identity + transaction state this protocol needs \u2014 the real Vitess Session proto carries shard sessions, options, warnings and a DDL strategy; the client reads NONE of them (it echoes the session verbatim), so they are not fabricated", 'niche'),
|
|
463
|
+
todo('planetscale.session.concurrent_use_refusal', 'session', "ps-http-sim's PermissionDenied when a session is already in use by another IN-FLIGHT request. This twin serializes per state root instead of refusing (planetscale-store.ts's `serialized` gate), so the condition never arises in-process — but two SEPARATE processes over one root are ordered only by the kernel's own file locking, and neither refuses", 'niche'),
|
|
464
|
+
todo('planetscale.auth.password_expiry', 'auth', 'expiring branch passwords and the refusal a lapsed one gets', 'niche'),
|
|
465
|
+
// ── wire encoding ───────────────────────────────────────────────────────────────────────────
|
|
466
|
+
{
|
|
467
|
+
id: 'planetscale.wire.row_packing',
|
|
468
|
+
area: 'wire', title: 'rows are packed as concatenated bytes with per-column decimal byte lengths',
|
|
469
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
470
|
+
verify: () => withRoot(async (q) => {
|
|
471
|
+
await q(LINK_DDL);
|
|
472
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('ab', 'https://x.test')");
|
|
473
|
+
const res = await q('SELECT `key`, url FROM Link');
|
|
474
|
+
const raw = (res.raw.body.result?.rows ?? [])[0];
|
|
475
|
+
if (raw === undefined)
|
|
476
|
+
return false;
|
|
477
|
+
// Assert the LITERAL packing, not just the decode: lengths are decimal STRINGS, values are ONE
|
|
478
|
+
// base64 blob with no separators, and the decoded slices are the seeded values.
|
|
479
|
+
return raw.lengths.length === 2 && raw.lengths[0] === '2' && raw.lengths[1] === '14'
|
|
480
|
+
&& raw.values === btoa('abhttps://x.test')
|
|
481
|
+
&& res.rows[0][0] === 'ab' && res.rows[0][1] === 'https://x.test';
|
|
482
|
+
}),
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
id: 'planetscale.wire.null_lengths',
|
|
486
|
+
area: 'wire', title: 'a NULL column is length -1 and contributes no bytes; an all-NULL row omits `values`',
|
|
487
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
488
|
+
verify: () => withRoot(async (q) => {
|
|
489
|
+
await q(LINK_DDL);
|
|
490
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('n', 'https://n.test')");
|
|
491
|
+
const res = await q('SELECT archived, `key`, archived FROM Link');
|
|
492
|
+
const raw = (res.raw.body.result?.rows ?? [])[0];
|
|
493
|
+
const allNull = await q('SELECT archived FROM Link');
|
|
494
|
+
const allNullRaw = (allNull.raw.body.result?.rows ?? [])[0];
|
|
495
|
+
return raw !== undefined && raw.lengths[0] === '-1' && raw.lengths[1] === '1' && raw.lengths[2] === '-1'
|
|
496
|
+
&& raw.values === btoa('n')
|
|
497
|
+
&& res.rows[0][0] === null && res.rows[0][1] === 'n' && res.rows[0][2] === null
|
|
498
|
+
&& allNullRaw !== undefined && allNullRaw.lengths[0] === '-1' && allNullRaw.values === undefined;
|
|
499
|
+
}),
|
|
500
|
+
},
|
|
501
|
+
{
|
|
502
|
+
id: 'planetscale.wire.multibyte_lengths',
|
|
503
|
+
area: 'wire', title: 'a length is the BYTE count, not the character count',
|
|
504
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
505
|
+
verify: () => withRoot(async (q) => {
|
|
506
|
+
await q(LINK_DDL);
|
|
507
|
+
// '鍵' is 3 UTF-8 bytes, '🔑' is 4 — a character count here would be 2, and every LATER column
|
|
508
|
+
// in the row would be sliced from the wrong offset.
|
|
509
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('鍵🔑', 'https://after.test')");
|
|
510
|
+
const res = await q('SELECT `key`, url FROM Link');
|
|
511
|
+
const raw = (res.raw.body.result?.rows ?? [])[0];
|
|
512
|
+
if (raw === undefined || res.rows[0] === undefined)
|
|
513
|
+
return false;
|
|
514
|
+
const packedKey = res.rows[0][0];
|
|
515
|
+
if (packedKey === null)
|
|
516
|
+
return false;
|
|
517
|
+
// `unpackRow` returns the RAW BYTES as latin-1 chars (exactly what the client's `atob` gives
|
|
518
|
+
// it), so the assertion decodes them the way the client's own `decodeUtf8` does.
|
|
519
|
+
const decoded = new TextDecoder().decode(Uint8Array.from(packedKey, (c) => c.charCodeAt(0)));
|
|
520
|
+
return raw.lengths[0] === '7' && decoded === '鍵🔑' && res.rows[0][1] === 'https://after.test';
|
|
521
|
+
}),
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
id: 'planetscale.wire.omitted_zero_fields',
|
|
525
|
+
area: 'wire', title: 'protojson zero values are OMITTED — a DDL result is the empty object',
|
|
526
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
527
|
+
verify: () => withRoot(async (q) => {
|
|
528
|
+
const ddl = await q(LINK_DDL);
|
|
529
|
+
const ddlResult = ddl.raw.body.result;
|
|
530
|
+
const insert = await q("INSERT INTO Link (`key`, url) VALUES ('z', 'https://z.test')");
|
|
531
|
+
const insertResult = insert.raw.body.result;
|
|
532
|
+
const update = await q("UPDATE Link SET clicks = 3 WHERE `key` = 'z'");
|
|
533
|
+
const updateResult = update.raw.body.result;
|
|
534
|
+
// The client's own defaults (`result?.rowsAffected ? … : 0`, `insertId ?? '0'`) depend on this
|
|
535
|
+
// exactly — emitting `"rowsAffected":"0"` would be a different wire.
|
|
536
|
+
return ddlResult !== undefined && Object.keys(ddlResult).length === 0
|
|
537
|
+
&& insertResult?.rowsAffected === '1' && insertResult.insertId === '1'
|
|
538
|
+
&& insertResult.fields === undefined && insertResult.rows === undefined
|
|
539
|
+
&& updateResult?.rowsAffected === '1' && updateResult.insertId === undefined;
|
|
540
|
+
}),
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
id: 'planetscale.wire.field_metadata',
|
|
544
|
+
area: 'wire', title: 'Field carries name/type/table/orgName/charset/flags/columnType, and NULL_TYPE is omitted',
|
|
545
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
546
|
+
verify: () => withRoot(async (q) => {
|
|
547
|
+
await q(LINK_DDL);
|
|
548
|
+
const res = await q('SELECT id, `key`, clicks FROM Link');
|
|
549
|
+
const [id, key, clicks] = res.fields;
|
|
550
|
+
const nullField = (await q('SELECT NULL AS nothing FROM dual')).fields[0];
|
|
551
|
+
return id?.type === 'INT64' && id.table === 'Link' && id.orgName === 'id' && id.database === 'twin'
|
|
552
|
+
// NOT_NULL | PRI_KEY | AUTO_INCREMENT — the literal mysql_com.h flag bits, asserted as
|
|
553
|
+
// numbers rather than against the pack's own constants (which would be a tautology).
|
|
554
|
+
&& ((id.flags ?? 0) & 1) !== 0 && ((id.flags ?? 0) & 2) !== 0 && ((id.flags ?? 0) & 512) !== 0
|
|
555
|
+
&& id.columnType === 'bigint'
|
|
556
|
+
&& key?.type === 'VARCHAR' && key.charset === 45 && key.columnLength === 255
|
|
557
|
+
&& clicks?.type === 'INT32' && clicks.columnType === 'int'
|
|
558
|
+
// NULL_TYPE is enum 0 and protojson omits it — the client re-adds it with `field.type ||= 'NULL'`.
|
|
559
|
+
&& nullField !== undefined && nullField.name === 'nothing' && nullField.type === undefined;
|
|
560
|
+
}),
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
id: 'planetscale.wire.binary_charset',
|
|
564
|
+
area: 'wire', title: 'a BINARY/BLOB column reports charset 63 and packs one byte per character',
|
|
565
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
566
|
+
verify: () => withRoot(async (q) => {
|
|
567
|
+
await q('CREATE TABLE Blobs (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, payload VARBINARY(64) NULL)');
|
|
568
|
+
// `x'…'` is what the client's own `format()` emits for a Uint8Array argument
|
|
569
|
+
// (dist/text.js `uint8ArrayToHex`), so this is the literal shape a real caller sends.
|
|
570
|
+
await q("INSERT INTO Blobs (payload) VALUES (x'00ff41')");
|
|
571
|
+
const res = await q('SELECT payload FROM Blobs');
|
|
572
|
+
const raw = (res.raw.body.result?.rows ?? [])[0];
|
|
573
|
+
const field = res.fields[0];
|
|
574
|
+
// 3 BYTES, not the 5 a UTF-8 encoding of U+00FF would produce — charset 63 means latin-1
|
|
575
|
+
// layout, which is what makes the client's `Uint8Array.from` give back the original bytes.
|
|
576
|
+
return field?.charset === 63 && field.type === 'VARBINARY'
|
|
577
|
+
&& raw?.lengths[0] === '3' && raw.values === btoa('\x00\xff\x41');
|
|
578
|
+
}),
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
id: 'planetscale.wire.json_column',
|
|
582
|
+
area: 'wire', title: 'a JSON column is typed JSON so the client parses it back into an object',
|
|
583
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
584
|
+
verify: () => withRoot(async (q) => {
|
|
585
|
+
await q('CREATE TABLE Docs (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, body JSON NULL)');
|
|
586
|
+
await q(`INSERT INTO Docs (body) VALUES ('{"a":[1,2],"b":"x"}')`);
|
|
587
|
+
const res = await q('SELECT body FROM Docs');
|
|
588
|
+
return res.fields[0]?.type === 'JSON' && res.fields[0].columnLength === 4294967295
|
|
589
|
+
&& JSON.parse(res.rows[0][0]).a[1] === 2;
|
|
590
|
+
}),
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
id: 'planetscale.wire.decimal_and_bigint_stay_strings',
|
|
594
|
+
area: 'wire', title: 'DECIMAL and INT64 are typed so the client leaves them as strings (precision-safe)',
|
|
595
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
596
|
+
verify: () => withRoot(async (q) => {
|
|
597
|
+
await q('CREATE TABLE Money (id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, amount DECIMAL(20,4) NOT NULL, big BIGINT UNSIGNED NOT NULL)');
|
|
598
|
+
await q("INSERT INTO Money (amount, big) VALUES ('12345678901234.5678', '18446744073709551615')");
|
|
599
|
+
const res = await q('SELECT amount, big FROM Money');
|
|
600
|
+
// Both survive EXACTLY: a twin typing these FLOAT64 would hand the client a number and the
|
|
601
|
+
// last digits would silently vanish.
|
|
602
|
+
return res.fields[0]?.type === 'DECIMAL' && res.fields[1]?.type === 'UINT64'
|
|
603
|
+
&& res.rows[0][0] === '12345678901234.5678' && res.rows[0][1] === '18446744073709551615';
|
|
604
|
+
}),
|
|
605
|
+
},
|
|
606
|
+
// ── ddl ─────────────────────────────────────────────────────────────────────────────────────
|
|
607
|
+
{
|
|
608
|
+
id: 'planetscale.ddl.create_table',
|
|
609
|
+
area: 'ddl', title: 'CREATE TABLE creates a real schema the later DML sees',
|
|
610
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
611
|
+
verify: () => withRoot(async (q) => {
|
|
612
|
+
const before = await q('SELECT * FROM Link');
|
|
613
|
+
const created = await q(LINK_DDL);
|
|
614
|
+
const after = await q('SELECT * FROM Link');
|
|
615
|
+
return failed(before, 'NOT_FOUND', "Table 'twin.Link' doesn't exist")
|
|
616
|
+
&& ok(created) && ok(after) && after.fields.map((f) => f.name).join(',') === 'id,key,url,clicks,archived';
|
|
617
|
+
}),
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: 'planetscale.ddl.create_table_already_exists',
|
|
621
|
+
area: 'ddl', title: 'a second CREATE TABLE raises errno 1050, and IF NOT EXISTS makes it a no-op',
|
|
622
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
623
|
+
verify: () => withRoot(async (q) => {
|
|
624
|
+
await q(LINK_DDL);
|
|
625
|
+
const again = await q(LINK_DDL);
|
|
626
|
+
const guarded = await q('CREATE TABLE IF NOT EXISTS Link (id INT PRIMARY KEY)');
|
|
627
|
+
// The IF NOT EXISTS form must NOT have replaced the schema.
|
|
628
|
+
const shape = await q('SELECT * FROM Link');
|
|
629
|
+
return failed(again, 'ALREADY_EXISTS', "Table 'Link' already exists")
|
|
630
|
+
&& again.error.message.includes('(errno 1050) (sqlstate 42S01)')
|
|
631
|
+
&& ok(guarded) && shape.fields.length === 5;
|
|
632
|
+
}),
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
id: 'planetscale.ddl.drop_table',
|
|
636
|
+
area: 'ddl', title: 'DROP TABLE removes the schema AND its rows; IF EXISTS is a no-op',
|
|
637
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
638
|
+
verify: () => withRoot(async (q) => {
|
|
639
|
+
await q(LINK_DDL);
|
|
640
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
641
|
+
const dropped = await q('DROP TABLE Link');
|
|
642
|
+
const gone = await q('SELECT * FROM Link');
|
|
643
|
+
const missing = await q('DROP TABLE Nope');
|
|
644
|
+
const guarded = await q('DROP TABLE IF EXISTS Nope');
|
|
645
|
+
// Recreate it: the previous incarnation's row must NOT come back — the classic
|
|
646
|
+
// dropped-and-recreated-table dirty-state bug.
|
|
647
|
+
await q(LINK_DDL);
|
|
648
|
+
const fresh = await q('SELECT COUNT(*) FROM Link');
|
|
649
|
+
return ok(dropped) && failed(gone, 'NOT_FOUND', "doesn't exist")
|
|
650
|
+
&& failed(missing, 'NOT_FOUND', "Unknown table 'twin.Nope'") && ok(guarded)
|
|
651
|
+
&& fresh.rows[0][0] === '0';
|
|
652
|
+
}),
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
id: 'planetscale.ddl.truncate_resets_auto_increment',
|
|
656
|
+
area: 'ddl', title: 'TRUNCATE empties the table AND resets AUTO_INCREMENT (unlike DELETE)',
|
|
657
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
658
|
+
verify: () => withRoot(async (q) => {
|
|
659
|
+
await q(LINK_DDL);
|
|
660
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test')");
|
|
661
|
+
const truncated = await q('TRUNCATE TABLE Link');
|
|
662
|
+
const empty = await q('SELECT COUNT(*) FROM Link');
|
|
663
|
+
const reinserted = await q("INSERT INTO Link (`key`, url) VALUES ('c', 'https://c.test')");
|
|
664
|
+
return ok(truncated) && empty.rows[0][0] === '0' && reinserted.insertId === '1';
|
|
665
|
+
}),
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
id: 'planetscale.ddl.show_tables',
|
|
669
|
+
area: 'ddl', title: 'SHOW TABLES lists the real tables under the Tables_in_<db> column',
|
|
670
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
671
|
+
verify: () => withRoot(async (q) => {
|
|
672
|
+
const before = await q('SHOW TABLES');
|
|
673
|
+
await q(LINK_DDL);
|
|
674
|
+
await q('CREATE TABLE Domain (id INT NOT NULL PRIMARY KEY, slug VARCHAR(64) NOT NULL)');
|
|
675
|
+
const after = await q('SHOW TABLES');
|
|
676
|
+
await q('DROP TABLE Domain');
|
|
677
|
+
const dropped = await q('SHOW TABLES');
|
|
678
|
+
return before.rows.length === 0 && after.fields[0]?.name === 'Tables_in_twin'
|
|
679
|
+
&& after.rows.map((r) => r[0]).join(',') === 'Domain,Link'
|
|
680
|
+
&& dropped.rows.map((r) => r[0]).join(',') === 'Link';
|
|
681
|
+
}),
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
id: 'planetscale.ddl.describe',
|
|
685
|
+
area: 'ddl', title: 'DESCRIBE / SHOW COLUMNS report Field/Type/Null/Key/Default/Extra',
|
|
686
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
687
|
+
verify: () => withRoot(async (q) => {
|
|
688
|
+
await q(LINK_DDL);
|
|
689
|
+
const described = await q('DESCRIBE Link');
|
|
690
|
+
const columns = await q('SHOW COLUMNS FROM Link');
|
|
691
|
+
const byName = new Map(described.rows.map((r) => [r[0], r]));
|
|
692
|
+
const missing = await q('DESCRIBE Nope');
|
|
693
|
+
return described.fields.map((f) => f.name).join(',') === 'Field,Type,Null,Key,Default,Extra'
|
|
694
|
+
&& byName.get('id')?.slice(1).join('|') === 'bigint|NO|PRI||auto_increment'
|
|
695
|
+
&& byName.get('key')?.slice(1).join('|') === 'varchar(255)|NO|UNI||'
|
|
696
|
+
&& byName.get('clicks')?.slice(1).join('|') === 'int|NO||0|'
|
|
697
|
+
// `archived` is nullable with no explicit DEFAULT, which MySQL reports as DEFAULT NULL.
|
|
698
|
+
&& byName.get('archived')?.[4] === null
|
|
699
|
+
&& JSON.stringify(columns.rows) === JSON.stringify(described.rows)
|
|
700
|
+
&& failed(missing, 'NOT_FOUND', "Table 'twin.Nope' doesn't exist");
|
|
701
|
+
}),
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
id: 'planetscale.ddl.column_definition_errors',
|
|
705
|
+
area: 'ddl', title: 'a duplicate column, a keyless AUTO_INCREMENT and a phantom key column are all refused',
|
|
706
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
707
|
+
verify: () => withRoot(async (q) => {
|
|
708
|
+
const dup = await q('CREATE TABLE A (id INT PRIMARY KEY, id INT)');
|
|
709
|
+
const keyless = await q('CREATE TABLE B (id INT AUTO_INCREMENT, name VARCHAR(8))');
|
|
710
|
+
const phantom = await q('CREATE TABLE C (id INT, PRIMARY KEY (nope))');
|
|
711
|
+
const twoAuto = await q('CREATE TABLE D (a INT AUTO_INCREMENT PRIMARY KEY, b INT AUTO_INCREMENT UNIQUE)');
|
|
712
|
+
// …and none of them left a half-built table behind.
|
|
713
|
+
const listed = await q('SHOW TABLES');
|
|
714
|
+
return failed(dup, 'INVALID_ARGUMENT', "Duplicate column name 'id'")
|
|
715
|
+
&& failed(keyless, 'INVALID_ARGUMENT', 'there can be only one auto column')
|
|
716
|
+
&& failed(phantom, 'INVALID_ARGUMENT', "Key column 'nope' doesn't exist in table")
|
|
717
|
+
&& failed(twoAuto, 'INVALID_ARGUMENT', 'there can be only one auto column')
|
|
718
|
+
&& listed.rows.length === 0;
|
|
719
|
+
}),
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
id: 'planetscale.ddl.secondary_index_accepted',
|
|
723
|
+
area: 'ddl', title: 'a non-unique KEY/INDEX declaration is accepted (it changes access paths, not results)',
|
|
724
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
725
|
+
verify: () => withRoot(async (q) => {
|
|
726
|
+
const created = await q('CREATE TABLE Click (id BIGINT NOT NULL AUTO_INCREMENT, link_id BIGINT NOT NULL, country CHAR(2) NULL, '
|
|
727
|
+
+ 'PRIMARY KEY (id), KEY link_idx (link_id), KEY country_idx (country(2)))');
|
|
728
|
+
await q('INSERT INTO Click (link_id, country) VALUES (7, \'GB\'), (7, \'US\'), (8, \'GB\')');
|
|
729
|
+
const filtered = await q('SELECT COUNT(*) FROM Click WHERE link_id = 7');
|
|
730
|
+
// The index does NOT become a uniqueness constraint — two rows with link_id 7 coexist.
|
|
731
|
+
return ok(created) && filtered.rows[0][0] === '2';
|
|
732
|
+
}),
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
id: 'planetscale.ddl.default_current_timestamp',
|
|
736
|
+
area: 'ddl', title: 'DEFAULT CURRENT_TIMESTAMP(fsp) is accepted, reported by INFORMATION_SCHEMA and evaluated from the World clock at insert',
|
|
737
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
738
|
+
verify: () => withRoot(async (q) => {
|
|
739
|
+
// Prisma's rendering of `@default(now())` on a `DateTime` (@db.DateTime(3)), verbatim in shape.
|
|
740
|
+
const created = await q('CREATE TABLE Ev (id INT NOT NULL, at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id))');
|
|
741
|
+
const mismatched = await q('CREATE TABLE Bad (id INT PRIMARY KEY, at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP)');
|
|
742
|
+
await q('INSERT INTO Ev (id) VALUES (1)', { at: '2026-03-04T05:06:07.890Z' });
|
|
743
|
+
const read = await q('SELECT at FROM Ev');
|
|
744
|
+
const column = await q("SELECT COLUMN_DEFAULT, EXTRA FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = 'Ev' AND COLUMN_NAME = 'at'");
|
|
745
|
+
// The instant is the request's World-clock instant, never the wall clock.
|
|
746
|
+
return ok(created) && read.rows[0][0] === '2026-03-04 05:06:07.890'
|
|
747
|
+
&& column.rows[0][0] === 'CURRENT_TIMESTAMP(3)' && column.rows[0][1] === 'DEFAULT_GENERATED'
|
|
748
|
+
&& failed(mismatched, 'INVALID_ARGUMENT', "Invalid default value for 'at'") && mismatched.error.message.includes('(errno 1067)');
|
|
749
|
+
}),
|
|
750
|
+
},
|
|
751
|
+
todo('planetscale.ddl.on_update_current_timestamp', 'ddl', 'ON UPDATE CURRENT_TIMESTAMP evaluated from the World clock at write time', 'common'),
|
|
752
|
+
{
|
|
753
|
+
id: 'planetscale.ddl.composite_unique',
|
|
754
|
+
area: 'ddl', title: 'a composite UNIQUE (a, b) constrains the tuple, not each column on its own',
|
|
755
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
756
|
+
verify: () => withRoot(async (q) => {
|
|
757
|
+
await q('CREATE TABLE Member (id INT NOT NULL, email VARCHAR(191) NOT NULL, projectId VARCHAR(191) NOT NULL, '
|
|
758
|
+
+ 'UNIQUE INDEX Member_email_projectId_key(email, projectId), PRIMARY KEY (id))');
|
|
759
|
+
const first = await q("INSERT INTO Member (id, email, projectId) VALUES (1, 'a@x.test', 'p1')");
|
|
760
|
+
const sameEmail = await q("INSERT INTO Member (id, email, projectId) VALUES (2, 'a@x.test', 'p2')");
|
|
761
|
+
const dup = await q("INSERT INTO Member (id, email, projectId) VALUES (3, 'a@x.test', 'p1')");
|
|
762
|
+
return ok(first) && ok(sameEmail)
|
|
763
|
+
&& failed(dup, 'ALREADY_EXISTS', "Duplicate entry 'a@x.test-p1' for key 'Member.Member_email_projectId_key'");
|
|
764
|
+
}),
|
|
765
|
+
},
|
|
766
|
+
todo('planetscale.ddl.alter_table', 'ddl', 'ALTER TABLE beyond ADD and DROP COLUMN (modify/rename column, add/drop index, a column added as a key)', 'core'),
|
|
767
|
+
{
|
|
768
|
+
id: 'planetscale.ddl.alter_table_add_column',
|
|
769
|
+
area: 'ddl', title: 'ALTER TABLE … ADD COLUMN places the column last, FIRST or AFTER a column, fills existing rows with its default, and refuses a duplicate name (errno 1060)',
|
|
770
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
771
|
+
verify: () => withRoot(async (q) => {
|
|
772
|
+
await q('CREATE TABLE Link (id BIGINT PRIMARY KEY AUTO_INCREMENT, domain VARCHAR(190) NOT NULL)');
|
|
773
|
+
await q("INSERT INTO Link (domain) VALUES ('go.t.io')");
|
|
774
|
+
const add = await q('ALTER TABLE Link ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0, ADD COLUMN note VARCHAR(20) AFTER id, ADD COLUMN rank INT NOT NULL FIRST');
|
|
775
|
+
const dup = await q('ALTER TABLE Link ADD COLUMN archived INT');
|
|
776
|
+
const rows = await q('SELECT * FROM Link');
|
|
777
|
+
return ok(add) && failed(dup, 'INVALID_ARGUMENT', "Duplicate column name 'archived'") && dup.error.message.includes('1060')
|
|
778
|
+
&& JSON.stringify(rows.rows.map((r) => r.map((v) => (v === null ? null : String(v))))) === JSON.stringify([['0', '1', null, 'go.t.io', '0']]);
|
|
779
|
+
}),
|
|
780
|
+
},
|
|
781
|
+
todo('planetscale.ddl.foreign_keys', 'ddl', 'FOREIGN KEY constraints and their referential actions', 'common'),
|
|
782
|
+
todo('planetscale.ddl.check_constraints', 'ddl', 'CHECK constraints', 'niche'),
|
|
783
|
+
todo('planetscale.ddl.generated_columns', 'ddl', 'GENERATED ALWAYS AS (…) VIRTUAL/STORED columns', 'niche'),
|
|
784
|
+
todo('planetscale.ddl.create_view', 'ddl', 'CREATE VIEW / DROP VIEW and querying a view', 'niche'),
|
|
785
|
+
{
|
|
786
|
+
id: 'planetscale.ddl.enum_set_validation',
|
|
787
|
+
area: 'ddl', title: 'ENUM/SET values are checked against their members (errno 1265) and stored in the declared spelling and order',
|
|
788
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
789
|
+
verify: () => withRoot(async (q) => {
|
|
790
|
+
await q("CREATE TABLE Pet (id INT PRIMARY KEY, kind ENUM('cat','dog') NOT NULL, tags SET('a','b','c'))");
|
|
791
|
+
const good = await q("INSERT INTO Pet VALUES (1, 'DOG', 'c,a'), (2, 1, NULL)");
|
|
792
|
+
const bad = await q("INSERT INTO Pet VALUES (3, 'fish', NULL)");
|
|
793
|
+
const badSet = await q("INSERT INTO Pet VALUES (4, 'cat', 'a,z')");
|
|
794
|
+
const rows = await q('SELECT kind, tags FROM Pet ORDER BY id');
|
|
795
|
+
return ok(good) && failed(bad, 'INVALID_ARGUMENT', "Data truncated for column 'kind' at row 1") && bad.error.message.includes('1265')
|
|
796
|
+
&& failed(badSet, 'INVALID_ARGUMENT', "Data truncated for column 'tags'")
|
|
797
|
+
&& JSON.stringify(rows.rows) === JSON.stringify([['dog', 'a,c'], ['cat', null]]);
|
|
798
|
+
}),
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
id: 'planetscale.ddl.column_length_enforcement',
|
|
802
|
+
area: 'ddl', title: 'CHAR/VARCHAR(n) count characters and VARBINARY(n)/TEXT count bytes: a longer value is errno 1406; excess trailing spaces are dropped',
|
|
803
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
804
|
+
verify: () => withRoot(async (q) => {
|
|
805
|
+
await q('CREATE TABLE Note (id INT PRIMARY KEY, title VARCHAR(5), code CHAR(3), raw VARBINARY(2), body TINYTEXT)');
|
|
806
|
+
const fits = await q("INSERT INTO Note (id, title, code) VALUES (1, 'héllo', 'ab ')");
|
|
807
|
+
const long = await q("INSERT INTO Note (id, title) VALUES (2, 'héllo!')");
|
|
808
|
+
const bytes = await q("INSERT INTO Note (id, raw) VALUES (3, 'abc')");
|
|
809
|
+
const text = await q(`INSERT INTO Note (id, body) VALUES (4, '${'é'.repeat(128)}')`);
|
|
810
|
+
const read = await q('SELECT title, code FROM Note WHERE id = 1');
|
|
811
|
+
return ok(fits) && failed(long, 'INVALID_ARGUMENT', "Data too long for column 'title' at row 1") && long.error.message.includes('1406')
|
|
812
|
+
&& failed(bytes, 'INVALID_ARGUMENT', "Data too long for column 'raw'") && failed(text, 'INVALID_ARGUMENT', "Data too long for column 'body'")
|
|
813
|
+
// The wire carries UTF-8 bytes (`unpackRow` hands them back as latin-1 chars, as the client's
|
|
814
|
+
// `atob` does), so decode them as the client's `decodeUtf8` does before comparing text.
|
|
815
|
+
&& JSON.stringify(read.rows.map((row) => row.map((v) => v === null ? null : new TextDecoder().decode(Uint8Array.from(v, (c) => c.charCodeAt(0))))))
|
|
816
|
+
=== JSON.stringify([['héllo', 'ab']]);
|
|
817
|
+
}),
|
|
818
|
+
},
|
|
819
|
+
{
|
|
820
|
+
id: 'planetscale.ddl.numeric_range_enforcement',
|
|
821
|
+
area: 'ddl', title: 'integers and DECIMAL(p,s) are range-checked on write (errno 1264); DECIMAL is rounded to its scale; INSERT IGNORE clips instead',
|
|
822
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
823
|
+
verify: () => withRoot(async (q) => {
|
|
824
|
+
await q('CREATE TABLE Acct (id INT PRIMARY KEY, n TINYINT UNSIGNED, bal DECIMAL(5,2))');
|
|
825
|
+
const tiny = await q('INSERT INTO Acct (id, n) VALUES (1, 256)');
|
|
826
|
+
const neg = await q('INSERT INTO Acct (id, n) VALUES (2, -1)');
|
|
827
|
+
const wide = await q('INSERT INTO Acct (id, bal) VALUES (3, 1000)');
|
|
828
|
+
const rounds = await q("INSERT INTO Acct (id, n, bal) VALUES (4, '7.5', 2.345)");
|
|
829
|
+
const clipped = await q('INSERT IGNORE INTO Acct (id, n, bal) VALUES (5, 999, 99999)');
|
|
830
|
+
const rows = await q('SELECT n, bal FROM Acct ORDER BY id');
|
|
831
|
+
return failed(tiny, 'INVALID_ARGUMENT', "Out of range value for column 'n' at row 1") && tiny.error.message.includes('1264')
|
|
832
|
+
&& failed(neg, 'INVALID_ARGUMENT', 'Out of range') && failed(wide, 'INVALID_ARGUMENT', "Out of range value for column 'bal'")
|
|
833
|
+
&& ok(rounds) && ok(clipped) && JSON.stringify(rows.rows) === JSON.stringify([['8', '2.35'], ['255', '999.99']]);
|
|
834
|
+
}),
|
|
835
|
+
},
|
|
836
|
+
// ── limits ──────────────────────────────────────────────────────────────────────────────────
|
|
837
|
+
{
|
|
838
|
+
id: 'planetscale.limits.tables_per_schema',
|
|
839
|
+
area: 'limits', title: 'the published 2048-table-per-schema limit is genuinely enforced (errno 1116)',
|
|
840
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
841
|
+
verify: () => withRoot(async () => {
|
|
842
|
+
// §9 ROUND ONE, FINDING 2: this used to be titled "the 2048-TABLE limit is enforced" while
|
|
843
|
+
// exercising only the COLUMN limit — delete the `db.tables.size >= …` branch and it stayed
|
|
844
|
+
// green. It now actually creates 2048 tables and refuses the 2049th. That is affordable at
|
|
845
|
+
// the ENGINE level (measured: ~8 ms), which is why this verify does not go through HTTP.
|
|
846
|
+
if (PLANETSCALE_LIMITS.maxTablesPerSchema !== 2048)
|
|
847
|
+
return false; // the LITERAL published figure
|
|
848
|
+
const image = emptyDatabase('twin');
|
|
849
|
+
for (let i = 0; i < 2048; i++)
|
|
850
|
+
applyWritesToImage(image, runSql(image, `CREATE TABLE t${i} (id INT PRIMARY KEY)`).writes);
|
|
851
|
+
if (image.tables.size !== 2048)
|
|
852
|
+
return false;
|
|
853
|
+
let refused = null;
|
|
854
|
+
try {
|
|
855
|
+
runSql(image, 'CREATE TABLE overflow (id INT PRIMARY KEY)');
|
|
856
|
+
}
|
|
857
|
+
catch (e) {
|
|
858
|
+
refused = e instanceof SqlError ? e : null;
|
|
859
|
+
}
|
|
860
|
+
// …and the 2048th was ACCEPTED, so the cap is off-by-none rather than merely present.
|
|
861
|
+
return refused !== null && refused.errno === 1116 && refused.code === 'RESOURCE_EXHAUSTED'
|
|
862
|
+
&& refused.message.includes('limited to 2048 tables')
|
|
863
|
+
&& image.tables.has('t2047');
|
|
864
|
+
}),
|
|
865
|
+
},
|
|
866
|
+
{
|
|
867
|
+
id: 'planetscale.limits.columns_per_table',
|
|
868
|
+
area: 'limits', title: 'the published 1017-column-per-table limit is enforced (errno 1117)',
|
|
869
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
870
|
+
verify: () => withRoot(async (q) => {
|
|
871
|
+
// Split out of the entry above by §9 round one: this is what that verify actually proved, so
|
|
872
|
+
// it now has its own id and a title that matches it. Routed through the real handler.
|
|
873
|
+
if (PLANETSCALE_LIMITS.maxColumnsPerTable !== 1017)
|
|
874
|
+
return false; // the LITERAL published figure
|
|
875
|
+
const refused = await q(`CREATE TABLE Wide (${Array.from({ length: 1018 }, (_, i) => `c${i} INT`).join(', ')})`);
|
|
876
|
+
const fits = await q(`CREATE TABLE Fits (${Array.from({ length: 1017 }, (_, i) => `c${i} INT`).join(', ')})`);
|
|
877
|
+
const listed = await q('SHOW TABLES');
|
|
878
|
+
return failed(refused, 'RESOURCE_EXHAUSTED', 'limited to 1017 columns')
|
|
879
|
+
&& refused.error.message.includes('(errno 1117)')
|
|
880
|
+
&& ok(fits)
|
|
881
|
+
// The refused table left NOTHING behind — the check precedes the write.
|
|
882
|
+
&& listed.rows.map((r) => r[0]).join(',') === 'Fits';
|
|
883
|
+
}),
|
|
884
|
+
},
|
|
885
|
+
{
|
|
886
|
+
id: 'planetscale.limits.rows_per_query',
|
|
887
|
+
area: 'limits', title: 'a result set beyond the published 100000-row cap is refused (errno 1153)',
|
|
888
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
889
|
+
verify: () => withRoot(async () => {
|
|
890
|
+
// §9 ROUND ONE, FINDING 1: this used to assert the constant and then read back 40 rows —
|
|
891
|
+
// delete every `rowLimitExceeded` throw and it stayed green. It now builds a 100_001-row
|
|
892
|
+
// image and asserts the refusal for SELECT, UPDATE and DELETE, plus that 100_000 is ACCEPTED
|
|
893
|
+
// (an off-by-one cap would refuse a legal query). Measured at ~6 ms; the seeding is done at
|
|
894
|
+
// the ENGINE level because seeding 100k rows through HTTP is not affordable in a verify.
|
|
895
|
+
if (PLANETSCALE_LIMITS.maxRowsPerQuery !== 100_000)
|
|
896
|
+
return false; // the LITERAL published figure
|
|
897
|
+
const image = emptyDatabase('twin');
|
|
898
|
+
applyWritesToImage(image, runSql(image, 'CREATE TABLE Big (id INT NOT NULL PRIMARY KEY, n INT NULL)').writes);
|
|
899
|
+
const rows = [];
|
|
900
|
+
for (let i = 1; i <= 100_001; i++)
|
|
901
|
+
rows.push({ rowid: i, cells: { id: String(i), n: null } });
|
|
902
|
+
image.rows.set('big', rows);
|
|
903
|
+
const refusal = (sql) => {
|
|
904
|
+
try {
|
|
905
|
+
runSql(image, sql);
|
|
906
|
+
return null;
|
|
907
|
+
}
|
|
908
|
+
catch (e) {
|
|
909
|
+
return e instanceof SqlError ? e : null;
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
const read = refusal('SELECT id FROM Big');
|
|
913
|
+
const updated = refusal('UPDATE Big SET n = 1');
|
|
914
|
+
const deleted = refusal('DELETE FROM Big');
|
|
915
|
+
// Exactly at the cap: allowed. One over: refused. That is what makes this a CAP, not a mood.
|
|
916
|
+
const atCap = runSql(image, 'SELECT id FROM Big LIMIT 100000');
|
|
917
|
+
return read !== null && read.errno === 1153 && read.code === 'RESOURCE_EXHAUSTED'
|
|
918
|
+
&& read.message.includes('got 100001')
|
|
919
|
+
&& updated !== null && updated.errno === 1153
|
|
920
|
+
&& deleted !== null && deleted.errno === 1153
|
|
921
|
+
&& atCap.rows.length === 100_000;
|
|
922
|
+
}),
|
|
923
|
+
},
|
|
924
|
+
todo('planetscale.limits.result_set_bytes', 'limits', 'the published 64 MiB per-query result-set size limit', 'niche'),
|
|
925
|
+
todo('planetscale.limits.query_timeout', 'limits', 'the published 900s autocommit / 20s transaction timeouts', 'niche'),
|
|
926
|
+
// ── insert ──────────────────────────────────────────────────────────────────────────────────
|
|
927
|
+
{
|
|
928
|
+
id: 'planetscale.insert.single_and_multi_row',
|
|
929
|
+
area: 'insert', title: 'INSERT writes one or many rows and reports rowsAffected + insertId',
|
|
930
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
931
|
+
verify: () => withRoot(async (q) => {
|
|
932
|
+
await q(LINK_DDL);
|
|
933
|
+
const one = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
934
|
+
const many = await q("INSERT INTO Link (`key`, url) VALUES ('b', 'https://b.test'), ('c', 'https://c.test')");
|
|
935
|
+
const read = await q('SELECT id, `key` FROM Link ORDER BY id');
|
|
936
|
+
// insertId is the FIRST id of the batch, exactly as MySQL reports it.
|
|
937
|
+
return one.rowsAffected === 1 && one.insertId === '1'
|
|
938
|
+
&& many.rowsAffected === 2 && many.insertId === '2'
|
|
939
|
+
&& read.rows.map((r) => r.join(':')).join(',') === '1:a,2:b,3:c';
|
|
940
|
+
}),
|
|
941
|
+
},
|
|
942
|
+
{
|
|
943
|
+
id: 'planetscale.insert.defaults_and_omitted_columns',
|
|
944
|
+
area: 'insert', title: 'an omitted column takes its DEFAULT, or NULL, or raises errno 1364',
|
|
945
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
946
|
+
verify: () => withRoot(async (q) => {
|
|
947
|
+
await q(LINK_DDL);
|
|
948
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
949
|
+
const read = await q("SELECT clicks, archived FROM Link WHERE `key` = 'a'");
|
|
950
|
+
const noDefault = await q("INSERT INTO Link (`key`) VALUES ('b')"); // url is NOT NULL, no default
|
|
951
|
+
const explicitNull = await q("INSERT INTO Link (`key`, url) VALUES ('c', NULL)");
|
|
952
|
+
return read.rows[0][0] === '0' && read.rows[0][1] === null
|
|
953
|
+
&& failed(noDefault, 'INVALID_ARGUMENT', "Field 'url' doesn't have a default value")
|
|
954
|
+
&& noDefault.error.message.includes('(errno 1364)')
|
|
955
|
+
&& failed(explicitNull, 'INVALID_ARGUMENT', "Column 'url' cannot be null")
|
|
956
|
+
&& explicitNull.error.message.includes('(errno 1048) (sqlstate 23000)');
|
|
957
|
+
}),
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
id: 'planetscale.insert.auto_increment',
|
|
961
|
+
area: 'insert', title: 'AUTO_INCREMENT mints the next id and an EXPLICIT id pushes the watermark past it',
|
|
962
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
963
|
+
verify: () => withRoot(async (q) => {
|
|
964
|
+
await q(LINK_DDL);
|
|
965
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
966
|
+
// An explicit id ABOVE the watermark — the shape a connector-pulled row has. The next minted
|
|
967
|
+
// id must jump past it; a naive counter would mint 2 and clobber this row.
|
|
968
|
+
await q("INSERT INTO Link (id, `key`, url) VALUES (500, 'b', 'https://b.test')");
|
|
969
|
+
const next = await q("INSERT INTO Link (`key`, url) VALUES ('c', 'https://c.test')");
|
|
970
|
+
const read = await q('SELECT id, `key` FROM Link ORDER BY id');
|
|
971
|
+
return next.insertId === '501'
|
|
972
|
+
&& read.rows.map((r) => r.join(':')).join(',') === '1:a,500:b,501:c';
|
|
973
|
+
}),
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
id: 'planetscale.insert.auto_increment_survives_delete',
|
|
977
|
+
area: 'insert', title: 'a deleted id is NEVER reissued — the watermark survives DELETE (dirty state)',
|
|
978
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
979
|
+
verify: () => withRoot(async (q) => {
|
|
980
|
+
await q(LINK_DDL);
|
|
981
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test'), ('c', 'https://c.test')");
|
|
982
|
+
await q("DELETE FROM Link WHERE `key` IN ('b', 'c')");
|
|
983
|
+
const next = await q("INSERT INTO Link (`key`, url) VALUES ('d', 'https://d.test')");
|
|
984
|
+
const again = await q("INSERT INTO Link (`key`, url) VALUES ('e', 'https://e.test')");
|
|
985
|
+
const read = await q('SELECT id, `key` FROM Link ORDER BY id');
|
|
986
|
+
// 4 and 5, NOT 2 and 3: a watermark derived from the surviving row COUNT (or from MAX(id) of
|
|
987
|
+
// what is left) would reissue the retired ids, silently rebinding old references to new rows.
|
|
988
|
+
return next.insertId === '4' && again.insertId === '5'
|
|
989
|
+
&& read.rows.map((r) => r.join(':')).join(',') === '1:a,4:d,5:e';
|
|
990
|
+
}),
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
id: 'planetscale.insert.duplicate_key',
|
|
994
|
+
area: 'insert', title: 'a PRIMARY/UNIQUE collision raises errno 1062 and writes NOTHING',
|
|
995
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
996
|
+
verify: () => withRoot(async (q) => {
|
|
997
|
+
await q(LINK_DDL);
|
|
998
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
999
|
+
const dupUnique = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://other.test')");
|
|
1000
|
+
const dupPk = await q("INSERT INTO Link (id, `key`, url) VALUES (1, 'z', 'https://z.test')");
|
|
1001
|
+
const read = await q('SELECT `key`, url FROM Link');
|
|
1002
|
+
return failed(dupUnique, 'ALREADY_EXISTS', "Duplicate entry 'a' for key 'Link.key_unique'")
|
|
1003
|
+
&& dupUnique.error.message.includes('(errno 1062) (sqlstate 23000)')
|
|
1004
|
+
&& failed(dupPk, 'ALREADY_EXISTS', "Duplicate entry '1' for key 'Link.PRIMARY'")
|
|
1005
|
+
&& read.rows.length === 1 && read.rows[0][1] === 'https://a.test';
|
|
1006
|
+
}),
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
id: 'planetscale.insert.ignore',
|
|
1010
|
+
area: 'insert', title: 'INSERT IGNORE skips the colliding row and still writes the others',
|
|
1011
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1012
|
+
verify: () => withRoot(async (q) => {
|
|
1013
|
+
await q(LINK_DDL);
|
|
1014
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
1015
|
+
const res = await q("INSERT IGNORE INTO Link (`key`, url) VALUES ('a', 'https://dup.test'), ('b', 'https://b.test')");
|
|
1016
|
+
const read = await q('SELECT `key`, url FROM Link ORDER BY id');
|
|
1017
|
+
return ok(res) && res.rowsAffected === 1
|
|
1018
|
+
&& read.rows.map((r) => r.join('=')).join(',') === 'a=https://a.test,b=https://b.test';
|
|
1019
|
+
}),
|
|
1020
|
+
},
|
|
1021
|
+
{
|
|
1022
|
+
id: 'planetscale.insert.on_duplicate_key_update',
|
|
1023
|
+
area: 'insert', title: 'ON DUPLICATE KEY UPDATE updates in place with MySQL\'s 1/2/0 rowsAffected',
|
|
1024
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1025
|
+
verify: () => withRoot(async (q) => {
|
|
1026
|
+
await q(LINK_DDL);
|
|
1027
|
+
const inserted = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test') ON DUPLICATE KEY UPDATE clicks = clicks + 1");
|
|
1028
|
+
const bumped = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test') ON DUPLICATE KEY UPDATE clicks = clicks + 1");
|
|
1029
|
+
const bumpedAgain = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test') ON DUPLICATE KEY UPDATE clicks = clicks + 1");
|
|
1030
|
+
const noop = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test') ON DUPLICATE KEY UPDATE clicks = clicks");
|
|
1031
|
+
const read = await q('SELECT clicks FROM Link');
|
|
1032
|
+
const count = await q('SELECT COUNT(*) FROM Link');
|
|
1033
|
+
// 1 for the insert, 2 for a real update, 0 for an update that changed nothing — MySQL's own
|
|
1034
|
+
// documented counting, and the reason `clicks` is 2 rather than 3 rows existing.
|
|
1035
|
+
return inserted.rowsAffected === 1 && bumped.rowsAffected === 2 && bumpedAgain.rowsAffected === 2
|
|
1036
|
+
&& noop.rowsAffected === 0 && read.rows[0][0] === '2' && count.rows[0][0] === '1';
|
|
1037
|
+
}),
|
|
1038
|
+
},
|
|
1039
|
+
{
|
|
1040
|
+
id: 'planetscale.insert.shape_errors',
|
|
1041
|
+
area: 'insert', title: 'a column-count mismatch and an unknown column are refused (errno 1136 / 1054)',
|
|
1042
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1043
|
+
verify: () => withRoot(async (q) => {
|
|
1044
|
+
await q(LINK_DDL);
|
|
1045
|
+
const mismatch = await q("INSERT INTO Link (`key`, url) VALUES ('a')");
|
|
1046
|
+
const secondRow = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b')");
|
|
1047
|
+
const unknown = await q("INSERT INTO Link (`key`, nope) VALUES ('a', 'x')");
|
|
1048
|
+
const count = await q('SELECT COUNT(*) FROM Link');
|
|
1049
|
+
// The batch is refused WHOLE — the good first row of `secondRow` must not have landed.
|
|
1050
|
+
return failed(mismatch, 'INVALID_ARGUMENT', "Column count doesn't match value count at row 1")
|
|
1051
|
+
&& failed(secondRow, 'INVALID_ARGUMENT', "Column count doesn't match value count at row 2")
|
|
1052
|
+
&& failed(unknown, 'INVALID_ARGUMENT', "Unknown column 'nope' in 'field list'")
|
|
1053
|
+
&& count.rows[0][0] === '0';
|
|
1054
|
+
}),
|
|
1055
|
+
},
|
|
1056
|
+
todo('planetscale.insert.select', 'insert', 'INSERT … SELECT', 'common'),
|
|
1057
|
+
todo('planetscale.insert.replace_into', 'insert', 'REPLACE INTO (delete-then-insert semantics)', 'niche'),
|
|
1058
|
+
todo('planetscale.insert.set_syntax', 'insert', 'INSERT … SET col = value (MySQL\'s alternative form)', 'niche'),
|
|
1059
|
+
{
|
|
1060
|
+
id: 'planetscale.insert.values_function',
|
|
1061
|
+
area: 'insert', title: 'ON DUPLICATE KEY UPDATE reads the proposed row through VALUES(col) and the 8.0.19 row alias (`AS new`, `AS new (m, n)`)',
|
|
1062
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1063
|
+
verify: () => withRoot(async (q) => {
|
|
1064
|
+
await q('CREATE TABLE Counter (name VARCHAR(20) PRIMARY KEY, hits INT NOT NULL, seen INT NOT NULL DEFAULT 0)');
|
|
1065
|
+
await q("INSERT INTO Counter (name, hits) VALUES ('x', 1)");
|
|
1066
|
+
const viaValues = await q("INSERT INTO Counter (name, hits) VALUES ('x', 5) ON DUPLICATE KEY UPDATE hits = hits + VALUES(hits)");
|
|
1067
|
+
const viaAlias = await q("INSERT INTO Counter (name, hits) VALUES ('x', 10) AS new ON DUPLICATE KEY UPDATE hits = new.hits, seen = seen + 1");
|
|
1068
|
+
const viaColumns = await q("INSERT INTO Counter (name, hits) VALUES ('x', 3) AS n (a, b) ON DUPLICATE KEY UPDATE hits = hits * b");
|
|
1069
|
+
const row = await q("SELECT hits, seen FROM Counter WHERE name = 'x'");
|
|
1070
|
+
return viaValues.rowsAffected === 2 && viaAlias.rowsAffected === 2 && ok(viaColumns)
|
|
1071
|
+
&& JSON.stringify(row.rows) === JSON.stringify([['30', '1']]);
|
|
1072
|
+
}),
|
|
1073
|
+
},
|
|
1074
|
+
// ── select ──────────────────────────────────────────────────────────────────────────────────
|
|
1075
|
+
{
|
|
1076
|
+
id: 'planetscale.select.star_and_projection',
|
|
1077
|
+
area: 'select', title: 'SELECT * expands the schema; a projection returns exactly its columns, aliased',
|
|
1078
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1079
|
+
verify: () => withRoot(async (q) => {
|
|
1080
|
+
await q(LINK_DDL);
|
|
1081
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
1082
|
+
const star = await q('SELECT * FROM Link');
|
|
1083
|
+
const qualified = await q('SELECT Link.* FROM Link');
|
|
1084
|
+
const projected = await q('SELECT `key` AS slug, clicks FROM Link');
|
|
1085
|
+
// a string-quoted alias, as the manual's `SELECT 1 AS `one`, 2 AS 'two'` (11.2 Schema Object Names)
|
|
1086
|
+
const stringAliased = await q(`SELECT \`key\` AS "click", clicks AS 'two' FROM Link`);
|
|
1087
|
+
const unknown = await q('SELECT nope FROM Link');
|
|
1088
|
+
return star.fields.map((f) => f.name).join(',') === 'id,key,url,clicks,archived'
|
|
1089
|
+
&& qualified.fields.length === 5
|
|
1090
|
+
&& projected.fields.map((f) => f.name).join(',') === 'slug,clicks'
|
|
1091
|
+
&& projected.fields[0].orgName === 'key'
|
|
1092
|
+
&& projected.rows[0].join(':') === 'a:0'
|
|
1093
|
+
&& stringAliased.fields.map((f) => f.name).join(',') === 'click,two' && stringAliased.rows[0].join(':') === 'a:0'
|
|
1094
|
+
&& failed(unknown, 'INVALID_ARGUMENT', "Unknown column 'nope' in 'field list'");
|
|
1095
|
+
}),
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
id: 'planetscale.select.where_operators',
|
|
1099
|
+
area: 'select', title: 'WHERE handles =, !=, <, <=, >, >=, AND, OR and parentheses',
|
|
1100
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1101
|
+
verify: () => withRoot(async (q) => {
|
|
1102
|
+
await q(LINK_DDL);
|
|
1103
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://a.test', 1), ('b', 'https://b.test', 5), ('c', 'https://c.test', 9)");
|
|
1104
|
+
const keys = async (where) => (await q(`SELECT \`key\` FROM Link WHERE ${where} ORDER BY \`key\``)).rows.map((r) => r[0]).join('');
|
|
1105
|
+
// Numeric columns compare NUMERICALLY, not lexically: '9' < '10' as strings.
|
|
1106
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('d', 'https://d.test', 10)");
|
|
1107
|
+
return await keys("`key` = 'b'") === 'b'
|
|
1108
|
+
&& await keys("`key` != 'b'") === 'acd'
|
|
1109
|
+
&& await keys('clicks > 5') === 'cd'
|
|
1110
|
+
&& await keys('clicks >= 5') === 'bcd'
|
|
1111
|
+
&& await keys('clicks < 5') === 'a'
|
|
1112
|
+
&& await keys('clicks <= 5') === 'ab'
|
|
1113
|
+
&& await keys("clicks > 4 AND `key` != 'c'") === 'bd'
|
|
1114
|
+
&& await keys("`key` = 'a' OR clicks = 9") === 'ac'
|
|
1115
|
+
&& await keys("(`key` = 'a' OR clicks = 9) AND clicks < 5") === 'a';
|
|
1116
|
+
}),
|
|
1117
|
+
},
|
|
1118
|
+
// ── COLLATION (MySQL 8 manual ch. 10) — src/planetscale-collation.ts ──────────────────────
|
|
1119
|
+
{
|
|
1120
|
+
id: 'planetscale.collation.connection_literals',
|
|
1121
|
+
area: 'collation', title: 'string literals compare under the connection collation utf8mb4_0900_ai_ci: case- and accent-insensitive, NO PAD, LIKE per character',
|
|
1122
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1123
|
+
verify: () => withRoot(async (q) => {
|
|
1124
|
+
const r = await q("SELECT 'A' = 'a', 'é' = 'e', 'ß' = 'ss', 'a ' = 'a', 'abc' LIKE 'ABC', 'é' LIKE 'e', 'ß' LIKE 'ss' FROM dual");
|
|
1125
|
+
return ok(r) && r.rows[0].join(',') === '1,1,1,0,1,1,0';
|
|
1126
|
+
}),
|
|
1127
|
+
},
|
|
1128
|
+
{
|
|
1129
|
+
id: 'planetscale.collation.column_resolution',
|
|
1130
|
+
area: 'collation', title: "a column compares under its resolved collation (its COLLATE, else the table's, else utf8mb4_0900_ai_ci), which INFORMATION_SCHEMA reports",
|
|
1131
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1132
|
+
verify: () => withRoot(async (q) => {
|
|
1133
|
+
await q('CREATE TABLE p (id INT PRIMARY KEY, s VARCHAR(20) COLLATE utf8mb4_bin, t VARCHAR(20)) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci');
|
|
1134
|
+
await q("INSERT INTO p VALUES (1, 'Bob', 'alice'), (2, 'bob', 'Bob')");
|
|
1135
|
+
const bin = await q("SELECT id FROM p WHERE s = 'bob'");
|
|
1136
|
+
const ci = await q("SELECT id FROM p WHERE t = 'BOB'");
|
|
1137
|
+
const info = await q("SELECT COLUMN_NAME, COLLATION_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = 'p' ORDER BY ORDINAL_POSITION");
|
|
1138
|
+
const table = await q("SELECT TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_NAME = 'p'");
|
|
1139
|
+
return bin.rows.map((x) => x[0]).join() === '2' && ci.rows.map((x) => x[0]).join() === '2'
|
|
1140
|
+
&& JSON.stringify(info.rows) === JSON.stringify([['id', null], ['s', 'utf8mb4_bin'], ['t', 'utf8mb4_unicode_ci']])
|
|
1141
|
+
&& table.rows[0][0] === 'utf8mb4_unicode_ci';
|
|
1142
|
+
}),
|
|
1143
|
+
},
|
|
1144
|
+
{
|
|
1145
|
+
id: 'planetscale.collation.unique_keys',
|
|
1146
|
+
area: 'collation', title: 'PRIMARY/UNIQUE keys collide under the column collation: a case variant in a _ci column is errno 1062, in a _bin column a distinct key',
|
|
1147
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1148
|
+
verify: () => withRoot(async (q) => {
|
|
1149
|
+
await q('CREATE TABLE u (id INT PRIMARY KEY, email VARCHAR(191), s VARCHAR(20) COLLATE utf8mb4_bin, UNIQUE KEY u_email (email), UNIQUE KEY u_s (s)) COLLATE utf8mb4_unicode_ci');
|
|
1150
|
+
await q("INSERT INTO u VALUES (1, 'abcxyz12@x.test', 'bob')");
|
|
1151
|
+
const variant = await q("INSERT INTO u VALUES (2, 'aBcXyZ12@x.test', 'other')");
|
|
1152
|
+
const binVariant = await q("INSERT INTO u VALUES (3, 'z@x.test', 'BOB')");
|
|
1153
|
+
const binSame = await q("INSERT INTO u VALUES (4, 'y@x.test', 'bob')");
|
|
1154
|
+
const lookup = await q("SELECT id FROM u WHERE email = 'ABCXYZ12@X.TEST'");
|
|
1155
|
+
return failed(variant, 'ALREADY_EXISTS', "Duplicate entry 'aBcXyZ12@x.test' for key 'u.u_email'")
|
|
1156
|
+
&& ok(binVariant) && failed(binSame, 'ALREADY_EXISTS', "for key 'u.u_s'")
|
|
1157
|
+
&& lookup.rows.map((x) => x[0]).join() === '1';
|
|
1158
|
+
}),
|
|
1159
|
+
},
|
|
1160
|
+
{
|
|
1161
|
+
id: 'planetscale.collation.order_group_distinct',
|
|
1162
|
+
area: 'collation', title: 'ORDER BY, GROUP BY, DISTINCT, COUNT(DISTINCT) and MAX order and fold strings under their collation',
|
|
1163
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1164
|
+
verify: () => withRoot(async (q) => {
|
|
1165
|
+
await q('CREATE TABLE g (id INT PRIMARY KEY, s VARCHAR(20) COLLATE utf8mb4_bin, t VARCHAR(20)) COLLATE utf8mb4_unicode_ci');
|
|
1166
|
+
await q("INSERT INTO g VALUES (1, 'Bob', 'alice'), (2, 'bob', 'Bob'), (3, 'carol', 'carol'), (4, 'Dup', 'dup'), (5, 'dup2', 'DUP')");
|
|
1167
|
+
const col = async (sql) => (await q(sql)).rows.map((x) => x.join('|')).join(',');
|
|
1168
|
+
return await col('SELECT t FROM g ORDER BY t, id') === 'alice,Bob,carol,dup,DUP'
|
|
1169
|
+
&& await col('SELECT s FROM g ORDER BY s') === 'Bob,Dup,bob,carol,dup2'
|
|
1170
|
+
&& await col('SELECT MIN(t), COUNT(*) FROM g GROUP BY t ORDER BY MIN(t)') === 'alice|1,Bob|1,carol|1,dup|2'
|
|
1171
|
+
&& await col('SELECT DISTINCT t FROM g ORDER BY t') === 'alice,Bob,carol,dup'
|
|
1172
|
+
&& await col('SELECT COUNT(DISTINCT t), COUNT(DISTINCT s), MAX(t) FROM g') === '4|5|dup';
|
|
1173
|
+
}),
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
id: 'planetscale.collation.predicates',
|
|
1177
|
+
area: 'collation', title: 'IN, BETWEEN, row constructors, simple CASE, NULLIF and join conditions compare under the operands\' collation',
|
|
1178
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1179
|
+
verify: () => withRoot(async (q) => {
|
|
1180
|
+
await q('CREATE TABLE a (id INT PRIMARY KEY, t VARCHAR(20)) COLLATE utf8mb4_unicode_ci');
|
|
1181
|
+
await q("INSERT INTO a VALUES (1, 'alice'), (2, 'Bob'), (3, 'carol')");
|
|
1182
|
+
await q('CREATE TABLE b (k VARCHAR(20) PRIMARY KEY, n INT) COLLATE utf8mb4_0900_as_cs');
|
|
1183
|
+
await q("INSERT INTO b VALUES ('ALICE', 1), ('carol', 2)");
|
|
1184
|
+
const col = async (sql) => (await q(sql)).rows.map((x) => x.join('|')).join(',');
|
|
1185
|
+
return await col("SELECT id FROM a WHERE t IN ('ALICE', 'CAROL') ORDER BY id") === '1,3'
|
|
1186
|
+
&& await col("SELECT id FROM a WHERE t BETWEEN 'B' AND 'CAROL' ORDER BY id") === '2,3'
|
|
1187
|
+
&& await col("SELECT id FROM a WHERE (t, id) IN (('BOB', 2), ('Alice', 9))") === '2'
|
|
1188
|
+
&& await col("SELECT CASE t WHEN 'ALICE' THEN 'hit' ELSE 'miss' END, NULLIF(t, 'ALICE') IS NULL FROM a WHERE id = 1") === 'hit|1'
|
|
1189
|
+
&& await col('SELECT a.id, b.n FROM a JOIN b ON a.t = b.k COLLATE utf8mb4_unicode_ci ORDER BY a.id') === '1|1,3|2'
|
|
1190
|
+
&& await col("SELECT k FROM b WHERE k = 'alice'") === '';
|
|
1191
|
+
}),
|
|
1192
|
+
},
|
|
1193
|
+
{
|
|
1194
|
+
id: 'planetscale.collation.coercibility',
|
|
1195
|
+
area: 'collation', title: "COLLATE, BINARY and introducers choose the comparison by MySQL's coercibility rules; an illegal mix is errno 1267, an unknown collation 1273",
|
|
1196
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1197
|
+
verify: () => withRoot(async (q) => {
|
|
1198
|
+
await q('CREATE TABLE a (t VARCHAR(20)) COLLATE utf8mb4_unicode_ci');
|
|
1199
|
+
await q('CREATE TABLE b (k VARCHAR(20)) COLLATE utf8mb4_0900_as_cs');
|
|
1200
|
+
await q("INSERT INTO a VALUES ('x')");
|
|
1201
|
+
await q("INSERT INTO b VALUES ('x')");
|
|
1202
|
+
const r = await q("SELECT BINARY 'A' = 'a', 'A' = 'a' COLLATE utf8mb4_bin, 'é' = 'e' COLLATE utf8mb4_0900_as_ci, 'a ' = 'a' COLLATE utf8mb4_bin, _utf8mb4'A' = 'a', _binary'A' = 'a' FROM dual");
|
|
1203
|
+
const mix = await q('SELECT a.t FROM a JOIN b ON a.t = b.k');
|
|
1204
|
+
const unknown = await q("SELECT 'a' COLLATE no_such_collation FROM dual");
|
|
1205
|
+
return ok(r) && r.rows[0].join(',') === '0,0,0,1,1,0'
|
|
1206
|
+
&& failed(mix, 'INVALID_ARGUMENT', "Illegal mix of collations (utf8mb4_unicode_ci,IMPLICIT) and (utf8mb4_0900_as_cs,IMPLICIT) for operation '='")
|
|
1207
|
+
&& failed(unknown, 'INVALID_ARGUMENT', "Unknown collation: 'no_such_collation'");
|
|
1208
|
+
}),
|
|
1209
|
+
},
|
|
1210
|
+
{
|
|
1211
|
+
id: 'planetscale.collation.named_collations',
|
|
1212
|
+
area: 'collation', title: 'utf8mb4_0900_ai_ci/_as_ci/_as_cs/_bin, utf8mb4_unicode_ci (UCA 4.0.0), utf8mb4_general_ci and utf8mb4_bin weigh characters by their own tables',
|
|
1213
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1214
|
+
verify: () => withRoot(async (q) => {
|
|
1215
|
+
const r = await q("SELECT 'ß' = 's' COLLATE utf8mb4_general_ci, 'ß' = 'ss' COLLATE utf8mb4_unicode_ci, 'Ø' = 'O' COLLATE utf8mb4_unicode_ci, 'Ø' = 'O', 'a' = 'A' COLLATE utf8mb4_0900_as_cs FROM dual");
|
|
1216
|
+
return ok(r) && r.rows[0].join(',') === '1,1,0,1,0';
|
|
1217
|
+
}),
|
|
1218
|
+
},
|
|
1219
|
+
todo('planetscale.collation.uca_full_weight_table', 'collation', 'the DUCET weights outside U+0000..U+052F, U+1E00..U+206F, U+20A0..U+20CF, U+2100..U+218F, U+FB00..U+FB06 and U+FF01..U+FF5E (Armenian, Hebrew, Arabic, Indic, kana, Hangul, CJK order, emoji, …): those characters take the UCA implicit weight, so they equal only themselves and sort after the modelled ranges', 'niche'),
|
|
1220
|
+
todo('planetscale.collation.other_collations', 'collation', 'character sets and collations beyond utf8mb4/utf8mb3/binary and the eleven modelled collations (latin1, ascii, utf16, the language-tailored utf8mb4_*_0900_* and utf8mb4_unicode_520_ci families, …): refused as unsupported', 'niche'),
|
|
1221
|
+
todo('planetscale.connector.column_collations', 'connector', "a pulled table's column collations: refresh reads DESCRIBE, which carries none, so pulled character columns compare under utf8mb4_0900_ai_ci whatever the upstream column declares", 'niche'),
|
|
1222
|
+
{
|
|
1223
|
+
id: 'planetscale.select.where_in_like_between',
|
|
1224
|
+
area: 'select', title: 'IN, NOT IN, LIKE, NOT LIKE and BETWEEN all filter correctly',
|
|
1225
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1226
|
+
verify: () => withRoot(async (q) => {
|
|
1227
|
+
await q(LINK_DDL);
|
|
1228
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('alpha', 'https://a.test', 1), ('beta', 'https://b.test', 5), ('gamma', 'https://c.test', 9)");
|
|
1229
|
+
const keys = async (where) => (await q(`SELECT \`key\` FROM Link WHERE ${where} ORDER BY \`key\``)).rows.map((r) => r[0]).join(',');
|
|
1230
|
+
return await keys("`key` IN ('alpha', 'gamma')") === 'alpha,gamma'
|
|
1231
|
+
&& await keys("`key` NOT IN ('alpha', 'gamma')") === 'beta'
|
|
1232
|
+
&& await keys("`key` LIKE 'a%'") === 'alpha'
|
|
1233
|
+
&& await keys("`key` LIKE '%m%'") === 'gamma'
|
|
1234
|
+
&& await keys("`key` LIKE 'bet_'") === 'beta'
|
|
1235
|
+
&& await keys("`key` NOT LIKE 'a%'") === 'beta,gamma'
|
|
1236
|
+
&& await keys('clicks BETWEEN 2 AND 9') === 'beta,gamma'
|
|
1237
|
+
&& await keys('clicks NOT BETWEEN 2 AND 9') === 'alpha';
|
|
1238
|
+
}),
|
|
1239
|
+
},
|
|
1240
|
+
{
|
|
1241
|
+
id: 'planetscale.select.null_three_valued_logic',
|
|
1242
|
+
area: 'select', title: 'NULL comparisons are UNKNOWN — IS NULL / IS NOT NULL / <=> behave like MySQL',
|
|
1243
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1244
|
+
verify: () => withRoot(async (q) => {
|
|
1245
|
+
await q(LINK_DDL);
|
|
1246
|
+
await q("INSERT INTO Link (`key`, url, archived) VALUES ('a', 'https://a.test', 1), ('b', 'https://b.test', NULL)");
|
|
1247
|
+
const keys = async (where) => (await q(`SELECT \`key\` FROM Link WHERE ${where} ORDER BY \`key\``)).rows.map((r) => r[0]).join(',');
|
|
1248
|
+
// The bug this pins: `archived != 1` must NOT return the NULL row. A twin comparing `null !=
|
|
1249
|
+
// '1'` in JavaScript returns true and silently leaks it into every "not archived" query.
|
|
1250
|
+
return await keys('archived IS NULL') === 'b'
|
|
1251
|
+
&& await keys('archived IS NOT NULL') === 'a'
|
|
1252
|
+
&& await keys('archived != 1') === ''
|
|
1253
|
+
&& await keys('archived = 1') === 'a'
|
|
1254
|
+
&& await keys('archived <=> NULL') === 'b'
|
|
1255
|
+
&& await keys('NOT (archived = 1)') === ''
|
|
1256
|
+
&& await keys("archived IN (1, NULL)") === 'a';
|
|
1257
|
+
}),
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
id: 'planetscale.select.order_by',
|
|
1261
|
+
area: 'select', title: 'ORDER BY sorts ASC/DESC, numerically for numeric columns, NULLs first ascending',
|
|
1262
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1263
|
+
verify: () => withRoot(async (q) => {
|
|
1264
|
+
await q(LINK_DDL);
|
|
1265
|
+
await q("INSERT INTO Link (`key`, url, clicks, archived) VALUES ('a', 'https://a.test', 9, NULL), "
|
|
1266
|
+
+ "('b', 'https://b.test', 10, 1), ('c', 'https://c.test', 2, 0)");
|
|
1267
|
+
const order = async (by) => (await q(`SELECT \`key\` FROM Link ORDER BY ${by}`)).rows.map((r) => r[0]).join(',');
|
|
1268
|
+
return await order('clicks') === 'c,a,b' // numeric: 2 < 9 < 10 (lexically '10' < '2')
|
|
1269
|
+
&& await order('clicks DESC') === 'b,a,c'
|
|
1270
|
+
&& await order('`key` DESC') === 'c,b,a'
|
|
1271
|
+
&& await order('archived') === 'a,c,b' // MySQL sorts NULL first ascending
|
|
1272
|
+
&& await order('archived DESC') === 'b,c,a' // …and last descending
|
|
1273
|
+
&& await order('archived, `key`') === 'a,c,b';
|
|
1274
|
+
}),
|
|
1275
|
+
},
|
|
1276
|
+
{
|
|
1277
|
+
id: 'planetscale.select.limit_offset',
|
|
1278
|
+
area: 'select', title: 'LIMIT n, LIMIT n OFFSET m and MySQL\'s LIMIT m, n all page correctly',
|
|
1279
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1280
|
+
verify: () => withRoot(async (q) => {
|
|
1281
|
+
await q(LINK_DDL);
|
|
1282
|
+
const values = Array.from({ length: 6 }, (_, i) => `('k${i}', 'https://x${i}.test')`).join(', ');
|
|
1283
|
+
await q(`INSERT INTO Link (\`key\`, url) VALUES ${values}`);
|
|
1284
|
+
const page = async (clause) => (await q(`SELECT \`key\` FROM Link ORDER BY id ${clause}`)).rows.map((r) => r[0]).join(',');
|
|
1285
|
+
return await page('LIMIT 2') === 'k0,k1'
|
|
1286
|
+
&& await page('LIMIT 2 OFFSET 2') === 'k2,k3'
|
|
1287
|
+
&& await page('LIMIT 2, 3') === 'k2,k3,k4' // MySQL's offset-first two-argument form
|
|
1288
|
+
&& await page('LIMIT 0') === ''
|
|
1289
|
+
&& await page('LIMIT 100') === 'k0,k1,k2,k3,k4,k5';
|
|
1290
|
+
}),
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
id: 'planetscale.select.count_star',
|
|
1294
|
+
area: 'select', title: 'COUNT(*) and COUNT(col) honour WHERE and NULLs, typed INT64',
|
|
1295
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1296
|
+
verify: () => withRoot(async (q) => {
|
|
1297
|
+
await q(LINK_DDL);
|
|
1298
|
+
await q("INSERT INTO Link (`key`, url, archived) VALUES ('a', 'https://a.test', 1), ('b', 'https://b.test', NULL), ('c', 'https://c.test', 0)");
|
|
1299
|
+
const all = await q('SELECT COUNT(*) AS total FROM Link');
|
|
1300
|
+
const filtered = await q("SELECT COUNT(*) FROM Link WHERE `key` != 'a'");
|
|
1301
|
+
const nonNull = await q('SELECT COUNT(archived) AS n FROM Link');
|
|
1302
|
+
const none = await q("SELECT COUNT(*) FROM Link WHERE `key` = 'zzz'");
|
|
1303
|
+
return all.rows[0][0] === '3' && all.fields[0].type === 'INT64' && all.fields[0].name === 'total'
|
|
1304
|
+
&& filtered.rows[0][0] === '2'
|
|
1305
|
+
&& nonNull.rows[0][0] === '2' // COUNT(col) skips NULL
|
|
1306
|
+
&& none.rows[0][0] === '0' && none.rows.length === 1; // …and still returns ONE row
|
|
1307
|
+
}),
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
id: 'planetscale.select.constant_and_dual',
|
|
1311
|
+
area: 'select', title: 'SELECT <expr> with no FROM (or FROM dual) returns one constant row',
|
|
1312
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1313
|
+
verify: () => withRoot(async (q) => {
|
|
1314
|
+
const bare = await q('SELECT 1');
|
|
1315
|
+
const dual = await q("SELECT 1 AS one, 'x' AS letter, NULL AS nothing FROM dual");
|
|
1316
|
+
const filteredOut = await q('SELECT 1 FROM dual WHERE 1 = 2');
|
|
1317
|
+
const star = await q('SELECT * FROM dual');
|
|
1318
|
+
return bare.rows[0][0] === '1' && bare.fields[0].name === '1'
|
|
1319
|
+
&& dual.rows[0][0] === '1' && dual.rows[0][1] === 'x' && dual.rows[0][2] === null
|
|
1320
|
+
&& dual.fields.map((f) => f.name).join(',') === 'one,letter,nothing'
|
|
1321
|
+
&& filteredOut.rows.length === 0
|
|
1322
|
+
&& failed(star, 'INVALID_ARGUMENT', 'No tables used');
|
|
1323
|
+
}),
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
id: 'planetscale.select.no_such_table',
|
|
1327
|
+
area: 'select', title: 'SELECT from a missing table raises errno 1146 with the database-qualified name',
|
|
1328
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1329
|
+
verify: () => withRoot(async (q) => {
|
|
1330
|
+
const missing = await q('SELECT * FROM Nope');
|
|
1331
|
+
// The DATABASE NAME in the message is the server's, not a constant: a different schema name
|
|
1332
|
+
// must appear in the error.
|
|
1333
|
+
const other = await q.raw({ body: JSON.stringify({ query: 'SELECT * FROM Nope' }), database: 'shop' });
|
|
1334
|
+
const otherQ = decode(other);
|
|
1335
|
+
return failed(missing, 'NOT_FOUND', "Table 'twin.Nope' doesn't exist")
|
|
1336
|
+
&& missing.error.message.includes('(errno 1146) (sqlstate 42S02)')
|
|
1337
|
+
&& failed(otherQ, 'NOT_FOUND', "Table 'shop.Nope' doesn't exist");
|
|
1338
|
+
}),
|
|
1339
|
+
},
|
|
1340
|
+
{
|
|
1341
|
+
id: 'planetscale.select.unmodeled_clauses_refused',
|
|
1342
|
+
area: 'select', title: 'UNION, RIGHT/NATURAL joins, JOIN … USING, window functions and WITH ROLLUP fail like Vitess, never as empty results',
|
|
1343
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1344
|
+
verify: () => withRoot(async (q) => {
|
|
1345
|
+
await q(LINK_DDL);
|
|
1346
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
1347
|
+
const cases = [
|
|
1348
|
+
['SELECT id FROM Link UNION SELECT id FROM Link', 'UNION'],
|
|
1349
|
+
['SELECT * FROM Link RIGHT JOIN Link l2 ON Link.id = l2.id', 'RIGHT JOIN'],
|
|
1350
|
+
['SELECT * FROM Link NATURAL JOIN Link l2', 'NATURAL JOIN'],
|
|
1351
|
+
['SELECT * FROM Link JOIN Link l2 USING (id)', 'USING'],
|
|
1352
|
+
['SELECT ROW_NUMBER() OVER (ORDER BY id) FROM Link', 'window functions'],
|
|
1353
|
+
['SELECT clicks, COUNT(*) FROM Link GROUP BY clicks WITH ROLLUP', 'WITH ROLLUP'],
|
|
1354
|
+
];
|
|
1355
|
+
for (const [sql, fragment] of cases) {
|
|
1356
|
+
const res = await q(sql);
|
|
1357
|
+
// An empty result set here would read to the caller as "ran fine, matched nothing" — the
|
|
1358
|
+
// fake success the bar forbids. It must be UNIMPLEMENTED with Vitess's own `unsupported:`.
|
|
1359
|
+
if (!failed(res, 'UNIMPLEMENTED', 'unsupported:'))
|
|
1360
|
+
return false;
|
|
1361
|
+
if (!res.error.message.includes(fragment))
|
|
1362
|
+
return false;
|
|
1363
|
+
if (res.rows.length !== 0 || res.status !== 200)
|
|
1364
|
+
return false;
|
|
1365
|
+
}
|
|
1366
|
+
return true;
|
|
1367
|
+
}),
|
|
1368
|
+
},
|
|
1369
|
+
{
|
|
1370
|
+
id: 'planetscale.select.derived_tables',
|
|
1371
|
+
area: 'select', title: 'A derived table in FROM is read as a table, and one without an alias is errno 1248',
|
|
1372
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1373
|
+
verify: () => withRoot(async (q) => {
|
|
1374
|
+
await q(LINK_DDL);
|
|
1375
|
+
await q("INSERT INTO Link (`key`, url, archived) VALUES ('a', 'https://a.test', 1), ('b', 'https://b.test', NULL), ('c', 'https://c.test', 0)");
|
|
1376
|
+
// Prisma's count over a filtered set, verbatim in shape.
|
|
1377
|
+
const counted = await q("SELECT COUNT(*) AS `_count$_all` FROM (SELECT Link.id FROM Link WHERE `key` != 'a') AS `sub`");
|
|
1378
|
+
const projected = await q("SELECT sub.url FROM (SELECT url, archived FROM Link) AS sub WHERE sub.archived IS NULL");
|
|
1379
|
+
const unaliased = await q('SELECT COUNT(*) FROM (SELECT id FROM Link)');
|
|
1380
|
+
return counted.rows[0][0] === '2' && counted.fields[0].name === '_count$_all' && counted.fields[0].type === 'INT64'
|
|
1381
|
+
&& projected.rows.length === 1 && projected.rows[0][0] === 'https://b.test' && projected.fields[0].table === 'sub'
|
|
1382
|
+
&& failed(unaliased, 'INVALID_ARGUMENT', 'Every derived table must have its own alias') && unaliased.error.message.includes('1248');
|
|
1383
|
+
}),
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
id: 'planetscale.select.information_schema_prisma_describer',
|
|
1387
|
+
area: 'select', title: "INFORMATION_SCHEMA TABLES / COLUMNS / STATISTICS / VIEWS / ROUTINES / KEY_COLUMN_USAGE ⋈ REFERENTIAL_CONSTRAINTS answer Prisma's MySQL describer, derived from the live catalog",
|
|
1388
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1389
|
+
verify: () => withRoot(async (q) => {
|
|
1390
|
+
// Prisma's own DDL shape and its describer's own queries (sql-schema-describer/src/mysql.rs), verbatim.
|
|
1391
|
+
await q('CREATE TABLE `Link` ( `id` VARCHAR(191) NOT NULL, `projectId` VARCHAR(191) NULL, `url` LONGTEXT NOT NULL, '
|
|
1392
|
+
+ '`archived` BOOLEAN NOT NULL DEFAULT false, `clicks` INTEGER NOT NULL DEFAULT 0, `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), '
|
|
1393
|
+
+ "`kind` ENUM('short', 'long') NOT NULL DEFAULT 'short', `fee` DECIMAL(5, 2) NULL, "
|
|
1394
|
+
+ 'INDEX `Link_projectId_url_idx`(`projectId`, `url`(500)), INDEX `Link_createdAt_idx`(`createdAt` DESC), UNIQUE INDEX `Link_url_key`(`url`(191)), '
|
|
1395
|
+
+ 'FULLTEXT INDEX `Link_url_idx`(`url`), PRIMARY KEY (`id`) ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
|
|
1396
|
+
const tables = await q("SELECT DISTINCT BINARY table_info.table_name AS table_name, table_info.create_options AS create_options, table_info.table_comment AS table_comment "
|
|
1397
|
+
+ 'FROM information_schema.tables AS table_info JOIN information_schema.columns AS column_info ON BINARY column_info.table_name = BINARY table_info.table_name '
|
|
1398
|
+
+ "WHERE table_info.table_schema = 'twin' AND column_info.table_schema = 'twin' -- Exclude views.\n AND table_info.table_type = 'BASE TABLE' ORDER BY BINARY table_info.table_name");
|
|
1399
|
+
const columns = await q("SELECT column_name column_name, data_type data_type, column_type full_data_type, character_maximum_length character_maximum_length, "
|
|
1400
|
+
+ 'numeric_precision numeric_precision, numeric_scale numeric_scale, datetime_precision datetime_precision, column_default column_default, '
|
|
1401
|
+
+ "is_nullable is_nullable, extra extra, table_name table_name, IF(column_comment = '', NULL, column_comment) AS column_comment "
|
|
1402
|
+
+ "FROM information_schema.columns WHERE table_schema = 'twin' ORDER BY ordinal_position");
|
|
1403
|
+
const indexes = await q('SELECT table_name AS table_name, index_name AS index_name, column_name AS column_name, sub_part AS partial, seq_in_index AS seq_in_index, '
|
|
1404
|
+
+ "collation AS column_order, non_unique AS non_unique, index_type AS index_type FROM information_schema.statistics WHERE table_schema = 'twin' "
|
|
1405
|
+
+ 'ORDER BY BINARY table_name, BINARY index_name, seq_in_index');
|
|
1406
|
+
const fks = await q('SELECT kcu.constraint_name constraint_name, kcu.column_name column_name, rc.delete_rule delete_rule FROM information_schema.key_column_usage AS kcu '
|
|
1407
|
+
+ "INNER JOIN information_schema.referential_constraints AS rc ON BINARY kcu.constraint_name = BINARY rc.constraint_name WHERE BINARY kcu.table_schema = 'twin' "
|
|
1408
|
+
+ "AND BINARY rc.constraint_schema = 'twin' AND kcu.referenced_column_name IS NOT NULL");
|
|
1409
|
+
const views = await q("SELECT TABLE_NAME AS view_name, VIEW_DEFINITION AS view_sql FROM INFORMATION_SCHEMA.VIEWS WHERE TABLE_SCHEMA = 'twin';");
|
|
1410
|
+
const routines = await q("SELECT routine_name AS name, routine_definition AS definition FROM information_schema.routines WHERE ROUTINE_SCHEMA = 'twin' AND ROUTINE_TYPE = 'PROCEDURE'");
|
|
1411
|
+
const unknown = await q('SELECT * FROM information_schema.NOPE');
|
|
1412
|
+
const col = (name) => columns.rows.find((r) => r[0] === name).slice(0, 11).join('|');
|
|
1413
|
+
const index = indexes.rows.map((r) => r.slice(1).join('|')).join(';');
|
|
1414
|
+
return tables.rows.length === 1 && tables.rows[0][0] === 'Link' && tables.fields[0].type === 'VARBINARY'
|
|
1415
|
+
&& columns.rows.length === 8 && columns.fields[0].name === 'column_name'
|
|
1416
|
+
&& col('id') === 'id|varchar|varchar(191)|191|||||NO||Link'
|
|
1417
|
+
&& col('archived') === 'archived|tinyint|tinyint(1)||3|0||0|NO||Link'
|
|
1418
|
+
&& col('clicks') === 'clicks|int|int||10|0||0|NO||Link'
|
|
1419
|
+
&& col('createdAt') === 'createdAt|datetime|datetime(3)||||3|CURRENT_TIMESTAMP(3)|NO|DEFAULT_GENERATED|Link'
|
|
1420
|
+
&& col('kind') === "kind|enum|enum('short','long')|5||||short|NO||Link"
|
|
1421
|
+
&& col('fee') === 'fee|decimal|decimal(5,2)||5|2|||YES||Link'
|
|
1422
|
+
&& col('url') === 'url|longtext|longtext|4294967295|||||NO||Link'
|
|
1423
|
+
&& index === 'Link_createdAt_idx|createdAt||1|D|1|BTREE;Link_projectId_url_idx|projectId||1|A|1|BTREE;Link_projectId_url_idx|url|500|2|A|1|BTREE;'
|
|
1424
|
+
+ 'Link_url_idx|url||1||1|FULLTEXT;Link_url_key|url|191|1|A|0|BTREE;PRIMARY|id||1|A|0|BTREE'
|
|
1425
|
+
&& ok(fks) && fks.rows.length === 0 && ok(views) && views.rows.length === 0 && ok(routines) && routines.rows.length === 0
|
|
1426
|
+
&& failed(unknown, 'NOT_FOUND', "Unknown table 'NOPE' in information_schema") && unknown.error.message.includes('(errno 1109)');
|
|
1427
|
+
}),
|
|
1428
|
+
},
|
|
1429
|
+
todo('planetscale.select.information_schema_other_views', 'select', "INFORMATION_SCHEMA beyond Prisma's describer reads: TABLE_CONSTRAINTS / CHECK_CONSTRAINTS / TRIGGERS / SCHEMATA are derived but unverified, and PARTITIONS, ENGINES, CHARACTER_SETS, COLLATIONS, PROCESSLIST and the other views are absent", 'niche'),
|
|
1430
|
+
{
|
|
1431
|
+
id: 'planetscale.select.joins',
|
|
1432
|
+
area: 'select', title: '[INNER|CROSS] JOIN, LEFT [OUTER] JOIN … ON and comma joins across stored tables, with aliases, `t.*`, NULL-extended LEFT JOIN rows and errno 1052 for an ambiguous column',
|
|
1433
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1434
|
+
verify: () => withRoot(async (q) => {
|
|
1435
|
+
await q(LINK_DDL);
|
|
1436
|
+
await q('CREATE TABLE Tag (id INT NOT NULL, linkId BIGINT NULL, name VARCHAR(20) NOT NULL, PRIMARY KEY (id))');
|
|
1437
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test')");
|
|
1438
|
+
await q("INSERT INTO Tag (id, linkId, name) VALUES (1, 1, 'x'), (2, 1, 'y'), (3, NULL, 'z')");
|
|
1439
|
+
// The shape of Dub's lib/planetscale/get-link-with-partner.ts: base-table `*` plus aliased LEFT JOINs.
|
|
1440
|
+
const left = await q('SELECT Link.`key`, t.name AS tagName FROM Link LEFT JOIN Tag AS t ON t.linkId = Link.id ORDER BY Link.id, t.name');
|
|
1441
|
+
const inner = await q("SELECT l.`key`, t.* FROM Tag t JOIN Link l ON l.id = t.linkId WHERE t.name <> 'y'");
|
|
1442
|
+
const comma = await q('SELECT COUNT(*) FROM Link, Tag');
|
|
1443
|
+
const ambiguous = await q('SELECT id FROM Link JOIN Tag ON Tag.linkId = Link.id');
|
|
1444
|
+
const leftRows = left.rows.map((r) => r.join(':')).join(',');
|
|
1445
|
+
return leftRows === 'a:x,a:y,b:' && left.rows[2][1] === null && left.fields[1].name === 'tagName'
|
|
1446
|
+
&& inner.rows.length === 1 && inner.rows[0].join(':') === 'a:1:1:x' && inner.fields.map((f) => f.name).join(',') === 'key,id,linkId,name'
|
|
1447
|
+
&& comma.rows[0][0] === '6'
|
|
1448
|
+
&& failed(ambiguous, 'INVALID_ARGUMENT', "Column 'id' in field list is ambiguous") && ambiguous.error.message.includes('1052');
|
|
1449
|
+
}),
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
id: 'planetscale.select.group_by',
|
|
1453
|
+
area: 'select', title: 'GROUP BY (columns, select aliases, positions) with HAVING, groups in first-seen order and functionally dependent columns read from the group',
|
|
1454
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1455
|
+
verify: () => withRoot(async (q) => {
|
|
1456
|
+
await q(LINK_DDL);
|
|
1457
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://x.test', 1), ('b', 'https://y.test', 5), ('c', 'https://x.test', 2)");
|
|
1458
|
+
const grouped = await q('SELECT url, COUNT(*) AS n, SUM(clicks) AS total FROM Link GROUP BY url ORDER BY url');
|
|
1459
|
+
const having = await q('SELECT url AS u, COUNT(*) FROM Link GROUP BY u HAVING COUNT(*) > 1');
|
|
1460
|
+
const positional = await q('SELECT url, MAX(clicks) FROM Link GROUP BY 1 ORDER BY 2 DESC');
|
|
1461
|
+
const empty = await q("SELECT url, COUNT(*) FROM Link WHERE `key` = 'zzz' GROUP BY url");
|
|
1462
|
+
return grouped.rows.map((r) => r.join(':')).join(',') === 'https://x.test:2:3,https://y.test:1:5'
|
|
1463
|
+
&& having.rows.length === 1 && having.rows[0].join(':') === 'https://x.test:2'
|
|
1464
|
+
&& positional.rows.map((r) => r.join(':')).join(',') === 'https://y.test:5,https://x.test:2'
|
|
1465
|
+
&& ok(empty) && empty.rows.length === 0;
|
|
1466
|
+
}),
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
id: 'planetscale.select.aggregates',
|
|
1470
|
+
area: 'select', title: 'COUNT([DISTINCT]) / SUM / AVG / MIN / MAX / JSON_ARRAYAGG with MySQL result types (SUM/AVG of an integer is DECIMAL, AVG adds 4 digits of scale) and NULL over an empty set',
|
|
1471
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1472
|
+
verify: () => withRoot(async (q) => {
|
|
1473
|
+
await q(LINK_DDL);
|
|
1474
|
+
await q("INSERT INTO Link (`key`, url, clicks, archived) VALUES ('a', 'https://x.test', 1, 1), ('b', 'https://y.test', 2, NULL), ('c', 'https://x.test', 4, 0)");
|
|
1475
|
+
const all = await q('SELECT COUNT(DISTINCT url), SUM(clicks), AVG(clicks), MIN(`key`), MAX(clicks), COUNT(archived) FROM Link');
|
|
1476
|
+
const none = await q("SELECT SUM(clicks), MAX(clicks), COUNT(*), JSON_ARRAYAGG(`key`) FROM Link WHERE `key` = 'zzz'");
|
|
1477
|
+
const agg = await q("SELECT JSON_ARRAYAGG(`key`) FROM Link WHERE url = 'https://x.test'");
|
|
1478
|
+
return all.rows[0].join('|') === '2|7|2.3333|a|4|2'
|
|
1479
|
+
&& all.fields[1].type === 'DECIMAL' && all.fields[2].type === 'DECIMAL' && all.fields[4].type === 'INT32' && all.fields[0].type === 'INT64'
|
|
1480
|
+
&& none.rows.length === 1 && none.rows[0].join('|') === '||0|' && none.rows[0][0] === null
|
|
1481
|
+
&& agg.rows[0][0] === '["a", "c"]' && agg.fields[0].type === 'JSON';
|
|
1482
|
+
}),
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
id: 'planetscale.select.subqueries',
|
|
1486
|
+
area: 'select', title: 'scalar, IN, NOT IN and [NOT] EXISTS subqueries, correlated or not (Prisma cursor pagination and relation filters), with errno 1242 for a scalar subquery of several rows',
|
|
1487
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1488
|
+
verify: () => withRoot(async (q) => {
|
|
1489
|
+
await q(LINK_DDL);
|
|
1490
|
+
await q('CREATE TABLE LinkTag (linkId BIGINT NOT NULL, tagId VARCHAR(8) NOT NULL, PRIMARY KEY (linkId, tagId))');
|
|
1491
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test'), ('c', 'https://c.test')");
|
|
1492
|
+
await q("INSERT INTO LinkTag (linkId, tagId) VALUES (1, 't1'), (3, 't1'), (3, 't2')");
|
|
1493
|
+
// Prisma's `startingAfter` cursor and its `tags: { some }` / `{ none }` relation filters, in shape.
|
|
1494
|
+
const cursor = await q("SELECT `twin`.`Link`.`key` FROM `twin`.`Link` WHERE `twin`.`Link`.`id` <= (SELECT `twin`.`Link`.`id` FROM `twin`.`Link` WHERE (`twin`.`Link`.`id`) = (2)) ORDER BY `twin`.`Link`.`id` DESC");
|
|
1495
|
+
const some = await q("SELECT `key` FROM Link WHERE EXISTS(SELECT `t0`.`linkId` FROM LinkTag AS `t0` WHERE (`t0`.`tagId` IN ('t1') AND (Link.id) = (`t0`.`linkId`)))");
|
|
1496
|
+
const none = await q('SELECT `key` FROM Link WHERE NOT EXISTS(SELECT 1 FROM LinkTag t0 WHERE t0.linkId = Link.id)');
|
|
1497
|
+
const inSub = await q("SELECT `key` FROM Link WHERE id IN (SELECT linkId FROM LinkTag WHERE tagId = 't2')");
|
|
1498
|
+
const notIn = await q('SELECT `key` FROM Link WHERE id NOT IN (SELECT linkId FROM LinkTag)');
|
|
1499
|
+
const counted = await q('SELECT `key`, (SELECT COUNT(*) FROM LinkTag WHERE LinkTag.linkId = Link.id) AS tags FROM Link');
|
|
1500
|
+
const tooMany = await q('SELECT (SELECT linkId FROM LinkTag) FROM Link');
|
|
1501
|
+
return cursor.rows.map((r) => r[0]).join(',') === 'b,a'
|
|
1502
|
+
&& some.rows.map((r) => r[0]).join(',') === 'a,c'
|
|
1503
|
+
&& none.rows.map((r) => r[0]).join(',') === 'b'
|
|
1504
|
+
&& inSub.rows.map((r) => r[0]).join(',') === 'c'
|
|
1505
|
+
&& notIn.rows.map((r) => r[0]).join(',') === 'b'
|
|
1506
|
+
&& counted.rows.map((r) => r.join(':')).join(',') === 'a:1,b:0,c:2'
|
|
1507
|
+
&& failed(tooMany, 'INVALID_ARGUMENT', 'Subquery returns more than 1 row');
|
|
1508
|
+
}),
|
|
1509
|
+
},
|
|
1510
|
+
{
|
|
1511
|
+
id: 'planetscale.select.row_constructors',
|
|
1512
|
+
area: 'select', title: 'row constructors: `(a, b) IN ((…), (…))` and `(a, b) = (c, d)` compare element-wise (Prisma composite-key relation loads)',
|
|
1513
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1514
|
+
verify: () => withRoot(async (q) => {
|
|
1515
|
+
await q('CREATE TABLE Enrollment (programId VARCHAR(8) NOT NULL, partnerId VARCHAR(8) NOT NULL, tenantId VARCHAR(8) NULL, PRIMARY KEY (programId, partnerId))');
|
|
1516
|
+
await q("INSERT INTO Enrollment (programId, partnerId, tenantId) VALUES ('p1', 'a', 't1'), ('p1', 'b', NULL), ('p2', 'a', 't3')");
|
|
1517
|
+
const loaded = await q("SELECT tenantId FROM Enrollment WHERE (Enrollment.programId, Enrollment.partnerId) IN (('p1','a'),('p2','a'),('p9','z'))");
|
|
1518
|
+
const equal = await q("SELECT partnerId FROM Enrollment WHERE (programId, partnerId) = ('p1', 'b')");
|
|
1519
|
+
const nullRow = await q("SELECT (tenantId, partnerId) = (NULL, 'a') FROM Enrollment WHERE programId = 'p2'");
|
|
1520
|
+
const width = await q("SELECT * FROM Enrollment WHERE (programId, partnerId) IN (('p1'))");
|
|
1521
|
+
return loaded.rows.map((r) => r[0]).join(',') === 't1,t3' && equal.rows.length === 1 && equal.rows[0][0] === 'b'
|
|
1522
|
+
&& nullRow.rows[0][0] === null && failed(width, 'INVALID_ARGUMENT', 'Operand should contain 2 column(s)');
|
|
1523
|
+
}),
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
id: 'planetscale.select.distinct',
|
|
1527
|
+
area: 'select', title: 'SELECT DISTINCT over stored tables and joins, applied before LIMIT',
|
|
1528
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1529
|
+
verify: () => withRoot(async (q) => {
|
|
1530
|
+
await q(LINK_DDL);
|
|
1531
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://x.test', 1), ('b', 'https://x.test', 1), ('c', 'https://y.test', 1)");
|
|
1532
|
+
const urls = await q('SELECT DISTINCT url FROM Link ORDER BY url');
|
|
1533
|
+
const limited = await q('SELECT DISTINCT url, clicks FROM Link ORDER BY url LIMIT 1 OFFSET 1');
|
|
1534
|
+
return urls.rows.map((r) => r[0]).join(',') === 'https://x.test,https://y.test' && limited.rows.length === 1 && limited.rows[0][0] === 'https://y.test';
|
|
1535
|
+
}),
|
|
1536
|
+
},
|
|
1537
|
+
{
|
|
1538
|
+
id: 'planetscale.select.clustered_order',
|
|
1539
|
+
area: 'select', title: 'a full scan with no ORDER BY returns rows in PRIMARY KEY order (InnoDB\'s clustered index; MySQL promises no order, and a secondary-index scan there differs)',
|
|
1540
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1541
|
+
verify: () => withRoot(async (q) => {
|
|
1542
|
+
await q('CREATE TABLE Item (id VARCHAR(16) NOT NULL, n INT NOT NULL, PRIMARY KEY (id))');
|
|
1543
|
+
await q("INSERT INTO Item (id, n) VALUES ('item_3', 3), ('item_1', 1), ('item_2', 2)");
|
|
1544
|
+
await q("UPDATE Item SET n = 10 WHERE id = 'item_1'");
|
|
1545
|
+
const all = await q('SELECT id FROM Item');
|
|
1546
|
+
const listed = await q("SELECT id FROM Item WHERE id IN ('item_3', 'item_2', 'item_1')");
|
|
1547
|
+
const deleted = await q('DELETE FROM Item LIMIT 1');
|
|
1548
|
+
const rest = await q('SELECT id FROM Item');
|
|
1549
|
+
return all.rows.map((r) => r[0]).join(',') === 'item_1,item_2,item_3' && listed.rows.map((r) => r[0]).join(',') === 'item_1,item_2,item_3'
|
|
1550
|
+
&& deleted.rowsAffected === 1 && rest.rows.map((r) => r[0]).join(',') === 'item_2,item_3';
|
|
1551
|
+
}),
|
|
1552
|
+
},
|
|
1553
|
+
todo('planetscale.select.union', 'select', 'UNION / UNION ALL', 'common'),
|
|
1554
|
+
{
|
|
1555
|
+
id: 'planetscale.select.for_update',
|
|
1556
|
+
area: 'select', title: 'SELECT … FOR UPDATE / FOR SHARE / LOCK IN SHARE MODE return the current rows and lock them until the transaction ends',
|
|
1557
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
1558
|
+
verify: () => withRoot(async (q) => {
|
|
1559
|
+
await q(LINK_DDL);
|
|
1560
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test')");
|
|
1561
|
+
const s1 = (await q('BEGIN', { session: null })).session;
|
|
1562
|
+
const locked = await q("SELECT `key` FROM Link WHERE `key` = 'a' FOR UPDATE", { session: s1 });
|
|
1563
|
+
let done = false;
|
|
1564
|
+
const blocked = q("UPDATE Link SET clicks = 5 WHERE `key` = 'a'", { session: null }).then((r) => { done = true; return r; });
|
|
1565
|
+
const other = await q("UPDATE Link SET clicks = 1 WHERE `key` = 'b'", { session: null });
|
|
1566
|
+
const share = await q("SELECT `key` FROM Link WHERE `key` = 'b' LOCK IN SHARE MODE", { session: null });
|
|
1567
|
+
const waited = !done;
|
|
1568
|
+
await q('COMMIT', { session: s1 });
|
|
1569
|
+
const released = await blocked;
|
|
1570
|
+
const after = await q("SELECT clicks FROM Link WHERE `key` = 'a'", { session: null });
|
|
1571
|
+
return locked.rows.length === 1 && waited && other.rowsAffected === 1 && share.rows.length === 1
|
|
1572
|
+
&& released.rowsAffected === 1 && after.rows[0][0] === '5';
|
|
1573
|
+
}),
|
|
1574
|
+
},
|
|
1575
|
+
todo('planetscale.select.window_functions', 'select', 'window functions (ROW_NUMBER, RANK, OVER)', 'niche'),
|
|
1576
|
+
todo('planetscale.select.fulltext', 'select', 'MATCH … AGAINST full-text search', 'niche'),
|
|
1577
|
+
todo('planetscale.select.explain', 'select', 'EXPLAIN <statement> / EXPLAIN ANALYZE and the Vitess query plan they return (refused as unmodeled today — §9 round one found it mis-parsed as DESCRIBE)', 'common'),
|
|
1578
|
+
todo('planetscale.select.index_hints', 'select', 'FORCE/USE/IGNORE INDEX and PARTITION (…) selection — accepted by the vendor, refused as unmodeled here because this engine has exactly one access path (a scan), so honouring a hint would be theatre', 'common'),
|
|
1579
|
+
todo('planetscale.select.vitess_literal_normalization', 'select', "Vitess query normalization — a literal becomes the bind variable `:vtg1` and the column is NAMED `:vtg1` rather than by its literal text", 'niche'),
|
|
1580
|
+
// ── update ──────────────────────────────────────────────────────────────────────────────────
|
|
1581
|
+
{
|
|
1582
|
+
id: 'planetscale.update.set_where_limit',
|
|
1583
|
+
area: 'update', title: 'UPDATE … SET … WHERE … LIMIT writes the matching rows and only those',
|
|
1584
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1585
|
+
verify: () => withRoot(async (q) => {
|
|
1586
|
+
await q(LINK_DDL);
|
|
1587
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://a.test', 1), ('b', 'https://b.test', 2), ('c', 'https://c.test', 3)");
|
|
1588
|
+
const one = await q("UPDATE Link SET url = 'https://new.test' WHERE `key` = 'b'");
|
|
1589
|
+
const expr = await q('UPDATE Link SET clicks = clicks * 10 WHERE clicks < 3');
|
|
1590
|
+
const limited = await q('UPDATE Link SET clicks = 99 ORDER BY id LIMIT 1');
|
|
1591
|
+
const read = await q('SELECT `key`, url, clicks FROM Link ORDER BY id');
|
|
1592
|
+
return one.rowsAffected === 1 && expr.rowsAffected === 2 && limited.rowsAffected === 1
|
|
1593
|
+
&& read.rows.map((r) => r.join(':')).join(',')
|
|
1594
|
+
=== 'a:https://a.test:99,b:https://new.test:20,c:https://c.test:3';
|
|
1595
|
+
}),
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
id: 'planetscale.update.counts_only_changed_rows',
|
|
1599
|
+
area: 'update', title: 'rowsAffected counts rows whose values actually CHANGED, as MySQL does',
|
|
1600
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1601
|
+
verify: () => withRoot(async (q) => {
|
|
1602
|
+
await q(LINK_DDL);
|
|
1603
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://a.test', 5), ('b', 'https://b.test', 5)");
|
|
1604
|
+
const changed = await q('UPDATE Link SET clicks = 7');
|
|
1605
|
+
const unchanged = await q('UPDATE Link SET clicks = 7');
|
|
1606
|
+
const partial = await q("UPDATE Link SET clicks = 7 WHERE `key` = 'a'");
|
|
1607
|
+
return changed.rowsAffected === 2 && unchanged.rowsAffected === 0 && partial.rowsAffected === 0;
|
|
1608
|
+
}),
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
id: 'planetscale.update.constraint_errors',
|
|
1612
|
+
area: 'update', title: 'an UPDATE into a duplicate key, a NULL in a NOT NULL column, or an unknown column is refused',
|
|
1613
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1614
|
+
verify: () => withRoot(async (q) => {
|
|
1615
|
+
await q(LINK_DDL);
|
|
1616
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test')");
|
|
1617
|
+
const dup = await q("UPDATE Link SET `key` = 'a' WHERE `key` = 'b'");
|
|
1618
|
+
const nul = await q("UPDATE Link SET url = NULL WHERE `key` = 'a'");
|
|
1619
|
+
const unknown = await q("UPDATE Link SET nope = 1 WHERE `key` = 'a'");
|
|
1620
|
+
const read = await q('SELECT `key`, url FROM Link ORDER BY id');
|
|
1621
|
+
// A row updating to ITS OWN key must still be allowed — the conflict check must exclude the
|
|
1622
|
+
// row it is rewriting, or every idempotent UPSERT would start failing.
|
|
1623
|
+
const selfSame = await q("UPDATE Link SET `key` = 'a', clicks = 1 WHERE `key` = 'a'");
|
|
1624
|
+
return failed(dup, 'ALREADY_EXISTS', "Duplicate entry 'a' for key 'Link.key_unique'")
|
|
1625
|
+
&& failed(nul, 'INVALID_ARGUMENT', "Column 'url' cannot be null")
|
|
1626
|
+
&& failed(unknown, 'INVALID_ARGUMENT', "Unknown column 'nope' in 'field list'")
|
|
1627
|
+
&& read.rows.map((r) => r.join('=')).join(',') === 'a=https://a.test,b=https://b.test'
|
|
1628
|
+
&& ok(selfSame) && selfSame.rowsAffected === 1;
|
|
1629
|
+
}),
|
|
1630
|
+
},
|
|
1631
|
+
{
|
|
1632
|
+
id: 'planetscale.update.multi_table',
|
|
1633
|
+
area: 'update', title: 'multiple-table UPDATE: aliased joins, each matching row updated once, only changed rows counted, a table under two aliases takes both, ORDER BY/LIMIT 1221, an unknown qualifier 1054, a subquery reading a target 1093',
|
|
1634
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1635
|
+
verify: () => withRoot(async (q) => {
|
|
1636
|
+
// the shape of Dub's payout claim (aggregate-due-commissions): UPDATE Commission c INNER JOIN Payout p ON … SET c.… WHERE …
|
|
1637
|
+
await q('CREATE TABLE Payout (id VARCHAR(20) PRIMARY KEY, status VARCHAR(20) NOT NULL)');
|
|
1638
|
+
await q('CREATE TABLE Commission (id VARCHAR(20) PRIMARY KEY, status VARCHAR(20) NOT NULL, payoutId VARCHAR(20) NULL, earnings INT NOT NULL)');
|
|
1639
|
+
await q("INSERT INTO Payout (id, status) VALUES ('po_1', 'pending'), ('po_2', 'processing')");
|
|
1640
|
+
await q("INSERT INTO Commission (id, status, earnings) VALUES ('c1', 'pending', 10), ('c2', 'pending', 20), ('c3', 'paid', 5)");
|
|
1641
|
+
const claimed = await q("UPDATE Commission c INNER JOIN Payout p ON p.id = 'po_1' SET c.status = 'processed', c.payoutId = 'po_1' WHERE c.id IN ('c1', 'c2', 'c3') AND c.status = 'pending' AND p.status IN ('pending')");
|
|
1642
|
+
const locked = await q("UPDATE Commission c INNER JOIN Payout p ON p.id = 'po_2' SET c.payoutId = 'po_2' WHERE c.id = 'c3' AND p.status IN ('pending')");
|
|
1643
|
+
// a row matched by two join rows is updated once; a comma join with a value from the other table
|
|
1644
|
+
await q("UPDATE Commission c, Payout p SET c.status = p.status WHERE c.payoutId = 'po_1' AND p.id = 'po_1'");
|
|
1645
|
+
const rows = await q('SELECT id, status, payoutId FROM Commission ORDER BY id');
|
|
1646
|
+
const ordered = await q("UPDATE Commission c INNER JOIN Payout p ON p.id = c.payoutId SET c.earnings = 1 ORDER BY c.id");
|
|
1647
|
+
// a row two join rows match is updated once, from its first match; the PRIMARY KEY clause spells the column in
|
|
1648
|
+
// another case than its definition
|
|
1649
|
+
await q('CREATE TABLE T (Id INT NOT NULL, v INT, w INT, PRIMARY KEY (id))');
|
|
1650
|
+
await q('CREATE TABLE U (id INT PRIMARY KEY, tid INT)');
|
|
1651
|
+
await q('INSERT INTO T VALUES (1, 0, 0), (2, 0, 0)');
|
|
1652
|
+
await q('INSERT INTO U VALUES (10, 1), (11, 1), (12, 2)');
|
|
1653
|
+
const once = await q('UPDATE T a JOIN U b ON b.tid = a.Id SET a.v = b.id');
|
|
1654
|
+
const onceRows = await q('SELECT Id, v FROM T ORDER BY Id');
|
|
1655
|
+
// one table under two aliases takes the assignments of both
|
|
1656
|
+
await q('UPDATE T a JOIN T b ON a.Id = b.Id SET a.v = 7, b.w = 8');
|
|
1657
|
+
const both = await q('SELECT v, w FROM T ORDER BY Id');
|
|
1658
|
+
const unknown = await q('UPDATE T a JOIN U b ON b.tid = a.Id SET z.v = 1');
|
|
1659
|
+
const reads = await q('UPDATE T a JOIN U b ON b.tid = a.Id SET a.v = (SELECT MAX(v) FROM T)');
|
|
1660
|
+
return claimed.rowsAffected === 2 && locked.rowsAffected === 0
|
|
1661
|
+
&& JSON.stringify(rows.rows) === JSON.stringify([['c1', 'pending', 'po_1'], ['c2', 'pending', 'po_1'], ['c3', 'paid', null]])
|
|
1662
|
+
&& (ordered.error?.message ?? '').includes('Incorrect usage of UPDATE and ORDER BY (errno 1221)')
|
|
1663
|
+
&& once.rowsAffected === 2 && JSON.stringify(onceRows.rows) === JSON.stringify([['1', '10'], ['2', '12']])
|
|
1664
|
+
&& JSON.stringify(both.rows) === JSON.stringify([['7', '8'], ['7', '8']])
|
|
1665
|
+
&& (unknown.error?.message ?? '').includes("Unknown column 'z.v' in 'field list' (errno 1054)")
|
|
1666
|
+
&& (reads.error?.message ?? '').includes('(errno 1093)');
|
|
1667
|
+
}),
|
|
1668
|
+
},
|
|
1669
|
+
{
|
|
1670
|
+
id: 'planetscale.update.aliased_single_table',
|
|
1671
|
+
area: 'update', title: 'a single table under an alias is the single-table UPDATE: qualified SET and WHERE, ORDER BY and LIMIT, a wrong qualifier 1054',
|
|
1672
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1673
|
+
verify: () => withRoot(async (q) => {
|
|
1674
|
+
await q('CREATE TABLE T (id INT PRIMARY KEY, v INT)');
|
|
1675
|
+
await q('INSERT INTO T VALUES (1, 0), (2, 0), (3, 0)');
|
|
1676
|
+
const limited = await q('UPDATE T x SET x.v = 5 WHERE x.id > 1 ORDER BY x.id DESC LIMIT 1');
|
|
1677
|
+
const wrong = await q('UPDATE T x SET y.v = 5');
|
|
1678
|
+
const rows = await q('SELECT id, v FROM T ORDER BY id');
|
|
1679
|
+
return limited.rowsAffected === 1 && JSON.stringify(rows.rows) === JSON.stringify([['1', '0'], ['2', '0'], ['3', '5']])
|
|
1680
|
+
&& (wrong.error?.message ?? '').includes("Unknown column 'y.v' in 'field list' (errno 1054)");
|
|
1681
|
+
}),
|
|
1682
|
+
},
|
|
1683
|
+
// ── delete ──────────────────────────────────────────────────────────────────────────────────
|
|
1684
|
+
{
|
|
1685
|
+
id: 'planetscale.delete.where_and_limit',
|
|
1686
|
+
area: 'delete', title: 'DELETE removes the matching rows, honours LIMIT, and reports rowsAffected',
|
|
1687
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1688
|
+
verify: () => withRoot(async (q) => {
|
|
1689
|
+
await q(LINK_DDL);
|
|
1690
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test'), ('c', 'https://c.test')");
|
|
1691
|
+
const one = await q("DELETE FROM Link WHERE `key` = 'b'");
|
|
1692
|
+
const none = await q("DELETE FROM Link WHERE `key` = 'zzz'");
|
|
1693
|
+
const limited = await q('DELETE FROM Link LIMIT 1');
|
|
1694
|
+
const read = await q('SELECT `key` FROM Link ORDER BY id');
|
|
1695
|
+
return one.rowsAffected === 1 && none.rowsAffected === 0 && limited.rowsAffected === 1
|
|
1696
|
+
&& read.rows.map((r) => r[0]).join(',') === 'c';
|
|
1697
|
+
}),
|
|
1698
|
+
},
|
|
1699
|
+
{
|
|
1700
|
+
id: 'planetscale.delete.recreate_after_delete',
|
|
1701
|
+
area: 'delete', title: 'a deleted row can be re-created with the same unique key (no sticky tombstone)',
|
|
1702
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1703
|
+
verify: () => withRoot(async (q) => {
|
|
1704
|
+
await q(LINK_DDL);
|
|
1705
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://first.test')");
|
|
1706
|
+
await q("DELETE FROM Link WHERE `key` = 'a'");
|
|
1707
|
+
// Deliberate DIRTY STATE: a delete that only marked a tombstone would make this collide, and
|
|
1708
|
+
// a delete that left the row visible would make the read wrong. Both are real bugs this pins.
|
|
1709
|
+
const readd = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://second.test')");
|
|
1710
|
+
const read = await q("SELECT id, url FROM Link WHERE `key` = 'a'");
|
|
1711
|
+
const count = await q('SELECT COUNT(*) FROM Link');
|
|
1712
|
+
return ok(readd) && readd.insertId === '2'
|
|
1713
|
+
&& read.rows.length === 1 && read.rows[0].join(':') === '2:https://second.test'
|
|
1714
|
+
&& count.rows[0][0] === '1';
|
|
1715
|
+
}),
|
|
1716
|
+
},
|
|
1717
|
+
{
|
|
1718
|
+
id: 'planetscale.delete.no_such_table',
|
|
1719
|
+
area: 'delete', title: 'DELETE from a missing table raises errno 1146 rather than succeeding silently',
|
|
1720
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1721
|
+
verify: () => withRoot(async (q) => {
|
|
1722
|
+
const res = await q('DELETE FROM Nope WHERE id = 1');
|
|
1723
|
+
return failed(res, 'NOT_FOUND', "Table 'twin.Nope' doesn't exist") && res.rowsAffected === 0;
|
|
1724
|
+
}),
|
|
1725
|
+
},
|
|
1726
|
+
// ── transactions ────────────────────────────────────────────────────────────────────────────
|
|
1727
|
+
{
|
|
1728
|
+
id: 'planetscale.transactions.commit',
|
|
1729
|
+
area: 'transactions', title: 'BEGIN … COMMIT makes the buffered writes visible in one step',
|
|
1730
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1731
|
+
verify: () => withRoot(async (q) => {
|
|
1732
|
+
await q(LINK_DDL);
|
|
1733
|
+
await q('BEGIN');
|
|
1734
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
1735
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('b', 'https://b.test')");
|
|
1736
|
+
const inTx = await q('SELECT COUNT(*) FROM Link');
|
|
1737
|
+
const inTxSession = inTx.session;
|
|
1738
|
+
await q('COMMIT');
|
|
1739
|
+
const after = await q('SELECT `key` FROM Link ORDER BY id');
|
|
1740
|
+
const afterSession = after.session;
|
|
1741
|
+
return inTx.rows[0][0] === '2'
|
|
1742
|
+
&& inTxSession.vitessSession?.inTransaction === true && inTxSession.vitessSession.autocommit === false
|
|
1743
|
+
&& after.rows.map((r) => r[0]).join(',') === 'a,b'
|
|
1744
|
+
&& afterSession.vitessSession?.inTransaction === false;
|
|
1745
|
+
}),
|
|
1746
|
+
},
|
|
1747
|
+
{
|
|
1748
|
+
id: 'planetscale.transactions.rollback',
|
|
1749
|
+
area: 'transactions', title: 'ROLLBACK discards every buffered write and leaves committed state untouched',
|
|
1750
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1751
|
+
verify: () => withRoot(async (q) => {
|
|
1752
|
+
await q(LINK_DDL);
|
|
1753
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('keep', 'https://keep.test')");
|
|
1754
|
+
await q('BEGIN');
|
|
1755
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('drop', 'https://drop.test')");
|
|
1756
|
+
await q("UPDATE Link SET url = 'https://changed.test' WHERE `key` = 'keep'");
|
|
1757
|
+
await q("DELETE FROM Link WHERE `key` = 'keep'");
|
|
1758
|
+
const inside = await q('SELECT COUNT(*) FROM Link');
|
|
1759
|
+
await q('ROLLBACK');
|
|
1760
|
+
const after = await q('SELECT `key`, url FROM Link');
|
|
1761
|
+
// Every one of the three buffered writes is gone — including the DELETE, which is the one a
|
|
1762
|
+
// naive "only buffer inserts" implementation would have applied straight through.
|
|
1763
|
+
return inside.rows[0][0] === '1'
|
|
1764
|
+
&& after.rows.length === 1 && after.rows[0].join('=') === 'keep=https://keep.test';
|
|
1765
|
+
}),
|
|
1766
|
+
},
|
|
1767
|
+
{
|
|
1768
|
+
id: 'planetscale.transactions.read_your_writes',
|
|
1769
|
+
area: 'transactions', title: 'statements inside a transaction see its own uncommitted writes',
|
|
1770
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1771
|
+
verify: () => withRoot(async (q) => {
|
|
1772
|
+
await q(LINK_DDL);
|
|
1773
|
+
await q('BEGIN');
|
|
1774
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
1775
|
+
const seen = await q("SELECT url FROM Link WHERE `key` = 'a'");
|
|
1776
|
+
await q("UPDATE Link SET clicks = 5 WHERE `key` = 'a'");
|
|
1777
|
+
const updated = await q("SELECT clicks FROM Link WHERE `key` = 'a'");
|
|
1778
|
+
// A uniqueness violation must be detected against the BUFFER too, not only against committed
|
|
1779
|
+
// rows — otherwise a transaction could stage two rows sharing a unique key.
|
|
1780
|
+
const dup = await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://dup.test')");
|
|
1781
|
+
await q('COMMIT');
|
|
1782
|
+
const committed = await q("SELECT clicks FROM Link WHERE `key` = 'a'");
|
|
1783
|
+
return seen.rows[0][0] === 'https://a.test' && updated.rows[0][0] === '5'
|
|
1784
|
+
&& failed(dup, 'ALREADY_EXISTS', "Duplicate entry 'a'")
|
|
1785
|
+
&& committed.rows[0][0] === '5';
|
|
1786
|
+
}),
|
|
1787
|
+
},
|
|
1788
|
+
{
|
|
1789
|
+
id: 'planetscale.transactions.session_isolation',
|
|
1790
|
+
area: 'transactions', title: 'an uncommitted transaction is invisible to another session',
|
|
1791
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1792
|
+
verify: () => withRoot(async (q) => {
|
|
1793
|
+
await q(LINK_DDL);
|
|
1794
|
+
await q('BEGIN');
|
|
1795
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
1796
|
+
const txSession = (await q('SELECT COUNT(*) FROM Link')).session;
|
|
1797
|
+
// A SECOND session, explicitly null so a fresh one is minted.
|
|
1798
|
+
const outside = await q('SELECT COUNT(*) FROM Link', { session: null });
|
|
1799
|
+
const inside = await q('SELECT COUNT(*) FROM Link', { session: txSession });
|
|
1800
|
+
await q('COMMIT', { session: txSession });
|
|
1801
|
+
const afterOutside = await q('SELECT COUNT(*) FROM Link', { session: null });
|
|
1802
|
+
return outside.rows[0][0] === '0' && inside.rows[0][0] === '1' && afterOutside.rows[0][0] === '1';
|
|
1803
|
+
}),
|
|
1804
|
+
},
|
|
1805
|
+
{
|
|
1806
|
+
id: 'planetscale.transactions.begin_implicitly_commits',
|
|
1807
|
+
area: 'transactions', title: 'BEGIN inside an open transaction implicitly COMMITs the one in flight',
|
|
1808
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
1809
|
+
verify: () => withRoot(async (q) => {
|
|
1810
|
+
await q(LINK_DDL);
|
|
1811
|
+
await q('BEGIN');
|
|
1812
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('first', 'https://1.test')");
|
|
1813
|
+
await q('BEGIN'); // MySQL commits the first transaction here
|
|
1814
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('second', 'https://2.test')");
|
|
1815
|
+
await q('ROLLBACK');
|
|
1816
|
+
const after = await q('SELECT `key` FROM Link');
|
|
1817
|
+
return after.rows.map((r) => r[0]).join(',') === 'first';
|
|
1818
|
+
}),
|
|
1819
|
+
},
|
|
1820
|
+
{
|
|
1821
|
+
id: 'planetscale.transactions.ddl_inside_transaction',
|
|
1822
|
+
area: 'transactions', title: 'DDL buffered in a transaction is visible to it and discarded by ROLLBACK',
|
|
1823
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
1824
|
+
verify: () => withRoot(async (q) => {
|
|
1825
|
+
await q('BEGIN');
|
|
1826
|
+
await q('CREATE TABLE Temp (id INT NOT NULL PRIMARY KEY, name VARCHAR(16) NOT NULL)');
|
|
1827
|
+
await q("INSERT INTO Temp (id, name) VALUES (1, 'x')");
|
|
1828
|
+
const inside = await q('SELECT name FROM Temp');
|
|
1829
|
+
await q('ROLLBACK');
|
|
1830
|
+
const after = await q('SELECT * FROM Temp');
|
|
1831
|
+
// NOTE (honest divergence, filed as planetscale.transactions.ddl_implicit_commit): real MySQL
|
|
1832
|
+
// implicitly COMMITs before DDL, so a rolled-back CREATE TABLE would persist there. This twin
|
|
1833
|
+
// buffers it, which is the SAFER direction for a local twin and is stated rather than hidden.
|
|
1834
|
+
return inside.rows[0][0] === 'x' && failed(after, 'NOT_FOUND', "Table 'twin.Temp' doesn't exist");
|
|
1835
|
+
}),
|
|
1836
|
+
},
|
|
1837
|
+
todo('planetscale.transactions.ddl_implicit_commit', 'transactions', "MySQL's implicit COMMIT before a DDL statement (this twin buffers DDL inside the transaction instead)", 'niche'),
|
|
1838
|
+
todo('planetscale.transactions.autoinc_gap_on_rollback', 'transactions', 'InnoDB does NOT roll back the AUTO_INCREMENT counter, so a rolled-back insert leaves a permanent id gap (this twin reuses the id)', 'niche'),
|
|
1839
|
+
todo('planetscale.transactions.savepoints', 'transactions', 'SAVEPOINT / ROLLBACK TO SAVEPOINT / RELEASE SAVEPOINT', 'niche'),
|
|
1840
|
+
{
|
|
1841
|
+
id: 'planetscale.transactions.row_locks',
|
|
1842
|
+
area: 'transactions', title: "an open transaction's written rows block other sessions' writes until it ends, which then run on the committed rows (no lost update); plain reads never wait; a lock cycle is errno 1213 and rolls the waiter back",
|
|
1843
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1844
|
+
verify: () => withRoot(async (q) => {
|
|
1845
|
+
await q(LINK_DDL);
|
|
1846
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test'), ('b', 'https://b.test')");
|
|
1847
|
+
const s1 = (await q('BEGIN', { session: null })).session;
|
|
1848
|
+
await q("UPDATE Link SET clicks = clicks + 1 WHERE `key` = 'a'", { session: s1 });
|
|
1849
|
+
let done = false;
|
|
1850
|
+
const blocked = q("UPDATE Link SET clicks = clicks + 1 WHERE `key` = 'a'", { session: null }).then((r) => { done = true; return r; });
|
|
1851
|
+
const read = await q("SELECT clicks FROM Link WHERE `key` = 'a'", { session: null });
|
|
1852
|
+
const waited = !done;
|
|
1853
|
+
await q('COMMIT', { session: s1 });
|
|
1854
|
+
await blocked;
|
|
1855
|
+
const both = await q("SELECT clicks FROM Link WHERE `key` = 'a'", { session: null });
|
|
1856
|
+
// A lock cycle: s2 holds a and waits for b; s3 holds b and asks for a.
|
|
1857
|
+
const s2 = (await q('BEGIN', { session: null })).session;
|
|
1858
|
+
const s3 = (await q('BEGIN', { session: null })).session;
|
|
1859
|
+
await q("UPDATE Link SET clicks = 10 WHERE `key` = 'a'", { session: s2 });
|
|
1860
|
+
await q("UPDATE Link SET clicks = 20 WHERE `key` = 'b'", { session: s3 });
|
|
1861
|
+
const first = q("UPDATE Link SET clicks = 11 WHERE `key` = 'b'", { session: s2 });
|
|
1862
|
+
await q('SELECT 1', { session: null }); // s2 is waiting by the time this has run
|
|
1863
|
+
const cycle = await q("UPDATE Link SET clicks = 21 WHERE `key` = 'a'", { session: s3 });
|
|
1864
|
+
const firstRan = await first;
|
|
1865
|
+
await q('COMMIT', { session: s2 });
|
|
1866
|
+
const final = await q('SELECT clicks FROM Link ORDER BY id', { session: null });
|
|
1867
|
+
return read.rows[0][0] === '0' && waited && both.rows[0][0] === '2'
|
|
1868
|
+
&& failed(cycle, 'ABORTED', 'Deadlock found when trying to get lock') && cycle.error.message.includes('1213')
|
|
1869
|
+
&& ok(firstRan) && final.rows.map((r) => r[0]).join(',') === '10,11';
|
|
1870
|
+
}),
|
|
1871
|
+
},
|
|
1872
|
+
todo('planetscale.transactions.isolation_levels', 'transactions', "the four MySQL isolation levels' distinct read semantics: SET TRANSACTION ISOLATION LEVEL parses and is accepted, but every transaction reads the latest committed rows plus its own writes (no REPEATABLE READ snapshot, no SERIALIZABLE shared locks on plain reads)", 'niche'),
|
|
1873
|
+
todo('planetscale.transactions.autocommit_toggle', 'transactions', 'SET autocommit = 0 and its implicit transaction', 'niche'),
|
|
1874
|
+
{
|
|
1875
|
+
id: 'planetscale.transactions.timeout',
|
|
1876
|
+
area: 'transactions', title: 'the published 20s transaction timeout measured against the World clock',
|
|
1877
|
+
dimension: 'api', tier: 'niche', expected: 'todo',
|
|
1878
|
+
},
|
|
1879
|
+
// ── functions / expressions ─────────────────────────────────────────────────────────────────
|
|
1880
|
+
{
|
|
1881
|
+
id: 'planetscale.functions.arithmetic',
|
|
1882
|
+
area: 'functions', title: 'arithmetic (+ - * / % DIV) evaluates with MySQL\'s result types (exact DECIMAL/BIGINT, DOUBLE for approximate operands), NULL on division by zero',
|
|
1883
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1884
|
+
verify: () => withRoot(async (q) => {
|
|
1885
|
+
const one = async (expr) => (await q(`SELECT ${expr} AS v FROM dual`)).rows[0][0];
|
|
1886
|
+
return await one('2 + 3') === '5'
|
|
1887
|
+
&& await one('10 - 4') === '6'
|
|
1888
|
+
&& await one('6 * 7') === '42'
|
|
1889
|
+
&& await one('9 / 2') === '4.5000' // exact: scale of 9 plus div_precision_increment (4)
|
|
1890
|
+
&& await one('1 / 3') === '0.3333'
|
|
1891
|
+
&& await one('0.1 + 0.2') === '0.3'
|
|
1892
|
+
&& await one('0.1e0 + 0.2e0') === '0.30000000000000004' // DOUBLE stays DOUBLE
|
|
1893
|
+
&& await one('9 DIV 2') === '4'
|
|
1894
|
+
&& await one('9 % 4') === '1'
|
|
1895
|
+
&& await one('1 / 0') === null // MySQL returns NULL, not an error
|
|
1896
|
+
&& await one('-5 + 1') === '-4'
|
|
1897
|
+
&& await one('(2 + 3) * 4') === '20';
|
|
1898
|
+
}),
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
id: 'planetscale.functions.null_handling',
|
|
1902
|
+
area: 'functions', title: 'COALESCE / IFNULL / NULLIF / IF behave like MySQL over NULL',
|
|
1903
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1904
|
+
verify: () => withRoot(async (q) => {
|
|
1905
|
+
await q(LINK_DDL);
|
|
1906
|
+
await q("INSERT INTO Link (`key`, url, archived) VALUES ('a', 'https://a.test', NULL)");
|
|
1907
|
+
const one = async (expr) => (await q(`SELECT ${expr} AS v FROM Link`)).rows[0][0];
|
|
1908
|
+
return await one('COALESCE(archived, 7)') === '7'
|
|
1909
|
+
&& await one('COALESCE(NULL, NULL, 3)') === '3'
|
|
1910
|
+
&& await one('IFNULL(archived, 0)') === '0'
|
|
1911
|
+
&& await one("NULLIF(`key`, 'a')") === null
|
|
1912
|
+
&& await one("NULLIF(`key`, 'b')") === 'a'
|
|
1913
|
+
&& await one("IF(archived IS NULL, 'unset', 'set')") === 'unset'
|
|
1914
|
+
&& await one('1 + archived') === null; // NULL propagates through arithmetic
|
|
1915
|
+
}),
|
|
1916
|
+
},
|
|
1917
|
+
{
|
|
1918
|
+
id: 'planetscale.functions.string_functions',
|
|
1919
|
+
area: 'functions', title: 'LOWER / UPPER / CONCAT / LENGTH / TRIM evaluate over real column data',
|
|
1920
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1921
|
+
verify: () => withRoot(async (q) => {
|
|
1922
|
+
await q(LINK_DDL);
|
|
1923
|
+
await q("INSERT INTO Link (`key`, url) VALUES (' MiXeD ', 'https://a.test')");
|
|
1924
|
+
const one = async (expr) => (await q(`SELECT ${expr} AS v FROM Link`)).rows[0][0];
|
|
1925
|
+
return await one('LOWER(`key`)') === ' mixed '
|
|
1926
|
+
&& await one('UPPER(`key`)') === ' MIXED '
|
|
1927
|
+
&& await one('TRIM(`key`)') === 'MiXeD'
|
|
1928
|
+
&& await one('LENGTH(TRIM(`key`))') === '5'
|
|
1929
|
+
&& await one("CONCAT(TRIM(`key`), '-', url)") === 'MiXeD-https://a.test'
|
|
1930
|
+
&& await one("CONCAT(TRIM(`key`), NULL)") === null; // CONCAT with NULL is NULL
|
|
1931
|
+
}),
|
|
1932
|
+
},
|
|
1933
|
+
{
|
|
1934
|
+
id: 'planetscale.functions.byte_vs_char_length',
|
|
1935
|
+
area: 'functions', title: 'LENGTH() counts BYTES and CHAR_LENGTH() counts CHARACTERS, as MySQL defines them',
|
|
1936
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
1937
|
+
verify: () => withRoot(async (q) => {
|
|
1938
|
+
// Found while walking §9's revert matrix: both used to return the JavaScript string length,
|
|
1939
|
+
// so LENGTH('日') was 1 where MySQL says 3. `functions.string_functions` never caught it
|
|
1940
|
+
// because its verify only used ASCII, where the two agree — the same "a verify that exercises
|
|
1941
|
+
// only the easy case" class round one flagged twice.
|
|
1942
|
+
await q(LINK_DDL);
|
|
1943
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('鍵🔑', 'https://a.test')");
|
|
1944
|
+
const text = await q('SELECT LENGTH(`key`) AS bytes, CHAR_LENGTH(`key`) AS chars FROM Link');
|
|
1945
|
+
const literal = await q("SELECT LENGTH('日') AS b, CHAR_LENGTH('日') AS c FROM dual");
|
|
1946
|
+
const ascii = await q("SELECT LENGTH('abc') AS b, CHAR_LENGTH('abc') AS c FROM dual");
|
|
1947
|
+
// '鍵' is 3 UTF-8 bytes and '🔑' is 4, so 7 bytes — and 2 CHARACTERS (code points), which is
|
|
1948
|
+
// also where a naive `.length` would answer 3, since '🔑' is a surrogate PAIR in JavaScript.
|
|
1949
|
+
return text.rows[0].join(',') === '7,2'
|
|
1950
|
+
&& literal.rows[0].join(',') === '3,1'
|
|
1951
|
+
&& ascii.rows[0].join(',') === '3,3'
|
|
1952
|
+
&& (await q('SELECT LENGTH(archived) AS n FROM Link')).rows[0][0] === null;
|
|
1953
|
+
}),
|
|
1954
|
+
},
|
|
1955
|
+
{
|
|
1956
|
+
id: 'planetscale.functions.database',
|
|
1957
|
+
area: 'functions', title: 'DATABASE() returns the schema this connection is bound to',
|
|
1958
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
1959
|
+
verify: () => withRoot(async (q) => {
|
|
1960
|
+
const dflt = await q('SELECT DATABASE() AS db FROM dual');
|
|
1961
|
+
const other = decode(await q.raw({ body: JSON.stringify({ query: 'SELECT DATABASE() AS db FROM dual' }), database: 'shop' }));
|
|
1962
|
+
return dflt.rows[0][0] === 'twin' && other.rows[0][0] === 'shop';
|
|
1963
|
+
}),
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
id: 'planetscale.functions.clock',
|
|
1967
|
+
area: 'functions', title: 'NOW(fsp) and its synonyms, UTC_TIMESTAMP(), CURDATE() and UNIX_TIMESTAMP() read the World clock instant of the statement, typed as MySQL types them',
|
|
1968
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
1969
|
+
verify: () => withRoot(async (q) => {
|
|
1970
|
+
// Dub's payout aggregation (`UPDATE … JOIN … SET c.updatedAt = NOW()`) and every `WHERE at < NOW()` read it.
|
|
1971
|
+
const at = { at: '2026-03-04T05:06:07.890Z' };
|
|
1972
|
+
const read = await q('SELECT NOW() AS a, NOW(3) AS b, CURRENT_TIMESTAMP AS c, CURDATE() AS d, UNIX_TIMESTAMP() AS e', at);
|
|
1973
|
+
await q('CREATE TABLE Ev (id INT NOT NULL, at DATETIME(3) NULL, PRIMARY KEY (id))');
|
|
1974
|
+
await q("INSERT INTO Ev (id, at) VALUES (1, NOW(3)), (2, '2026-03-04 05:06:07.000'), (3, '2027-01-01 00:00:00.000')", at);
|
|
1975
|
+
const before = await q('SELECT id FROM Ev WHERE at < NOW(3) ORDER BY id', at);
|
|
1976
|
+
const stored = await q('SELECT at FROM Ev WHERE id = 1');
|
|
1977
|
+
// unbound, in INSERT values, a clock value keeps its type: NOW() + 0 is its YYYYMMDDhhmmss number
|
|
1978
|
+
await q('CREATE TABLE Num (id INT PRIMARY KEY, n BIGINT)');
|
|
1979
|
+
await q('INSERT INTO Num VALUES (1, NOW() + 0)', at);
|
|
1980
|
+
const numeric = await q('SELECT n FROM Num');
|
|
1981
|
+
// NOW's precision is a bare number in MySQL's grammar
|
|
1982
|
+
const twoArgs = await q('SELECT NOW(1, 2)', at);
|
|
1983
|
+
const stringFsp = await q("SELECT NOW('3')", at);
|
|
1984
|
+
return ok(read) && JSON.stringify(read.rows[0]) === JSON.stringify(['2026-03-04 05:06:07', '2026-03-04 05:06:07.890', '2026-03-04 05:06:07', '2026-03-04', '1772600767'])
|
|
1985
|
+
&& read.fields.map((f) => f.type).join() === 'DATETIME,DATETIME,DATETIME,DATE,INT64'
|
|
1986
|
+
&& JSON.stringify(before.rows) === '[["2"]]' && stored.rows[0][0] === '2026-03-04 05:06:07.890'
|
|
1987
|
+
&& numeric.rows[0][0] === '20260304050607'
|
|
1988
|
+
&& (twoArgs.error?.message ?? '').includes('(errno 1064)') && (stringFsp.error?.message ?? '').includes('(errno 1064)');
|
|
1989
|
+
}),
|
|
1990
|
+
},
|
|
1991
|
+
{
|
|
1992
|
+
id: 'planetscale.functions.entropy',
|
|
1993
|
+
area: 'functions', title: 'RAND() / UUID() served from seeded entropy (today refused)',
|
|
1994
|
+
dimension: 'api', tier: 'core', expected: 'todo',
|
|
1995
|
+
},
|
|
1996
|
+
{
|
|
1997
|
+
id: 'planetscale.functions.entropy_refusal_is_real',
|
|
1998
|
+
area: 'functions', title: 'the entropy functions are genuinely REFUSED, not silently frozen',
|
|
1999
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2000
|
+
verify: () => withRoot(async (q) => {
|
|
2001
|
+
// The refusal above is a CLAIM; this is its proof. A refusal note is worthless if the
|
|
2002
|
+
// twin quietly answers anyway with a fabricated constant.
|
|
2003
|
+
for (const fn of ['RAND()', 'UUID()']) {
|
|
2004
|
+
const res = await q(`SELECT ${fn} AS v FROM dual`);
|
|
2005
|
+
if (!failed(res, 'UNIMPLEMENTED', 'unsupported:'))
|
|
2006
|
+
return false;
|
|
2007
|
+
if (!res.error.message.includes('serves no randomness'))
|
|
2008
|
+
return false;
|
|
2009
|
+
if (res.rows.length !== 0)
|
|
2010
|
+
return false;
|
|
2011
|
+
}
|
|
2012
|
+
return true;
|
|
2013
|
+
}),
|
|
2014
|
+
},
|
|
2015
|
+
{
|
|
2016
|
+
id: 'planetscale.functions.date_format_convert_tz',
|
|
2017
|
+
area: 'functions', title: "DATE_FORMAT (all specifiers but the week ones) and CONVERT_TZ between offsets and named zones, NULL for an unknown zone (Dub's partner-analytics timeseries)",
|
|
2018
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2019
|
+
verify: () => withRoot(async (q) => {
|
|
2020
|
+
await q('CREATE TABLE C (id INT NOT NULL, createdAt DATETIME(3) NOT NULL, earnings INT NOT NULL, PRIMARY KEY (id))');
|
|
2021
|
+
await q("INSERT INTO C (id, createdAt, earnings) VALUES (1, '2026-03-04 23:30:00.000', 100), (2, '2026-03-05 02:00:00.000', 250), (3, '2026-03-05 12:00:00.000', 7)");
|
|
2022
|
+
const series = await q("SELECT DATE_FORMAT(CONVERT_TZ(createdAt, '+00:00', '-05:00'), '%Y-%m-%d') AS start, SUM(earnings) AS earnings FROM C GROUP BY start ORDER BY start ASC");
|
|
2023
|
+
const named = await q("SELECT DATE_FORMAT(CONVERT_TZ('2026-07-01 12:00:00', 'UTC', 'America/New_York'), '%Y-%m-%d %H:00'), CONVERT_TZ('2026-07-01 12:00:00', '+00:00', 'Nowhere/Nope')");
|
|
2024
|
+
const words = await q("SELECT DATE_FORMAT('2026-03-01 15:04:05.25', '%W %M %D %y, %l:%i:%s %p %f %j %%')");
|
|
2025
|
+
return series.rows.map((r) => r.join(':')).join(',') === '2026-03-04:350,2026-03-05:7'
|
|
2026
|
+
&& named.rows[0][0] === '2026-07-01 08:00' && named.rows[0][1] === null
|
|
2027
|
+
&& words.rows[0][0] === 'Sunday March 1st 26, 3:04:05 PM 250000 060 %';
|
|
2028
|
+
}),
|
|
2029
|
+
},
|
|
2030
|
+
todo('planetscale.functions.date_functions', 'functions', 'the rest of the date library: DATE_ADD / DATE_SUB / INTERVAL, DATEDIFF, STR_TO_DATE, YEAR/MONTH/DAY, the DATE_FORMAT week specifiers', 'common'),
|
|
2031
|
+
{
|
|
2032
|
+
id: 'planetscale.functions.json_functions',
|
|
2033
|
+
area: 'functions', title: 'JSON_CONTAINS (Prisma `array_contains`), JSON_TYPE, JSON_EXTRACT, JSON_UNQUOTE, the -> and ->> operators, JSON_LENGTH, JSON_ARRAY and JSON_OBJECT over JSON documents, printed as MySQL prints JSON',
|
|
2034
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2035
|
+
verify: () => withRoot(async (q) => {
|
|
2036
|
+
await q('CREATE TABLE Webhook (id INT NOT NULL, triggers JSON NOT NULL, meta JSON NULL, PRIMARY KEY (id))');
|
|
2037
|
+
await q(`INSERT INTO Webhook (id, triggers, meta) VALUES (1, '["link.created","link.deleted"]', '{"b": {"c": 2}, "a": "x"}'), (2, '["lead.created"]', NULL)`);
|
|
2038
|
+
// Prisma's `triggers: { array_contains: ['link.created'] }`, verbatim in shape.
|
|
2039
|
+
const contains = await q(`SELECT id FROM Webhook WHERE (JSON_CONTAINS(Webhook.triggers, '["link.created"]') AND (JSON_TYPE(Webhook.triggers) = 'ARRAY'))`);
|
|
2040
|
+
const scalar = await q(`SELECT JSON_CONTAINS('{"a": 1, "b": [1, 2]}', '{"b": [2]}'), JSON_CONTAINS('[1, 2]', '3'), JSON_CONTAINS(meta, '2', '$.b.c') FROM Webhook WHERE id = 1`);
|
|
2041
|
+
const extract = await q(`SELECT JSON_EXTRACT(meta, '$.b'), meta->'$.a', meta->>'$.a', JSON_LENGTH(triggers), JSON_EXTRACT(triggers, '$[*]') FROM Webhook WHERE id = 1`);
|
|
2042
|
+
const built = await q(`SELECT JSON_OBJECT('id', id, 'ok', 'yes'), JSON_ARRAY(1, 'two', NULL) FROM Webhook WHERE id = 2`);
|
|
2043
|
+
const invalid = await q(`SELECT JSON_CONTAINS('not json', '1')`);
|
|
2044
|
+
return contains.rows.map((r) => r[0]).join(',') === '1'
|
|
2045
|
+
&& scalar.rows[0].join('|') === '1|0|1'
|
|
2046
|
+
&& extract.rows[0].join('|') === '{"c": 2}|"x"|x|2|["link.created", "link.deleted"]'
|
|
2047
|
+
&& built.rows[0].join('|') === '{"id": 2, "ok": "yes"}|[1, "two", null]'
|
|
2048
|
+
&& failed(invalid, 'INVALID_ARGUMENT', 'Invalid JSON text') && invalid.error.message.includes('3141');
|
|
2049
|
+
}),
|
|
2050
|
+
},
|
|
2051
|
+
todo('planetscale.functions.json_modification', 'functions', 'JSON_SET / JSON_INSERT / JSON_REPLACE / JSON_REMOVE / JSON_ARRAY_APPEND / JSON_MERGE_PATCH and the other document-modifying JSON functions', 'common'),
|
|
2052
|
+
{
|
|
2053
|
+
id: 'planetscale.functions.json_normalized_storage',
|
|
2054
|
+
area: 'functions', title: 'a JSON column is validated on write (errno 3140) and stored normalized — members in MySQL order, integers exact, doubles as doubles — and read back so',
|
|
2055
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2056
|
+
verify: () => withRoot(async (q) => {
|
|
2057
|
+
await q('CREATE TABLE Doc (id INT PRIMARY KEY, body JSON)');
|
|
2058
|
+
await q(`INSERT INTO Doc VALUES (1, '{"zeta":1,"a":[1.50,2],"id":12345678901234567890}')`);
|
|
2059
|
+
const bad = await q("INSERT INTO Doc VALUES (2, 'not json')");
|
|
2060
|
+
const number = await q('INSERT INTO Doc VALUES (3, 5)');
|
|
2061
|
+
const read = await q('SELECT body, body->>\'$.id\' FROM Doc');
|
|
2062
|
+
return read.rows[0][0] === '{"a": [1.5, 2], "id": 12345678901234567890, "zeta": 1}' && read.rows[0][1] === '12345678901234567890'
|
|
2063
|
+
&& failed(bad, 'INVALID_ARGUMENT', `Invalid JSON text: "Invalid value." at position 0 in value for column 'Doc.body'.`) && bad.error.message.includes('3140')
|
|
2064
|
+
&& failed(number, 'INVALID_ARGUMENT', 'not a JSON text, may need CAST');
|
|
2065
|
+
}),
|
|
2066
|
+
},
|
|
2067
|
+
{
|
|
2068
|
+
id: 'planetscale.functions.case_when',
|
|
2069
|
+
area: 'functions', title: 'CASE … WHEN … THEN … ELSE … END, searched and simple forms, NULL without a matching WHEN or ELSE',
|
|
2070
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2071
|
+
verify: () => withRoot(async (q) => {
|
|
2072
|
+
await q(LINK_DDL);
|
|
2073
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://a.test', 0), ('b', 'https://b.test', 5), ('c', 'https://c.test', 50)");
|
|
2074
|
+
const searched = await q("SELECT CASE WHEN clicks >= 10 THEN 'hot' WHEN clicks > 0 THEN 'warm' ELSE 'cold' END AS heat FROM Link");
|
|
2075
|
+
const simple = await q("SELECT CASE `key` WHEN 'a' THEN 1 WHEN 'b' THEN 2 END FROM Link");
|
|
2076
|
+
return searched.rows.map((r) => r[0]).join(',') === 'cold,warm,hot' && searched.fields[0].name === 'heat'
|
|
2077
|
+
&& simple.rows.map((r) => r[0]).join(',') === '1,2,';
|
|
2078
|
+
}),
|
|
2079
|
+
},
|
|
2080
|
+
todo('planetscale.functions.substring_replace', 'functions', 'SUBSTRING / REPLACE / LEFT / RIGHT / LOCATE / LPAD', 'common'),
|
|
2081
|
+
{
|
|
2082
|
+
id: 'planetscale.functions.cast_convert',
|
|
2083
|
+
area: 'functions', title: 'CAST(… AS type) and CONVERT(…, type) / CONVERT(… USING charset) with MySQL\'s conversions and result types',
|
|
2084
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2085
|
+
verify: () => withRoot(async (q) => {
|
|
2086
|
+
const r = await q("SELECT CAST('2024-01-01 10:00:00' AS DATE), CAST(1.5 AS SIGNED), CAST('1.7' AS SIGNED), CAST(-1 AS UNSIGNED), CAST('12.345' AS DECIMAL(5,2)), CAST('{\"b\":1,\"a\":2}' AS JSON), CONVERT(42, CHAR), CAST('2024-01-01T10:00:00.5' AS DATETIME(1)) FROM dual");
|
|
2087
|
+
const bad = await q("SELECT CAST('x' AS JSON) FROM dual");
|
|
2088
|
+
return JSON.stringify(r.rows[0]) === JSON.stringify(['2024-01-01', '2', '1', '18446744073709551615', '12.35', '{"a": 2, "b": 1}', '42', '2024-01-01 10:00:00.5'])
|
|
2089
|
+
&& r.fields.map((f) => f.type).join(',') === 'DATE,INT64,INT64,UINT64,DECIMAL,JSON,VARCHAR,DATETIME'
|
|
2090
|
+
&& failed(bad, 'INVALID_ARGUMENT', 'Invalid JSON text in argument 1 to function cast_as_json');
|
|
2091
|
+
}),
|
|
2092
|
+
},
|
|
2093
|
+
todo('planetscale.functions.regexp', 'functions', 'REGEXP / RLIKE and REGEXP_REPLACE', 'niche'),
|
|
2094
|
+
// ── errors ──────────────────────────────────────────────────────────────────────────────────
|
|
2095
|
+
{
|
|
2096
|
+
id: 'planetscale.errors.query_error_arrives_at_http_200',
|
|
2097
|
+
area: 'errors', title: 'a query error is HTTP 200 carrying {session, error:{code,message}}',
|
|
2098
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2099
|
+
verify: () => withRoot(async (q) => {
|
|
2100
|
+
const res = await q('SELECT * FROM Nope');
|
|
2101
|
+
const body = res.raw.body;
|
|
2102
|
+
// The 200 is load-bearing: the CLIENT synthesizes status 400 from this shape
|
|
2103
|
+
// (dist/index.js `execute`). Serving a real 400 would change the status every consumer sees.
|
|
2104
|
+
return res.status === 200 && body.error?.code === 'NOT_FOUND' && body.result === undefined
|
|
2105
|
+
&& body.session !== undefined
|
|
2106
|
+
&& typeof body.session.vitessSession.sessionUUID === 'string';
|
|
2107
|
+
}),
|
|
2108
|
+
},
|
|
2109
|
+
{
|
|
2110
|
+
id: 'planetscale.errors.parse_error',
|
|
2111
|
+
area: 'errors', title: 'a syntax error raises errno 1064 with a position, like MySQL',
|
|
2112
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2113
|
+
verify: () => withRoot(async (q) => {
|
|
2114
|
+
const truncated = await q('SELECT * FROM');
|
|
2115
|
+
const empty = await q('');
|
|
2116
|
+
const unclosed = await q("SELECT 'unterminated FROM dual");
|
|
2117
|
+
const trailing = await q('DESCRIBE Link and some junk');
|
|
2118
|
+
// §9 round one, finding 14b: this used to open by asserting that `SELEKT * FROM Link` answers
|
|
2119
|
+
// UNIMPLEMENTED — the OPPOSITE of the title, since real MySQL gives 1064 for a mistyped verb.
|
|
2120
|
+
// That case now lives in `errors.unmodeled_statement`, where it belongs, and this capability
|
|
2121
|
+
// asserts only genuine 1064 parse errors. `trailing` is the branch §9 finding 12 found
|
|
2122
|
+
// silently accepted (DESCRIBE returned without calling finish()).
|
|
2123
|
+
return failed(trailing, 'INVALID_ARGUMENT', 'syntax error at position')
|
|
2124
|
+
&& failed(truncated, 'INVALID_ARGUMENT', 'syntax error at position')
|
|
2125
|
+
&& truncated.error.message.includes('(errno 1064) (sqlstate 42000)')
|
|
2126
|
+
&& failed(empty, 'INVALID_ARGUMENT', 'syntax error at position 1')
|
|
2127
|
+
&& failed(unclosed, 'INVALID_ARGUMENT', 'syntax error at position');
|
|
2128
|
+
}),
|
|
2129
|
+
},
|
|
2130
|
+
{
|
|
2131
|
+
id: 'planetscale.errors.errno_sqlstate_suffix',
|
|
2132
|
+
area: 'errors', title: 'every query error message carries MySQL\'s `(errno N) (sqlstate S)` suffix',
|
|
2133
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2134
|
+
verify: () => withRoot(async (q) => {
|
|
2135
|
+
await q(LINK_DDL);
|
|
2136
|
+
const pairs = [
|
|
2137
|
+
['SELECT * FROM Nope', 1146, '42S02'],
|
|
2138
|
+
['SELECT nope FROM Link', 1054, '42S22'],
|
|
2139
|
+
["INSERT INTO Link (`key`) VALUES ('x')", 1364, 'HY000'],
|
|
2140
|
+
['CREATE TABLE Link (id INT PRIMARY KEY)', 1050, '42S01'],
|
|
2141
|
+
];
|
|
2142
|
+
for (const [sql, errno, sqlstate] of pairs) {
|
|
2143
|
+
const res = await q(sql);
|
|
2144
|
+
if (res.error === undefined)
|
|
2145
|
+
return false;
|
|
2146
|
+
if (!res.error.message.endsWith(`(errno ${errno}) (sqlstate ${sqlstate})`))
|
|
2147
|
+
return false;
|
|
2148
|
+
}
|
|
2149
|
+
return true;
|
|
2150
|
+
}),
|
|
2151
|
+
},
|
|
2152
|
+
{
|
|
2153
|
+
id: 'planetscale.errors.unmodeled_statement',
|
|
2154
|
+
area: 'errors', title: 'a statement family this twin does not model raises Vitess\'s `unsupported:` UNIMPLEMENTED',
|
|
2155
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2156
|
+
verify: () => withRoot(async (q) => {
|
|
2157
|
+
const cases = [
|
|
2158
|
+
'ALTER TABLE Link DROP COLUMN domain', 'CREATE DATABASE other', 'GRANT ALL ON *.* TO x',
|
|
2159
|
+
'SET autocommit = 0', 'SHOW DATABASES',
|
|
2160
|
+
// A mistyped verb is an unknown STATEMENT FAMILY to this parser, so it takes this channel
|
|
2161
|
+
// rather than 1064 (§9 round one, finding 14b — it used to be asserted under the
|
|
2162
|
+
// parse-error capability, whose title claims the opposite).
|
|
2163
|
+
'SELEKT * FROM Link',
|
|
2164
|
+
// §9 round one, finding 12: `EXPLAIN <statement>` used to be parsed as `DESCRIBE SELECT`
|
|
2165
|
+
// and answered a confidently wrong errno 1146 naming a table called "SELECT".
|
|
2166
|
+
'EXPLAIN SELECT * FROM Link', 'EXPLAIN ANALYZE SELECT * FROM Link',
|
|
2167
|
+
];
|
|
2168
|
+
for (const sql of cases) {
|
|
2169
|
+
const res = await q(sql);
|
|
2170
|
+
if (res.error?.code !== 'UNIMPLEMENTED')
|
|
2171
|
+
return false;
|
|
2172
|
+
if (!res.error.message.startsWith('unsupported: '))
|
|
2173
|
+
return false;
|
|
2174
|
+
// Never a fabricated success: no rows, no rowsAffected.
|
|
2175
|
+
if (res.rows.length !== 0 || res.rowsAffected !== 0)
|
|
2176
|
+
return false;
|
|
2177
|
+
}
|
|
2178
|
+
return true;
|
|
2179
|
+
}),
|
|
2180
|
+
},
|
|
2181
|
+
todo('planetscale.errors.vttablet_prefix', 'errors', "the vttablet routing prefix real errors carry (`target: <keyspace>.<shard>.<tablet_type>: vttablet: rpc error: code = … desc = `) — deliberately not fabricated, since a local twin has no keyspace or shard", 'niche'),
|
|
2182
|
+
todo('planetscale.errors.warnings', 'errors', 'SHOW WARNINGS and the warning list a lenient statement produces', 'niche'),
|
|
2183
|
+
// ── readonly ────────────────────────────────────────────────────────────────────────────────
|
|
2184
|
+
{
|
|
2185
|
+
id: 'planetscale.readonly.refuses_writes',
|
|
2186
|
+
area: 'readonly', title: 'a read-only twin refuses every write with MySQL errno 1290 and still serves reads',
|
|
2187
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2188
|
+
verify: () => withRoot(async (q) => {
|
|
2189
|
+
await q(LINK_DDL);
|
|
2190
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
2191
|
+
const reads = await q('SELECT `key` FROM Link', { readOnly: true });
|
|
2192
|
+
for (const sql of [
|
|
2193
|
+
"INSERT INTO Link (`key`, url) VALUES ('b', 'https://b.test')",
|
|
2194
|
+
"UPDATE Link SET clicks = 1 WHERE `key` = 'a'",
|
|
2195
|
+
"DELETE FROM Link WHERE `key` = 'a'",
|
|
2196
|
+
'DROP TABLE Link',
|
|
2197
|
+
'TRUNCATE TABLE Link',
|
|
2198
|
+
'CREATE TABLE Other (id INT PRIMARY KEY)',
|
|
2199
|
+
]) {
|
|
2200
|
+
const res = await q(sql, { readOnly: true });
|
|
2201
|
+
if (!failed(res, 'FAILED_PRECONDITION', '--read-only option'))
|
|
2202
|
+
return false;
|
|
2203
|
+
if (!res.error.message.includes('(errno 1290) (sqlstate HY000)'))
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
// …and NOTHING landed: the refusals must precede the write, not follow it.
|
|
2207
|
+
const after = await q('SELECT `key`, clicks FROM Link');
|
|
2208
|
+
return reads.rows[0][0] === 'a' && after.rows.length === 1 && after.rows[0].join(':') === 'a:0';
|
|
2209
|
+
}),
|
|
2210
|
+
},
|
|
2211
|
+
// ── §9 ROUND-ONE PINS ───────────────────────────────────────────────────────────────────────
|
|
2212
|
+
// Each of these exists because round one REFUTED a claim or found a defect by reading. They are
|
|
2213
|
+
// the revert-matrix cells: re-introduce the named bug and the named capability must go red.
|
|
2214
|
+
{
|
|
2215
|
+
id: 'planetscale.wire.binary_column_stores_text_as_utf8_bytes',
|
|
2216
|
+
area: 'wire', title: 'text bound to a binary column is stored as UTF-8 BYTES — the table never becomes unreadable',
|
|
2217
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2218
|
+
verify: () => withRoot(async (q) => {
|
|
2219
|
+
// §9 ROUND ONE, FINDING 3. `INSERT INTO B (p) VALUES ('日')` used to store the raw JS string;
|
|
2220
|
+
// the next READ threw a plain Error out of the wire packer, which is not a SqlError, so it
|
|
2221
|
+
// escaped the handler entirely and the server answered HTTP 500 — forever, for that table.
|
|
2222
|
+
// Real MySQL stores the UTF-8 bytes. The engine now coerces at write time (`storeValue`).
|
|
2223
|
+
await q('CREATE TABLE B (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, p VARBINARY(64) NULL, note VARCHAR(16) NOT NULL)');
|
|
2224
|
+
const inserted = await q("INSERT INTO B (p, note) VALUES ('日', 'after')");
|
|
2225
|
+
const read = await q('SELECT p, note FROM B');
|
|
2226
|
+
if (!ok(inserted) || !ok(read))
|
|
2227
|
+
return false;
|
|
2228
|
+
const bytes = Array.from(Uint8Array.from(read.rows[0][0], (c) => c.charCodeAt(0)));
|
|
2229
|
+
// THE ASSERTIONS THAT GIVE THIS PIN TEETH (added after the §9 revert matrix found the first
|
|
2230
|
+
// version HOLLOW). Reverting `storeValue` alone does NOT change the wire bytes, because
|
|
2231
|
+
// `encodeCell`'s UTF-8 fallback — the other half of the same fix — re-encodes at pack time
|
|
2232
|
+
// and lands on the identical payload. So asserting the bytes cannot distinguish the two.
|
|
2233
|
+
// What DOES distinguish them is the STORED FORM, and MySQL makes it observable twice:
|
|
2234
|
+
// • a binary column compares equal to its own hex representation, because what is stored
|
|
2235
|
+
// IS the bytes. With `storeValue` reverted the cell is a 1-character JS string and this
|
|
2236
|
+
// match fails.
|
|
2237
|
+
// • LENGTH()/CHAR_LENGTH() over a binary column count BYTES: 3, not 1.
|
|
2238
|
+
const matched = await q("SELECT note FROM B WHERE p = x'E697A5'");
|
|
2239
|
+
const lengths = await q('SELECT LENGTH(p) AS l, CHAR_LENGTH(p) AS c FROM B');
|
|
2240
|
+
// …and the STORED CELL itself, read straight off the projection. §9 ROUND TWO (finding 1)
|
|
2241
|
+
// proved the first version of this pin HOLLOW: every assertion read through the packer, and
|
|
2242
|
+
// the fix's other half — `encodeCell`'s UTF-8 fallback — re-encodes at pack time to the
|
|
2243
|
+
// identical bytes, so reverting `storeValue` was invisible. Two independent changes need two
|
|
2244
|
+
// independent assertions, and this is the one that can only pass if the ENGINE coerced.
|
|
2245
|
+
const stored = readDatabase(q.root).rows.get('b')?.[0]?.cells.p ?? null;
|
|
2246
|
+
// The packer's own guarantee, asserted directly rather than through a query: it must NEVER
|
|
2247
|
+
// throw, whatever it is handed. That is the half that turned one INSERT into a permanently
|
|
2248
|
+
// unreadable table.
|
|
2249
|
+
let packerThrew = false;
|
|
2250
|
+
try {
|
|
2251
|
+
packRow([{ name: 'p', type: 'VARBINARY', charset: 63 }], ['🔑']);
|
|
2252
|
+
}
|
|
2253
|
+
catch {
|
|
2254
|
+
packerThrew = true;
|
|
2255
|
+
}
|
|
2256
|
+
// A hex literal is ALREADY bytes and must NOT be re-encoded.
|
|
2257
|
+
await q("UPDATE B SET p = x'00ff41' WHERE id = 1");
|
|
2258
|
+
const hex = await q('SELECT p FROM B');
|
|
2259
|
+
const hexBytes = Array.from(Uint8Array.from(hex.rows[0][0], (c) => c.charCodeAt(0)));
|
|
2260
|
+
return bytes.join(',') === '230,151,165' // '日' = E6 97 A5
|
|
2261
|
+
&& read.rows[0][1] === 'after' // the LATER column is still aligned
|
|
2262
|
+
&& matched.rows.length === 1 && matched.rows[0][0] === 'after'
|
|
2263
|
+
&& lengths.rows[0].join(',') === '3,3'
|
|
2264
|
+
&& stored !== null && stored.length === 3 // THREE characters in the cell, not one
|
|
2265
|
+
&& !packerThrew
|
|
2266
|
+
&& hexBytes.join(',') === '0,255,65'
|
|
2267
|
+
&& read.fields[0].charset === 63;
|
|
2268
|
+
}),
|
|
2269
|
+
},
|
|
2270
|
+
{
|
|
2271
|
+
id: 'planetscale.select.expression_column_typing',
|
|
2272
|
+
area: 'select', title: 'a computed column is typed from the WHOLE column, and a binary expression reports charset 63',
|
|
2273
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2274
|
+
verify: () => withRoot(async (q) => {
|
|
2275
|
+
// §9 ROUND ONE, FINDINGS 4 AND 5 — both "the twin serves bytes the client decodes wrongly".
|
|
2276
|
+
await q('CREATE TABLE M (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, v VARCHAR(16) NOT NULL)');
|
|
2277
|
+
await q("INSERT INTO M (v) VALUES ('1.5'), ('abc')");
|
|
2278
|
+
// FINDING 4: the type used to come from row ZERO, so this column was typed FLOAT64 and the
|
|
2279
|
+
// client's own cast turned 'abc' into NaN. A numeric type may only be claimed when EVERY
|
|
2280
|
+
// non-null value has that shape.
|
|
2281
|
+
const mixed = await q('SELECT CONCAT(v) AS c FROM M ORDER BY id');
|
|
2282
|
+
// …and a genuinely all-integer column still gets INT64, so the fix did not just give up.
|
|
2283
|
+
const ints = await q('SELECT id + 0 AS n FROM M ORDER BY id');
|
|
2284
|
+
// FINDING 5: `binary` used to be `isNumericVitessType`, which is FALSE for VARBINARY — so a
|
|
2285
|
+
// hex literal went out with charset 45, the packer UTF-8-expanded it to 4 bytes, and the
|
|
2286
|
+
// client returned a mojibake string instead of a Uint8Array.
|
|
2287
|
+
const hex = await q("SELECT x'00ff41' AS b FROM dual");
|
|
2288
|
+
const raw = (hex.raw.body.result?.rows ?? [])[0];
|
|
2289
|
+
return mixed.fields[0].type === 'VARCHAR' && mixed.fields[0].charset === 45
|
|
2290
|
+
&& mixed.rows.map((r) => r[0]).join(',') === '1.5,abc'
|
|
2291
|
+
&& ints.fields[0].type === 'INT64'
|
|
2292
|
+
&& hex.fields[0].type === 'VARBINARY' && hex.fields[0].charset === 63
|
|
2293
|
+
&& raw?.lengths[0] === '3' && raw.values === btoa('\x00\xff\x41');
|
|
2294
|
+
}),
|
|
2295
|
+
},
|
|
2296
|
+
{
|
|
2297
|
+
id: 'planetscale.select.unknown_column_in_where',
|
|
2298
|
+
area: 'select', title: 'an unknown column in WHERE/ORDER BY is errno 1054 even over an EMPTY table',
|
|
2299
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2300
|
+
verify: () => withRoot(async (q) => {
|
|
2301
|
+
// §9 ROUND ONE, FINDING 7. `readColumn` only runs PER ROW, so on an empty table the reference
|
|
2302
|
+
// was never checked and the twin answered `[]` — which reads to a caller as "ran fine,
|
|
2303
|
+
// matched nothing", the fabricated success this pack's own header forbids, and which makes a
|
|
2304
|
+
// renamed-column schema drift invisible. Validation now precedes the scan.
|
|
2305
|
+
await q(LINK_DDL);
|
|
2306
|
+
const emptySelect = await q('SELECT * FROM Link WHERE nosuch = 1');
|
|
2307
|
+
const emptyDelete = await q('DELETE FROM Link WHERE nosuch = 1');
|
|
2308
|
+
const emptyUpdate = await q('UPDATE Link SET clicks = 1 WHERE nosuch = 1');
|
|
2309
|
+
const emptyOrder = await q('SELECT * FROM Link ORDER BY nosuch');
|
|
2310
|
+
// …and the same over a table that DOES have rows, so the fix is not empty-table-only.
|
|
2311
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
2312
|
+
const populated = await q('SELECT * FROM Link WHERE nosuch = 1');
|
|
2313
|
+
// A LEGAL query over the same shapes must still work — the guard must not over-reject.
|
|
2314
|
+
const legal = await q("SELECT `key` FROM Link WHERE clicks = 0 ORDER BY id");
|
|
2315
|
+
return failed(emptySelect, 'INVALID_ARGUMENT', "Unknown column 'nosuch' in 'where clause'")
|
|
2316
|
+
&& emptySelect.error.message.includes('(errno 1054) (sqlstate 42S22)')
|
|
2317
|
+
&& failed(emptyDelete, 'INVALID_ARGUMENT', "Unknown column 'nosuch'") && emptyDelete.rowsAffected === 0
|
|
2318
|
+
&& failed(emptyUpdate, 'INVALID_ARGUMENT', "Unknown column 'nosuch'")
|
|
2319
|
+
&& failed(emptyOrder, 'INVALID_ARGUMENT', "Unknown column 'nosuch' in 'order clause'")
|
|
2320
|
+
&& failed(populated, 'INVALID_ARGUMENT', "Unknown column 'nosuch'")
|
|
2321
|
+
&& ok(legal) && legal.rows[0][0] === 'a';
|
|
2322
|
+
}),
|
|
2323
|
+
},
|
|
2324
|
+
{
|
|
2325
|
+
id: 'planetscale.transactions.failed_statement_keeps_it_open',
|
|
2326
|
+
area: 'transactions', title: 'a failed statement does NOT end the transaction, and the echoed session says so',
|
|
2327
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2328
|
+
verify: () => withRoot(async (q) => {
|
|
2329
|
+
// §9 ROUND ONE, FINDING 10. The error path used to echo a flat `inTransaction: false`, so a
|
|
2330
|
+
// statement that failed inside an open transaction told the caller it had ended — while the
|
|
2331
|
+
// server still held the buffer and a later COMMIT still landed everything. MySQL does not end
|
|
2332
|
+
// a transaction on a failed statement; the wire must not claim otherwise.
|
|
2333
|
+
await q(LINK_DDL);
|
|
2334
|
+
await q('BEGIN');
|
|
2335
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('a', 'https://a.test')");
|
|
2336
|
+
const failedStmt = await q('SELECT * FROM NoSuchTable');
|
|
2337
|
+
const stillOpen = failedStmt.session.vitessSession;
|
|
2338
|
+
// The transaction really IS still open: a second write buffers, and COMMIT lands both.
|
|
2339
|
+
await q("INSERT INTO Link (`key`, url) VALUES ('b', 'https://b.test')");
|
|
2340
|
+
const outside = await q('SELECT COUNT(*) FROM Link', { session: null });
|
|
2341
|
+
await q('COMMIT');
|
|
2342
|
+
const after = await q('SELECT `key` FROM Link ORDER BY id', { session: null });
|
|
2343
|
+
// …and OUTSIDE a transaction a failed statement still reports false, so this is not a constant.
|
|
2344
|
+
const plain = await q('SELECT * FROM NoSuchTable', { session: null });
|
|
2345
|
+
const plainSession = plain.session.vitessSession;
|
|
2346
|
+
return failedStmt.error?.code === 'NOT_FOUND'
|
|
2347
|
+
&& stillOpen?.inTransaction === true && stillOpen.autocommit === false
|
|
2348
|
+
&& outside.rows[0][0] === '0'
|
|
2349
|
+
&& after.rows.map((r) => r[0]).join(',') === 'a,b'
|
|
2350
|
+
&& plainSession?.inTransaction === false;
|
|
2351
|
+
}),
|
|
2352
|
+
},
|
|
2353
|
+
{
|
|
2354
|
+
id: 'planetscale.select.unmodeled_clauses_take_the_right_channel',
|
|
2355
|
+
area: 'select', title: 'index hints, PARTITION and set operations are UNIMPLEMENTED, not a syntax error',
|
|
2356
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2357
|
+
verify: () => withRoot(async (q) => {
|
|
2358
|
+
// §9 ROUND ONE, FINDINGS 13 AND 14a. `SELECT * FROM t FORCE INDEX (PRIMARY)` used to swallow
|
|
2359
|
+
// FORCE as the table ALIAS and then answer a bare syntax error — a confidently wrong reply to
|
|
2360
|
+
// legal MySQL an ORM emits. `SELECT 1 UNION SELECT 2` answered 1064 even though
|
|
2361
|
+
// `planetscale.select.union` is a filed todo, so the pack knew the surface exists and still
|
|
2362
|
+
// told the caller their SQL was malformed. Unmodeled surface takes the `unsupported:` channel.
|
|
2363
|
+
await q(LINK_DDL);
|
|
2364
|
+
const cases = [
|
|
2365
|
+
['SELECT * FROM Link FORCE INDEX (PRIMARY)', 'index hints'],
|
|
2366
|
+
['SELECT * FROM Link USE INDEX (key_unique)', 'index hints'],
|
|
2367
|
+
['SELECT * FROM Link IGNORE INDEX (key_unique)', 'index hints'],
|
|
2368
|
+
['SELECT * FROM Link PARTITION (p0)', 'PARTITION'],
|
|
2369
|
+
['SELECT 1 FROM dual UNION SELECT 2 FROM dual', 'UNION'],
|
|
2370
|
+
['SELECT 1 FROM dual INTERSECT SELECT 2 FROM dual', 'INTERSECT'],
|
|
2371
|
+
];
|
|
2372
|
+
for (const [sql, fragment] of cases) {
|
|
2373
|
+
const res = await q(sql);
|
|
2374
|
+
if (!failed(res, 'UNIMPLEMENTED', 'unsupported:'))
|
|
2375
|
+
return false;
|
|
2376
|
+
if (!res.error.message.includes(fragment))
|
|
2377
|
+
return false;
|
|
2378
|
+
if (res.rows.length !== 0)
|
|
2379
|
+
return false;
|
|
2380
|
+
}
|
|
2381
|
+
// A genuine syntax error must STILL be 1064 — the fix must not turn every parse failure into
|
|
2382
|
+
// UNIMPLEMENTED, which would be the opposite over-correction.
|
|
2383
|
+
return failed(await q('SELECT * FROM'), 'INVALID_ARGUMENT', 'syntax error at position');
|
|
2384
|
+
}),
|
|
2385
|
+
},
|
|
2386
|
+
{
|
|
2387
|
+
id: 'planetscale.ddl.bare_key_is_primary_key',
|
|
2388
|
+
area: 'ddl', title: 'a bare `KEY` in a column definition is PRIMARY KEY, as MySQL defines it',
|
|
2389
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
2390
|
+
verify: () => withRoot(async (q) => {
|
|
2391
|
+
// §9 ROUND ONE, FINDING 15: this branch was EMPTY behind a comment claiming it was
|
|
2392
|
+
// implemented, so `CREATE TABLE k (id INT KEY)` answered a syntax error.
|
|
2393
|
+
const created = await q('CREATE TABLE K (id INT KEY, name VARCHAR(16) NOT NULL)');
|
|
2394
|
+
const described = await q('DESCRIBE K');
|
|
2395
|
+
await q("INSERT INTO K (id, name) VALUES (1, 'a')");
|
|
2396
|
+
const dup = await q("INSERT INTO K (id, name) VALUES (1, 'b')");
|
|
2397
|
+
// A table-level `KEY (col)` still means a NON-unique index — the two spellings differ, and
|
|
2398
|
+
// conflating them would make every secondary index a uniqueness constraint.
|
|
2399
|
+
await q('CREATE TABLE S (id INT NOT NULL PRIMARY KEY, tag VARCHAR(8) NOT NULL, KEY tag_idx (tag))');
|
|
2400
|
+
await q("INSERT INTO S (id, tag) VALUES (1, 'x'), (2, 'x')");
|
|
2401
|
+
const shared = await q('SELECT COUNT(*) FROM S');
|
|
2402
|
+
return ok(created)
|
|
2403
|
+
&& described.rows.find((r) => r[0] === 'id')?.[3] === 'PRI'
|
|
2404
|
+
&& failed(dup, 'ALREADY_EXISTS', "Duplicate entry '1' for key 'K.PRIMARY'")
|
|
2405
|
+
&& shared.rows[0][0] === '2';
|
|
2406
|
+
}),
|
|
2407
|
+
},
|
|
2408
|
+
{
|
|
2409
|
+
id: 'planetscale.functions.mysql_string_truthiness',
|
|
2410
|
+
area: 'functions', title: "a non-numeric string is FALSE in a boolean context, as MySQL coerces it",
|
|
2411
|
+
dimension: 'api', tier: 'niche', expected: 'done',
|
|
2412
|
+
verify: () => withRoot(async (q) => {
|
|
2413
|
+
// §9 ROUND ONE, FINDING 16: `truthy` used to return "any non-empty string is TRUE", so
|
|
2414
|
+
// `WHERE 'abc'` and `WHERE some_text_column` selected rows real MySQL skips. MySQL coerces a
|
|
2415
|
+
// string by its LEADING NUMERIC PREFIX.
|
|
2416
|
+
const rows = async (where) => (await q(`SELECT 1 AS n FROM dual WHERE ${where}`)).rows.length;
|
|
2417
|
+
return await rows("'abc'") === 0
|
|
2418
|
+
&& await rows("'0abc'") === 0
|
|
2419
|
+
&& await rows("'0'") === 0
|
|
2420
|
+
&& await rows("''") === 0
|
|
2421
|
+
&& await rows("'1abc'") === 1
|
|
2422
|
+
&& await rows("'2'") === 1
|
|
2423
|
+
// 'Infinity' is the case that separates MySQL's LEADING-NUMERIC-PREFIX rule from
|
|
2424
|
+
// JavaScript's `parseFloat`, which additionally accepts it. §9 round two (finding 13) caught
|
|
2425
|
+
// the twin selecting a row MySQL skips — and the REVERT MATRIX then caught this pin staying
|
|
2426
|
+
// green against that exact revert, because it never asserted the one input that differs.
|
|
2427
|
+
// A pin that does not cover the bug it names is not a pin.
|
|
2428
|
+
&& await rows("'Infinity'") === 0
|
|
2429
|
+
&& await rows("'-Infinity'") === 0
|
|
2430
|
+
&& await rows("'NaN'") === 0
|
|
2431
|
+
&& await rows("IF('abc', 1, 0)") === 0
|
|
2432
|
+
&& await rows("IF('Infinity', 1, 0)") === 0
|
|
2433
|
+
&& await rows("IF('1abc', 1, 0)") === 1;
|
|
2434
|
+
}),
|
|
2435
|
+
},
|
|
2436
|
+
// ── §9 ROUND-TWO PINS ───────────────────────────────────────────────────────────────────────
|
|
2437
|
+
// Round two attacked round one's FIXES and found three of them had introduced defects of the very
|
|
2438
|
+
// classes they were closing. These pin the repairs.
|
|
2439
|
+
{
|
|
2440
|
+
id: 'planetscale.wire.binary_values_survive_re_assignment',
|
|
2441
|
+
area: 'wire', title: 'UPDATE p = p is a NO-OP on a binary column, and a DEFAULT stores the same bytes as a literal',
|
|
2442
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2443
|
+
verify: () => withRoot(async (q) => {
|
|
2444
|
+
// §9 ROUND TWO, FINDING 2 — a REGRESSION introduced by round one's own fix. `storeValue`
|
|
2445
|
+
// decided "already bytes?" from the source's expression KIND, so anything that was not a hex
|
|
2446
|
+
// literal got UTF-8-encoded again over an already-encoded value: `UPDATE b SET p = p` — a
|
|
2447
|
+
// no-op in MySQL — doubled the length every time (3 → 6 → 12 → 24 bytes), and
|
|
2448
|
+
// `ON DUPLICATE KEY UPDATE p = p`, the canonical upsert-touch idiom, did the same. The
|
|
2449
|
+
// predicate is now the source's STATIC TYPE, which is MySQL's own rule.
|
|
2450
|
+
await q("CREATE TABLE B (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, p VARBINARY(64) NULL, d VARBINARY(8) DEFAULT 'é', k VARCHAR(8) NOT NULL UNIQUE)");
|
|
2451
|
+
await q("INSERT INTO B (p, k) VALUES ('日', 'a')");
|
|
2452
|
+
const first = await q('SELECT LENGTH(p) AS n FROM B');
|
|
2453
|
+
await q('UPDATE B SET p = p');
|
|
2454
|
+
await q('UPDATE B SET p = p');
|
|
2455
|
+
await q("INSERT INTO B (p, k) VALUES ('日', 'a') ON DUPLICATE KEY UPDATE p = p");
|
|
2456
|
+
const stable = await q('SELECT LENGTH(p) AS n FROM B');
|
|
2457
|
+
// A DEFAULT and the identical literal in VALUES must store the SAME bytes — they used to
|
|
2458
|
+
// differ, because the default took a Latin-1 short-circuit.
|
|
2459
|
+
const viaDefault = await q('SELECT LENGTH(d) AS n FROM B');
|
|
2460
|
+
await q("INSERT INTO B (p, d, k) VALUES ('x', 'é', 'b')");
|
|
2461
|
+
const viaLiteral = await q("SELECT LENGTH(d) AS n FROM B WHERE k = 'b'");
|
|
2462
|
+
return first.rows[0][0] === '3' && stable.rows[0][0] === '3'
|
|
2463
|
+
&& viaDefault.rows[0][0] === '2' && viaLiteral.rows[0][0] === '2';
|
|
2464
|
+
}),
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
id: 'planetscale.select.binariness_propagates_through_expressions',
|
|
2468
|
+
area: 'select', title: 'CONCAT/UPPER over a binary column stay binary — the column and expression paths agree',
|
|
2469
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2470
|
+
verify: () => withRoot(async (q) => {
|
|
2471
|
+
// §9 ROUND TWO, FINDING 3 — the second regression from round one's fix, and the same
|
|
2472
|
+
// wrong-bytes-to-the-client class round one had refuted as ITS finding 5, re-entering through
|
|
2473
|
+
// the expression door. Because a binary cell is stored one char per byte, an expression over
|
|
2474
|
+
// it LOOKS like text to a shape sniffer: `CONCAT(p)` was typed VARCHAR at charset 45, the
|
|
2475
|
+
// packer UTF-8-expanded the bytes (6 instead of 3), and the client's own `cast` handed the
|
|
2476
|
+
// caller the mojibake string `æ—¥` instead of a Uint8Array. `UPPER(p)` additionally
|
|
2477
|
+
// case-folded a BYTE (0xE6 → 0xC6), corrupting binary data.
|
|
2478
|
+
await q('CREATE TABLE B (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, p VARBINARY(64) NULL, t VARCHAR(16) NOT NULL)');
|
|
2479
|
+
await q("INSERT INTO B (p, t) VALUES ('日', 'mixed')");
|
|
2480
|
+
const bare = await q('SELECT p FROM B');
|
|
2481
|
+
const concat = await q('SELECT CONCAT(p) AS c FROM B');
|
|
2482
|
+
const upper = await q('SELECT UPPER(p) AS u FROM B');
|
|
2483
|
+
const text = await q('SELECT UPPER(t) AS u FROM B');
|
|
2484
|
+
const bytesOf = (row) => Array.from(row[0], (c) => c.charCodeAt(0)).join(',');
|
|
2485
|
+
return bare.fields[0].charset === 63 && bare.fields[0].type === 'VARBINARY'
|
|
2486
|
+
// The expression path must agree with the column path, in BOTH type and charset.
|
|
2487
|
+
&& concat.fields[0].charset === 63 && concat.fields[0].type === 'VARBINARY'
|
|
2488
|
+
&& bytesOf(concat.rows[0]) === '230,151,165'
|
|
2489
|
+
&& upper.fields[0].charset === 63 && bytesOf(upper.rows[0]) === '230,151,165'
|
|
2490
|
+
// …while UPPER over TEXT still folds, so the fix did not just disable the function.
|
|
2491
|
+
&& text.rows[0][0] === 'MIXED';
|
|
2492
|
+
}),
|
|
2493
|
+
},
|
|
2494
|
+
{
|
|
2495
|
+
id: 'planetscale.select.order_by_select_alias',
|
|
2496
|
+
area: 'select', title: 'ORDER BY may name a select-list alias, as MySQL allows',
|
|
2497
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2498
|
+
verify: () => withRoot(async (q) => {
|
|
2499
|
+
// §9 ROUND TWO, FINDING 4: round one's pre-scan column validation turned this from an
|
|
2500
|
+
// intermittent wrong answer into a DETERMINISTIC errno 1054 — for legal SQL that every ORM
|
|
2501
|
+
// emits for a computed sort key.
|
|
2502
|
+
await q(LINK_DDL);
|
|
2503
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('a', 'https://a.test', 3), ('b', 'https://b.test', 1)");
|
|
2504
|
+
const byAlias = await q('SELECT `key`, clicks AS c FROM Link ORDER BY c');
|
|
2505
|
+
const computed = await q('SELECT `key`, clicks * -1 AS neg FROM Link ORDER BY neg DESC');
|
|
2506
|
+
// An alias that shadows a real column WINS in ORDER BY: MySQL 8 manual, SELECT, "MySQL
|
|
2507
|
+
// resolves an unqualified column or alias reference in an ORDER BY clause by searching in the
|
|
2508
|
+
// select_expr values, then in the columns of the tables in the FROM clause" (GROUP BY is the
|
|
2509
|
+
// reverse). So this sorts by url.
|
|
2510
|
+
const shadowed = await q('SELECT `key`, url AS clicks FROM Link ORDER BY clicks');
|
|
2511
|
+
// …and an unknown name is still errno 1054, so the fix did not disable the guard.
|
|
2512
|
+
const unknown = await q('SELECT `key` AS c FROM Link ORDER BY nosuch');
|
|
2513
|
+
return byAlias.rows.map((r) => r[0]).join(',') === 'b,a'
|
|
2514
|
+
&& computed.rows.map((r) => r[0]).join(',') === 'b,a'
|
|
2515
|
+
&& shadowed.rows.map((r) => r[0]).join(',') === 'a,b'
|
|
2516
|
+
&& failed(unknown, 'INVALID_ARGUMENT', "Unknown column 'nosuch' in 'order clause'");
|
|
2517
|
+
}),
|
|
2518
|
+
},
|
|
2519
|
+
{
|
|
2520
|
+
id: 'planetscale.update.order_by_decides_which_rows_limit_touches',
|
|
2521
|
+
area: 'update', title: 'UPDATE/DELETE … ORDER BY … LIMIT touches the rows the ORDER BY names',
|
|
2522
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2523
|
+
verify: () => withRoot(async (q) => {
|
|
2524
|
+
// §9 ROUND TWO, FINDING 12: both statements PARSED their ORDER BY and threw it away, so
|
|
2525
|
+
// `DELETE FROM t ORDER BY created_at LIMIT 1` — the delete-the-oldest idiom — deleted
|
|
2526
|
+
// whichever row came first in the projection instead. Silently ignoring a clause that decides
|
|
2527
|
+
// WHICH row a LIMIT touches is a fabricated success.
|
|
2528
|
+
await q(LINK_DDL);
|
|
2529
|
+
await q("INSERT INTO Link (`key`, url, clicks) VALUES ('mid', 'https://m.test', 20), ('old', 'https://o.test', 10), ('new', 'https://n.test', 30)");
|
|
2530
|
+
const deleted = await q('DELETE FROM Link ORDER BY clicks LIMIT 1');
|
|
2531
|
+
const left = await q('SELECT `key` FROM Link ORDER BY clicks');
|
|
2532
|
+
const updated = await q("UPDATE Link SET url = 'https://touched.test' ORDER BY clicks DESC LIMIT 1");
|
|
2533
|
+
const touched = await q("SELECT `key` FROM Link WHERE url = 'https://touched.test'");
|
|
2534
|
+
// An unknown column in the ORDER BY must be errno 1054 here too — it used to be swallowed.
|
|
2535
|
+
const unknown = await q('DELETE FROM Link ORDER BY nosuch LIMIT 1');
|
|
2536
|
+
return deleted.rowsAffected === 1 && left.rows.map((r) => r[0]).join(',') === 'mid,new'
|
|
2537
|
+
&& updated.rowsAffected === 1 && touched.rows[0][0] === 'new'
|
|
2538
|
+
&& failed(unknown, 'INVALID_ARGUMENT', "Unknown column 'nosuch' in 'order clause'");
|
|
2539
|
+
}),
|
|
2540
|
+
},
|
|
2541
|
+
{
|
|
2542
|
+
id: 'planetscale.errors.internal_faults_stay_on_the_protocol',
|
|
2543
|
+
area: 'errors', title: 'an internal fault answers a Connect-shaped error, never an escaped exception',
|
|
2544
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
2545
|
+
verify: () => withRoot(async (q) => {
|
|
2546
|
+
// §9 ROUND TWO, FINDING 11: anything that was not a `SqlError` was RE-THROWN out of the
|
|
2547
|
+
// handler — the same door round one's finding 3 came through, fixed there at the one site it
|
|
2548
|
+
// was found rather than as a class. A deeply-nested expression still walked out of it as a
|
|
2549
|
+
// RangeError. The wire must never be the crash surface.
|
|
2550
|
+
const deep = `SELECT ${'('.repeat(6000)}1${')'.repeat(6000)} FROM dual`;
|
|
2551
|
+
const res = await q.raw({ body: JSON.stringify({ query: deep }) });
|
|
2552
|
+
const body = res.body;
|
|
2553
|
+
// A Connect transport envelope — a code and a message, and NOT a fabricated result.
|
|
2554
|
+
const contained = (res.status === 400 || res.status === 500) && typeof body.code === 'string' && body.result === undefined;
|
|
2555
|
+
// …and the twin is still alive afterwards: the fault was request-scoped.
|
|
2556
|
+
const alive = await q('SELECT 1 AS n FROM dual');
|
|
2557
|
+
return contained && ok(alive) && alive.rows[0][0] === '1';
|
|
2558
|
+
}),
|
|
2559
|
+
},
|
|
2560
|
+
{
|
|
2561
|
+
id: 'planetscale.transactions.concurrent_statements_are_serialized',
|
|
2562
|
+
area: 'transactions', title: 'concurrent statements on one root all land, with distinct rows and ordinals',
|
|
2563
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2564
|
+
verify: () => withRoot(async (q, root) => {
|
|
2565
|
+
// §9 ROUND TWO, FINDING 7: the serialization fix had NO test anywhere in the pack, so the
|
|
2566
|
+
// property three documents assert was still unverified. `executeSql` reads the whole
|
|
2567
|
+
// projection (snapshotting the per-subject `_rev` watermarks and the row ids) and writes it
|
|
2568
|
+
// back with `await` points in between, so overlapping requests on one root would compute the
|
|
2569
|
+
// same next `_rev` and the same `nextRowId` from one stale snapshot — and a write would be
|
|
2570
|
+
// swallowed by the kernel's content dedupe or land on another's subject.
|
|
2571
|
+
await q('CREATE TABLE C (id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, n VARCHAR(8) NOT NULL)');
|
|
2572
|
+
const ctx = { root, occurredAt: AT }; // ONE pinned instant, so only the gate can order them
|
|
2573
|
+
await Promise.all(['a', 'b', 'c', 'd', 'e', 'f'].map((n) => executeSql(ctx, `INSERT INTO C (n) VALUES ('${n}')`, null)));
|
|
2574
|
+
const all = await q('SELECT id, n FROM C ORDER BY id');
|
|
2575
|
+
const rowids = (readDatabase(root).rows.get('c') ?? []).map((r) => r.rowid);
|
|
2576
|
+
return all.rows.length === 6
|
|
2577
|
+
&& all.rows.map((r) => r[1]).sort().join(',') === 'a,b,c,d,e,f'
|
|
2578
|
+
&& all.rows.map((r) => r[0]).join(',') === '1,2,3,4,5,6' // six DISTINCT auto ids
|
|
2579
|
+
&& new Set(rowids).size === 6; // …and six distinct subjects
|
|
2580
|
+
}),
|
|
2581
|
+
},
|
|
2582
|
+
{
|
|
2583
|
+
id: 'planetscale.connector.pull_keeps_duplicate_rows_distinct',
|
|
2584
|
+
area: 'connector', title: 'a keyless table’s DUPLICATE rows stay distinct subjects',
|
|
2585
|
+
dimension: 'connector', tier: 'common', expected: 'done',
|
|
2586
|
+
verify: () => withRoot(async (q, root) => {
|
|
2587
|
+
// §9 ROUND TWO, FINDING 6 — the third regression from round one's fix, and the sharpest,
|
|
2588
|
+
// because it was CERTAIN rather than probabilistic: MySQL permits duplicate rows in a keyless
|
|
2589
|
+
// table, and hashing the row's identity folded them into ONE twin subject every time. The
|
|
2590
|
+
// scheme round one replaced handled this correctly. An ordinal now disambiguates them.
|
|
2591
|
+
const ledger = join(root, 'rate-budget.json');
|
|
2592
|
+
const { client } = fakeClient({
|
|
2593
|
+
'SHOW TABLES': { rows: [{ t: 'Keyless' }] },
|
|
2594
|
+
'DESCRIBE `Keyless`': describeRows([['a', 'varchar(8)', 'YES', '', null, ''], ['b', 'varchar(8)', 'YES', '', null, '']]),
|
|
2595
|
+
'SELECT * FROM `Keyless` LIMIT 500': { rows: [{ a: '1', b: 'x' }, { a: '1', b: 'x' }, { a: '2', b: 'y' }] },
|
|
2596
|
+
});
|
|
2597
|
+
const pulled = await syncPlanetscaleFromReal(client, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
2598
|
+
const served = await q('SELECT a, b FROM Keyless');
|
|
2599
|
+
const subjects = new Set((readDatabase(root).rows.get('keyless') ?? []).map((r) => r.rowid));
|
|
2600
|
+
// THREE real rows, THREE subjects, THREE rows served — not two.
|
|
2601
|
+
return pulled.observed === 4 && served.rows.length === 3 && subjects.size === 3
|
|
2602
|
+
&& served.rows.map((r) => r.join(':')).sort().join(',') === '1:x,1:x,2:y';
|
|
2603
|
+
}),
|
|
2604
|
+
},
|
|
2605
|
+
// ── connector ───────────────────────────────────────────────────────────────────────────────
|
|
2606
|
+
{
|
|
2607
|
+
id: 'planetscale.connector.pull_tables',
|
|
2608
|
+
area: 'connector', title: 'the connector discovers the real database\'s tables with SHOW TABLES',
|
|
2609
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2610
|
+
verify: () => withRoot(async (q) => {
|
|
2611
|
+
const { client, calls } = fakeClient({
|
|
2612
|
+
'SHOW TABLES': { rows: [{ Tables_in_shop: 'Link' }, { Tables_in_shop: 'Domain' }] },
|
|
2613
|
+
});
|
|
2614
|
+
const names = await pullPlanetscaleTables(client, { budgetOptions: { path: join(q.root, 'rate-budget.json') } });
|
|
2615
|
+
return names.join(',') === 'Link,Domain' && calls.join(',') === 'SHOW TABLES';
|
|
2616
|
+
}),
|
|
2617
|
+
},
|
|
2618
|
+
{
|
|
2619
|
+
id: 'planetscale.connector.pull_schema_and_rows',
|
|
2620
|
+
area: 'connector', title: 'a pull folds the real schema AND its rows into the twin, which then serves them',
|
|
2621
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2622
|
+
verify: () => withRoot(async (q, root) => {
|
|
2623
|
+
const { client } = fakeClient({
|
|
2624
|
+
'SHOW TABLES': { rows: [{ Tables_in_shop: 'Link' }] },
|
|
2625
|
+
'DESCRIBE `Link`': describeRows([
|
|
2626
|
+
['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'],
|
|
2627
|
+
['key', 'varchar(255)', 'NO', 'UNI', null, ''],
|
|
2628
|
+
['url', 'text', 'NO', '', null, ''],
|
|
2629
|
+
['clicks', 'int', 'NO', '', '0', ''],
|
|
2630
|
+
]),
|
|
2631
|
+
// The rows are shaped as the REAL client emits them: already cast (INT64 → string,
|
|
2632
|
+
// INT32 → number), keyed by column name.
|
|
2633
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [
|
|
2634
|
+
{ id: '7', key: 'dub', url: 'https://dub.co', clicks: 12 },
|
|
2635
|
+
{ id: '9', key: 'ref', url: 'https://ref.test', clicks: 0 },
|
|
2636
|
+
] },
|
|
2637
|
+
});
|
|
2638
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
2639
|
+
const pulled = await syncPlanetscaleFromReal(client, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
2640
|
+
// Proven THROUGH THE TWIN, not by inspecting the connector's return value: the pulled state
|
|
2641
|
+
// must be served by the real handler, or the pull folded into nothing.
|
|
2642
|
+
const served = await q('SELECT id, `key`, url, clicks FROM Link ORDER BY id');
|
|
2643
|
+
const described = await q('DESCRIBE Link');
|
|
2644
|
+
// …and a local INSERT must NOT collide with the pulled id 9 — the "create after pull" class.
|
|
2645
|
+
const minted = await q("INSERT INTO Link (`key`, url) VALUES ('new', 'https://new.test')");
|
|
2646
|
+
return pulled.observed === 3 && pulled.deltasAppended === 3
|
|
2647
|
+
&& served.rows.map((r) => r.join(':')).join(',') === '7:dub:https://dub.co:12,9:ref:https://ref.test:0'
|
|
2648
|
+
&& served.fields[0].type === 'INT64' && served.fields[3].type === 'INT32'
|
|
2649
|
+
&& described.rows.length === 4
|
|
2650
|
+
&& minted.insertId === '10';
|
|
2651
|
+
}),
|
|
2652
|
+
},
|
|
2653
|
+
{
|
|
2654
|
+
id: 'planetscale.connector.pull_after_local_create',
|
|
2655
|
+
area: 'connector', title: 'a SECOND pull never lands on a locally-created row, and never loses a new real one',
|
|
2656
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2657
|
+
verify: () => withRoot(async (q, root) => {
|
|
2658
|
+
// §9 ROUND ONE, FINDING 6 — reproduced end-to-end by the reviewer. The pull used to assign
|
|
2659
|
+
// `rowid = i + 1`, POSITIONALLY over an unordered SELECT and into the SAME namespace
|
|
2660
|
+
// `nextRowId` mints from. Pull 1 -> rowids 1,2. A local INSERT -> 3. Pull 2, now returning a
|
|
2661
|
+
// third real row, positionally assigned it 3 as well — so it landed on the LOCAL row's
|
|
2662
|
+
// subject, the real row was SILENTLY DROPPED, and syncPull reported deltas that changed
|
|
2663
|
+
// nothing. Pulled ids are now derived from the real row's identity, in a disjoint band.
|
|
2664
|
+
const ledger = join(root, 'rate-budget.json');
|
|
2665
|
+
const describe = describeRows([
|
|
2666
|
+
['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'],
|
|
2667
|
+
['key', 'varchar(255)', 'NO', 'UNI', null, ''],
|
|
2668
|
+
]);
|
|
2669
|
+
const first = fakeClient({
|
|
2670
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2671
|
+
'DESCRIBE `Link`': describe,
|
|
2672
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '7', key: 'a' }, { id: '9', key: 'b' }] },
|
|
2673
|
+
});
|
|
2674
|
+
await syncPlanetscaleFromReal(first.client, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
2675
|
+
// The pulled schema is (id, key) — the DESCRIBE fixture above — so the local insert names
|
|
2676
|
+
// only those columns; naming a column the pulled schema lacks would be errno 1054.
|
|
2677
|
+
const minted = await q("INSERT INTO Link (`key`) VALUES ('LOCAL')");
|
|
2678
|
+
// The real database has since gained a THIRD row. The old bug lost it here.
|
|
2679
|
+
const second = fakeClient({
|
|
2680
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2681
|
+
'DESCRIBE `Link`': describe,
|
|
2682
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '7', key: 'a' }, { id: '9', key: 'b' }, { id: '11', key: 'c' }] },
|
|
2683
|
+
});
|
|
2684
|
+
const result = await syncPlanetscaleFromReal(second.client, { root, occurredAt: laterAt(1000), budgetOptions: { path: ledger } });
|
|
2685
|
+
const all = await q('SELECT `key` FROM Link ORDER BY id');
|
|
2686
|
+
const keys = all.rows.map((r) => r[0]).join(',');
|
|
2687
|
+
// ALL FOUR rows survive: the two originally pulled, the LOCAL one, and the newly-observed 'c'.
|
|
2688
|
+
// A pull that REORDERS its rows must also change nothing — identity, not position.
|
|
2689
|
+
const reordered = fakeClient({
|
|
2690
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2691
|
+
'DESCRIBE `Link`': describe,
|
|
2692
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '11', key: 'c' }, { id: '7', key: 'a' }, { id: '9', key: 'b' }] },
|
|
2693
|
+
});
|
|
2694
|
+
const third = await syncPlanetscaleFromReal(reordered.client, { root, occurredAt: laterAt(2000), budgetOptions: { path: ledger } });
|
|
2695
|
+
const stable = await q('SELECT `key` FROM Link ORDER BY id');
|
|
2696
|
+
// Keyed local rows and observations now share semantic identity. Four distinct keys must
|
|
2697
|
+
// remain four subjects, and each must have the identity a subsequent refresh will derive.
|
|
2698
|
+
const projected = readDatabase(root).rows.get('link') ?? [];
|
|
2699
|
+
return projected.length === 4 && new Set(projected.map(r => r.rowid)).size === 4
|
|
2700
|
+
&& projected.every(r => r.rowid === keyedRowId({ name: 'Link', primaryKey: ['id'] }, r.cells))
|
|
2701
|
+
&& minted.insertId === '10'
|
|
2702
|
+
&& keys === 'a,b,LOCAL,c'
|
|
2703
|
+
&& result.deltasAppended > 0 // the newly-observed real row really landed
|
|
2704
|
+
&& third.deltasAppended === 0 // a REORDERED pull of identical state appends nothing
|
|
2705
|
+
&& stable.rows.map((r) => r[0]).join(',') === 'a,b,LOCAL,c';
|
|
2706
|
+
}),
|
|
2707
|
+
},
|
|
2708
|
+
{
|
|
2709
|
+
id: 'planetscale.connector.pull_idempotent',
|
|
2710
|
+
area: 'connector', title: 're-pulling identical state appends ZERO deltas',
|
|
2711
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2712
|
+
verify: () => withRoot(async (q, root) => {
|
|
2713
|
+
const script = {
|
|
2714
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2715
|
+
'DESCRIBE `Link`': describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'], ['key', 'varchar(255)', 'NO', 'UNI', null, '']]),
|
|
2716
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '1', key: 'a' }] },
|
|
2717
|
+
};
|
|
2718
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
2719
|
+
const { client } = fakeClient(script);
|
|
2720
|
+
const first = await syncPlanetscaleFromReal(client, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
2721
|
+
// A LATER instant, deliberately: a second pull at a different time must still append nothing,
|
|
2722
|
+
// which is what proves the dedup is over CONTENT rather than an artefact of a pinned clock.
|
|
2723
|
+
const second = await syncPlanetscaleFromReal(client, { root, occurredAt: laterAt(60_000), budgetOptions: { path: ledger } });
|
|
2724
|
+
const served = await q('SELECT `key` FROM Link');
|
|
2725
|
+
const updated = await q("UPDATE Link SET `key` = 'LOCAL' WHERE id = 1");
|
|
2726
|
+
const third = await syncPlanetscaleFromReal(client, { root, occurredAt: laterAt(120_000), budgetOptions: { path: ledger } });
|
|
2727
|
+
const fourth = await syncPlanetscaleFromReal(client, { root, occurredAt: laterAt(180_000), budgetOptions: { path: ledger } });
|
|
2728
|
+
const local = await q('SELECT `key` FROM Link');
|
|
2729
|
+
return first.deltasAppended === 2 && second.observed === 2 && second.deltasAppended === 0
|
|
2730
|
+
&& served.rows[0][0] === 'a' && updated.rowsAffected === 1
|
|
2731
|
+
&& third.deltasAppended === 0 && fourth.deltasAppended === 0
|
|
2732
|
+
&& local.rows[0][0] === 'LOCAL';
|
|
2733
|
+
}),
|
|
2734
|
+
},
|
|
2735
|
+
{
|
|
2736
|
+
id: 'planetscale.connector.pull_row_limit',
|
|
2737
|
+
area: 'connector', title: 'the pull ALWAYS bounds rows read — the LIMIT is in the statement, never optional',
|
|
2738
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2739
|
+
verify: () => withRoot(async (q) => {
|
|
2740
|
+
const describe = describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment']]);
|
|
2741
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
2742
|
+
const { client, calls } = fakeClient({
|
|
2743
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2744
|
+
'DESCRIBE `Link`': describe,
|
|
2745
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '1' }] },
|
|
2746
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 3': { rows: [{ id: '1' }] },
|
|
2747
|
+
});
|
|
2748
|
+
await pullPlanetscaleDatabase(client, { budgetOptions: { path: ledger } });
|
|
2749
|
+
await pullPlanetscaleDatabase(client, { rowLimit: 3, budgetOptions: { path: ledger } });
|
|
2750
|
+
// PlanetScale meters ROWS READ, so an unbounded `SELECT *` is the expensive mistake this
|
|
2751
|
+
// guards. Both the default and the caller's limit must appear IN THE STATEMENT.
|
|
2752
|
+
return calls.includes('SELECT * FROM `Link` ORDER BY `id` LIMIT 500') && calls.includes('SELECT * FROM `Link` ORDER BY `id` LIMIT 3')
|
|
2753
|
+
&& !calls.some((c) => c.startsWith('SELECT * FROM `Link`') && !c.includes('LIMIT'));
|
|
2754
|
+
}),
|
|
2755
|
+
},
|
|
2756
|
+
{
|
|
2757
|
+
id: 'planetscale.connector.pull_skips_a_vanished_table',
|
|
2758
|
+
area: 'connector', title: 'a failed schema read aborts the refresh without observing a partial database',
|
|
2759
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2760
|
+
verify: () => withRoot(async (q, root) => {
|
|
2761
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
2762
|
+
// First pull: two tables, both readable.
|
|
2763
|
+
const full = fakeClient({
|
|
2764
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }, { t: 'Domain' }] },
|
|
2765
|
+
'DESCRIBE `Link`': describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'], ['key', 'varchar(255)', 'NO', 'UNI', null, '']]),
|
|
2766
|
+
'DESCRIBE `Domain`': describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'], ['slug', 'varchar(255)', 'NO', 'UNI', null, '']]),
|
|
2767
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '1', key: 'a' }] },
|
|
2768
|
+
'SELECT * FROM `Domain` ORDER BY `id` LIMIT 500': { rows: [{ id: '1', slug: 'dub.sh' }] },
|
|
2769
|
+
});
|
|
2770
|
+
await syncPlanetscaleFromReal(full.client, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
2771
|
+
// Second pull: Domain's DESCRIBE now FAILS (it was dropped between discovery and read).
|
|
2772
|
+
const partial = fakeClient({
|
|
2773
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }, { t: 'Domain' }] },
|
|
2774
|
+
'DESCRIBE `Link`': describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'], ['key', 'varchar(255)', 'NO', 'UNI', null, '']]),
|
|
2775
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '1', key: 'a' }] },
|
|
2776
|
+
}, { fail: new Set(['DESCRIBE `Domain`']) });
|
|
2777
|
+
let failed = false;
|
|
2778
|
+
try {
|
|
2779
|
+
await syncPlanetscaleFromReal(partial.client, { root, occurredAt: laterAt(1000), budgetOptions: { path: ledger } });
|
|
2780
|
+
}
|
|
2781
|
+
catch {
|
|
2782
|
+
failed = true;
|
|
2783
|
+
}
|
|
2784
|
+
// The vanished table must be left ALONE — recording it as an empty account would let a later
|
|
2785
|
+
// push delete a real table's rows.
|
|
2786
|
+
const domain = await q('SELECT slug FROM Domain');
|
|
2787
|
+
return failed && domain.rows[0][0] === 'dub.sh';
|
|
2788
|
+
}),
|
|
2789
|
+
},
|
|
2790
|
+
{
|
|
2791
|
+
id: 'planetscale.connector.pull_refuses_a_failed_query',
|
|
2792
|
+
area: 'connector', title: 'a refused SELECT throws — it is never mapped to an empty result set',
|
|
2793
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2794
|
+
verify: () => withRoot(async (q, root) => {
|
|
2795
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
2796
|
+
const good = fakeClient({
|
|
2797
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2798
|
+
'DESCRIBE `Link`': describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'], ['key', 'varchar(255)', 'NO', 'UNI', null, '']]),
|
|
2799
|
+
'SELECT * FROM `Link` ORDER BY `id` LIMIT 500': { rows: [{ id: '1', key: 'a' }] },
|
|
2800
|
+
});
|
|
2801
|
+
await syncPlanetscaleFromReal(good.client, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
2802
|
+
const broken = fakeClient({
|
|
2803
|
+
'SHOW TABLES': { rows: [{ t: 'Link' }] },
|
|
2804
|
+
'DESCRIBE `Link`': describeRows([['id', 'bigint', 'NO', 'PRI', null, 'auto_increment'], ['key', 'varchar(255)', 'NO', 'UNI', null, '']]),
|
|
2805
|
+
}, { fail: new Set(['SELECT * FROM `Link` ORDER BY `id` LIMIT 500']) });
|
|
2806
|
+
let threw = false;
|
|
2807
|
+
try {
|
|
2808
|
+
await syncPlanetscaleFromReal(broken.client, { root, occurredAt: laterAt(1000), budgetOptions: { path: ledger } });
|
|
2809
|
+
}
|
|
2810
|
+
catch {
|
|
2811
|
+
threw = true;
|
|
2812
|
+
}
|
|
2813
|
+
// The refusal must NOT have emptied the twin: a connector that caught it and folded `[]` would
|
|
2814
|
+
// hand syncPull an empty account to fold over real observed state.
|
|
2815
|
+
const survived = await q('SELECT `key` FROM Link');
|
|
2816
|
+
return threw && survived.rows[0][0] === 'a';
|
|
2817
|
+
}),
|
|
2818
|
+
},
|
|
2819
|
+
{
|
|
2820
|
+
id: 'planetscale.connector.map_describe_row',
|
|
2821
|
+
area: 'connector', title: 'mapDescribeRow reads MySQL\'s DESCRIBE output into a faithful ColumnDef',
|
|
2822
|
+
dimension: 'connector', tier: 'common', expected: 'done',
|
|
2823
|
+
verify: () => withRoot(async () => {
|
|
2824
|
+
const auto = mapDescribeRow({ Field: 'id', Type: 'bigint unsigned', Null: 'NO', Key: 'PRI', Default: null, Extra: 'auto_increment' });
|
|
2825
|
+
const withDefault = mapDescribeRow({ Field: 'clicks', Type: 'int', Null: 'NO', Key: '', Default: '0', Extra: '' });
|
|
2826
|
+
const nullable = mapDescribeRow({ Field: 'archived', Type: 'tinyint(1)', Null: 'YES', Key: '', Default: null, Extra: '' });
|
|
2827
|
+
const unique = mapDescribeRow({ Field: 'slug', Type: 'varchar(191)', Null: 'NO', Key: 'UNI', Default: null, Extra: '' });
|
|
2828
|
+
// The distinction that matters: `id` has NO default (errno 1364 territory) while `archived`
|
|
2829
|
+
// defaults to NULL. Flattening both to `null` loses a real MySQL behaviour.
|
|
2830
|
+
return auto.dataType === 'BIGINT' && auto.unsigned && auto.primaryKey && auto.autoIncrement
|
|
2831
|
+
&& !auto.nullable && auto.defaultValue === undefined && auto.columnType === 'bigint unsigned'
|
|
2832
|
+
&& withDefault.defaultValue === '0' && withDefault.length === null
|
|
2833
|
+
&& nullable.nullable && nullable.defaultValue === null && nullable.length === 1
|
|
2834
|
+
&& unique.unique && !unique.primaryKey && unique.length === 191;
|
|
2835
|
+
}),
|
|
2836
|
+
},
|
|
2837
|
+
{
|
|
2838
|
+
id: 'planetscale.connector.identity_collision_refusal',
|
|
2839
|
+
area: 'connector', title: 'hash collisions are refused before local row loss or pull observation',
|
|
2840
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2841
|
+
verify: () => withRoot(async (q, root) => {
|
|
2842
|
+
const keys = ['customer-2479052881', 'customer-2205617487'];
|
|
2843
|
+
await q('CREATE TABLE notes (id VARCHAR(64) PRIMARY KEY)');
|
|
2844
|
+
const inserted = await q(`INSERT INTO notes VALUES ('${keys[0]}'),('${keys[1]}')`);
|
|
2845
|
+
if (!inserted.error?.message.includes('row identity hash collision') || (await q('SELECT * FROM notes')).rows.length !== 0)
|
|
2846
|
+
return false;
|
|
2847
|
+
const remote = (ids) => fakeClient({
|
|
2848
|
+
'SHOW TABLES': { rows: [{ Tables_in_twin: 'notes' }] },
|
|
2849
|
+
'DESCRIBE `notes`': { rows: [{ Field: 'id', Type: 'varchar(64)', Null: 'NO', Key: 'PRI', Default: null, Extra: '' }] },
|
|
2850
|
+
'SELECT * FROM `notes` ORDER BY `id` LIMIT 500': { rows: ids.map(id => ({ id })) },
|
|
2851
|
+
}).client;
|
|
2852
|
+
const pull = (root, ids) => syncPlanetscaleFromReal(remote(ids), { root, budgetOptions: { path: join(root, 'collision-budget') } });
|
|
2853
|
+
const refuses = async (root, ids) => { try {
|
|
2854
|
+
await pull(root, ids);
|
|
2855
|
+
return false;
|
|
2856
|
+
}
|
|
2857
|
+
catch (e) {
|
|
2858
|
+
return String(e).includes('row identity hash collision');
|
|
2859
|
+
} };
|
|
2860
|
+
if (!await refuses(root, keys) || (await q('SELECT * FROM notes')).rows.length !== 0)
|
|
2861
|
+
return false;
|
|
2862
|
+
await q(`INSERT INTO notes VALUES ('${keys[0]}')`);
|
|
2863
|
+
if (!await refuses(root, [keys[1]]) || (await q('SELECT id FROM notes')).rows[0]?.[0] !== keys[0])
|
|
2864
|
+
return false;
|
|
2865
|
+
return withRoot(async (q2, root2) => {
|
|
2866
|
+
await pull(root2, [keys[0]]);
|
|
2867
|
+
if (!await refuses(root2, [keys[1]]) || (await q2('SELECT id FROM notes')).rows[0]?.[0] !== keys[0])
|
|
2868
|
+
return false;
|
|
2869
|
+
await q2('DELETE FROM notes');
|
|
2870
|
+
// The local tombstone must not hide an upstream collision from the next observation.
|
|
2871
|
+
return await refuses(root2, [keys[1]]) && (await q2('SELECT * FROM notes')).rows.length === 0;
|
|
2872
|
+
});
|
|
2873
|
+
}),
|
|
2874
|
+
},
|
|
2875
|
+
{
|
|
2876
|
+
id: 'planetscale.connector.exact_beforeimages',
|
|
2877
|
+
area: 'connector', title: 'captured beforeimages compare bytes with CAST AS BINARY, including case and trailing spaces',
|
|
2878
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2879
|
+
verify: () => withRoot(async (q, root) => {
|
|
2880
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY, label VARCHAR(64), n INT)');
|
|
2881
|
+
await q("INSERT INTO notes VALUES (1,'a',10)");
|
|
2882
|
+
await q('UPDATE notes SET n=11');
|
|
2883
|
+
const calls = [];
|
|
2884
|
+
const tx = { execute: async (sql) => { calls.push(sql); return { rows: [], rowsAffected: 1 }; } };
|
|
2885
|
+
await applyPlanetscaleRows({ ...tx, transaction: async (fn) => fn(tx) }, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'binary-budget') } });
|
|
2886
|
+
const cast = await q("SELECT CAST('a' AS BINARY) = CAST('A' AS BINARY) AS c, CAST('a' AS BINARY) = CAST('a ' AS BINARY) AS s, CAST('日' AS BINARY) = X'e697a5' AS u, CAST('日' AS BINARY) = X'e69c88' AS different, CAST('01' AS BINARY) = X'31' AS digits FROM dual");
|
|
2887
|
+
return calls.length === 1 && calls[0].includes("CAST(`label` AS BINARY) = CAST('a' AS BINARY)")
|
|
2888
|
+
&& JSON.stringify(cast.rows) === JSON.stringify([['0', '0', '1', '0', '0']]);
|
|
2889
|
+
}),
|
|
2890
|
+
},
|
|
2891
|
+
{
|
|
2892
|
+
id: 'planetscale.connector.complete_plan_budget',
|
|
2893
|
+
area: 'connector', title: 'known row fanout reserves its full call cost before the transaction begins',
|
|
2894
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2895
|
+
verify: () => withRoot(async (q, root) => {
|
|
2896
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY)');
|
|
2897
|
+
await q(`INSERT INTO notes VALUES ${Array.from({ length: 27 }, (_, i) => `(${i})`).join(',')}`);
|
|
2898
|
+
let calls = 0;
|
|
2899
|
+
const tx = { execute: async () => { calls++; return { rows: [], rowsAffected: 1 }; } };
|
|
2900
|
+
const client = { ...tx, transaction: async (fn) => { calls++; return fn(tx); } };
|
|
2901
|
+
try {
|
|
2902
|
+
await applyPlanetscaleRows(client, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'batch-budget') } });
|
|
2903
|
+
return false;
|
|
2904
|
+
}
|
|
2905
|
+
catch (e) {
|
|
2906
|
+
return String(e).includes('budget') && calls === 0;
|
|
2907
|
+
}
|
|
2908
|
+
}),
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
id: 'planetscale.connector.executor_cooldown',
|
|
2912
|
+
area: 'connector', title: 'executor throttling preserves status and headers even when the body is not JSON',
|
|
2913
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2914
|
+
verify: () => withRoot(async (q, root) => {
|
|
2915
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY)');
|
|
2916
|
+
await q('INSERT INTO notes VALUES (1)');
|
|
2917
|
+
const plan = deployableEntries('planetscale', root).at(-1).input?.plan;
|
|
2918
|
+
for (const mode of ['text', 'rollback']) {
|
|
2919
|
+
let calls = 0;
|
|
2920
|
+
const execute = async (req) => {
|
|
2921
|
+
calls++;
|
|
2922
|
+
if (mode === 'rollback' && req.path.endsWith('CreateSession'))
|
|
2923
|
+
return { status: 200, headers: {}, body: '{"session":{"id":"s"}}' };
|
|
2924
|
+
if (mode === 'rollback' && JSON.parse(String(req.body)).query === 'BEGIN')
|
|
2925
|
+
return { status: 200, headers: {}, body: '{"result":{}}' };
|
|
2926
|
+
return { status: 429, headers: { 'retry-after': '60' }, body: 'slow down' };
|
|
2927
|
+
};
|
|
2928
|
+
const opts = { budgetOptions: { path: join(root, `cooldown-budget-${mode}`) } };
|
|
2929
|
+
let failures = 0;
|
|
2930
|
+
for (let i = 0; i < 2; i++)
|
|
2931
|
+
try {
|
|
2932
|
+
await applyPlanetscaleRows(planetscaleClientOver(execute), plan, opts);
|
|
2933
|
+
}
|
|
2934
|
+
catch {
|
|
2935
|
+
failures++;
|
|
2936
|
+
}
|
|
2937
|
+
if (failures !== 2 || calls !== (mode === 'text' ? 1 : 4))
|
|
2938
|
+
return false;
|
|
2939
|
+
}
|
|
2940
|
+
return true;
|
|
2941
|
+
}),
|
|
2942
|
+
},
|
|
2943
|
+
{
|
|
2944
|
+
id: 'planetscale.connector.key_representation',
|
|
2945
|
+
area: 'connector', title: 'a noncanonical integer key deploys as the integer MySQL stored, never under its captured spelling',
|
|
2946
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2947
|
+
verify: () => withRoot(async (q, root) => {
|
|
2948
|
+
// MySQL converts the string '01' assigned to an INT column to the integer 1 (MySQL 8.0
|
|
2949
|
+
// Reference Manual §12.3, Type Conversion in Expression Evaluation), so the captured row image is `1`.
|
|
2950
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY)');
|
|
2951
|
+
await q("INSERT INTO notes VALUES ('01')");
|
|
2952
|
+
const calls = [];
|
|
2953
|
+
const tx = { execute: async (sql) => { calls.push(sql); return { rows: [], rowsAffected: 1 }; } };
|
|
2954
|
+
await applyPlanetscaleRows({ ...tx, transaction: async (fn) => fn(tx) }, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'representation-budget') } });
|
|
2955
|
+
return JSON.stringify(calls) === JSON.stringify(["INSERT INTO `notes` (`id`) VALUES ('1')"])
|
|
2956
|
+
&& JSON.stringify((await q('SELECT id FROM notes')).rows) === '[["1"]]';
|
|
2957
|
+
}),
|
|
2958
|
+
},
|
|
2959
|
+
{
|
|
2960
|
+
id: 'planetscale.connector.special_key_representation',
|
|
2961
|
+
area: 'connector', title: 'an AUTO_INCREMENT zero key deploys the generated value; a ZEROFILL key cannot silently acquire another identity',
|
|
2962
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
2963
|
+
verify: () => withRoot(async (q, root) => {
|
|
2964
|
+
// Inserting 0 into an AUTO_INCREMENT column generates the next sequence value unless
|
|
2965
|
+
// NO_AUTO_VALUE_ON_ZERO is set (MySQL 8.0 Reference Manual §3.6.9, Using AUTO_INCREMENT), so the captured
|
|
2966
|
+
// key is the generated 1 and that is what deploys.
|
|
2967
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY AUTO_INCREMENT)');
|
|
2968
|
+
await q('INSERT INTO notes VALUES (0)');
|
|
2969
|
+
const calls = [];
|
|
2970
|
+
const generated = { execute: async (sql) => { calls.push(sql); return { rows: [], rowsAffected: 1 }; } };
|
|
2971
|
+
await applyPlanetscaleRows({ ...generated, transaction: async (fn) => fn(generated) }, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'key-budget') } });
|
|
2972
|
+
if (JSON.stringify(calls) !== JSON.stringify(["INSERT INTO `notes` (`id`) VALUES ('1')"]))
|
|
2973
|
+
return false;
|
|
2974
|
+
const tx = { execute: async () => { throw new Error('unexpected vendor call'); } };
|
|
2975
|
+
const refuse = async (root) => {
|
|
2976
|
+
try {
|
|
2977
|
+
await applyPlanetscaleRows({ ...tx, transaction: async (fn) => fn(tx) }, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'key-budget') } });
|
|
2978
|
+
return false;
|
|
2979
|
+
}
|
|
2980
|
+
catch (e) {
|
|
2981
|
+
return String(e).includes('primary-key representation');
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
return withRoot(async (q2, root2) => {
|
|
2985
|
+
const { client } = fakeClient({ 'SHOW TABLES': { rows: [{ Tables_in_twin: 'notes' }] },
|
|
2986
|
+
'DESCRIBE `notes`': { rows: [{ Field: 'id', Type: 'int(4) unsigned zerofill', Null: 'NO', Key: 'PRI', Default: null, Extra: '' }] },
|
|
2987
|
+
'SELECT * FROM `notes` ORDER BY `id` LIMIT 500': { rows: [] } });
|
|
2988
|
+
await syncPlanetscaleFromReal(client, { root: root2, budgetOptions: { path: join(root2, 'pull-budget') } });
|
|
2989
|
+
await q2('INSERT INTO notes VALUES (5)');
|
|
2990
|
+
return refuse(root2);
|
|
2991
|
+
});
|
|
2992
|
+
}),
|
|
2993
|
+
},
|
|
2994
|
+
{
|
|
2995
|
+
id: 'planetscale.insert.upsert_constraint_recheck',
|
|
2996
|
+
area: 'insert', title: 'upsert assignments cannot violate another primary or unique key or erase the occupied row',
|
|
2997
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
2998
|
+
verify: () => withRoot(async (q) => {
|
|
2999
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY, label VARCHAR(64) UNIQUE, n INT)');
|
|
3000
|
+
await q("INSERT INTO notes VALUES (1,'a',10),(2,'b',20)");
|
|
3001
|
+
const primary = await q("INSERT INTO notes VALUES (3,'a',30) ON DUPLICATE KEY UPDATE id=2");
|
|
3002
|
+
const unique = await q("INSERT INTO notes VALUES (1,'c',30) ON DUPLICATE KEY UPDATE label='b'");
|
|
3003
|
+
return failed(primary, 'ALREADY_EXISTS', 'Duplicate entry') && failed(unique, 'ALREADY_EXISTS', 'Duplicate entry')
|
|
3004
|
+
&& JSON.stringify((await q('SELECT * FROM notes ORDER BY id')).rows) === JSON.stringify([['1', 'a', '10'], ['2', 'b', '20']]);
|
|
3005
|
+
}),
|
|
3006
|
+
},
|
|
3007
|
+
{
|
|
3008
|
+
id: 'planetscale.connector.storage_conversion',
|
|
3009
|
+
area: 'connector', title: 'rounding, padding and AUTO_INCREMENT generation deploy the value MySQL stored; floating point refuses the entire plan before IO',
|
|
3010
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
3011
|
+
verify: async () => {
|
|
3012
|
+
// The stored representations, per the MySQL 8.0 Reference Manual: a zero (after conversion) in an
|
|
3013
|
+
// AUTO_INCREMENT column generates the next value (§3.6.9); a string assigned to INT is rounded,
|
|
3014
|
+
// half away from zero (§12.25.4, Rounding Behavior); DECIMAL(4,2) rounds to its scale (§11.1.3);
|
|
3015
|
+
// CHAR drops trailing spaces on retrieval (§11.3.2); BINARY(n) right-pads with 0x00 (§11.3.3).
|
|
3016
|
+
for (const [type, value, next, stored] of [['INT UNIQUE AUTO_INCREMENT', '0', '4', "'1'"], ['INT UNIQUE AUTO_INCREMENT', "'00'", '4', "'1'"], ['INT UNIQUE AUTO_INCREMENT', "'0.0'", '4', "'1'"], ['INT UNIQUE AUTO_INCREMENT', "'-0'", '4', "'1'"], ['INT', "'2.5'", '4', "'3'"], ['DECIMAL(4,2)', "'1.235'", '2', "'1.24'"], ['CHAR(4)', "'a '", "'b'", "'a'"], ['BINARY(4)', "X'61'", "X'62626262'", "X'61000000'"]]) {
|
|
3017
|
+
if (!await withRoot(async (q, root) => {
|
|
3018
|
+
await q(`CREATE TABLE notes (id INT PRIMARY KEY, n ${type})`);
|
|
3019
|
+
await q('BEGIN');
|
|
3020
|
+
await q(`INSERT INTO notes VALUES (1,${value})`);
|
|
3021
|
+
await q(`UPDATE notes SET n=${next} WHERE id=1`);
|
|
3022
|
+
await q('COMMIT');
|
|
3023
|
+
const calls = [];
|
|
3024
|
+
const tx = { execute: async (sql) => { calls.push(sql); return { rows: [], rowsAffected: 1 }; } };
|
|
3025
|
+
await applyPlanetscaleRows({ ...tx, transaction: async (fn) => fn(tx) }, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'conversion-budget') } });
|
|
3026
|
+
return calls.length === 2 && calls[0] === `INSERT INTO \`notes\` (\`id\`, \`n\`) VALUES ('1', ${stored})`;
|
|
3027
|
+
}))
|
|
3028
|
+
return false;
|
|
3029
|
+
}
|
|
3030
|
+
// FLOAT's stored single-precision value has no vendor-faithful codec here: the plan refuses.
|
|
3031
|
+
return withRoot(async (q, root) => {
|
|
3032
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY, n FLOAT)');
|
|
3033
|
+
await q('BEGIN');
|
|
3034
|
+
await q('INSERT INTO notes VALUES (1,0.1)');
|
|
3035
|
+
await q('UPDATE notes SET n=0.2 WHERE id=1');
|
|
3036
|
+
await q('COMMIT');
|
|
3037
|
+
let calls = 0;
|
|
3038
|
+
const tx = { execute: async () => { calls++; return { rows: [], rowsAffected: 1 }; } };
|
|
3039
|
+
try {
|
|
3040
|
+
await applyPlanetscaleRows({ ...tx, transaction: async (fn) => fn(tx) }, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'conversion-budget') } });
|
|
3041
|
+
return false;
|
|
3042
|
+
}
|
|
3043
|
+
catch (e) {
|
|
3044
|
+
return String(e).includes('Unsupported stored representation') && calls === 0;
|
|
3045
|
+
}
|
|
3046
|
+
});
|
|
3047
|
+
},
|
|
3048
|
+
},
|
|
3049
|
+
{
|
|
3050
|
+
id: 'planetscale.connector.numeric_beforeimages',
|
|
3051
|
+
area: 'connector', title: 'numeric guards accept normalized values without DOUBLE coercion of adjacent BIGINTs',
|
|
3052
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
3053
|
+
verify: () => withRoot(async (q, root) => {
|
|
3054
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY, n BIGINT)');
|
|
3055
|
+
await q('BEGIN');
|
|
3056
|
+
await q("INSERT INTO notes VALUES (1,'01')");
|
|
3057
|
+
await q('UPDATE notes SET n=2');
|
|
3058
|
+
await q('COMMIT');
|
|
3059
|
+
const calls = [];
|
|
3060
|
+
const tx = { execute: async (sql) => { calls.push(sql); return { rows: [], rowsAffected: 1 }; } };
|
|
3061
|
+
const client = { ...tx, transaction: async (fn) => fn(tx) };
|
|
3062
|
+
await applyPlanetscaleRows(client, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'numeric-budget') } });
|
|
3063
|
+
// '01' is stored as the BIGINT 1 (MySQL 8.0 Reference Manual §12.3); the guard is that exact, unquoted integer.
|
|
3064
|
+
if (!calls[1]?.endsWith('WHERE `id` = 1 AND `n` = 1 LIMIT 1'))
|
|
3065
|
+
return false;
|
|
3066
|
+
await q("INSERT INTO notes VALUES (2,'9007199254740992')");
|
|
3067
|
+
await q('UPDATE notes SET n=7 WHERE id=2');
|
|
3068
|
+
calls.length = 0;
|
|
3069
|
+
await applyPlanetscaleRows(client, deployableEntries('planetscale', root).at(-1).input?.plan, { budgetOptions: { path: join(root, 'numeric-budget') } });
|
|
3070
|
+
// This exact unquoted literal keeps MySQL's integer comparison exact above 2^53.
|
|
3071
|
+
return calls.length === 1 && calls[0].includes('WHERE `id` = 2 AND `n` = 9007199254740992 LIMIT 1');
|
|
3072
|
+
}),
|
|
3073
|
+
},
|
|
3074
|
+
{
|
|
3075
|
+
id: 'planetscale.connector.captured_transaction',
|
|
3076
|
+
area: 'connector', title: 'one committed action carries ordered row effects; rollback and sessions never deploy',
|
|
3077
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
3078
|
+
verify: () => withRoot(async (q, root) => {
|
|
3079
|
+
await q('CREATE TABLE notes (id INT PRIMARY KEY, n INT)');
|
|
3080
|
+
const baseline = deployableEntries('planetscale', root).length;
|
|
3081
|
+
await q('BEGIN');
|
|
3082
|
+
await q('INSERT INTO notes VALUES (9,9)');
|
|
3083
|
+
await q('ROLLBACK');
|
|
3084
|
+
if (deployableEntries('planetscale', root).length !== baseline)
|
|
3085
|
+
return false;
|
|
3086
|
+
await q('BEGIN');
|
|
3087
|
+
await q('INSERT INTO notes VALUES (1,10),(2,20)');
|
|
3088
|
+
await q('UPDATE notes SET n=n+1 ORDER BY id');
|
|
3089
|
+
await q('DELETE FROM notes WHERE id=2');
|
|
3090
|
+
await q('COMMIT');
|
|
3091
|
+
const entries = deployableEntries('planetscale', root);
|
|
3092
|
+
if (entries.length !== baseline + 1)
|
|
3093
|
+
throw new Error(`captured count ${entries.length}/${baseline}`);
|
|
3094
|
+
const action = entries.at(-1);
|
|
3095
|
+
const calls = [];
|
|
3096
|
+
let transactions = 0;
|
|
3097
|
+
const tx = { execute: async (sql) => { calls.push(sql); return { rows: [], rowsAffected: 1 }; } };
|
|
3098
|
+
await applyPlanetscaleRows({ ...tx, transaction: async (fn) => { transactions++; return fn(tx); } }, action.input?.plan, { budgetOptions: { path: join(root, 'captured-budget') } });
|
|
3099
|
+
return transactions === 1 && calls.length === 5 && calls[0].startsWith('INSERT')
|
|
3100
|
+
&& calls[2].includes("`n` = 10") && calls[4].startsWith('DELETE')
|
|
3101
|
+
&& calls[4].includes("`n` = 21") && (await q('SELECT * FROM notes')).rows.length === 1;
|
|
3102
|
+
}),
|
|
3103
|
+
},
|
|
3104
|
+
{
|
|
3105
|
+
id: 'planetscale.connector.push_row',
|
|
3106
|
+
area: 'connector', title: 'push inserts a captured keyed row without overwriting a conflict and confirms the action',
|
|
3107
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
3108
|
+
verify: () => withRoot(async (q, root) => {
|
|
3109
|
+
await q('CREATE TABLE Link (`key` VARCHAR(64) PRIMARY KEY, url VARCHAR(255))');
|
|
3110
|
+
await q("INSERT INTO Link VALUES ('o\\'brien', 'https://x.test')");
|
|
3111
|
+
const action = deployableEntries('planetscale', root).at(-1);
|
|
3112
|
+
const ledger = join(root, 'rate-budget.json');
|
|
3113
|
+
const expected = "INSERT INTO `Link` (`key`, `url`) VALUES ('o\\'brien', 'https://x.test')";
|
|
3114
|
+
const { client, calls } = fakeClient({ [expected]: { rows: [], rowsAffected: 1 } });
|
|
3115
|
+
const res = await pushPlanetscaleAction(client, { ...action, subjectType: action.subject.type, subjectId: action.subject.id }, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
3116
|
+
return res.pushed && res.statement === expected && calls.length === 1 && calls[0] === expected
|
|
3117
|
+
&& !deployableEntries('planetscale', root).some(e => e.id === action.id)
|
|
3118
|
+
&& parentEntries('planetscale', root).some(e => e.landsId === action.id && e.receipt?.status === 'deployed');
|
|
3119
|
+
}),
|
|
3120
|
+
},
|
|
3121
|
+
{
|
|
3122
|
+
id: 'planetscale.connector.push_delete_and_refusals',
|
|
3123
|
+
area: 'connector', title: 'push issues a bounded DELETE and refuses entries without captured plans',
|
|
3124
|
+
dimension: 'connector', tier: 'common', expected: 'done',
|
|
3125
|
+
verify: () => withRoot(async (_q, root) => {
|
|
3126
|
+
const ledger = join(root, 'rate-budget.json');
|
|
3127
|
+
const expected = "DELETE FROM `Link` WHERE CAST(`key` AS BINARY) = CAST('a' AS BINARY) AND CAST(`url` AS BINARY) = CAST('https://a.test' AS BINARY) LIMIT 1";
|
|
3128
|
+
const { client, calls } = fakeClient({ [expected]: { rows: [], rowsAffected: 1 } });
|
|
3129
|
+
const deleted = await pushPlanetscaleAction(client, {
|
|
3130
|
+
id: 'act-2', operation: 'row.delete', subjectType: 'row', subjectId: 'row:link:1',
|
|
3131
|
+
fields: { table_name: 'Link', rowid: 1, cells: { key: 'a', url: 'https://a.test' }, gone: true, _rev: 2 },
|
|
3132
|
+
input: { plan: { version: 1, statements: [], refusals: [], changes: [{ table: 'Link', primaryKey: ['key'], binary: [], before: { key: 'a', url: 'https://a.test' } }] } },
|
|
3133
|
+
}, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
3134
|
+
const refusals = [];
|
|
3135
|
+
for (const action of [
|
|
3136
|
+
{ id: 'act-3', operation: 'table.write', subjectType: 'table', subjectId: 'table:link', fields: { name: 'Link' } },
|
|
3137
|
+
{ id: 'act-4', operation: 'row.delete', subjectType: 'row', subjectId: 'row:link:2', fields: { table_name: 'Link', rowid: 2, cells: {}, gone: true } },
|
|
3138
|
+
]) {
|
|
3139
|
+
try {
|
|
3140
|
+
await pushPlanetscaleAction(client, action, { root, occurredAt: AT, budgetOptions: { path: ledger } });
|
|
3141
|
+
refusals.push('NOT REFUSED');
|
|
3142
|
+
}
|
|
3143
|
+
catch (e) {
|
|
3144
|
+
refusals.push(String(e.message));
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
// The DELETE carries a LIMIT and a full WHERE — an unqualified DELETE against a real database
|
|
3148
|
+
// is the single most destructive thing this connector could emit, so it is refused by name.
|
|
3149
|
+
return deleted.pushed && calls.length === 1 && calls[0] === expected
|
|
3150
|
+
&& refusals[0].includes('no captured row plan')
|
|
3151
|
+
&& refusals[1].includes('no captured row plan');
|
|
3152
|
+
}),
|
|
3153
|
+
},
|
|
3154
|
+
{
|
|
3155
|
+
id: 'planetscale.connector.rate_budget_fail_closed',
|
|
3156
|
+
area: 'connector', title: 'the client-side budget refuses past its ceiling with the call count UNCHANGED',
|
|
3157
|
+
dimension: 'connector', tier: 'core', expected: 'done',
|
|
3158
|
+
verify: () => withRoot(async (q) => {
|
|
3159
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
3160
|
+
let now = 1_800_000_000_000;
|
|
3161
|
+
const calls = [];
|
|
3162
|
+
const raw = {
|
|
3163
|
+
async execute(query) { calls.push(query); return { rows: [] }; },
|
|
3164
|
+
};
|
|
3165
|
+
const budget = new PlanetscaleBudget({ path: ledger, now: () => now });
|
|
3166
|
+
const client = guardPlanetscaleClient(raw, { budget });
|
|
3167
|
+
// LITERALS, not the pack's own constants (§9 round one, finding 18): importing the numbers
|
|
3168
|
+
// the guard is built from makes both sides drift together, so the assertion could never catch
|
|
3169
|
+
// the ceiling being loosened. 60 units / weight 2 = 30 calls per minute — and the declaration
|
|
3170
|
+
// is separately checked against the KERNEL fallback in planetscale-budget.test.ts.
|
|
3171
|
+
const allowed = 30;
|
|
3172
|
+
if (PLANETSCALE_BUDGET_CEILING !== 60 || PLANETSCALE_CALL_WEIGHTS.other !== 2)
|
|
3173
|
+
return false;
|
|
3174
|
+
for (let i = 0; i < allowed; i++)
|
|
3175
|
+
await client.execute('SELECT 1');
|
|
3176
|
+
const before = calls.length;
|
|
3177
|
+
let refused = false;
|
|
3178
|
+
try {
|
|
3179
|
+
await client.execute('SELECT 1');
|
|
3180
|
+
}
|
|
3181
|
+
catch {
|
|
3182
|
+
refused = true;
|
|
3183
|
+
}
|
|
3184
|
+
// "It threw" is not the proof — the COUNT is. An unchanged count is what shows the refusal
|
|
3185
|
+
// happened BEFORE the request, not after it.
|
|
3186
|
+
const after = calls.length;
|
|
3187
|
+
// …and the window really rolls: advancing past it restores the allowance.
|
|
3188
|
+
now += 61_000;
|
|
3189
|
+
await client.execute('SELECT 1');
|
|
3190
|
+
return before === allowed && refused && after === before && calls.length === allowed + 1;
|
|
3191
|
+
}),
|
|
3192
|
+
},
|
|
3193
|
+
{
|
|
3194
|
+
id: 'planetscale.connector.budget_guard_is_unavoidable',
|
|
3195
|
+
area: 'connector', title: 'the guard is idempotent, refuses an impostor budget, and re-guards a derived connection',
|
|
3196
|
+
dimension: 'connector', tier: 'common', expected: 'done',
|
|
3197
|
+
verify: () => withRoot(async (q) => {
|
|
3198
|
+
const ledger = join(q.root, 'rate-budget.json');
|
|
3199
|
+
const calls = [];
|
|
3200
|
+
const inner = { async execute(q) { calls.push(q); return { rows: [] }; } };
|
|
3201
|
+
// A client shaped like the real `Client`: `connection()` hands back a NEW Connection whose
|
|
3202
|
+
// `execute` would otherwise escape the budget entirely.
|
|
3203
|
+
const raw = { ...inner, connection: () => inner };
|
|
3204
|
+
const budget = new PlanetscaleBudget({ path: ledger });
|
|
3205
|
+
const guarded = guardPlanetscaleClient(raw, { budget });
|
|
3206
|
+
const twice = guardPlanetscaleClient(guarded, { budget });
|
|
3207
|
+
let refusedImpostor = false;
|
|
3208
|
+
try {
|
|
3209
|
+
guardPlanetscaleClient(raw, { budget: { checkBudget: () => ({}), weightFor: () => 0, recordCall: () => { } } });
|
|
3210
|
+
}
|
|
3211
|
+
catch {
|
|
3212
|
+
refusedImpostor = true;
|
|
3213
|
+
}
|
|
3214
|
+
const derived = guarded.connection();
|
|
3215
|
+
await derived.execute('SELECT 2');
|
|
3216
|
+
const spentAfterDerived = budget.snapshot().spend;
|
|
3217
|
+
return twice === guarded && refusedImpostor && calls.join(',') === 'SELECT 2'
|
|
3218
|
+
// 2 for `connection()` itself + 2 for the derived `execute`, as a LITERAL 4 (§9 round one,
|
|
3219
|
+
// finding 18) — the derived connection is genuinely behind the same ledger rather than
|
|
3220
|
+
// escaping it, and an unguarded one would spend 2.
|
|
3221
|
+
&& spentAfterDerived === 4;
|
|
3222
|
+
}),
|
|
3223
|
+
},
|
|
3224
|
+
{
|
|
3225
|
+
id: 'planetscale.connector.pull_pagination', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3226
|
+
title: 'bounded keyset pages report completion, preserve composite-key identity and retain partial scans',
|
|
3227
|
+
verify: () => withRoot(async (q, root) => {
|
|
3228
|
+
const schema = describeRows([['tenant', 'varchar(20)', 'NO', 'PRI', null, ''], ['id', 'bigint', 'NO', 'PRI', null, '']]);
|
|
3229
|
+
const script = {
|
|
3230
|
+
'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': schema,
|
|
3231
|
+
'SELECT * FROM `items` ORDER BY `tenant`, `id` LIMIT 2': { rows: [{ tenant: "a'b", id: '9007199254740992' }, { tenant: "a'b", id: '9007199254740993' }] },
|
|
3232
|
+
"SELECT * FROM `items` WHERE (`tenant` > 'a\\'b') OR (`tenant` = 'a\\'b' AND `id` > 9007199254740993) ORDER BY `tenant`, `id` LIMIT 2": { rows: [{ tenant: 'z', id: '1' }] },
|
|
3233
|
+
};
|
|
3234
|
+
const { client, calls } = fakeClient(script);
|
|
3235
|
+
const opts = { root, rowLimit: 2, budgetOptions: { path: join(root, 'pages-budget') } };
|
|
3236
|
+
const full = await syncPlanetscaleFromReal(client, opts);
|
|
3237
|
+
assert.deepEqual(full.completeTables, ['items']);
|
|
3238
|
+
assert.deepEqual(full.partialTables, []);
|
|
3239
|
+
assert.equal((await q('SELECT * FROM items')).rows.length, 3);
|
|
3240
|
+
assert.equal(calls.filter(c => c.startsWith('SELECT')).length, 2);
|
|
3241
|
+
const partial = await syncPlanetscaleFromReal(client, { ...opts, maxPages: 1 });
|
|
3242
|
+
assert.deepEqual(partial.completeTables, []);
|
|
3243
|
+
assert.deepEqual(partial.partialTables, ['items']);
|
|
3244
|
+
assert.equal(partial.removed, 0);
|
|
3245
|
+
assert.equal((await q('SELECT * FROM items')).rows.length, 3);
|
|
3246
|
+
return true;
|
|
3247
|
+
}),
|
|
3248
|
+
},
|
|
3249
|
+
{
|
|
3250
|
+
id: 'planetscale.connector.pull_row_deletion', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3251
|
+
title: 'completed reads confirm missing upstream rows, preserve local edits and honor table scope',
|
|
3252
|
+
verify: () => withRoot(async (q, root) => {
|
|
3253
|
+
const schema = describeRows([['id', 'int', 'NO', 'PRI', null, ''], ['label', 'varchar(20)', 'YES', '', null, '']]);
|
|
3254
|
+
const script = {
|
|
3255
|
+
'SHOW TABLES': { rows: [{ t: 'items' }, { t: 'outside' }] },
|
|
3256
|
+
'DESCRIBE `items`': schema, 'DESCRIBE `outside`': schema,
|
|
3257
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1, label: 'delete' }, { id: 2, label: 'edit' }, { id: 3, label: 'stay' }] },
|
|
3258
|
+
'SELECT * FROM `outside` ORDER BY `id` LIMIT 500': { rows: [{ id: 9, label: 'outside' }] },
|
|
3259
|
+
'SELECT * FROM `items` WHERE `id` = 1 LIMIT 2': { rows: [] },
|
|
3260
|
+
'SELECT * FROM `items` WHERE `id` = 2 LIMIT 2': { rows: [] },
|
|
3261
|
+
};
|
|
3262
|
+
const { client, calls } = fakeClient(script);
|
|
3263
|
+
const opts = { root, budgetOptions: { path: join(root, 'delete-budget') } };
|
|
3264
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3265
|
+
await q("UPDATE items SET label='LOCAL' WHERE id=2");
|
|
3266
|
+
await q("INSERT INTO items VALUES (4,'new-local')");
|
|
3267
|
+
script['SELECT * FROM `items` ORDER BY `id` LIMIT 500'] = { rows: [{ id: 3, label: 'stay' }] };
|
|
3268
|
+
const result = await syncPlanetscaleFromReal(client, { ...opts, tables: ['items'] });
|
|
3269
|
+
assert.equal(result.removed, 2);
|
|
3270
|
+
assert.equal(result.deltasAppended, 2);
|
|
3271
|
+
assert.deepEqual((await q('SELECT id,label FROM items ORDER BY id')).rows, [['2', 'LOCAL'], ['3', 'stay'], ['4', 'new-local']]);
|
|
3272
|
+
assert.equal((await q('SELECT * FROM outside')).rows.length, 1);
|
|
3273
|
+
assert(calls.includes('SELECT * FROM `items` WHERE `id` = 1 LIMIT 2'));
|
|
3274
|
+
assert(!calls.some(c => c.includes('WHERE `id` = 4')));
|
|
3275
|
+
assert.equal((await syncPlanetscaleFromReal(client, { ...opts, tables: ['items'] })).deltasAppended, 0);
|
|
3276
|
+
return true;
|
|
3277
|
+
}),
|
|
3278
|
+
},
|
|
3279
|
+
{
|
|
3280
|
+
id: 'planetscale.connector.refresh_atomicity', area: 'connector', dimension: 'connector', tier: 'core', expected: 'done',
|
|
3281
|
+
title: 'page, schema, budget and absence-check failures append no partial observation',
|
|
3282
|
+
verify: () => withRoot(async (q, root) => {
|
|
3283
|
+
const schema = describeRows([['id', 'int', 'NO', 'PRI', null, ''], ['label', 'varchar(20)', 'YES', '', null, '']]);
|
|
3284
|
+
const initial = fakeClient({ 'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': schema,
|
|
3285
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1, label: 'old' }, { id: 2, label: 'old' }] } }).client;
|
|
3286
|
+
await syncPlanetscaleFromReal(initial, { root, budgetOptions: { path: join(root, 'initial-budget') } });
|
|
3287
|
+
const before = parentEntries('planetscale', root);
|
|
3288
|
+
const cases = [
|
|
3289
|
+
{ 'SELECT * FROM `items` ORDER BY `id` LIMIT 1': { rows: [{ id: 1, label: 'new' }] } }, // next page fails
|
|
3290
|
+
{ 'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1, label: 'new' }] } }, // point lookup fails
|
|
3291
|
+
{ 'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1, label: 'new' }] }, 'SELECT * FROM `items` WHERE `id` = 2 LIMIT 2': { rows: [{ id: 2, label: 'moved' }] } },
|
|
3292
|
+
{ 'DESCRIBE `items`': { rows: [] } },
|
|
3293
|
+
{ 'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1 }] } },
|
|
3294
|
+
{ 'SHOW TABLES': { rows: [{}] } },
|
|
3295
|
+
{ 'DESCRIBE `items`': { rows: [{ Field: 'id', Type: 'int' }] } },
|
|
3296
|
+
{ 'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1, label: 'new', added_column: 'schema moved' }] } },
|
|
3297
|
+
];
|
|
3298
|
+
for (const [i, changes] of cases.entries()) {
|
|
3299
|
+
const { client } = fakeClient({ 'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': schema, ...changes });
|
|
3300
|
+
await assert.rejects(() => syncPlanetscaleFromReal(client, { root, ...(i === 0 ? { rowLimit: 1 } : {}), budgetOptions: { path: join(root, `failure-${i}`) } }));
|
|
3301
|
+
assert.deepEqual(parentEntries('planetscale', root), before);
|
|
3302
|
+
}
|
|
3303
|
+
await assert.rejects(() => syncPlanetscaleFromReal(initial, { root, budgetOptions: { path: join(root, 'exhausted'), ceiling: 2 } }));
|
|
3304
|
+
assert.deepEqual(parentEntries('planetscale', root), before);
|
|
3305
|
+
assert.deepEqual((await q('SELECT label FROM items')).rows, [['old'], ['old']]);
|
|
3306
|
+
return true;
|
|
3307
|
+
}),
|
|
3308
|
+
},
|
|
3309
|
+
{
|
|
3310
|
+
id: 'planetscale.connector.refresh_bounds', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3311
|
+
title: 'invalid bounds refuse before IO; keyless scans never continue past a full page',
|
|
3312
|
+
verify: () => withRoot(async (_q, root) => {
|
|
3313
|
+
const { client, calls } = fakeClient({ 'SHOW TABLES': { rows: [{ t: 'items' }] },
|
|
3314
|
+
'DESCRIBE `items`': describeRows([['label', 'varchar(20)', 'YES', '', null, '']]),
|
|
3315
|
+
'SELECT * FROM `items` LIMIT 1': { rows: [{ label: 'a' }] } });
|
|
3316
|
+
for (const option of ['rowLimit', 'maxPages', 'maxMissingChecks']) {
|
|
3317
|
+
for (const value of [0, -1, 1.5, NaN, Infinity, null, '1; DROP TABLE items', 10001]) {
|
|
3318
|
+
await assert.rejects(() => pullPlanetscaleSnapshot(client, { [option]: value, budgetOptions: { path: join(root, 'bounds-budget') } }));
|
|
3319
|
+
}
|
|
3320
|
+
}
|
|
3321
|
+
assert.equal(calls.length, 0);
|
|
3322
|
+
const scan = await pullPlanetscaleSnapshot(client, { rowLimit: 1, budgetOptions: { path: join(root, 'bounds-budget') } });
|
|
3323
|
+
assert.deepEqual(scan.partialTables, ['items']);
|
|
3324
|
+
assert.deepEqual(scan.completeTables, []);
|
|
3325
|
+
assert.equal(calls.length, 3);
|
|
3326
|
+
await assert.rejects(() => syncPlanetscaleFromReal({}, { root, budgetOptions: { path: join(root, 'empty-client-budget') } }));
|
|
3327
|
+
assert.equal(parentEntries('planetscale', root).length, 0);
|
|
3328
|
+
return true;
|
|
3329
|
+
}),
|
|
3330
|
+
},
|
|
3331
|
+
{
|
|
3332
|
+
id: 'planetscale.connector.refresh_empty_database', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3333
|
+
title: 'an empty discovered database removes upstream tables and rows but an empty table selection does not',
|
|
3334
|
+
verify: () => withRoot(async (_q, root) => {
|
|
3335
|
+
const schema = describeRows([['id', 'int', 'NO', 'PRI', null, '']]);
|
|
3336
|
+
const opts = { root, budgetOptions: { path: join(root, 'empty-budget') } };
|
|
3337
|
+
await syncPlanetscaleFromReal(fakeClient({ 'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': schema,
|
|
3338
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1 }] } }).client, opts);
|
|
3339
|
+
const { client } = fakeClient({ 'SHOW TABLES': { rows: [] } });
|
|
3340
|
+
assert.equal((await syncPlanetscaleFromReal(client, { ...opts, tables: [] })).removed, 0);
|
|
3341
|
+
assert.equal(readDatabase(root).tables.size, 1);
|
|
3342
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).removed, 2);
|
|
3343
|
+
assert.equal(readDatabase(root).tables.size, 0);
|
|
3344
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).deltasAppended, 0);
|
|
3345
|
+
return true;
|
|
3346
|
+
}),
|
|
3347
|
+
},
|
|
3348
|
+
{
|
|
3349
|
+
id: 'planetscale.connector.refresh_read_metadata', area: 'connector', dimension: 'connector', tier: 'core', expected: 'done',
|
|
3350
|
+
title: 'a DDL-shaped empty wire result cannot prove table or row absence',
|
|
3351
|
+
verify: () => withRoot(async (_q, root) => {
|
|
3352
|
+
const schema = describeRows([['id', 'int', 'NO', 'PRI', null, '']]);
|
|
3353
|
+
const good = fakeClient({ 'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': schema,
|
|
3354
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1 }] } }).client;
|
|
3355
|
+
await syncPlanetscaleFromReal(good, { root, budgetOptions: { path: join(root, 'metadata-initial') } });
|
|
3356
|
+
const before = parentEntries('planetscale', root);
|
|
3357
|
+
const wire = planetscaleClientOver(async (req) => ({ status: 200, headers: {}, body: JSON.stringify(req.path.endsWith('CreateSession') ? { session: { id: 's' } } : { result: {} }) }));
|
|
3358
|
+
await assert.rejects(() => syncPlanetscaleFromReal(wire, { root, budgetOptions: { path: join(root, 'metadata-wire') } }), /field metadata/);
|
|
3359
|
+
const { client } = fakeClient({ 'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': schema,
|
|
3360
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [] },
|
|
3361
|
+
'SELECT * FROM `items` WHERE `id` = 1 LIMIT 2': { rows: [], fields: [] } });
|
|
3362
|
+
await assert.rejects(() => syncPlanetscaleFromReal(client, { root, budgetOptions: { path: join(root, 'metadata-point') } }), /field metadata/);
|
|
3363
|
+
assert.deepEqual(parentEntries('planetscale', root), before);
|
|
3364
|
+
return true;
|
|
3365
|
+
}),
|
|
3366
|
+
},
|
|
3367
|
+
{
|
|
3368
|
+
id: 'planetscale.connector.refresh_identity_changes', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3369
|
+
title: 'stable collation spelling and primary-key definition changes retire old identities without endless retries',
|
|
3370
|
+
verify: () => withRoot(async (q, root) => {
|
|
3371
|
+
const original = describeRows([['id', 'varchar(20)', 'NO', 'PRI', null, ''], ['label', 'varchar(20)', 'NO', '', null, '']]);
|
|
3372
|
+
const script = { 'SHOW TABLES': { rows: [{ t: 'items' }] }, 'DESCRIBE `items`': original,
|
|
3373
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 'a', label: 'one' }] },
|
|
3374
|
+
// An independent case-insensitive peer: the old spelling resolves to the new row.
|
|
3375
|
+
"SELECT * FROM `items` WHERE `id` = 'a' LIMIT 2": { rows: [{ id: 'A', label: 'one' }] } };
|
|
3376
|
+
const { client } = fakeClient(script);
|
|
3377
|
+
const opts = { root, budgetOptions: { path: join(root, 'identity-budget') } };
|
|
3378
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3379
|
+
script['SELECT * FROM `items` ORDER BY `id` LIMIT 500'] = { rows: [{ id: 'A', label: 'one' }] };
|
|
3380
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).removed, 1);
|
|
3381
|
+
assert.deepEqual((await q('SELECT * FROM items')).rows, [['A', 'one']]);
|
|
3382
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).deltasAppended, 0);
|
|
3383
|
+
script['DESCRIBE `items`'] = describeRows([['id', 'varchar(20)', 'NO', 'PRI', null, ''], ['label', 'varchar(20)', 'NO', 'PRI', null, '']]);
|
|
3384
|
+
script['SELECT * FROM `items` ORDER BY `id`, `label` LIMIT 500'] = { rows: [{ id: 'A', label: 'one' }] };
|
|
3385
|
+
script["SELECT * FROM `items` WHERE `id` = 'A' AND `label` = 'one' LIMIT 2"] = { rows: [{ id: 'A', label: 'one' }] };
|
|
3386
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).removed, 1);
|
|
3387
|
+
script['DESCRIBE `items`'] = describeRows([['id', 'varchar(20)', 'NO', '', null, ''], ['label', 'varchar(20)', 'NO', '', null, ''], ['surrogate', 'int', 'NO', 'PRI', null, '']]);
|
|
3388
|
+
script['SELECT * FROM `items` ORDER BY `surrogate` LIMIT 500'] = { rows: [{ id: 'A', label: 'one', surrogate: 10 }] };
|
|
3389
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).removed, 1);
|
|
3390
|
+
assert.deepEqual((await q('SELECT * FROM items')).rows, [['A', 'one', '10']]);
|
|
3391
|
+
return true;
|
|
3392
|
+
}),
|
|
3393
|
+
},
|
|
3394
|
+
{
|
|
3395
|
+
id: 'planetscale.connector.exact_numeric_paging', area: 'connector', dimension: 'connector', tier: 'core', expected: 'done',
|
|
3396
|
+
title: 'actual SQL continuation distinguishes adjacent BIGINT keys instead of rounding through DOUBLE',
|
|
3397
|
+
verify: () => withRoot(async (q, root) => withRoot(async (remote) => {
|
|
3398
|
+
await remote('CREATE TABLE items (id BIGINT PRIMARY KEY)');
|
|
3399
|
+
await remote('INSERT INTO items VALUES (9007199254740993),(9007199254740992),(-9007199254740992),(-9007199254740993)');
|
|
3400
|
+
const client = { execute: async (sql) => {
|
|
3401
|
+
const r = await remote(sql);
|
|
3402
|
+
assert(!r.error, JSON.stringify(r.error));
|
|
3403
|
+
return { fields: r.fields, rows: r.rows.map(row => Object.fromEntries(r.fields.map((f, i) => [f.name, row[i]]))) };
|
|
3404
|
+
} };
|
|
3405
|
+
const result = await syncPlanetscaleFromReal(client, { root, rowLimit: 1, budgetOptions: { path: join(root, 'bigint-budget') } });
|
|
3406
|
+
assert.deepEqual(result.completeTables, ['items']);
|
|
3407
|
+
assert.deepEqual((await q('SELECT id FROM items ORDER BY id')).rows, [['-9007199254740993'], ['-9007199254740992'], ['9007199254740992'], ['9007199254740993']]);
|
|
3408
|
+
return true;
|
|
3409
|
+
})),
|
|
3410
|
+
},
|
|
3411
|
+
{
|
|
3412
|
+
id: 'planetscale.connector.pending_delete_collision', area: 'connector', dimension: 'connector', tier: 'core', expected: 'done',
|
|
3413
|
+
title: 'pending local deletion cannot free a hash address still occupied by a distinct upstream key',
|
|
3414
|
+
verify: () => withRoot(async (q, root) => {
|
|
3415
|
+
const keys = ['customer-2479052881', 'customer-2205617487'];
|
|
3416
|
+
const remote = fakeClient({ 'SHOW TABLES': { rows: [{ t: 'notes' }] },
|
|
3417
|
+
'DESCRIBE `notes`': describeRows([['id', 'varchar(64)', 'NO', 'PRI', null, '']]),
|
|
3418
|
+
'SELECT * FROM `notes` ORDER BY `id` LIMIT 500': { rows: [{ id: keys[0] }] } }).client;
|
|
3419
|
+
const opts = { root, budgetOptions: { path: join(root, 'pending-collision-budget') } };
|
|
3420
|
+
await syncPlanetscaleFromReal(remote, opts);
|
|
3421
|
+
await q('DELETE FROM notes');
|
|
3422
|
+
const before = deployableEntries('planetscale', root).length;
|
|
3423
|
+
const inserted = await q(`INSERT INTO notes VALUES ('${keys[1]}')`);
|
|
3424
|
+
assert(inserted.error?.message.includes('row identity hash collision'));
|
|
3425
|
+
assert.equal(deployableEntries('planetscale', root).length, before);
|
|
3426
|
+
assert.equal((await syncPlanetscaleFromReal(remote, opts)).deltasAppended, 0);
|
|
3427
|
+
assert.deepEqual((await q('SELECT * FROM notes')).rows, []);
|
|
3428
|
+
return true;
|
|
3429
|
+
}),
|
|
3430
|
+
},
|
|
3431
|
+
{
|
|
3432
|
+
id: 'planetscale.connector.refresh_check_cap', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3433
|
+
title: 'exhausting the missing-row check cap aborts before any observation',
|
|
3434
|
+
verify: () => withRoot(async (_q, root) => {
|
|
3435
|
+
const script = { 'SHOW TABLES': { rows: [{ t: 'items' }] },
|
|
3436
|
+
'DESCRIBE `items`': describeRows([['id', 'int', 'NO', 'PRI', null, '']]),
|
|
3437
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1 }, { id: 2 }] },
|
|
3438
|
+
'SELECT * FROM `items` WHERE `id` = 1 LIMIT 2': { rows: [] },
|
|
3439
|
+
'SELECT * FROM `items` WHERE `id` = 2 LIMIT 2': { rows: [] } };
|
|
3440
|
+
const { client, calls } = fakeClient(script);
|
|
3441
|
+
const opts = { root, budgetOptions: { path: join(root, 'cap-budget') } };
|
|
3442
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3443
|
+
const before = parentEntries('planetscale', root);
|
|
3444
|
+
script['SELECT * FROM `items` ORDER BY `id` LIMIT 500'] = { rows: [] };
|
|
3445
|
+
await assert.rejects(() => syncPlanetscaleFromReal(client, { ...opts, maxMissingChecks: 1 }), /check limit/);
|
|
3446
|
+
assert.equal(calls.filter(c => c.includes(' WHERE ')).length, 1);
|
|
3447
|
+
assert.deepEqual(parentEntries('planetscale', root), before);
|
|
3448
|
+
return true;
|
|
3449
|
+
}),
|
|
3450
|
+
},
|
|
3451
|
+
{
|
|
3452
|
+
id: 'planetscale.connector.partial_identity_transition', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3453
|
+
title: 'incomplete primary-key changes leave schema and rows untouched until a complete refresh',
|
|
3454
|
+
verify: () => withRoot(async (q, root) => {
|
|
3455
|
+
const script = { 'SHOW TABLES': { rows: [{ t: 'items' }] },
|
|
3456
|
+
'DESCRIBE `items`': describeRows([['id', 'int', 'NO', 'PRI', null, '']]),
|
|
3457
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1 }] } };
|
|
3458
|
+
const { client } = fakeClient(script);
|
|
3459
|
+
const opts = { root, budgetOptions: { path: join(root, 'partial-key-budget') } };
|
|
3460
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3461
|
+
const before = parentEntries('planetscale', root);
|
|
3462
|
+
script['DESCRIBE `items`'] = describeRows([['id', 'int', 'NO', '', null, ''], ['surrogate', 'int', 'NO', 'PRI', null, '']]);
|
|
3463
|
+
script['SELECT * FROM `items` ORDER BY `surrogate` LIMIT 1'] = { rows: [{ id: 1, surrogate: 10 }] };
|
|
3464
|
+
script['SELECT * FROM `items` ORDER BY `surrogate` LIMIT 500'] = { rows: [{ id: 1, surrogate: 10 }] };
|
|
3465
|
+
await assert.rejects(() => syncPlanetscaleFromReal(client, { ...opts, rowLimit: 1, maxPages: 1 }), /Incomplete PlanetScale primary-key change/);
|
|
3466
|
+
assert.deepEqual(parentEntries('planetscale', root), before);
|
|
3467
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).removed, 1);
|
|
3468
|
+
assert.deepEqual((await q('SELECT * FROM items')).rows, [['1', '10']]);
|
|
3469
|
+
return true;
|
|
3470
|
+
}),
|
|
3471
|
+
},
|
|
3472
|
+
{
|
|
3473
|
+
id: 'planetscale.connector.buffered_collision', area: 'connector', dimension: 'connector', tier: 'core', expected: 'done',
|
|
3474
|
+
title: 'COMMIT and implicit commit recheck buffered row identities after an intervening refresh',
|
|
3475
|
+
verify: async () => {
|
|
3476
|
+
for (const hidden of [false, true])
|
|
3477
|
+
for (const commit of ['COMMIT', 'BEGIN']) {
|
|
3478
|
+
const passed = await withRoot(async (q, root) => {
|
|
3479
|
+
const script = { 'SHOW TABLES': { rows: [{ t: 'notes' }] },
|
|
3480
|
+
'DESCRIBE `notes`': describeRows([['id', 'varchar(64)', 'NO', 'PRI', null, '']]),
|
|
3481
|
+
'SELECT * FROM `notes` ORDER BY `id` LIMIT 500': { rows: [] } };
|
|
3482
|
+
const { client } = fakeClient(script);
|
|
3483
|
+
const opts = { root, budgetOptions: { path: join(root, 'buffered-collision-budget') } };
|
|
3484
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3485
|
+
await q('BEGIN');
|
|
3486
|
+
await q("INSERT INTO notes VALUES ('customer-2205617487')");
|
|
3487
|
+
script['SELECT * FROM `notes` ORDER BY `id` LIMIT 500'] = { rows: [{ id: 'customer-2479052881' }] };
|
|
3488
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3489
|
+
// A local tombstone can hide upstream from the projected-row check. The independent
|
|
3490
|
+
// upstream check must still protect its occupied address at commit.
|
|
3491
|
+
if (hidden)
|
|
3492
|
+
assert(!((await q('DELETE FROM notes', { session: null })).error));
|
|
3493
|
+
const before = deployableEntries('planetscale', root).length;
|
|
3494
|
+
const result = await q(commit);
|
|
3495
|
+
assert(result.error?.message.includes('row identity hash collision'));
|
|
3496
|
+
assert.equal(deployableEntries('planetscale', root).length, before);
|
|
3497
|
+
await q('ROLLBACK');
|
|
3498
|
+
assert.deepEqual((await q('SELECT id FROM notes')).rows, hidden ? [] : [['customer-2479052881']]);
|
|
3499
|
+
return true;
|
|
3500
|
+
});
|
|
3501
|
+
if (!passed)
|
|
3502
|
+
return false;
|
|
3503
|
+
}
|
|
3504
|
+
return true;
|
|
3505
|
+
},
|
|
3506
|
+
},
|
|
3507
|
+
{
|
|
3508
|
+
id: 'planetscale.connector.key_column_case', area: 'connector', dimension: 'connector', tier: 'common', expected: 'done',
|
|
3509
|
+
title: 'case-only primary-key column renaming still permits confirmed row deletion',
|
|
3510
|
+
verify: () => withRoot(async (q, root) => {
|
|
3511
|
+
const script = { 'SHOW TABLES': { rows: [{ t: 'items' }] },
|
|
3512
|
+
'DESCRIBE `items`': describeRows([['id', 'int', 'NO', 'PRI', null, '']]),
|
|
3513
|
+
'SELECT * FROM `items` ORDER BY `id` LIMIT 500': { rows: [{ id: 1 }, { id: 2 }] } };
|
|
3514
|
+
const { client, calls } = fakeClient(script);
|
|
3515
|
+
const opts = { root, budgetOptions: { path: join(root, 'column-case-budget') } };
|
|
3516
|
+
await syncPlanetscaleFromReal(client, opts);
|
|
3517
|
+
script['DESCRIBE `items`'] = describeRows([['ID', 'int', 'NO', 'PRI', null, '']]);
|
|
3518
|
+
script['SELECT * FROM `items` ORDER BY `ID` LIMIT 500'] = { rows: [{ ID: 1 }] };
|
|
3519
|
+
script['SELECT * FROM `items` WHERE `ID` = 2 LIMIT 2'] = { rows: [] };
|
|
3520
|
+
assert.equal((await syncPlanetscaleFromReal(client, opts)).removed, 1);
|
|
3521
|
+
assert(calls.includes('SELECT * FROM `items` WHERE `ID` = 2 LIMIT 2'));
|
|
3522
|
+
assert.deepEqual((await q('SELECT ID FROM items')).rows, [['1']]);
|
|
3523
|
+
return true;
|
|
3524
|
+
}),
|
|
3525
|
+
},
|
|
3526
|
+
{
|
|
3527
|
+
id: 'planetscale.select.numeric_operand_types', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3528
|
+
title: 'comparisons of exact integers and decimals are exact, while a string against a number or a DOUBLE compares as DOUBLE',
|
|
3529
|
+
verify: () => withRoot(async (q) => {
|
|
3530
|
+
assert.deepEqual((await q("SELECT '9223372036854775807' = 9223372036854775806 FROM dual")).rows, [['1']]);
|
|
3531
|
+
assert.deepEqual((await q('SELECT 9223372036854775807 = 9223372036854775806 FROM dual')).rows, [['0']]);
|
|
3532
|
+
assert.deepEqual((await q('SELECT 9007199254740992.1 = 9007199254740992.2 FROM dual')).rows, [['0']]);
|
|
3533
|
+
assert.deepEqual((await q('SELECT .10000000000000001 = .1 FROM dual')).rows, [['0']]);
|
|
3534
|
+
assert.deepEqual((await q('SELECT -.10000000000000001 = -.1 FROM dual')).rows, [['0']]);
|
|
3535
|
+
assert.deepEqual((await q('SELECT 9007199254740993. = 9007199254740992. FROM dual')).rows, [['0']]);
|
|
3536
|
+
assert.deepEqual((await q('SELECT -9007199254740993 = -9007199254740992 FROM dual')).rows, [['0']]);
|
|
3537
|
+
await q('CREATE TABLE floating (n DOUBLE)');
|
|
3538
|
+
await q('INSERT INTO floating VALUES (9007199254740993)');
|
|
3539
|
+
assert.deepEqual((await q('SELECT n = 9007199254740992 FROM floating')).rows, [['1']]);
|
|
3540
|
+
return true;
|
|
3541
|
+
}),
|
|
3542
|
+
},
|
|
3543
|
+
{
|
|
3544
|
+
id: 'planetscale.transactions.buffered_identity_collision', area: 'transactions', dimension: 'api', tier: 'core', expected: 'done',
|
|
3545
|
+
title: 'buffered commit cannot overwrite a distinct key committed by another local session',
|
|
3546
|
+
verify: async () => {
|
|
3547
|
+
for (const bufferedPeer of [false, true])
|
|
3548
|
+
for (const commit of ['COMMIT', 'BEGIN']) {
|
|
3549
|
+
const passed = await withRoot(async (q, root) => {
|
|
3550
|
+
await q('CREATE TABLE notes (id VARCHAR(64) PRIMARY KEY)');
|
|
3551
|
+
await q('BEGIN');
|
|
3552
|
+
assert(!((await q("INSERT INTO notes VALUES ('customer-2205617487')")).error));
|
|
3553
|
+
const peer = bufferedPeer ? (await q('BEGIN', { session: null })).session : null;
|
|
3554
|
+
assert(!((await q("INSERT INTO notes VALUES ('customer-2479052881')", { session: peer })).error));
|
|
3555
|
+
if (bufferedPeer)
|
|
3556
|
+
assert(!((await q('COMMIT', { session: peer })).error));
|
|
3557
|
+
const before = deployableEntries('planetscale', root).length;
|
|
3558
|
+
const result = await q(commit);
|
|
3559
|
+
assert(result.error?.message.includes('row identity hash collision'));
|
|
3560
|
+
assert.equal(deployableEntries('planetscale', root).length, before);
|
|
3561
|
+
await q('ROLLBACK');
|
|
3562
|
+
assert.deepEqual((await q('SELECT id FROM notes')).rows, [['customer-2479052881']]);
|
|
3563
|
+
return true;
|
|
3564
|
+
});
|
|
3565
|
+
if (!passed)
|
|
3566
|
+
return false;
|
|
3567
|
+
}
|
|
3568
|
+
return true;
|
|
3569
|
+
},
|
|
3570
|
+
},
|
|
3571
|
+
todo('planetscale.connector.rowid_hash_collision', 'connector', "the residual after the §9 rowid rework: a pulled row's internal id is a 32-bit FNV-1a digest of its PRIMARY KEY values (plus an ordinal that disambiguates the identical rows a KEYLESS table may hold — §9 round two found that case merging deterministically, not probabilistically), shared by local keyed writes and refresh. Two DISTINCT rows can still collide at ~2^-32 per pair; local writes and pull now refuse such collisions before projecting anything. A wider digest removes this refusal", 'niche'),
|
|
3572
|
+
todo('planetscale.connector.push_type_normalization', 'connector', 'Deployment of values requiring integer/decimal rounding, padding, floating-point, temporal or JSON storage normalization needs a vendor-faithful codec; captured plans refuse those conversions before IO today', 'common'),
|
|
3573
|
+
todo('planetscale.connector.push_ddl', 'connector', "pushing a local SCHEMA change — PlanetScale schema changes go through its branch/deploy-request workflow, not an ad-hoc ALTER, so push refuses DDL by name today", 'common'),
|
|
3574
|
+
todo('planetscale.connector.auto_increment_watermark', 'connector', 'reading the real AUTO_INCREMENT counter (SHOW TABLE STATUS) instead of deriving a floor from the rows actually observed', 'common'),
|
|
3575
|
+
todo('planetscale.connector.pull_indexes', 'connector', 'pulling composite PRIMARY/UNIQUE keys and secondary indexes via SHOW INDEX (today only single-column keys are derived from DESCRIBE)', 'common'),
|
|
3576
|
+
{
|
|
3577
|
+
id: 'planetscale.auth.password_roles',
|
|
3578
|
+
area: 'auth', title: "a branch password's role is enforced: a reader cannot write, a writer cannot query, a readwriter cannot change schema (errno 1142, the password-roles page's words); a credential no password names acts as the World's own",
|
|
3579
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
3580
|
+
verify: async () => {
|
|
3581
|
+
const root = mkdtempSync(join(tmpdir(), 'planetscale-roles-'));
|
|
3582
|
+
try {
|
|
3583
|
+
const f = createPlanetscaleTwinFetch({ root, now: () => '2026-03-04T05:06:07.000Z' });
|
|
3584
|
+
const auth = { authorization: 'tokenid:pscale_tkn_twin', 'content-type': 'application/json' };
|
|
3585
|
+
const base = 'http://api.planetscale.com/v1/organizations/acme/databases/shop/branches/main/passwords';
|
|
3586
|
+
await f(new Request('http://api.planetscale.com/v1/organizations/acme/databases', { method: 'POST', headers: auth, body: JSON.stringify({ name: 'shop', cluster_size: 'PS_10' }) }));
|
|
3587
|
+
const pw = {};
|
|
3588
|
+
for (const role of ['reader', 'writer', 'readwriter', 'admin'])
|
|
3589
|
+
pw[role] = await (await f(new Request(base, { method: 'POST', headers: auth, body: JSON.stringify({ role }) }))).json();
|
|
3590
|
+
const exec = async (user, pass, query) => (await (await f(new Request('http://twin/psdb.v1alpha1.Database/Execute', { method: 'POST', headers: { authorization: `Basic ${btoa(`${user}:${pass}`)}` }, body: JSON.stringify({ query }) }))).json());
|
|
3591
|
+
const as = (role, query) => exec(pw[role].username, pw[role].plain_text, query);
|
|
3592
|
+
const ddl = await as('admin', 'CREATE TABLE customers (id INT PRIMARY KEY)');
|
|
3593
|
+
const rwDdl = await as('readwriter', 'CREATE TABLE other (id INT PRIMARY KEY)');
|
|
3594
|
+
const rwInsert = await as('readwriter', 'INSERT INTO customers VALUES (1)');
|
|
3595
|
+
const readerInsert = await as('reader', 'INSERT INTO customers VALUES (2)');
|
|
3596
|
+
const readerSelect = await as('reader', 'SELECT * FROM customers');
|
|
3597
|
+
const writerSelect = await as('writer', 'SELECT * FROM customers');
|
|
3598
|
+
const world = await exec('twin', 'twin', 'INSERT INTO customers VALUES (3)');
|
|
3599
|
+
return ddl.error === undefined && rwInsert.error === undefined && readerSelect.error === undefined && world.error === undefined
|
|
3600
|
+
&& rwDdl.error?.message === "DDL command denied to user 'planetscale-writer' for table 'other' (ACL check error) (errno 1142) (sqlstate 42000)"
|
|
3601
|
+
&& readerInsert.error?.message === "Insert command denied to user 'planetscale-reader' for table 'customers' (ACL check error) (errno 1142) (sqlstate 42000)"
|
|
3602
|
+
&& writerSelect.error?.message === "Select command denied to user 'planetscale-writer-only' for table 'customers' (ACL check error) (errno 1142) (sqlstate 42000)";
|
|
3603
|
+
}
|
|
3604
|
+
finally {
|
|
3605
|
+
rmSync(root, { recursive: true, force: true });
|
|
3606
|
+
}
|
|
3607
|
+
},
|
|
3608
|
+
},
|
|
3609
|
+
// ── management API (api.planetscale.com — the control plane; a branch's backups modeled) ────────
|
|
3610
|
+
todo('planetscale.management.list_databases', 'management', "GET /v1/organizations/{org}/databases — list databases, and a database's settings (create, read and delete are served)", 'core'),
|
|
3611
|
+
todo('planetscale.management.branches', 'management', 'branch create/list/delete and branch promotion', 'core'),
|
|
3612
|
+
todo('planetscale.management.deploy_requests', 'management', 'deploy requests: open, diff, deploy, revert', 'core'),
|
|
3613
|
+
todo('planetscale.management.schema_diff', 'management', 'GET /branches/{branch}/schema and the branch schema diff', 'common'),
|
|
3614
|
+
{
|
|
3615
|
+
id: 'planetscale.management.backups',
|
|
3616
|
+
area: 'management', title: "a branch's backups through the management API: create (201, pending until the World clock runs and completes it; retention validated, emergency 422 for MySQL), list with its filters, read (a deleted one too), delete; service-token auth; other management paths refused 404; never pushed to a real branch",
|
|
3617
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
3618
|
+
verify: async () => {
|
|
3619
|
+
const root = mkdtempSync(join(tmpdir(), 'planetscale-mgmt-'));
|
|
3620
|
+
try {
|
|
3621
|
+
const f = createPlanetscaleTwinFetch({ root, now: () => '2026-03-04T05:06:07.000Z' });
|
|
3622
|
+
const base = 'http://api.planetscale.com/v1/organizations/dub/databases/dub/branches/main/backups';
|
|
3623
|
+
const auth = { authorization: 'tokenid:pscale_tkn_twin', 'content-type': 'application/json' };
|
|
3624
|
+
// Dub's backup cron, verbatim in shape (apps/web/app/(ee)/api/cron/trigger-backup/route.ts)
|
|
3625
|
+
const made = await f(new Request(base, { method: 'POST', headers: auth, body: JSON.stringify({ name: 'cron-backup-1', retention_value: 7, retention_unit: 'day' }) }));
|
|
3626
|
+
const backup = await made.json();
|
|
3627
|
+
const listed = await (await f(new Request(base, { headers: auth }))).json();
|
|
3628
|
+
const read = await f(new Request(`${base}/${String(backup.id)}`, { headers: auth }));
|
|
3629
|
+
const deleted = await f(new Request(`${base}/${String(backup.id)}`, { method: 'DELETE', headers: auth }));
|
|
3630
|
+
const after = await (await f(new Request(base, { headers: auth }))).json();
|
|
3631
|
+
const unauth = await f(new Request(base, { method: 'POST', body: '{}' }));
|
|
3632
|
+
const tooLong = await f(new Request(base, { method: 'POST', headers: auth, body: JSON.stringify({ retention_value: 5000 }) }));
|
|
3633
|
+
const other = await f(new Request('http://api.planetscale.com/v1/organizations/dub/databases', { headers: auth }));
|
|
3634
|
+
const emergency = await f(new Request(base, { method: 'POST', headers: auth, body: JSON.stringify({ emergency: true }) }));
|
|
3635
|
+
await f(new Request(base, { method: 'POST', headers: auth, body: JSON.stringify({ name: 'cron-backup-2' }) }));
|
|
3636
|
+
const later = await (await f(new Request(`${base}?from=2026-03-04T05:06:07.000Z`, { headers: auth }))).json();
|
|
3637
|
+
const earlier = await (await f(new Request(`${base}?to=2026-03-04T05:06:07.000Z`, { headers: auth }))).json();
|
|
3638
|
+
const kept = await (await f(new Request(`${base}?from=2026-03-04T05:00:00.000Z&to=2026-03-04T06:00:00.000Z`, { headers: auth }))).json();
|
|
3639
|
+
const withDeleted = await (await f(new Request(`${base}?all=true`, { headers: auth }))).json();
|
|
3640
|
+
const deletedRead = await f(new Request(`${base}/${String(backup.id)}`, { headers: auth }));
|
|
3641
|
+
// a backup is the twin's bookkeeping: nothing of it is pushed to a real branch
|
|
3642
|
+
const pushable = deployableEntries('planetscale', root).filter((a) => a.subject.type.includes('backup'));
|
|
3643
|
+
// a new backup is pending until the World clock runs and completes it; the clock here stands still
|
|
3644
|
+
return made.status === 201 && backup.name === 'cron-backup-1' && backup.state === 'pending' && backup.expires_at === null
|
|
3645
|
+
&& listed.type === 'list' && listed.total_count === 1 && listed.data[0]?.id === backup.id && read.status === 200
|
|
3646
|
+
&& deleted.status === 204 && after.total_count === 0 && unauth.status === 401 && tooLong.status === 422 && other.status === 404
|
|
3647
|
+
&& emergency.status === 422 && later.total_count === 0 && earlier.total_count === 0 && kept.total_count === 0 && withDeleted.total_count === 2
|
|
3648
|
+
&& deletedRead.status === 200 && pushable.length === 0;
|
|
3649
|
+
}
|
|
3650
|
+
finally {
|
|
3651
|
+
rmSync(root, { recursive: true, force: true });
|
|
3652
|
+
}
|
|
3653
|
+
},
|
|
3654
|
+
},
|
|
3655
|
+
{
|
|
3656
|
+
id: 'planetscale.management.passwords',
|
|
3657
|
+
area: 'management', title: "a branch's passwords through the management API: create with a role (admin when none is sent: the reference marks none required and gives no default), plain_text shown once; list, delete; psdb authenticates them, refuses a deleted one, and a deleted database takes its passwords with it",
|
|
3658
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
3659
|
+
verify: async () => {
|
|
3660
|
+
const root = mkdtempSync(join(tmpdir(), 'planetscale-pw-'));
|
|
3661
|
+
try {
|
|
3662
|
+
const f = createPlanetscaleTwinFetch({ root, now: () => '2026-03-04T05:06:07.000Z' });
|
|
3663
|
+
const auth = { authorization: 'tokenid:pscale_tkn_twin', 'content-type': 'application/json' };
|
|
3664
|
+
const org = 'http://api.planetscale.com/v1/organizations/acme';
|
|
3665
|
+
const exec = async (user, pass) => (await f(new Request('http://twin/psdb.v1alpha1.Database/Execute', { method: 'POST', headers: { authorization: `Basic ${btoa(`${user}:${pass}`)}` }, body: JSON.stringify({ query: 'SELECT 1' }) }))).status;
|
|
3666
|
+
await f(new Request(`${org}/databases`, { method: 'POST', headers: auth, body: JSON.stringify({ name: 'shop', cluster_size: 'PS_10' }) }));
|
|
3667
|
+
const made = await f(new Request(`${org}/databases/shop/branches/main/passwords`, { method: 'POST', headers: auth, body: JSON.stringify({ name: 'app', role: 'readwriter' }) }));
|
|
3668
|
+
const pw = await made.json();
|
|
3669
|
+
const listed = await (await f(new Request(`${org}/databases/shop/branches/main/passwords`, { headers: auth }))).json();
|
|
3670
|
+
const works = await exec(pw.username, pw.plain_text);
|
|
3671
|
+
const wrong = await exec(pw.username, 'pscale_pw_wrong');
|
|
3672
|
+
const deleted = await f(new Request(`${org}/databases/shop/branches/main/passwords/${pw.id}`, { method: 'DELETE', headers: auth }));
|
|
3673
|
+
const afterDelete = await exec(pw.username, pw.plain_text);
|
|
3674
|
+
const second = await (await f(new Request(`${org}/databases/shop/branches/main/passwords`, { method: 'POST', headers: auth, body: JSON.stringify({ role: 'admin' }) }))).json();
|
|
3675
|
+
await f(new Request(`${org}/databases/shop`, { method: 'DELETE', headers: auth }));
|
|
3676
|
+
const afterDrop = await exec(second.username, second.plain_text);
|
|
3677
|
+
return made.status === 201 && pw.role === 'readwriter' && pw.plain_text.startsWith('pscale_pw_') && listed.data.length === 1 && listed.data[0].plain_text === null
|
|
3678
|
+
&& works === 200 && wrong === 401 && deleted.status === 204 && afterDelete === 401 && afterDrop === 401;
|
|
3679
|
+
}
|
|
3680
|
+
finally {
|
|
3681
|
+
rmSync(root, { recursive: true, force: true });
|
|
3682
|
+
}
|
|
3683
|
+
},
|
|
3684
|
+
},
|
|
3685
|
+
todo('planetscale.management.backup_restore', 'management', 'restore a backup to a new branch', 'common'),
|
|
3686
|
+
todo('planetscale.management.insights', 'management', 'query insights / performance metrics', 'niche'),
|
|
3687
|
+
todo('planetscale.management.oauth', 'management', 'the OAuth application flow for PlanetScale API access', 'niche'),
|
|
3688
|
+
todo('planetscale.management.webhooks', 'management', 'organization webhooks for deploy-request and branch events', 'niche'),
|
|
3689
|
+
// ── the MySQL type system (§9 engine review: the done checks used Dub's shapes; these use the
|
|
3690
|
+
// neighbouring ones the review found wrong) ──────────────────────────────────────────────
|
|
3691
|
+
{
|
|
3692
|
+
id: 'planetscale.select.expression_comparison_types', area: 'select', dimension: 'api', tier: 'core', expected: 'done',
|
|
3693
|
+
title: 'an expression compares and sorts by its MySQL type: COALESCE/CASE/IF/GREATEST, scalar subqueries and arithmetic over numbers are numeric',
|
|
3694
|
+
verify: () => withRoot(async (q) => {
|
|
3695
|
+
await q('CREATE TABLE Score (id INT PRIMARY KEY, pts INT, bonus INT)');
|
|
3696
|
+
await q('INSERT INTO Score VALUES (1, 9, 1), (2, 10, 1), (3, NULL, 100)');
|
|
3697
|
+
await q('CREATE TABLE Hit (id INT PRIMARY KEY, sid INT)');
|
|
3698
|
+
await q('INSERT INTO Hit VALUES (1, 1), (2, 1), (3, 2)');
|
|
3699
|
+
const ids = async (sql) => (await q(sql)).rows.map((r) => r[0]).join(',');
|
|
3700
|
+
return await ids('SELECT id FROM Score ORDER BY COALESCE(pts, 0) DESC') === '2,1,3'
|
|
3701
|
+
&& await ids('SELECT id FROM Score ORDER BY CASE WHEN id = 1 THEN 10 ELSE 9 END') === '2,3,1'
|
|
3702
|
+
&& await ids('SELECT id FROM Score ORDER BY GREATEST(pts, bonus)') === '3,1,2'
|
|
3703
|
+
&& await ids('SELECT s.id FROM Score s ORDER BY (SELECT COUNT(*) FROM Hit h WHERE h.sid = s.id) DESC, s.id') === '1,2,3'
|
|
3704
|
+
&& await ids('SELECT id FROM Score WHERE (SELECT COUNT(*) FROM Hit h WHERE h.sid = Score.id) > (SELECT 1 FROM dual)') === '1'
|
|
3705
|
+
&& await ids('SELECT id FROM Score WHERE IFNULL(pts, 0) + 0 > 9') === '2';
|
|
3706
|
+
}),
|
|
3707
|
+
},
|
|
3708
|
+
{
|
|
3709
|
+
id: 'planetscale.select.temporal_values', area: 'select', dimension: 'api', tier: 'core', expected: 'done',
|
|
3710
|
+
title: 'DATE/DATETIME/TIMESTAMP/TIME are stored canonically at their fsp from any literal form MySQL accepts (the `T` form @planetscale/database sends included) and compare as points in time',
|
|
3711
|
+
verify: () => withRoot(async (q) => {
|
|
3712
|
+
await q('CREATE TABLE Ev (id INT PRIMARY KEY, at DATETIME(3) NOT NULL, day DATE, t TIME)');
|
|
3713
|
+
await q("INSERT INTO Ev VALUES (1, '2024-01-01 23:00:00.000', '2024-01-01', '9:05'), (2, '2024-01-02T01:00:00.0005', 20240102, '101500')");
|
|
3714
|
+
const since = await q("SELECT id FROM Ev WHERE at >= '2024-01-01T00:00:00.000' ORDER BY id");
|
|
3715
|
+
const day = await q("SELECT id FROM Ev WHERE day = '2024-01-01 00:00:00'");
|
|
3716
|
+
const read = await q('SELECT at, day, t FROM Ev ORDER BY at');
|
|
3717
|
+
const bad = await q("INSERT INTO Ev (id, at) VALUES (3, '2024-02-30 00:00:00')");
|
|
3718
|
+
const zero = await q("INSERT INTO Ev (id, at) VALUES (4, '0000-00-00 00:00:00')");
|
|
3719
|
+
return since.rows.map((r) => r[0]).join(',') === '1,2' && JSON.stringify(day.rows) === '[["1"]]'
|
|
3720
|
+
&& JSON.stringify(read.rows) === JSON.stringify([['2024-01-01 23:00:00.000', '2024-01-01', '09:05:00'], ['2024-01-02 01:00:00.001', '2024-01-02', '10:15:00']])
|
|
3721
|
+
&& failed(bad, 'INVALID_ARGUMENT', "Incorrect datetime value: '2024-02-30 00:00:00' for column 'at' at row 1") && bad.error.message.includes('1292')
|
|
3722
|
+
&& failed(zero, 'INVALID_ARGUMENT', 'Incorrect datetime value');
|
|
3723
|
+
}),
|
|
3724
|
+
},
|
|
3725
|
+
{
|
|
3726
|
+
id: 'planetscale.functions.exact_arithmetic', area: 'functions', dimension: 'api', tier: 'core', expected: 'done',
|
|
3727
|
+
title: 'exact-value arithmetic stays exact (manual 14.24 "Precision Math"): DECIMAL scales, BIGINTs beyond 2^53, rounding on store, errno 1690 on overflow, 1365 for a division by zero written to a row',
|
|
3728
|
+
verify: () => withRoot(async (q) => {
|
|
3729
|
+
await q('CREATE TABLE Wallet (id BIGINT PRIMARY KEY, bal DECIMAL(10,2) NOT NULL)');
|
|
3730
|
+
await q('INSERT INTO Wallet VALUES (9007199254740993, 0.1)');
|
|
3731
|
+
await q('UPDATE Wallet SET bal = bal + 0.2');
|
|
3732
|
+
const read = await q('SELECT bal, bal * 3, bal / 3, id + 1, id DIV 2 FROM Wallet');
|
|
3733
|
+
const overflow = await q('SELECT 9223372036854775807 + 1 FROM dual');
|
|
3734
|
+
const divWrite = await q('UPDATE Wallet SET bal = 1 / 0');
|
|
3735
|
+
const divRead = await q('SELECT 1 / 0, 5 % 0 FROM dual');
|
|
3736
|
+
return JSON.stringify(read.rows[0]) === JSON.stringify(['0.30', '0.90', '0.100000', '9007199254740994', '4503599627370496'])
|
|
3737
|
+
&& read.fields.map((f) => f.type).join(',') === 'DECIMAL,DECIMAL,DECIMAL,INT64,INT64'
|
|
3738
|
+
&& failed(overflow, 'INVALID_ARGUMENT', "BIGINT value is out of range in '(9223372036854775807 + 1)'")
|
|
3739
|
+
&& failed(divWrite, 'INVALID_ARGUMENT', 'Division by 0') && JSON.stringify(divRead.rows) === '[[null,null]]';
|
|
3740
|
+
}),
|
|
3741
|
+
},
|
|
3742
|
+
{
|
|
3743
|
+
id: 'planetscale.insert.strict_type_conversion', area: 'insert', dimension: 'api', tier: 'core', expected: 'done',
|
|
3744
|
+
title: "a written value is converted to its column's type under MySQL 8's default strict sql_mode: 1366 for a non-number, 1265 for a partial one, 1292 for a bad temporal, 3140 for bad JSON, 1048 for NULL",
|
|
3745
|
+
verify: () => withRoot(async (q) => {
|
|
3746
|
+
await q('CREATE TABLE Val (id INT PRIMARY KEY, n INT NOT NULL DEFAULT 0, d DECIMAL(6,2), f DOUBLE, j JSON, at DATETIME)');
|
|
3747
|
+
const word = await q("INSERT INTO Val (id, n) VALUES (1, 'abc')");
|
|
3748
|
+
const partial = await q("INSERT INTO Val (id, d) VALUES (2, '12abc')");
|
|
3749
|
+
const temporal = await q("INSERT INTO Val (id, at) VALUES (3, 'garbage')");
|
|
3750
|
+
const json = await q("INSERT INTO Val (id, j) VALUES (4, '{bad')");
|
|
3751
|
+
const nul = await q('INSERT INTO Val (id, n) VALUES (5, NULL)');
|
|
3752
|
+
const converted = await q("INSERT INTO Val (id, n, d, f, at) VALUES (6, 1.7, 1.5, '2.50', 20240102030405)");
|
|
3753
|
+
const ignored = await q("INSERT IGNORE INTO Val (id, n) VALUES (7, NULL)");
|
|
3754
|
+
const rows = await q('SELECT n, d, f, at FROM Val ORDER BY id');
|
|
3755
|
+
return failed(word, 'INVALID_ARGUMENT', "Incorrect integer value: 'abc' for column 'n' at row 1") && word.error.message.includes('1366')
|
|
3756
|
+
&& failed(partial, 'INVALID_ARGUMENT', "Data truncated for column 'd' at row 1")
|
|
3757
|
+
&& failed(temporal, 'INVALID_ARGUMENT', "Incorrect datetime value: 'garbage' for column 'at' at row 1")
|
|
3758
|
+
&& failed(json, 'INVALID_ARGUMENT', "for column 'Val.j'") && json.error.message.includes('3140')
|
|
3759
|
+
&& failed(nul, 'INVALID_ARGUMENT', "Column 'n' cannot be null") && ok(converted) && ok(ignored)
|
|
3760
|
+
&& JSON.stringify(rows.rows) === JSON.stringify([['2', '1.50', '2.5', '2024-01-02 03:04:05'], ['0', null, null, null]]);
|
|
3761
|
+
}),
|
|
3762
|
+
},
|
|
3763
|
+
{
|
|
3764
|
+
id: 'planetscale.select.json_comparison', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3765
|
+
title: 'a JSON value compares with a non-JSON one as JSON (manual 13.5): `j->\'$.name\' = \'bob\'` matches a JSON string, JSON 2 is not the string \'2\', 1 equals 1.0',
|
|
3766
|
+
verify: () => withRoot(async (q) => {
|
|
3767
|
+
await q('CREATE TABLE Person (id INT PRIMARY KEY, j JSON)');
|
|
3768
|
+
await q(`INSERT INTO Person VALUES (1, '{"name":"bob","n":1,"tags":["a","b"]}'), (2, '{"name":"al","n":2.0}')`);
|
|
3769
|
+
const ids = async (w) => (await q(`SELECT id FROM Person WHERE ${w} ORDER BY id`)).rows.map((r) => r[0]).join(',');
|
|
3770
|
+
return await ids("j->'$.name' = 'bob'") === '1' && await ids("j->'$.n' = '2'") === '' && await ids("j->'$.n' = 2") === '2'
|
|
3771
|
+
&& await ids("j->'$.tags[0]' = 'a'") === '1' && await ids("'b' MEMBER OF (j->'$.tags')") === '1'
|
|
3772
|
+
&& await ids("JSON_EXTRACT(j, '$.tags[last-1]') = 'a'") === '1';
|
|
3773
|
+
}),
|
|
3774
|
+
},
|
|
3775
|
+
{
|
|
3776
|
+
id: 'planetscale.select.bigint_exact_keys', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3777
|
+
title: 'BIGINTs beyond 2^53 stay distinct in DISTINCT, GROUP BY and COUNT(DISTINCT), and JSON integers stay exact in JSON_EXTRACT and JSON_CONTAINS',
|
|
3778
|
+
verify: () => withRoot(async (q) => {
|
|
3779
|
+
await q('CREATE TABLE Snow (id INT PRIMARY KEY, uid BIGINT NOT NULL, j JSON)');
|
|
3780
|
+
await q(`INSERT INTO Snow VALUES (1, 1234567890123456789, '{"id": 1234567890123456789}'), (2, 1234567890123456790, '{}'), (3, 1234567890123456789, '{}')`);
|
|
3781
|
+
const distinct = await q('SELECT DISTINCT uid FROM Snow ORDER BY uid');
|
|
3782
|
+
const groups = await q('SELECT uid, COUNT(*) FROM Snow GROUP BY uid ORDER BY uid');
|
|
3783
|
+
const count = await q('SELECT COUNT(DISTINCT uid) FROM Snow');
|
|
3784
|
+
const json = await q("SELECT j->>'$.id', JSON_CONTAINS(j, '1234567890123456788', '$.id') FROM Snow WHERE id = 1");
|
|
3785
|
+
return distinct.rows.length === 2 && JSON.stringify(groups.rows) === JSON.stringify([['1234567890123456789', '2'], ['1234567890123456790', '1']])
|
|
3786
|
+
&& count.rows[0][0] === '2' && JSON.stringify(json.rows[0]) === JSON.stringify(['1234567890123456789', '0']);
|
|
3787
|
+
}),
|
|
3788
|
+
},
|
|
3789
|
+
{
|
|
3790
|
+
id: 'planetscale.select.is_boolean', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3791
|
+
title: 'IS [NOT] TRUE / FALSE / UNKNOWN test truthiness and never return NULL (manual 14.4.2)',
|
|
3792
|
+
verify: () => withRoot(async (q) => {
|
|
3793
|
+
await q('CREATE TABLE Flag (id INT PRIMARY KEY, on_ BOOLEAN)');
|
|
3794
|
+
await q('INSERT INTO Flag VALUES (1, 1), (2, NULL), (3, 0), (4, 2)');
|
|
3795
|
+
const ids = async (w) => (await q(`SELECT id FROM Flag WHERE ${w} ORDER BY id`)).rows.map((r) => r[0]).join(',');
|
|
3796
|
+
const consts = await q('SELECT 2 IS TRUE, NULL IS TRUE, NULL IS NOT TRUE, NULL IS NOT FALSE, NULL IS UNKNOWN FROM dual');
|
|
3797
|
+
return await ids('on_ IS TRUE') === '1,4' && await ids('on_ IS NOT TRUE') === '2,3' && await ids('on_ IS FALSE') === '3'
|
|
3798
|
+
&& await ids('on_ IS UNKNOWN') === '2' && JSON.stringify(consts.rows[0]) === JSON.stringify(['1', '0', '1', '1', '1']);
|
|
3799
|
+
}),
|
|
3800
|
+
},
|
|
3801
|
+
{
|
|
3802
|
+
id: 'planetscale.select.sql_mode_refusals', area: 'select', dimension: 'api', tier: 'core', expected: 'done',
|
|
3803
|
+
title: "MySQL 8's default sql_mode refusals: ONLY_FULL_GROUP_BY (1055/1140, with functional dependence on keys and equalities), DISTINCT + ORDER BY (3065), LIMIT in an IN subquery (1235), a subquery on the updated table (1093); @@sql_mode reports it",
|
|
3804
|
+
verify: () => withRoot(async (q) => {
|
|
3805
|
+
await q('CREATE TABLE Team (id INT PRIMARY KEY, name VARCHAR(20), city VARCHAR(20))');
|
|
3806
|
+
await q('CREATE TABLE Member (id INT PRIMARY KEY, team INT, score INT)');
|
|
3807
|
+
await q("INSERT INTO Team VALUES (1, 'a', 'x'), (2, 'b', 'y')");
|
|
3808
|
+
await q('INSERT INTO Member VALUES (1, 1, 5), (2, 1, 7), (3, 2, 1)');
|
|
3809
|
+
const nonGrouped = await q('SELECT city, COUNT(*) FROM Team GROUP BY name');
|
|
3810
|
+
const noGroupBy = await q('SELECT name, COUNT(*) FROM Team');
|
|
3811
|
+
const byKey = await q('SELECT t.*, SUM(m.score) FROM Team t JOIN Member m ON m.team = t.id GROUP BY t.id ORDER BY t.id');
|
|
3812
|
+
const byEquality = await q("SELECT name, COUNT(*) FROM Team WHERE name = 'a'");
|
|
3813
|
+
const distinctOrder = await q('SELECT DISTINCT name FROM Team ORDER BY city');
|
|
3814
|
+
const limitIn = await q('SELECT id FROM Team WHERE id IN (SELECT team FROM Member LIMIT 1)');
|
|
3815
|
+
const selfUpdate = await q('DELETE FROM Member WHERE id IN (SELECT id FROM Member WHERE score > 5)');
|
|
3816
|
+
const derived = await q('DELETE FROM Member WHERE id IN (SELECT id FROM (SELECT id FROM Member WHERE score > 5) x)');
|
|
3817
|
+
const mode = await q('SELECT @@sql_mode FROM dual');
|
|
3818
|
+
return failed(nonGrouped, 'INVALID_ARGUMENT', "Expression #1 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'twin.Team.city'")
|
|
3819
|
+
&& nonGrouped.error.message.includes('1055')
|
|
3820
|
+
&& failed(noGroupBy, 'INVALID_ARGUMENT', 'In aggregated query without GROUP BY') && noGroupBy.error.message.includes('1140')
|
|
3821
|
+
&& JSON.stringify(byKey.rows) === JSON.stringify([['1', 'a', 'x', '12'], ['2', 'b', 'y', '1']]) && ok(byEquality)
|
|
3822
|
+
&& failed(distinctOrder, 'INVALID_ARGUMENT', 'incompatible with DISTINCT') && distinctOrder.error.message.includes('3065')
|
|
3823
|
+
&& limitIn.error?.message.includes("doesn't yet support 'LIMIT & IN/ALL/ANY/SOME subquery'") === true
|
|
3824
|
+
&& failed(selfUpdate, 'INVALID_ARGUMENT', "You can't specify target table") && selfUpdate.error.message.includes('1093')
|
|
3825
|
+
&& ok(derived) && derived.rowsAffected === 1
|
|
3826
|
+
&& mode.rows[0][0] === 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
|
|
3827
|
+
}),
|
|
3828
|
+
},
|
|
3829
|
+
{
|
|
3830
|
+
id: 'planetscale.select.mysql_type_conversion', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3831
|
+
title: "mixed comparisons follow manual 14.3: 'abc' = 0 and '1abc' = 1 are true, 'abc' + 1 is 1; NOT BETWEEN with a NULL bound; 1242 only when a subquery is evaluated; GROUP_CONCAT cut at 1024 bytes",
|
|
3832
|
+
verify: () => withRoot(async (q) => {
|
|
3833
|
+
await q('CREATE TABLE Word (id INT PRIMARY KEY, w VARCHAR(600))');
|
|
3834
|
+
await q(`INSERT INTO Word VALUES (1, '${'x'.repeat(600)}'), (2, '${'y'.repeat(600)}')`);
|
|
3835
|
+
const mixed = await q("SELECT 'abc' = 0, '1abc' = 1, 'abc' + 1, '10' > '9', 10 > '9', 10 NOT BETWEEN NULL AND 5 FROM dual");
|
|
3836
|
+
const lazy = await q('SELECT (SELECT id FROM Word) FROM Word WHERE 1 = 0');
|
|
3837
|
+
const eager = await q('SELECT (SELECT id FROM Word) FROM Word');
|
|
3838
|
+
const concat = await q('SELECT LENGTH(GROUP_CONCAT(w)) FROM Word');
|
|
3839
|
+
return JSON.stringify(mixed.rows[0]) === JSON.stringify(['1', '1', '1', '0', '1', '1'])
|
|
3840
|
+
&& ok(lazy) && lazy.rows.length === 0 && failed(eager, 'INVALID_ARGUMENT', 'Subquery returns more than 1 row')
|
|
3841
|
+
&& concat.rows[0][0] === '1024';
|
|
3842
|
+
}),
|
|
3843
|
+
},
|
|
3844
|
+
{
|
|
3845
|
+
id: 'planetscale.select.operator_forms', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3846
|
+
title: "MySQL's other operator forms: `!`, XOR, the bit operators, MEMBER OF, LIKE … ESCAPE, JSON path ranges and `last`; JSON_ARRAYAGG(DISTINCT) and a bare `$**` are MySQL's errors",
|
|
3847
|
+
verify: () => withRoot(async (q) => {
|
|
3848
|
+
const r = await q("SELECT !1, 1 XOR 1, 5 & 3, 5 | 3, 5 ^ 3, 1 << 3, ~0, 'a_c' LIKE 'a!_c' ESCAPE '!', 2 MEMBER OF ('[1, 2]'), JSON_EXTRACT('[10, 20, 30]', '$[1 to last]'), JSON_EXTRACT('[10, 20, 30]', '$[last-1]') FROM dual");
|
|
3849
|
+
const agg = await q('SELECT JSON_ARRAYAGG(DISTINCT 1) FROM dual');
|
|
3850
|
+
const path = await q("SELECT JSON_EXTRACT('[1]', '$**') FROM dual");
|
|
3851
|
+
return JSON.stringify(r.rows[0]) === JSON.stringify(['0', '0', '1', '7', '6', '8', '18446744073709551615', '1', '1', '[20, 30]', '20'])
|
|
3852
|
+
&& failed(agg, 'INVALID_ARGUMENT', 'syntax error') && failed(path, 'INVALID_ARGUMENT', 'Invalid JSON path expression');
|
|
3853
|
+
}),
|
|
3854
|
+
},
|
|
3855
|
+
{
|
|
3856
|
+
id: 'planetscale.select.information_schema_name_case', area: 'select', dimension: 'api', tier: 'common', expected: 'done',
|
|
3857
|
+
title: 'INFORMATION_SCHEMA name columns compare case-insensitively, as the engine resolves table names (lower_case_table_names = 2)',
|
|
3858
|
+
verify: () => withRoot(async (q) => {
|
|
3859
|
+
await q('CREATE TABLE Mixed (Id INT PRIMARY KEY)');
|
|
3860
|
+
const upper = await q("SELECT TABLE_NAME, COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_NAME = 'MIXED' AND COLUMN_NAME = 'id'");
|
|
3861
|
+
const read = await q('SELECT id FROM MIXED');
|
|
3862
|
+
const lcn = await q('SELECT @@lower_case_table_names FROM dual');
|
|
3863
|
+
return JSON.stringify(upper.rows) === JSON.stringify([['Mixed', 'Id']]) && ok(read) && lcn.rows[0][0] === '2';
|
|
3864
|
+
}),
|
|
3865
|
+
},
|
|
3866
|
+
{
|
|
3867
|
+
id: 'planetscale.transactions.access_mode', area: 'transactions', dimension: 'api', tier: 'common', expected: 'done',
|
|
3868
|
+
title: 'SET [SESSION] TRANSACTION READ ONLY and START TRANSACTION READ ONLY are honoured (a write is errno 1792); SET TRANSACTION inside a transaction is 1568',
|
|
3869
|
+
verify: () => withRoot(async (q) => {
|
|
3870
|
+
await q('CREATE TABLE Ledger (id INT PRIMARY KEY)');
|
|
3871
|
+
await q('SET TRANSACTION READ ONLY');
|
|
3872
|
+
const nextOnly = await q('INSERT INTO Ledger VALUES (1)');
|
|
3873
|
+
const spent = await q('INSERT INTO Ledger VALUES (2)');
|
|
3874
|
+
await q('START TRANSACTION READ ONLY');
|
|
3875
|
+
const read = await q('SELECT id FROM Ledger');
|
|
3876
|
+
const inRo = await q('DELETE FROM Ledger');
|
|
3877
|
+
const change = await q('SET TRANSACTION READ WRITE');
|
|
3878
|
+
await q('ROLLBACK');
|
|
3879
|
+
await q('SET SESSION TRANSACTION READ ONLY');
|
|
3880
|
+
const session = await q('INSERT INTO Ledger VALUES (3)');
|
|
3881
|
+
await q('SET SESSION TRANSACTION READ WRITE');
|
|
3882
|
+
const back = await q('INSERT INTO Ledger VALUES (3)');
|
|
3883
|
+
return failed(nextOnly, 'INVALID_ARGUMENT', 'Cannot execute statement in a READ ONLY transaction') && nextOnly.error.message.includes('1792')
|
|
3884
|
+
&& ok(spent) && ok(read) && failed(inRo, 'INVALID_ARGUMENT', 'READ ONLY transaction')
|
|
3885
|
+
&& failed(change, 'FAILED_PRECONDITION', "Transaction characteristics can't be changed while a transaction is in progress")
|
|
3886
|
+
&& failed(session, 'INVALID_ARGUMENT', 'READ ONLY transaction') && ok(back);
|
|
3887
|
+
}),
|
|
3888
|
+
},
|
|
3889
|
+
{
|
|
3890
|
+
id: 'planetscale.transactions.lost_on_restart', area: 'transactions', dimension: 'api', tier: 'core', expected: 'done',
|
|
3891
|
+
title: 'a transaction left open by a process that is gone is rolled back, never committed over rows other sessions wrote once its locks were gone',
|
|
3892
|
+
verify: async () => {
|
|
3893
|
+
// The row locks live in the serving process. Serving a copy of the root reproduces a restart:
|
|
3894
|
+
// the stored session is still in its transaction, but no lock of it survives.
|
|
3895
|
+
const root = mkdtempSync(join(tmpdir(), 'planetscale-cap-'));
|
|
3896
|
+
const restarted = mkdtempSync(join(tmpdir(), 'planetscale-cap-'));
|
|
3897
|
+
try {
|
|
3898
|
+
const exec = async (at, sql, sid) => executeSql({ root: at === 'a' ? root : restarted, occurredAt: AT }, sql, sid);
|
|
3899
|
+
const first = await exec('a', 'CREATE TABLE Stock (id INT PRIMARY KEY, n INT)', null);
|
|
3900
|
+
await exec('a', 'INSERT INTO Stock VALUES (1, 1)', first.session.id);
|
|
3901
|
+
await exec('a', 'BEGIN', first.session.id);
|
|
3902
|
+
await exec('a', 'UPDATE Stock SET n = 100 WHERE id = 1', first.session.id);
|
|
3903
|
+
cpSync(root, restarted, { recursive: true });
|
|
3904
|
+
const other = await exec('b', 'UPDATE Stock SET n = 7 WHERE id = 1', null);
|
|
3905
|
+
const commit = await exec('b', 'COMMIT', first.session.id).then(() => null, (e) => e);
|
|
3906
|
+
const after = await exec('b', 'SELECT n FROM Stock', other.session.id);
|
|
3907
|
+
return commit instanceof SqlError && commit.errno === 2013 && JSON.stringify(after.outcome.rows) === '[["7"]]';
|
|
3908
|
+
}
|
|
3909
|
+
finally {
|
|
3910
|
+
rmSync(root, { recursive: true, force: true });
|
|
3911
|
+
rmSync(restarted, { recursive: true, force: true });
|
|
3912
|
+
}
|
|
3913
|
+
},
|
|
3914
|
+
},
|
|
3915
|
+
// ── conformance ─────────────────────────────────────────────────────────────────────────────
|
|
3916
|
+
{
|
|
3917
|
+
id: 'planetscale.conformance.endpoint_probe',
|
|
3918
|
+
area: 'conformance', title: 'every claimed endpoint is probed with a real request and graded on its OUTCOME',
|
|
3919
|
+
dimension: 'api', tier: 'common', expected: 'done',
|
|
3920
|
+
verify: async () => {
|
|
3921
|
+
const report = await checkPlanetscaleConformance();
|
|
3922
|
+
return report.ok && report.endpointsChecked >= 5 && report.routerSurfaceChecked >= 9 && report.resourceTypesChecked === 4;
|
|
3923
|
+
},
|
|
3924
|
+
},
|
|
3925
|
+
// ── native ──────────────────────────────────────────────────────────────────────────────────
|
|
3926
|
+
// MySQL's binary wire protocol, served by `serve --mysql` as a FRONTEND of the same psdb state
|
|
3927
|
+
// (planetscale-mysql.ts). Dub reaches one database through Prisma (native) AND the HTTP driver
|
|
3928
|
+
// (volter-ai/twin#255); the census lists what a native client can send that the frontend refuses.
|
|
3929
|
+
{
|
|
3930
|
+
id: 'planetscale.native.shared_state',
|
|
3931
|
+
area: 'native', title: 'a native MySQL client and the HTTP driver read and write ONE database through one root',
|
|
3932
|
+
dimension: 'api', tier: 'core', expected: 'done',
|
|
3933
|
+
verify: async () => {
|
|
3934
|
+
const [{ createPlanetscaleTwinServer, createPlanetscaleTwinFetch }, { startPlanetscaleMysql }] = await Promise.all([import("./planetscale-server.js"), import("./planetscale-mysql.js")]);
|
|
3935
|
+
const root = mkdtempSync(join(tmpdir(), 'planetscale-native-'));
|
|
3936
|
+
const http = await createPlanetscaleTwinServer({ root });
|
|
3937
|
+
const native = await startPlanetscaleMysql({ upstream: `http://twin:twin@127.0.0.1:${http.port}` });
|
|
3938
|
+
// The HTTP door itself, not the store: the same POST the @planetscale/database client sends,
|
|
3939
|
+
// handed IN-PROCESS to the fetch handler the server wraps — the pack issues no request of its own.
|
|
3940
|
+
const wire = createPlanetscaleTwinFetch({ root });
|
|
3941
|
+
const viaHttp = async (query) => {
|
|
3942
|
+
const res = await wire(new Request(`http://twin${EXECUTE}`, { method: 'POST', body: JSON.stringify({ query }),
|
|
3943
|
+
headers: { authorization: `Basic ${Buffer.from('twin:twin').toString('base64')}`, 'content-type': 'application/json' } }));
|
|
3944
|
+
const body = await res.json();
|
|
3945
|
+
return (body.result?.rows ?? []).map((row) => unpackRow(row));
|
|
3946
|
+
};
|
|
3947
|
+
// The native door driven over a raw socket — the manifest never imports a vendor client
|
|
3948
|
+
// (the smtp pack's precedent); planetscale-mysql.test.ts drives the unmodified mysql2 client.
|
|
3949
|
+
const db = await rawMysqlClient(native.port);
|
|
3950
|
+
try {
|
|
3951
|
+
await db.query('CREATE TABLE shared (id BIGINT PRIMARY KEY, title TEXT)');
|
|
3952
|
+
await db.query("INSERT INTO shared (id, title) VALUES (1, 'native')");
|
|
3953
|
+
const read = await viaHttp('SELECT title FROM shared WHERE id = 1');
|
|
3954
|
+
await viaHttp("UPDATE shared SET title = 'http' WHERE id = 1");
|
|
3955
|
+
const rows = await db.query('SELECT title FROM shared WHERE id = 1');
|
|
3956
|
+
await db.query('DELETE FROM shared WHERE id = 1');
|
|
3957
|
+
const gone = await viaHttp('SELECT id FROM shared');
|
|
3958
|
+
return JSON.stringify(read) === '[["native"]]' && JSON.stringify(rows) === '[["http"]]' && gone.length === 0;
|
|
3959
|
+
}
|
|
3960
|
+
finally {
|
|
3961
|
+
db.end();
|
|
3962
|
+
await native.stop();
|
|
3963
|
+
http.stop();
|
|
3964
|
+
rmSync(root, { recursive: true, force: true });
|
|
3965
|
+
}
|
|
3966
|
+
},
|
|
3967
|
+
},
|
|
3968
|
+
todo('planetscale.native.binary_time_results', 'native', 'TIME columns in a binary (prepared) result set', 'niche'),
|
|
3969
|
+
todo('planetscale.native.cursors', 'native', 'COM_STMT_EXECUTE cursor flags and COM_STMT_FETCH', 'niche'),
|
|
3970
|
+
todo('planetscale.native.send_long_data', 'native', 'COM_STMT_SEND_LONG_DATA streamed parameters', 'niche'),
|
|
3971
|
+
todo('planetscale.native.large_packets', 'native', 'packets above 16 MiB split across the wire (refused by closing the connection today)', 'niche'),
|
|
3972
|
+
todo('planetscale.native.caching_sha2_password', 'native', 'caching_sha2_password (MySQL 8 default) — only mysql_native_password is served', 'niche'),
|
|
3973
|
+
todo('planetscale.native.multi_statements', 'native', 'CLIENT_MULTI_STATEMENTS batches', 'niche'),
|
|
3974
|
+
];
|
|
3975
|
+
export function planetscaleCapabilities() {
|
|
3976
|
+
return checkCapabilities('planetscale', PLANETSCALE_CAPABILITIES);
|
|
3977
|
+
}
|