@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,339 @@
|
|
|
1
|
+
// Native MySQL frontend for the SAME psdb state owner. No database or kernel writes live here.
|
|
2
|
+
// Wire reference: https://dev.mysql.com/doc/dev/mysql-server/latest/PAGE_PROTOCOL.html
|
|
3
|
+
// Prepared parameters/result rows follow page_protocol_com_stmt_execute.html and
|
|
4
|
+
// page_protocol_binary_resultset.html; parameter types are cached per prepared statement.
|
|
5
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { parseStatement, SERVER_VERSION, SqlError, SYSTEM_VARIABLES, tokenize } from './planetscale-sql.ts';
|
|
7
|
+
import type { TwinStream, TwinStreamConnection } from '@volter/world-core';
|
|
8
|
+
import type { WireField, WireQueryResult, WireSession } from './planetscale-wire.ts';
|
|
9
|
+
|
|
10
|
+
const MAX_PACKET = 16 * 1024 * 1024 - 2;
|
|
11
|
+
const MAX_STATEMENTS = 1024;
|
|
12
|
+
const CAPABILITIES = 1 | 4 | 8 | 512 | 8192 | 32768 | 131072 | 262144 | 524288;
|
|
13
|
+
class MysqlError extends Error {
|
|
14
|
+
constructor(message: string, readonly errno = 1235, readonly state = '42000') { super(message); }
|
|
15
|
+
}
|
|
16
|
+
const integer = (value: number | bigint, bytes: number): Buffer => {
|
|
17
|
+
const out = Buffer.alloc(bytes);
|
|
18
|
+
if (bytes === 8) out.writeBigUInt64LE(BigInt.asUintN(64, BigInt(value)));
|
|
19
|
+
else out.writeUIntLE(Number(value), 0, bytes);
|
|
20
|
+
return out;
|
|
21
|
+
};
|
|
22
|
+
const length = (n: number): Buffer => n < 251 ? integer(n, 1) : n < 65536 ? Buffer.concat([Buffer.from([252]), integer(n, 2)])
|
|
23
|
+
: n < 16777216 ? Buffer.concat([Buffer.from([253]), integer(n, 3)]) : Buffer.concat([Buffer.from([254]), integer(n, 8)]);
|
|
24
|
+
const string = (value: string | Buffer): Buffer => { const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); return Buffer.concat([length(bytes.length), bytes]); };
|
|
25
|
+
|
|
26
|
+
class Reader {
|
|
27
|
+
offset = 0;
|
|
28
|
+
constructor(readonly buffer: Buffer) {}
|
|
29
|
+
bytes(n: number): Buffer {
|
|
30
|
+
if (!Number.isSafeInteger(n) || n < 0 || this.offset + n > this.buffer.length) throw new MysqlError('Malformed MySQL packet', 1835, 'HY000');
|
|
31
|
+
const value = this.buffer.subarray(this.offset, this.offset + n); this.offset += n; return value;
|
|
32
|
+
}
|
|
33
|
+
uint(n: number): number { return this.bytes(n).readUIntLE(0, n); }
|
|
34
|
+
len(): number { const first = this.uint(1); return first < 251 ? first : first === 252 ? this.uint(2) : first === 253 ? this.uint(3) : first === 254 ? Number(this.bytes(8).readBigUInt64LE()) : (() => { throw new MysqlError('Invalid length', 1835); })(); }
|
|
35
|
+
str(): Buffer { return this.bytes(this.len()); }
|
|
36
|
+
zero(): string { const end = this.buffer.indexOf(0, this.offset); if (end < 0) throw new MysqlError('Unterminated string', 1835); const result = this.bytes(end - this.offset).toString(); this.bytes(1); return result; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Placeholders only in executable SQL. A prepare never executes a mutation. */
|
|
40
|
+
export function mysqlPlaceholders(sql: string): number[] {
|
|
41
|
+
const positions: number[] = [];
|
|
42
|
+
let quote = ''; let comment = '';
|
|
43
|
+
for (let i = 0; i < sql.length; i++) {
|
|
44
|
+
const c = sql[i]!; const next = sql[i + 1];
|
|
45
|
+
if (comment === 'line') { if (c === '\n') comment = ''; continue; }
|
|
46
|
+
if (comment === 'block') { if (c === '*' && next === '/') { comment = ''; i++; } continue; }
|
|
47
|
+
if (quote) {
|
|
48
|
+
if (c === '\\' && quote !== '`') { i++; continue; }
|
|
49
|
+
if (c === quote) { if (next === quote) i++; else quote = ''; }
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (c === "'" || c === '"' || c === '`') quote = c;
|
|
53
|
+
else if (c === '#' || (c === '-' && next === '-')) { comment = 'line'; }
|
|
54
|
+
else if (c === '/' && next === '*') { comment = 'block'; i++; }
|
|
55
|
+
else if (c === '?') positions.push(i);
|
|
56
|
+
}
|
|
57
|
+
return positions;
|
|
58
|
+
}
|
|
59
|
+
function bind(sql: string, positions: number[], values: string[]): string {
|
|
60
|
+
let result = ''; let previous = 0;
|
|
61
|
+
positions.forEach((position, i) => { result += sql.slice(previous, position) + values[i]; previous = position + 1; });
|
|
62
|
+
return result + sql.slice(previous);
|
|
63
|
+
}
|
|
64
|
+
const quote = (value: Buffer): string => {
|
|
65
|
+
const text = value.toString('utf8');
|
|
66
|
+
if (!Buffer.from(text).equals(value)) return `X'${value.toString('hex')}'`;
|
|
67
|
+
return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\0/g, '\\0')}'`;
|
|
68
|
+
};
|
|
69
|
+
const TYPES: Record<string, number> = { NULL_TYPE: 6, INT8: 1, UINT8: 1, INT16: 2, UINT16: 2, INT24: 9, UINT24: 9,
|
|
70
|
+
INT32: 3, UINT32: 3, INT64: 8, UINT64: 8, FLOAT32: 4, FLOAT64: 5, TIMESTAMP: 7, DATE: 10, TIME: 11,
|
|
71
|
+
DATETIME: 12, YEAR: 13, DECIMAL: 246, TEXT: 252, BLOB: 252, VARCHAR: 253, VARBINARY: 253, CHAR: 254,
|
|
72
|
+
BINARY: 254, BIT: 16, ENUM: 254, SET: 254, JSON: 245 };
|
|
73
|
+
function column(field: WireField): Buffer {
|
|
74
|
+
const fixed = Buffer.concat([integer(field.charset ?? 45, 2), integer(field.columnLength ?? 1024, 4),
|
|
75
|
+
integer(TYPES[field.type ?? 'NULL_TYPE'] ?? 253, 1), integer(field.flags ?? 0, 2), Buffer.from([0, 0, 0])]);
|
|
76
|
+
return Buffer.concat([string('def'), string(field.database ?? ''), string(field.table ?? ''), string(field.orgTable ?? ''),
|
|
77
|
+
string(field.name), string(field.orgName ?? field.name), Buffer.from([12]), fixed]);
|
|
78
|
+
}
|
|
79
|
+
function dateValue(reader: Reader, type: number): string {
|
|
80
|
+
const size = reader.uint(1);
|
|
81
|
+
if (size === 0) return "'0000-00-00 00:00:00'";
|
|
82
|
+
if (type === 11) {
|
|
83
|
+
if (size !== 8 && size !== 12) throw new MysqlError('Invalid TIME parameter', 1835);
|
|
84
|
+
const sign = reader.uint(1); const days = reader.uint(4); const hours = days * 24 + reader.uint(1);
|
|
85
|
+
const minute = reader.uint(1); const second = reader.uint(1); const micro = size === 12 ? reader.uint(4) : 0;
|
|
86
|
+
return `'${sign ? '-' : ''}${hours}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}.${String(micro).padStart(6, '0')}'`;
|
|
87
|
+
}
|
|
88
|
+
if (![4, 7, 11].includes(size)) throw new MysqlError('Invalid date parameter', 1835);
|
|
89
|
+
const year = reader.uint(2); const month = reader.uint(1); const day = reader.uint(1);
|
|
90
|
+
const h = size >= 7 ? reader.uint(1) : 0; const m = size >= 7 ? reader.uint(1) : 0; const s = size >= 7 ? reader.uint(1) : 0;
|
|
91
|
+
const micro = size === 11 ? reader.uint(4) : 0;
|
|
92
|
+
return `'${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(micro).padStart(6, '0')}'`;
|
|
93
|
+
}
|
|
94
|
+
type Prepared = { sql: string; positions: number[]; types?: Array<{ type: number; unsigned: boolean }> };
|
|
95
|
+
function parameters(reader: Reader, statement: Prepared): string[] {
|
|
96
|
+
const n = statement.positions.length;
|
|
97
|
+
if (n === 0) return [];
|
|
98
|
+
const nulls = reader.bytes(Math.ceil(n / 8));
|
|
99
|
+
const fresh = reader.uint(1);
|
|
100
|
+
if (fresh === 1) statement.types = Array.from({ length: n }, () => ({ type: reader.uint(1), unsigned: (reader.uint(1) & 128) !== 0 }));
|
|
101
|
+
else if (fresh !== 0) throw new MysqlError('Invalid parameter bind flag', 1835);
|
|
102
|
+
if (!statement.types) throw new MysqlError('Parameter types were not bound', 2031, 'HY000');
|
|
103
|
+
return statement.types.map(({ type, unsigned }, i) => {
|
|
104
|
+
if ((nulls[Math.floor(i / 8)]! & (1 << (i % 8))) || type === 6) return 'NULL';
|
|
105
|
+
const width = type === 1 ? 1 : type === 2 || type === 13 ? 2 : type === 3 || type === 9 ? 4 : 0;
|
|
106
|
+
if (width) { const bytes = reader.bytes(width); return String(unsigned ? bytes.readUIntLE(0, width) : bytes.readIntLE(0, width)); }
|
|
107
|
+
if (type === 8) { const bytes = reader.bytes(8); return String(unsigned ? bytes.readBigUInt64LE() : bytes.readBigInt64LE()); }
|
|
108
|
+
if (type === 4 || type === 5) { const bytes = reader.bytes(type === 4 ? 4 : 8); const value = type === 4 ? bytes.readFloatLE() : bytes.readDoubleLE(); if (!Number.isFinite(value)) throw new MysqlError('Non-finite parameter', 1366); return String(value); }
|
|
109
|
+
if ([7, 10, 11, 12].includes(type)) return dateValue(reader, type);
|
|
110
|
+
if ([0, 15, 16, 245, 246, 249, 250, 251, 252, 253, 254].includes(type)) return quote(reader.str());
|
|
111
|
+
throw new MysqlError(`Parameter type ${type} is not supported`);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function binaryCell(field: WireField, bytes: Buffer): Buffer {
|
|
115
|
+
const type = TYPES[field.type ?? 'NULL_TYPE'] ?? 253;
|
|
116
|
+
const text = bytes.toString(); const unsigned = ((field.flags ?? 0) & 32) !== 0;
|
|
117
|
+
const width = type === 1 ? 1 : type === 2 || type === 13 ? 2 : type === 3 || type === 9 ? 4 : 0;
|
|
118
|
+
if (width) { const out = Buffer.alloc(width); if (unsigned) out.writeUIntLE(Number(text), 0, width); else out.writeIntLE(Number(text), 0, width); return out; }
|
|
119
|
+
if (type === 8) return integer(BigInt(text), 8);
|
|
120
|
+
if (type === 4 || type === 5) { const out = Buffer.alloc(type === 4 ? 4 : 8); if (type === 4) out.writeFloatLE(Number(text)); else out.writeDoubleLE(Number(text)); return out; }
|
|
121
|
+
if ([7, 10, 12].includes(type)) {
|
|
122
|
+
const parts = text.match(/^(\d{4})-(\d\d)-(\d\d)(?:[ T](\d\d):(\d\d):(\d\d)(?:\.(\d+))?)?/);
|
|
123
|
+
if (!parts) throw new MysqlError('Invalid stored datetime', 1292, '22007');
|
|
124
|
+
const values = parts.slice(1, 7).map((part) => Number(part ?? 0)); const micro = Number((parts[7] ?? '').padEnd(6, '0').slice(0, 6));
|
|
125
|
+
return Buffer.concat([Buffer.from([type === 10 ? 4 : 11]), integer(values[0]!, 2), Buffer.from(values.slice(1, 3)),
|
|
126
|
+
...(type === 10 ? [] : [Buffer.from(values.slice(3)), integer(micro, 4)])]);
|
|
127
|
+
}
|
|
128
|
+
if (type === 11) throw new MysqlError('Binary TIME results are not modeled');
|
|
129
|
+
return string(bytes);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type MysqlFrontendOptions = { port?: number; upstream: string; database?: string; username?: string; password?: string; onQuery?: (sql: string) => void };
|
|
133
|
+
/** The MySQL wire over any byte transport (the kernel's `TwinStream`), answering from the psdb HTTP
|
|
134
|
+
* twin through `upstream`: the loopback listener below hands it a socket's bytes and `fetch`, a
|
|
135
|
+
* hosted World a WebSocket's bytes and the twin's own in-process wire. */
|
|
136
|
+
export type MysqlStreamOptions = { upstream: (request: Request) => Promise<Response>; authorization?: string; database?: string; username?: string; password?: string; onQuery?: (sql: string) => void };
|
|
137
|
+
export function createPlanetscaleMysqlStream(options: MysqlStreamOptions): TwinStream {
|
|
138
|
+
const authorization = options.authorization ?? `Basic ${Buffer.from(`${options.username || 'twin'}:${options.password || 'twin'}`).toString('base64')}`;
|
|
139
|
+
let connectionId = 0;
|
|
140
|
+
return (socket) => {
|
|
141
|
+
let buffer = Buffer.alloc(0); let sequence = 0; let authenticated = false; let closing = false; let gone = false;
|
|
142
|
+
let session: WireSession | undefined; let autocommit = true; let queue = Promise.resolve(); let queued = 0;
|
|
143
|
+
let nextStatement = 0; const statements = new Map<number, Prepared>(); let longData: MysqlError | undefined;
|
|
144
|
+
const salt = randomBytes(20);
|
|
145
|
+
const end = (): void => { if (!gone) { gone = true; socket.end(); } };
|
|
146
|
+
const inTransaction = () => session?.vitessSession.inTransaction === true;
|
|
147
|
+
const status = () => (autocommit ? 2 : 0) | (inTransaction() ? 1 : 0);
|
|
148
|
+
const send = (payload: Buffer): void => {
|
|
149
|
+
if (payload.length > MAX_PACKET) throw new MysqlError('Result packet too large', 1153, '08S01');
|
|
150
|
+
socket.write(Buffer.concat([integer(payload.length, 3), integer(sequence++ % 256, 1), payload]));
|
|
151
|
+
};
|
|
152
|
+
const ok = (rows = 0, insert = 0): void => send(Buffer.concat([Buffer.from([0]), length(rows), length(insert), integer(status(), 2), integer(0, 2)]));
|
|
153
|
+
const eof = (): void => send(Buffer.concat([Buffer.from([254]), integer(0, 2), integer(status(), 2)]));
|
|
154
|
+
const fail = (error: unknown): void => {
|
|
155
|
+
// A statement the frontend parses itself (a prepare) fails with the engine's own errno.
|
|
156
|
+
const e = error instanceof MysqlError ? error : error instanceof SqlError ? new MysqlError(error.message, error.errno, error.sqlState) : new MysqlError(String(error), 1105, 'HY000');
|
|
157
|
+
send(Buffer.concat([Buffer.from([255]), integer(e.errno, 2), Buffer.from(`#${e.state}${e.message}`)]));
|
|
158
|
+
};
|
|
159
|
+
const rpc = async (method: string, body: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
|
160
|
+
const response = await options.upstream(new Request(`http://twin/psdb.v1alpha1.Database/${method}`, {
|
|
161
|
+
method: 'POST', headers: { authorization, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000),
|
|
162
|
+
}));
|
|
163
|
+
const data = await response.json() as Record<string, unknown>;
|
|
164
|
+
const returned = data.session as WireSession | undefined;
|
|
165
|
+
if (returned && session && returned.vitessSession.sessionUUID !== session.vitessSession.sessionUUID) {
|
|
166
|
+
// The upstream twin no longer knows this session (it restarted or the session was closed):
|
|
167
|
+
// it minted a fresh one, so any buffered transaction is gone. A real server would have
|
|
168
|
+
// dropped the connection; do the same rather than continue on a silently different session.
|
|
169
|
+
session = undefined; end();
|
|
170
|
+
throw new MysqlError('Lost connection to the PlanetScale twin during query: the upstream session was replaced', 2013, 'HY000');
|
|
171
|
+
}
|
|
172
|
+
if (returned) session = returned;
|
|
173
|
+
const error = data.error as { message?: string } | undefined;
|
|
174
|
+
if (!response.ok || error) {
|
|
175
|
+
const message = error?.message ?? String(data.message ?? `HTTP ${response.status}`);
|
|
176
|
+
throw new MysqlError(message, Number(message.match(/errno (\d+)/)?.[1] ?? 1105), message.match(/sqlstate ([A-Z0-9]+)/)?.[1] ?? 'HY000');
|
|
177
|
+
}
|
|
178
|
+
return data;
|
|
179
|
+
};
|
|
180
|
+
const normalize = (query: string): string => {
|
|
181
|
+
// The engine's own system variables (the psdb wire reads the same table), with this
|
|
182
|
+
// connection's autocommit state.
|
|
183
|
+
const variables: Record<string, string> = Object.fromEntries(Object.entries({ ...SYSTEM_VARIABLES, max_allowed_packet: MAX_PACKET, autocommit: autocommit ? 1 : 0 })
|
|
184
|
+
.map(([k, v]) => [k, typeof v === 'number' ? String(v) : `'${String(v).replace(/'/g, "''")}'`]));
|
|
185
|
+
let out = query;
|
|
186
|
+
const tokens = tokenize(query);
|
|
187
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
188
|
+
const token = tokens[i]!;
|
|
189
|
+
if (token.kind !== 'ident' || !token.value.startsWith('@@')) continue;
|
|
190
|
+
let name = token.value.slice(2).toLowerCase(); let end = token.pos - 1 + token.value.length;
|
|
191
|
+
// @@session.x / @@global.x / @@local.x: the tokenizer yields the scope, a '.', then the name.
|
|
192
|
+
const scoped = ['session', 'global', 'local'].includes(name) && tokens[i + 1]?.kind === 'punct' && tokens[i + 1]!.value === '.' && tokens[i + 2]?.kind === 'ident';
|
|
193
|
+
if (scoped) { const target = tokens[i + 2]!; name = target.value.toLowerCase(); end = target.pos - 1 + target.value.length; }
|
|
194
|
+
if (!(name in variables)) throw new MysqlError(`Unknown system variable '${name}'`, 1193, 'HY000');
|
|
195
|
+
out = out.slice(0, token.pos - 1) + variables[name] + out.slice(end);
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
};
|
|
199
|
+
const execute = async (query: string): Promise<WireQueryResult> => {
|
|
200
|
+
options.onQuery?.(query);
|
|
201
|
+
// Connection encoding is UTF-8. Other session settings stay loud instead of being no-ops.
|
|
202
|
+
if (/^SET NAMES (?:utf8mb4|utf8)\s*;?$/i.test(query.trim())) return {};
|
|
203
|
+
const auto = query.trim().match(/^SET (?:@@(?:session\.)?)?autocommit\s*=\s*([01])\s*;?$/i);
|
|
204
|
+
if (auto) { if (auto[1] === '1' && inTransaction()) await rpc('Execute', { query: 'COMMIT', session }); autocommit = auto[1] === '1'; return {}; }
|
|
205
|
+
if (!autocommit && !inTransaction() && !/^\s*(?:BEGIN|START TRANSACTION|COMMIT|ROLLBACK)\b/i.test(query)) await rpc('Execute', { query: 'BEGIN', session });
|
|
206
|
+
return read(query);
|
|
207
|
+
};
|
|
208
|
+
// A statement run for its result only: no autocommit bookkeeping (MySQL opens no transaction
|
|
209
|
+
// on a prepare), so the prepare probe below cannot start one.
|
|
210
|
+
const read = async (query: string): Promise<WireQueryResult> => (await rpc('Execute', { query: normalize(query), session })).result as WireQueryResult ?? {};
|
|
211
|
+
const result = (value: WireQueryResult, binary: boolean): void => {
|
|
212
|
+
const fields = value.fields ?? [];
|
|
213
|
+
if (!fields.length) { ok(Number(value.rowsAffected ?? 0), Number(value.insertId ?? 0)); return; }
|
|
214
|
+
send(length(fields.length)); fields.forEach((field) => send(column(field))); eof();
|
|
215
|
+
for (const row of value.rows ?? []) {
|
|
216
|
+
const data = Buffer.from(row.values ?? '', 'base64'); let offset = 0;
|
|
217
|
+
const nulls = Buffer.alloc(Math.ceil((fields.length + 2) / 8));
|
|
218
|
+
const values = row.lengths.map((n, i) => {
|
|
219
|
+
const count = Number(n);
|
|
220
|
+
if (count < 0) { nulls[Math.floor((i + 2) / 8)]! |= 1 << ((i + 2) % 8); return binary ? Buffer.alloc(0) : Buffer.from([251]); }
|
|
221
|
+
const bytes = data.subarray(offset, offset + count); offset += count;
|
|
222
|
+
return binary ? binaryCell(fields[i]!, bytes) : string(bytes);
|
|
223
|
+
});
|
|
224
|
+
send(Buffer.concat(binary ? [Buffer.from([0]), nulls, ...values] : values));
|
|
225
|
+
}
|
|
226
|
+
eof();
|
|
227
|
+
};
|
|
228
|
+
const closeSession = async (): Promise<void> => { if (session) { try { await rpc('CloseSession', { session }); } finally { session = undefined; } } };
|
|
229
|
+
const handshake = Buffer.concat([Buffer.from([10]), Buffer.from(`${SERVER_VERSION}\0`), integer(++connectionId, 4), salt.subarray(0, 8), Buffer.from([0]),
|
|
230
|
+
integer(CAPABILITIES & 65535, 2), Buffer.from([45]), integer(2, 2), integer(CAPABILITIES >>> 16, 2), Buffer.from([21]), Buffer.alloc(10), salt.subarray(8), Buffer.from('\0mysql_native_password\0')]);
|
|
231
|
+
send(handshake);
|
|
232
|
+
const command = async (payload: Buffer, incomingSequence: number): Promise<void> => {
|
|
233
|
+
sequence = (incomingSequence + 1) % 256;
|
|
234
|
+
const r = new Reader(payload);
|
|
235
|
+
if (!authenticated) {
|
|
236
|
+
const capabilities = r.uint(4); r.bytes(4); const charset = r.uint(1); r.bytes(23);
|
|
237
|
+
if ((capabilities & 512) === 0 || ![33, 45, 46, 224, 255].includes(charset)) throw new MysqlError('Unsupported handshake or character set', 1047, '08S01');
|
|
238
|
+
const username = r.zero(); const auth = capabilities & 32768 ? r.bytes(r.uint(1)) : Buffer.from(r.zero(), 'binary');
|
|
239
|
+
const database = capabilities & 8 ? r.zero() : '';
|
|
240
|
+
if (!username || !auth.length || options.username && username !== options.username) throw new MysqlError('Access denied', 1045, '28000');
|
|
241
|
+
if (options.password) {
|
|
242
|
+
const sha = (bytes: Buffer) => createHash('sha1').update(bytes).digest();
|
|
243
|
+
const first = sha(Buffer.from(options.password)); const scramble = sha(Buffer.concat([salt, sha(first)]));
|
|
244
|
+
const expected = Buffer.from(first.map((byte, i) => byte ^ scramble[i]!));
|
|
245
|
+
if (auth.length !== expected.length || !timingSafeEqual(auth, expected)) throw new MysqlError('Access denied', 1045, '28000');
|
|
246
|
+
}
|
|
247
|
+
if (options.database && database && database !== options.database) throw new MysqlError(`Unknown database '${database}'`, 1049, '42000');
|
|
248
|
+
await rpc('CreateSession', {}); authenticated = true; ok(); return;
|
|
249
|
+
}
|
|
250
|
+
const op = r.uint(1);
|
|
251
|
+
if (op === 1) { closing = true; await closeSession(); end(); return; }
|
|
252
|
+
if (op === 14) { ok(); return; }
|
|
253
|
+
if (op === 2) { const database = payload.subarray(1).toString(); if (options.database && database !== options.database) throw new MysqlError(`Unknown database '${database}'`, 1049); ok(); return; }
|
|
254
|
+
if (op === 3) { result(await execute(payload.subarray(1).toString()), false); return; }
|
|
255
|
+
if (op === 22) {
|
|
256
|
+
if (statements.size >= MAX_STATEMENTS) throw new MysqlError('Too many prepared statements', 1461);
|
|
257
|
+
const sql = payload.subarray(1).toString(); const positions = mysqlPlaceholders(sql);
|
|
258
|
+
const probe = normalize(bind(sql, positions, positions.map(() => '0')));
|
|
259
|
+
const parsed = parseStatement(probe);
|
|
260
|
+
const fields = parsed.kind === 'select' ? (await read(probe)).fields ?? [] : [];
|
|
261
|
+
const id = ++nextStatement; statements.set(id, { sql, positions });
|
|
262
|
+
send(Buffer.concat([Buffer.from([0]), integer(id, 4), integer(fields.length, 2), integer(positions.length, 2), Buffer.from([0]), integer(0, 2)]));
|
|
263
|
+
positions.forEach(() => send(column({ name: '?', type: 'VARCHAR' }))); if (positions.length) eof();
|
|
264
|
+
fields.forEach((field) => send(column(field))); if (fields.length) eof(); return;
|
|
265
|
+
}
|
|
266
|
+
if (op === 25) { statements.delete(r.uint(4)); return; }
|
|
267
|
+
if (op === 26) { const statement = statements.get(r.uint(4)); if (!statement) throw new MysqlError('Unknown prepared statement', 1243); statement.types = undefined; ok(); return; }
|
|
268
|
+
if (op === 24) { longData = new MysqlError('COM_STMT_SEND_LONG_DATA is not modeled'); return; } // the protocol defines no reply; the next execute answers
|
|
269
|
+
if (op === 23) {
|
|
270
|
+
if (longData) { const error = longData; longData = undefined; throw error; }
|
|
271
|
+
const statement = statements.get(r.uint(4)); if (!statement) throw new MysqlError('Unknown prepared statement', 1243);
|
|
272
|
+
if (r.uint(1) !== 0 || r.uint(4) !== 1) throw new MysqlError('Prepared cursors/iterations are not modeled');
|
|
273
|
+
const values = parameters(r, statement); // mysql2 reserves allocation padding for NULL parameters; it is not another parameter.
|
|
274
|
+
result(await execute(bind(statement.sql, statement.positions, values)), true); return;
|
|
275
|
+
}
|
|
276
|
+
throw new MysqlError(`MySQL command ${op} is not modeled`, 1047, '08S01');
|
|
277
|
+
};
|
|
278
|
+
const connection: TwinStreamConnection = {
|
|
279
|
+
settled: () => queue,
|
|
280
|
+
data(bytes) {
|
|
281
|
+
if (closing || gone) return;
|
|
282
|
+
buffer = Buffer.concat([buffer, Buffer.from(bytes)]);
|
|
283
|
+
if (buffer.length > MAX_PACKET + 4) { closing = true; end(); return; }
|
|
284
|
+
while (buffer.length >= 4) {
|
|
285
|
+
const size = buffer.readUIntLE(0, 3);
|
|
286
|
+
if (size > MAX_PACKET || ++queued > 64) { closing = true; end(); return; }
|
|
287
|
+
if (buffer.length < size + 4) { queued--; break; }
|
|
288
|
+
const seq = buffer[3]!; const payload = Buffer.from(buffer.subarray(4, size + 4)); buffer = buffer.subarray(size + 4);
|
|
289
|
+
queue = queue.then(async () => { if (!closing) await command(payload, seq); }).catch((error) => { if (!gone) fail(error); if (!authenticated) end(); }).finally(() => { queued--; });
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
close() {
|
|
293
|
+
closing = true; gone = true;
|
|
294
|
+
queue = queue.then(closeSession).catch(() => { /* upstream shutdown also disposes sessions */ });
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
return connection;
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function startPlanetscaleMysql(options: MysqlFrontendOptions): Promise<{ port: number; stop: () => Promise<void> }> {
|
|
302
|
+
const { createServer } = await import('node:net');
|
|
303
|
+
const upstream = new URL(options.upstream);
|
|
304
|
+
if (!['http:', 'https:'].includes(upstream.protocol) || !['127.0.0.1', 'localhost', '[::1]'].includes(upstream.hostname)) throw new Error('PlanetScale native frontend requires a local HTTP twin URL');
|
|
305
|
+
const authorization = `Basic ${Buffer.from(`${decodeURIComponent(upstream.username) || options.username || 'twin'}:${decodeURIComponent(upstream.password) || options.password || 'twin'}`).toString('base64')}`;
|
|
306
|
+
upstream.username = ''; upstream.password = '';
|
|
307
|
+
const stream = createPlanetscaleMysqlStream({
|
|
308
|
+
upstream: async (request) => fetch(new URL(new URL(request.url).pathname, upstream), { method: request.method, headers: request.headers, body: await request.text(), signal: request.signal }),
|
|
309
|
+
authorization,
|
|
310
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
311
|
+
...(options.username !== undefined ? { username: options.username } : {}),
|
|
312
|
+
...(options.password !== undefined ? { password: options.password } : {}),
|
|
313
|
+
...(options.onQuery ? { onQuery: options.onQuery } : {}),
|
|
314
|
+
});
|
|
315
|
+
const connections = new Map<import('node:net').Socket, TwinStreamConnection>(); const pending = new Set<Promise<void>>();
|
|
316
|
+
const server = createServer((socket) => {
|
|
317
|
+
socket.setTimeout(28_800_000, () => socket.destroy());
|
|
318
|
+
const connection = stream({ write: (bytes) => { if (!socket.destroyed) socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '');
|
|
319
|
+
connections.set(socket, connection);
|
|
320
|
+
socket.on('data', (chunk: Buffer) => connection.data(chunk));
|
|
321
|
+
socket.on('error', () => { socket.destroy(); });
|
|
322
|
+
socket.on('close', () => {
|
|
323
|
+
connections.delete(socket); connection.close();
|
|
324
|
+
const cleanup = connection.settled();
|
|
325
|
+
pending.add(cleanup); void cleanup.finally(() => pending.delete(cleanup));
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
await new Promise<void>((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => { server.off('error', reject); resolve(); }); });
|
|
329
|
+
return { port: (server.address() as { port: number }).port, stop: async () => {
|
|
330
|
+
await new Promise<void>((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); for (const socket of connections.keys()) socket.destroy(); });
|
|
331
|
+
await Promise.all([...pending]);
|
|
332
|
+
} };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The MySQL wire a hosted World serves on its stream door: answered by the twin's own wire, whose
|
|
336
|
+
* World token already admitted the stream. */
|
|
337
|
+
export function createPlanetscaleTwinStream(options: { root?: string; wire: (request: Request) => Promise<Response> }): TwinStream {
|
|
338
|
+
return createPlanetscaleMysqlStream({ upstream: options.wire });
|
|
339
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// A branch password's ROLE, enforced on the psdb data plane. The management lane (api/) issues passwords with a role;
|
|
2
|
+
// psdb authenticates a Basic credential against them and refuses what the role does not allow.
|
|
3
|
+
//
|
|
4
|
+
// PlanetScale's password-roles page (https://planetscale.com/docs/vitess/connecting/password-roles): Read-only "Can query
|
|
5
|
+
// rows", Write-only "Can modify rows", Read/write "Can query and modify rows", Admin "All read/write permissions and can
|
|
6
|
+
// modify schema"; its permissions table gives schema changes to Admin alone, inserts, updates and deletes to every role
|
|
7
|
+
// but Read-only, and queries to every role but Write-only. Its examples of the refusal, verbatim:
|
|
8
|
+
// "Select command denied to user 'planetscale-writer-only for table 'customers' (ACL check error)"
|
|
9
|
+
// "Insert command denied to user 'planetscale-reader' for table 'customers' (ACL check error)"
|
|
10
|
+
// "Delete command denied to user 'planetscale-reader' for table 'customers' (ACL check error)"
|
|
11
|
+
// "DDL command denied to user 'planetscale-writer' for table my-new-table' (ACL check error)"
|
|
12
|
+
// so the user a refusal names is the role's (reader: planetscale-reader, writer: planetscale-writer-only, readwriter:
|
|
13
|
+
// planetscale-writer; admin is refused nothing), never the password's username.
|
|
14
|
+
//
|
|
15
|
+
// Where the page stops: it gives no errno or code. The message is MySQL 8.0's ER_TABLEACCESS_DENIED_ERROR, "%s command
|
|
16
|
+
// denied to user '%s'@'%s' for table '%s'", errno 1142, SQLSTATE 42000 (dev.mysql.com, server error reference), so the
|
|
17
|
+
// refusal takes that errno and SQLSTATE, and Vitess's PERMISSION_DENIED code. The twin judges a statement by its kind and
|
|
18
|
+
// the table it names first (a subquery an UPDATE reads is not judged as a query); a statement naming no table (`SELECT 1`,
|
|
19
|
+
// `SHOW TABLES`, BEGIN) and a read of INFORMATION_SCHEMA are allowed to every role.
|
|
20
|
+
import { SqlError, type Statement, type TableFactor } from './planetscale-sql.ts';
|
|
21
|
+
|
|
22
|
+
export type PasswordRole = 'reader' | 'writer' | 'readwriter' | 'admin';
|
|
23
|
+
|
|
24
|
+
const ROLE_USER: Record<Exclude<PasswordRole, 'admin'>, string> = { reader: 'planetscale-reader', writer: 'planetscale-writer-only', readwriter: 'planetscale-writer' };
|
|
25
|
+
const ALLOWED: Record<PasswordRole, ReadonlySet<'Select' | 'Insert' | 'Update' | 'Delete' | 'DDL'>> = {
|
|
26
|
+
reader: new Set(['Select']),
|
|
27
|
+
writer: new Set(['Insert', 'Update', 'Delete']),
|
|
28
|
+
readwriter: new Set(['Select', 'Insert', 'Update', 'Delete']),
|
|
29
|
+
admin: new Set(['Select', 'Insert', 'Update', 'Delete', 'DDL']),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const baseTable = (factor: TableFactor | undefined): string | undefined =>
|
|
33
|
+
factor && 'table' in factor && factor.schema?.toLowerCase() !== 'information_schema' ? factor.table : undefined;
|
|
34
|
+
|
|
35
|
+
/** The command a statement is and the table it names first, or undefined for one no role is refused. */
|
|
36
|
+
function commandOf(stmt: Statement): { command: Command; table: string } | undefined {
|
|
37
|
+
if (stmt.kind === 'select' || stmt.kind === 'update-multi') {
|
|
38
|
+
const t = baseTable(stmt.from?.[0]?.factor);
|
|
39
|
+
return t === undefined ? undefined : { command: stmt.kind === 'select' ? 'Select' : 'Update', table: t };
|
|
40
|
+
}
|
|
41
|
+
const command = TABLE_COMMAND[stmt.kind];
|
|
42
|
+
return command === undefined || !('table' in stmt) ? undefined : { command, table: stmt.table };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Command = 'Select' | 'Insert' | 'Update' | 'Delete' | 'DDL';
|
|
46
|
+
/** The command each single-table statement is, by kind. */
|
|
47
|
+
const TABLE_COMMAND: Partial<Record<Statement['kind'], Command>> = {
|
|
48
|
+
describe: 'Select', 'show-create-table': 'Select', insert: 'Insert', update: 'Update', delete: 'Delete',
|
|
49
|
+
'create-table': 'DDL', 'create-table-select': 'DDL', 'drop-table': 'DDL', truncate: 'DDL', 'alter-add-columns': 'DDL', 'alter-drop-columns': 'DDL',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Whether a statement changes schema: what a branch with safe migrations refuses. */
|
|
53
|
+
export const isDdl = (stmt: Statement): boolean => TABLE_COMMAND[stmt.kind] === 'DDL';
|
|
54
|
+
|
|
55
|
+
/** A branch with safe migrations refuses DDL: "You'll see a `ERROR 1105 (HY000): direct DDL is disabled` message if you
|
|
56
|
+
* attempt to make schema changes in a branch with safe migrations enabled"
|
|
57
|
+
* (https://planetscale.com/docs/vitess/schema-changes/branching). Where the page stops: the message as the HTTP driver
|
|
58
|
+
* reports it; the twin answers the page's errno, SQLSTATE and words. */
|
|
59
|
+
export const directDdlDisabled = (): SqlError => new SqlError('direct DDL is disabled', 'FAILED_PRECONDITION', 1105, 'HY000');
|
|
60
|
+
|
|
61
|
+
/** The refusal a role gives a statement, or undefined when the role allows it. */
|
|
62
|
+
export function roleRefusal(role: PasswordRole, stmt: Statement): SqlError | undefined {
|
|
63
|
+
if (role === 'admin') return undefined;
|
|
64
|
+
const c = commandOf(stmt);
|
|
65
|
+
return c === undefined || ALLOWED[role].has(c.command) ? undefined : commandDenied(role, c);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** MySQL's ER_TABLEACCESS_DENIED_ERROR (1142), as the password-roles page prints it. */
|
|
69
|
+
function commandDenied(role: Exclude<PasswordRole, 'admin'>, c: { command: string; table: string }): SqlError {
|
|
70
|
+
return new SqlError(`${c.command} command denied to user '${ROLE_USER[role]}' for table '${c.table}' (ACL check error)`, 'PERMISSION_DENIED', 1142, '42000');
|
|
71
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Shared row identity and SQL quoting for local execution, observation and deployment.
|
|
2
|
+
import { PULLED_ROWID_BASE, tableKey, type Cell } from './planetscale-sql.ts';
|
|
3
|
+
|
|
4
|
+
export function pulledRowId(table: string, identity: string[], ordinal = 0): number {
|
|
5
|
+
// JSON tuple encoding distinguishes embedded separators and duplicate keyless rows.
|
|
6
|
+
let h = 0x811c9dc5;
|
|
7
|
+
const text = JSON.stringify([tableKey(table), identity, ordinal]);
|
|
8
|
+
for (let i = 0; i < text.length; i++) {
|
|
9
|
+
h ^= text.charCodeAt(i);
|
|
10
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
11
|
+
}
|
|
12
|
+
return PULLED_ROWID_BASE + h;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function keyedRowIdentity(table: { name: string; primaryKey: string[] }, cells: Record<string, Cell>): string[] {
|
|
16
|
+
const keys = Object.keys(cells);
|
|
17
|
+
const identity = table.primaryKey.map(k => k.toLowerCase()).sort().map(k => JSON.stringify([k, cells[keys.find(n => n.toLowerCase() === k) ?? k] ?? null]));
|
|
18
|
+
return identity;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function quoteSqlLiteral(value: Cell): string {
|
|
22
|
+
if (value === null) return 'NULL';
|
|
23
|
+
const escaped = value.replace(/[\0\b\n\r\t\x1a\\"']/g, (c) => {
|
|
24
|
+
switch (c) {
|
|
25
|
+
case '"': return '\\"';
|
|
26
|
+
case "'": return "\\'";
|
|
27
|
+
case '\n': return '\\n';
|
|
28
|
+
case '\r': return '\\r';
|
|
29
|
+
case '\t': return '\\t';
|
|
30
|
+
case '\\': return '\\\\';
|
|
31
|
+
case '\0': return '\\0';
|
|
32
|
+
case '\b': return '\\b';
|
|
33
|
+
default: return '\\Z';
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
return `'${escaped}'`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const quoteIdent = (name: string): string => `\`${name.replace(/`/g, '``')}\``;
|
|
40
|
+
|
|
41
|
+
export function keyedRowId(table: { name: string; primaryKey: string[] }, cells: Record<string, Cell>): number {
|
|
42
|
+
return pulledRowId(table.name, keyedRowIdentity(table, cells));
|
|
43
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// planetscale twin HTTP server — serve the psdb wire twin over HTTP so the real
|
|
2
|
+
// `@planetscale/database` client, pointed at it with nothing but its own public `url`/`host`
|
|
3
|
+
// option, works UNMODIFIED.
|
|
4
|
+
//
|
|
5
|
+
// ── THE ONE THING AN INTEGRATOR MUST KNOW ─────────────────────────────────────────────────────
|
|
6
|
+
// The client forces `https:` for every scheme except a literal `http:` (dist/index.js `protocol`),
|
|
7
|
+
// so a local twin is reachable by passing `url: 'http://user:pass@127.0.0.1:PORT'` — that is
|
|
8
|
+
// CONFIGURATION through the client's own public option, not a modification. `PLANETSCALE_DATABASE_URL`
|
|
9
|
+
// (what dub reads) takes exactly that shape.
|
|
10
|
+
//
|
|
11
|
+
// ── THE INJECTED CLOCK ────────────────────────────────────────────────────────────────────────
|
|
12
|
+
// `now` produces the `occurredAt` the kernel stamps each action with. It defaults to the wall clock
|
|
13
|
+
// (a server has to), but a test passes its own so a run is reproducible. Nothing in a SERVED
|
|
14
|
+
// response is derived from it except the kernel's own action ordinals — the response body is a pure
|
|
15
|
+
// function of (request, stored state), which is why `timing` is never emitted.
|
|
16
|
+
//
|
|
17
|
+
// FETCH-FIRST (runtime contract R12b): the surface is the plain `createPlanetscaleTwinFetch` and
|
|
18
|
+
// the SERVER is one line of `serveHttp` around it. The fetch is the derived dispatch over psdb's
|
|
19
|
+
// proto (`createDerivedFetch`), with the pack's earlier fetch as its `legacy`: psdb replies carry
|
|
20
|
+
// ONLY the handler's own headers (no default `content-type` is added), and the clock is injectable
|
|
21
|
+
// per instance.
|
|
22
|
+
import { serveHttp } from '@volter/world-core';
|
|
23
|
+
import { createPlanetscaleApiLaneFetch } from '../api/src/fetch.ts';
|
|
24
|
+
import { PSDB_METHODS, PSDB_SERVICE } from './planetscale-twin.ts';
|
|
25
|
+
import { psdbHandlers, psdbInternal } from './semantics/psdb.ts';
|
|
26
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
27
|
+
import { DEFAULT_BRANCH, DEFAULT_DATABASE, STORE_NAMES, planetscaleStore, type StoreName } from './planetscale-store.ts';
|
|
28
|
+
import { createDerivedFetch, worldNow, statefulTwinManifest, type DerivedFetch, type DerivedSurface } from '@volter/world-core';
|
|
29
|
+
|
|
30
|
+
export type PlanetscaleServerOptions = {
|
|
31
|
+
root?: string;
|
|
32
|
+
port?: number;
|
|
33
|
+
readOnly?: boolean;
|
|
34
|
+
/** The username the twin demands. Omit to accept any non-empty username. */
|
|
35
|
+
username?: string;
|
|
36
|
+
/** The password the twin demands. Omit to accept any non-empty password (still 401s a missing one). */
|
|
37
|
+
password?: string;
|
|
38
|
+
/** The schema name the twin presents. */
|
|
39
|
+
database?: string;
|
|
40
|
+
/** The branch name `CreateSession` reports. */
|
|
41
|
+
branch?: string;
|
|
42
|
+
/** The injected clock. Returns the ISO instant this request "happens at". */
|
|
43
|
+
now?: () => string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Options every planetscale-twin HTTP surface needs, independent of who owns the socket. */
|
|
47
|
+
export interface PlanetscaleTwinFetchOptions {
|
|
48
|
+
root?: string;
|
|
49
|
+
readOnly?: boolean;
|
|
50
|
+
/** The username the twin demands. Omit to accept any non-empty username. */
|
|
51
|
+
username?: string;
|
|
52
|
+
/** The password the twin demands. Omit to accept any non-empty password (still 401s a missing one). */
|
|
53
|
+
password?: string;
|
|
54
|
+
/** The schema name the twin presents. */
|
|
55
|
+
database?: string;
|
|
56
|
+
/** The branch name `CreateSession` reports. */
|
|
57
|
+
branch?: string;
|
|
58
|
+
/** The injected clock. Returns the ISO instant this request "happens at". */
|
|
59
|
+
now?: () => string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The twin's own doors, in front of the vendor's dispatch (docs/contributing/architecture.md, "Doors, screens and the
|
|
63
|
+
* gap"): `GET /twin`, the discovery manifest, and `GET /twin/store/<tables|rows>`, the store door. Undefined for any
|
|
64
|
+
* other request. */
|
|
65
|
+
function twinDoors(request: Request, options: PlanetscaleTwinFetchOptions): Response | undefined {
|
|
66
|
+
if (request.method !== 'GET') return undefined;
|
|
67
|
+
const cleanPath = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
|
|
68
|
+
if (cleanPath === '/twin') return discovery();
|
|
69
|
+
// THE STORE DOOR: `GET /twin/store/<tables|rows>` — this pack's own named, read-only,
|
|
70
|
+
// deterministic projection over stored state. The psdb wire is Connect-JSON and unary
|
|
71
|
+
// Connect is POST-ONLY, so even `Execute` (the vendor's read) is a POST. Nothing in the vendor
|
|
72
|
+
// surface can observe stored state with a GET, which is what this door is for — and it is what
|
|
73
|
+
// makes the pack's determinism checkable at the resource level (R9). Twin surface, not vendor
|
|
74
|
+
// surface: keyless.
|
|
75
|
+
if (cleanPath.startsWith('/twin/store/')) return storeDoor(cleanPath.slice('/twin/store/'.length), options);
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `GET /twin`. The manifest EDUCATES: it names the store door beside the others, so a caller who only ever sees
|
|
80
|
+
* `GET /twin` can find the one GET read this POST-only protocol has. */
|
|
81
|
+
function discovery(): Response {
|
|
82
|
+
const manifest = statefulTwinManifest({
|
|
83
|
+
vendor: 'planetscale',
|
|
84
|
+
twinOf: 'PlanetScale (psdb.v1alpha1.Database HTTP wire protocol)',
|
|
85
|
+
stores: 'a real MySQL-subset schema and its rows, served over the psdb Connect-JSON envelope',
|
|
86
|
+
});
|
|
87
|
+
return Response.json({
|
|
88
|
+
...manifest,
|
|
89
|
+
stores: STORE_NAMES,
|
|
90
|
+
doors: { ...(manifest.doors as Record<string, unknown>), store: 'GET /twin/store/<name>' },
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `GET /twin/store/<tables|rows>`, the store door. */
|
|
95
|
+
function storeDoor(name: string, options: PlanetscaleTwinFetchOptions): Response {
|
|
96
|
+
if (!(STORE_NAMES as readonly string[]).includes(name)) {
|
|
97
|
+
return Response.json({ error: 'unknown store', store: name, stores: STORE_NAMES }, { status: 404 });
|
|
98
|
+
}
|
|
99
|
+
return Response.json(planetscaleStore(name as StoreName, options.root, options.database ?? DEFAULT_DATABASE));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The gap: Connect's own answer for a request psdb does not serve. A path outside the service is `not_found` naming the
|
|
104
|
+
* service it asked for; a method the twin does not model is `unimplemented`, as Connect answers a method a service lacks;
|
|
105
|
+
* a psdb method asked with a verb other than POST is refused, since unary Connect is POST-only. StreamExecute is the gap:
|
|
106
|
+
* @planetscale/database never calls it (its dist/index.js sends only CreateSession and Execute) though ps-http-sim, the
|
|
107
|
+
* simulator PlanetScale's driver authors publish, serves it as a server stream of ExecuteResponse.
|
|
108
|
+
*/
|
|
109
|
+
function psdbGap(request: Request): Response {
|
|
110
|
+
const path = new URL(request.url).pathname.replace(/\/+$/, '') || '/';
|
|
111
|
+
const prefix = `/${PSDB_SERVICE}/`;
|
|
112
|
+
const json = (status: number, code: string, message: string): Response => Response.json({ code, message }, { status });
|
|
113
|
+
if (!path.startsWith(prefix)) return json(404, 'not_found', `unknown service: ${path.replace(/^\//, '').split('/')[0] ?? ''}`);
|
|
114
|
+
const method = path.slice(prefix.length);
|
|
115
|
+
if (request.method.toUpperCase() !== 'POST' && (PSDB_METHODS as readonly string[]).includes(method)) {
|
|
116
|
+
return json(405, 'unimplemented', `HTTP ${request.method.toUpperCase()} is not supported by this protocol`);
|
|
117
|
+
}
|
|
118
|
+
return json(404, 'unimplemented', `${PSDB_SERVICE}/${method} is not implemented`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** app.planetscale.com's pages the `api` lane serves (its sign-in, the service tokens settings and a deploy request's
|
|
122
|
+
* page), and the World's door
|
|
123
|
+
* that gives a person their PlanetScale password. */
|
|
124
|
+
const SETTINGS_PAGE = /^\/(?:sign-in|_twin\/users\/[^/]+\/password|[^/]+\/settings\/service-tokens(?:\/.*)?|[^/]+\/[^/]+\/deploy-requests\/\d+(?:\/review)?)$/;
|
|
125
|
+
|
|
126
|
+
/** The pack's wire (docs/contributing/architecture.md, "Protocol 3"): the twin's doors in front, the management API and
|
|
127
|
+
* app.planetscale.com's pages to the `api` lane, and the derived dispatch over psdb's proto: CreateSession, Execute,
|
|
128
|
+
* CloseSession and Prepare reach their handlers (semantics/psdb.ts), and everything else is the gap (psdbGap). No
|
|
129
|
+
* `around`: manifest.ts says why. */
|
|
130
|
+
export function createPlanetscaleTwinFetch(options: PlanetscaleTwinFetchOptions = {}): DerivedFetch {
|
|
131
|
+
const now = options.now ?? (() => worldNow());
|
|
132
|
+
const psdb = createDerivedFetch({
|
|
133
|
+
surface: surface as DerivedSurface,
|
|
134
|
+
handlers: psdbHandlers(options, now),
|
|
135
|
+
gap: (request) => psdbGap(request),
|
|
136
|
+
});
|
|
137
|
+
// api.planetscale.com's management API is the pack's `api` lane (../api/src/fetch.ts)
|
|
138
|
+
const api = createPlanetscaleApiLaneFetch({ ...(options.root !== undefined ? { root: options.root } : {}), readOnly: options.readOnly ?? false, clock: now });
|
|
139
|
+
return Object.assign(async (request: Request): Promise<Response> => {
|
|
140
|
+
const door = twinDoors(request, options);
|
|
141
|
+
if (door) return door;
|
|
142
|
+
const path = new URL(request.url).pathname;
|
|
143
|
+
if (path === '/v1/organizations' || path.startsWith('/v1/organizations/') || SETTINGS_PAGE.test(path)) {
|
|
144
|
+
try { return await api(request); } catch (error) { return psdbInternal(error); }
|
|
145
|
+
}
|
|
146
|
+
// a move the World clock makes happens when any request next arrives (a deploy finishing changes the branch psdb
|
|
147
|
+
// reads), so the management lane catches up before psdb answers
|
|
148
|
+
try { await api.catchUp(request); } catch (error) { return psdbInternal(error); }
|
|
149
|
+
return psdb(request);
|
|
150
|
+
}, { owners: () => psdb.owners() });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function createPlanetscaleTwinServer(options: PlanetscaleServerOptions = {}): Promise<{ port: number; stop: () => void }> {
|
|
154
|
+
const server = await serveHttp({
|
|
155
|
+
port: options.port ?? 0,
|
|
156
|
+
idleTimeout: 60,
|
|
157
|
+
fetch: createPlanetscaleTwinFetch(options),
|
|
158
|
+
});
|
|
159
|
+
return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export { DEFAULT_BRANCH, DEFAULT_DATABASE };
|