@volter/world-core 3.0.70 → 3.0.72

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.
@@ -0,0 +1,159 @@
1
+ // A MySQL client over any byte exchange: the other side of ./stream.ts, so a journey speaks the wire as an application's
2
+ // driver does (mysql_async under Prisma's engines): protocol v10 with mysql_native_password, text queries, and prepared
3
+ // statements with binary parameters and rows. It reads what the protocol answers; it decides nothing.
4
+ // source: https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basics.html
5
+ import { MysqlType, MysqlFlag, nativePasswordToken, type MysqlError } from './stream.ts';
6
+
7
+ /** Sends bytes and returns every byte the server answered to them. */
8
+ export type MysqlExchange = (bytes: Uint8Array) => Promise<Uint8Array>;
9
+ export type MysqlResultSet = { columns: { name: string; type: number; flags: number }[]; rows: (string | null)[][] };
10
+ export type MysqlAnswer = MysqlResultSet | { affectedRows: number; insertId: number } | { error: MysqlError };
11
+
12
+ const enc = new TextEncoder();
13
+ const dec = new TextDecoder();
14
+ const CLIENT_CAPS = 0x1 | 0x4 | 0x8 | 0x200 | 0x2000 | 0x8000 | 0x10000 | 0x20000 | 0x40000 | 0x80000 | 0x200000;
15
+
16
+ function packet(seq: number, payload: Uint8Array): Uint8Array {
17
+ const out = new Uint8Array(4 + payload.length);
18
+ out[0] = payload.length & 0xff; out[1] = (payload.length >> 8) & 0xff; out[2] = (payload.length >> 16) & 0xff; out[3] = seq;
19
+ out.set(payload, 4);
20
+ return out;
21
+ }
22
+ function packets(bytes: Uint8Array): Uint8Array[] {
23
+ const out: Uint8Array[] = []; let at = 0;
24
+ while (at + 4 <= bytes.length) {
25
+ const len = bytes[at]! | (bytes[at + 1]! << 8) | (bytes[at + 2]! << 16);
26
+ out.push(bytes.subarray(at + 4, at + 4 + len)); at += 4 + len;
27
+ }
28
+ return out;
29
+ }
30
+
31
+ class Cursor {
32
+ at = 0;
33
+ constructor(readonly b: Uint8Array) {}
34
+ u8(): number { return this.b[this.at++]!; }
35
+ u16(): number { return this.u8() | (this.u8() << 8); }
36
+ u32(): number { return (this.u16() | (this.u16() << 16)) >>> 0; }
37
+ u64(): bigint { let n = 0n; for (let i = 0n; i < 8n; i++) n |= BigInt(this.u8()) << (8n * i); return n; }
38
+ take(n: number): Uint8Array { const out = this.b.subarray(this.at, this.at + n); this.at += n; return out; }
39
+ nul(): string { const end = this.b.indexOf(0, this.at); const s = dec.decode(this.b.subarray(this.at, end)); this.at = end + 1; return s; }
40
+ lenenc(): number { const f = this.u8(); if (f < 0xfb) return f; if (f === 0xfc) return this.u16(); if (f === 0xfd) return this.u16() | (this.u8() << 16); return Number(this.u64()); }
41
+ lenencStr(): string { return dec.decode(this.take(this.lenenc())); }
42
+ }
43
+
44
+ const errorOf = (p: Uint8Array): MysqlError => {
45
+ const c = new Cursor(p); c.u8();
46
+ const errno = c.u16(); c.u8(); // '#'
47
+ const sqlstate = dec.decode(c.take(5));
48
+ return { errno, sqlstate, message: dec.decode(c.take(p.length - c.at)) };
49
+ };
50
+
51
+ function column(p: Uint8Array): { name: string; type: number; flags: number } {
52
+ const c = new Cursor(p);
53
+ c.lenencStr(); c.lenencStr(); c.lenencStr(); c.lenencStr(); // catalog, schema, table, org_table
54
+ const name = c.lenencStr(); c.lenencStr(); c.lenenc(); // org_name, fixed-length 0x0c
55
+ c.u16(); c.u32(); const type = c.u8(); const flags = c.u16();
56
+ return { name, type, flags };
57
+ }
58
+
59
+ const pad = (n: number, w = 2) => String(n).padStart(w, '0');
60
+ /** A binary row's value as MySQL's text protocol writes it, so a journey reads both protocols alike. */
61
+ function binaryValue(c: Cursor, type: number, flags: number): string {
62
+ const unsigned = (flags & MysqlFlag.UNSIGNED) !== 0;
63
+ switch (type) {
64
+ case MysqlType.TINY: { const v = c.u8(); return String(unsigned ? v : (v << 24) >> 24); }
65
+ case MysqlType.SHORT: case MysqlType.YEAR: { const v = c.u16(); return String(unsigned ? v : (v << 16) >> 16); }
66
+ case MysqlType.LONG: case MysqlType.INT24: { const v = c.u32(); return String(unsigned ? v : v | 0); }
67
+ case MysqlType.LONGLONG: { const v = c.u64(); return String(unsigned ? v : BigInt.asIntN(64, v)); }
68
+ case MysqlType.FLOAT: { const b = c.take(4); return String(new DataView(b.buffer, b.byteOffset, 4).getFloat32(0, true)); }
69
+ case MysqlType.DOUBLE: { const b = c.take(8); return String(new DataView(b.buffer, b.byteOffset, 8).getFloat64(0, true)); }
70
+ case MysqlType.DATE: case MysqlType.DATETIME: case MysqlType.TIMESTAMP: {
71
+ const len = c.u8();
72
+ if (!len) return type === MysqlType.DATE ? '0000-00-00' : '0000-00-00 00:00:00';
73
+ const y = c.u16(), m = c.u8(), d = c.u8();
74
+ const [h, i, s] = len >= 7 ? [c.u8(), c.u8(), c.u8()] : [0, 0, 0];
75
+ const us = len >= 11 ? c.u32() : 0;
76
+ const day = `${pad(y, 4)}-${pad(m)}-${pad(d)}`;
77
+ return type === MysqlType.DATE ? day : `${day} ${pad(h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}`;
78
+ }
79
+ case MysqlType.TIME: {
80
+ const len = c.u8();
81
+ if (!len) return '00:00:00';
82
+ const neg = c.u8(), days = c.u32(), h = c.u8(), i = c.u8(), s = c.u8();
83
+ const us = len >= 12 ? c.u32() : 0;
84
+ return `${neg ? '-' : ''}${pad(days * 24 + h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}`;
85
+ }
86
+ default: return c.lenencStr();
87
+ }
88
+ }
89
+
90
+ /** One command's answer: an OK, an ERR, or a result set (text or binary rows). */
91
+ function answer(payloads: Uint8Array[], binary: boolean): MysqlAnswer {
92
+ const first = payloads[0];
93
+ if (!first) return { error: { errno: 2013, sqlstate: 'HY000', message: 'Lost connection to MySQL server during query' } };
94
+ if (first[0] === 0xff) return { error: errorOf(first) };
95
+ // a result set begins with its column count (never 0): 0x00 is an OK
96
+ if (first[0] === 0x00) {
97
+ const c = new Cursor(first); c.u8();
98
+ return { affectedRows: c.lenenc(), insertId: c.lenenc() };
99
+ }
100
+ const c = new Cursor(first);
101
+ const count = c.lenenc();
102
+ const columns = payloads.slice(1, 1 + count).map(column);
103
+ const rows: (string | null)[][] = [];
104
+ for (const p of payloads.slice(2 + count)) {
105
+ if (p[0] === 0xfe && p.length < 9) break; // the closing EOF
106
+ if (p[0] === 0xff) return { error: errorOf(p) };
107
+ const r = new Cursor(p);
108
+ if (!binary) { rows.push(columns.map(() => (r.b[r.at] === 0xfb ? (r.at++, null) : r.lenencStr()))); continue; }
109
+ r.u8();
110
+ const bitmap = r.take(Math.floor((columns.length + 9) / 8));
111
+ rows.push(columns.map((col, i) => ((bitmap[Math.floor((i + 2) / 8)]! >> ((i + 2) % 8)) & 1 ? null : binaryValue(r, col.type, col.flags))));
112
+ }
113
+ return { columns, rows };
114
+ }
115
+
116
+ /** Sign in: the server's greeting, then the handshake response (mysql_native_password), following an auth switch. */
117
+ export async function mysqlSignIn(exchange: MysqlExchange, greeting: Uint8Array, user: string, password: string, database: string | null): Promise<{ ok: true } | { error: MysqlError }> {
118
+ const g = new Cursor(packets(greeting)[0]!);
119
+ g.u8(); g.nul(); g.u32();
120
+ const part1 = g.take(8); g.u8(); g.u16(); g.u8(); g.u16(); g.u16(); g.u8(); g.take(10);
121
+ const part2 = g.take(12);
122
+ const scramble = new Uint8Array([...part1, ...part2]);
123
+ const token = nativePasswordToken(password, scramble);
124
+ const body = [...new Uint8Array([CLIENT_CAPS & 0xff, (CLIENT_CAPS >> 8) & 0xff, (CLIENT_CAPS >> 16) & 0xff, (CLIENT_CAPS >> 24) & 0xff, 0, 0, 0, 1, 45]), ...new Array(23).fill(0),
125
+ ...enc.encode(user), 0, token.length, ...token, ...(database ? [...enc.encode(database), 0] : [0]), ...enc.encode('mysql_native_password'), 0];
126
+ let reply = packets(await exchange(packet(1, Uint8Array.from(body))))[0];
127
+ if (reply?.[0] === 0xfe) { // the server asks for another plugin's answer: mysql_native_password with its scramble
128
+ const c = new Cursor(reply); c.u8(); c.nul();
129
+ reply = packets(await exchange(packet(3, nativePasswordToken(password, c.take(20)))))[0];
130
+ }
131
+ if (!reply || reply[0] === 0xff) return { error: reply ? errorOf(reply) : { errno: 2013, sqlstate: 'HY000', message: 'Lost connection to MySQL server during handshake' } };
132
+ return { ok: true };
133
+ }
134
+
135
+ /** A text query (COM_QUERY): the first result of a script, as mysql_async reads it. */
136
+ export async function mysqlQuery(exchange: MysqlExchange, sql: string): Promise<MysqlAnswer> {
137
+ return answer(packets(await exchange(packet(0, Uint8Array.from([0x03, ...enc.encode(sql)])))), false);
138
+ }
139
+
140
+ /** A prepared statement executed once with text-typed parameters (COM_STMT_PREPARE, EXECUTE, CLOSE), as Prisma sends a
141
+ * parameterised query: binary parameters and rows. */
142
+ export async function mysqlExecute(exchange: MysqlExchange, sql: string, params: (string | null)[]): Promise<MysqlAnswer> {
143
+ const prepared = packets(await exchange(packet(0, Uint8Array.from([0x16, ...enc.encode(sql)]))));
144
+ if (prepared[0]?.[0] === 0xff) return { error: errorOf(prepared[0]) };
145
+ const c = new Cursor(prepared[0]!); c.u8();
146
+ const id = c.u32(); c.u16(); const count = c.u16();
147
+ const head = [0x17, id & 0xff, (id >> 8) & 0xff, (id >> 16) & 0xff, (id >>> 24) & 0xff, 0, 1, 0, 0, 0];
148
+ const values: number[] = [];
149
+ if (count) {
150
+ const bitmap = new Array(Math.floor((count + 7) / 8)).fill(0);
151
+ params.forEach((v, i) => { if (v === null) bitmap[Math.floor(i / 8)] |= 1 << (i % 8); });
152
+ head.push(...bitmap, 1);
153
+ for (let i = 0; i < count; i++) head.push(params[i] === null ? MysqlType.NULL : MysqlType.VAR_STRING, 0);
154
+ for (const v of params) if (v !== null) { const b = enc.encode(v); values.push(...(b.length < 251 ? [b.length] : [0xfc, b.length & 0xff, b.length >> 8]), ...b); }
155
+ }
156
+ const result = answer(packets(await exchange(packet(0, Uint8Array.from([...head, ...values])))), true);
157
+ await exchange(packet(0, Uint8Array.from([0x19, id & 0xff, (id >> 8) & 0xff, (id >> 16) & 0xff, (id >>> 24) & 0xff])));
158
+ return result;
159
+ }
@@ -0,0 +1,5 @@
1
+ // THE KERNEL'S MYSQL WIRE (`@volter/world-core/mysql`): MySQL's client/server protocol as a TwinStream. A pack that serves
2
+ // MySQL passes what a statement means (its engine) and whom it admits; the kernel names no vendor.
3
+ // docs/contributing/architecture.md, "Other wires", the MySQL wire.
4
+ export * from './stream.ts';
5
+ export * from './client.ts';
@@ -0,0 +1,490 @@
1
+ // THE KERNEL'S MYSQL WIRE (`@volter/world-core/mysql`): MySQL's client/server protocol as a TwinStream, so an unmodified
2
+ // MySQL client (Prisma's engines over mysql_async, mysql2) connects to a twin as to a server. The kernel holds the
3
+ // protocol, which every MySQL-speaking vendor shares; the pack holds what a statement means (it executes it in its own
4
+ // engine over the tree) and whom it admits (it checks a password against what it stores). The command subset is what the
5
+ // clients send, by citation (docs/contributing/architecture.md, "Other wires", the MySQL wire): protocol v10 with
6
+ // mysql_native_password, COM_QUERY (text results, several per multi-statement script), COM_STMT_PREPARE / EXECUTE /
7
+ // CLOSE / SEND_LONG_DATA / RESET (binary rows and parameters), COM_PING, COM_INIT_DB and COM_QUIT; any other command is
8
+ // answered as MySQL answers an unknown one.
9
+ // source: https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basics.html
10
+ import { createHash, randomBytes } from 'node:crypto';
11
+ import type { TwinStream, TwinStreamSink } from '../twin-fetch.ts';
12
+
13
+ /** A column as the pack's engine describes it: MySQL's column type code (`MysqlType`) and flags. */
14
+ export type MysqlColumn = {
15
+ name: string; type: number; flags?: number; charset?: number; length?: number; decimals?: number;
16
+ table?: string; orgTable?: string; orgName?: string; schema?: string;
17
+ };
18
+ /** A statement's outcome: rows (each value as MySQL's text protocol carries it, or null), or a count, or MySQL's error. */
19
+ export type MysqlOutcome =
20
+ | { columns: MysqlColumn[]; rows: (string | Uint8Array | null)[][]; warnings?: number }
21
+ | { affectedRows: number; insertId?: number | bigint; warnings?: number; info?: string }
22
+ | { error: MysqlError };
23
+ export type MysqlError = { errno: number; sqlstate: string; message: string };
24
+
25
+ export type MysqlStreamOptions<Session> = {
26
+ /** The handshake's server version: the one the pack's engine reports for `VERSION()`. */
27
+ version: string;
28
+ /** Admit `user` to `database` (null: none named) when `check` accepts what the pack stores for it: the password, or
29
+ * MySQL's own stored form of it (`nativePasswordHash`, SHA1(SHA1(password)), as a server keeps it). */
30
+ connect(user: string, database: string | null, check: MysqlPasswordCheck): Promise<{ session: Session } | { error: MysqlError }>;
31
+ /** Execute ONE statement (the kernel splits a script and binds parameters into the text) on the session. */
32
+ execute(session: Session, sql: string): Promise<MysqlOutcome>;
33
+ /** Whether the session holds an open transaction (SERVER_STATUS_IN_TRANS). */
34
+ inTransaction(session: Session): boolean;
35
+ /** Use another database on the session (COM_INIT_DB). */
36
+ useDatabase?(session: Session, database: string): Promise<MysqlError | null>;
37
+ /** The connection went away: roll back what it left open. */
38
+ close?(session: Session): void;
39
+ };
40
+
41
+ /** MySQL's column type codes. */
42
+ export const MysqlType = {
43
+ DECIMAL: 0x00, TINY: 0x01, SHORT: 0x02, LONG: 0x03, FLOAT: 0x04, DOUBLE: 0x05, NULL: 0x06, TIMESTAMP: 0x07,
44
+ LONGLONG: 0x08, INT24: 0x09, DATE: 0x0a, TIME: 0x0b, DATETIME: 0x0c, YEAR: 0x0d, VARCHAR: 0x0f, BIT: 0x10,
45
+ JSON: 0xf5, NEWDECIMAL: 0xf6, ENUM: 0xf7, SET: 0xf8, TINY_BLOB: 0xf9, MEDIUM_BLOB: 0xfa, LONG_BLOB: 0xfb, BLOB: 0xfc,
46
+ VAR_STRING: 0xfd, STRING: 0xfe, GEOMETRY: 0xff,
47
+ } as const;
48
+ export const MysqlFlag = { NOT_NULL: 0x0001, PRI_KEY: 0x0002, UNIQUE_KEY: 0x0004, MULTIPLE_KEY: 0x0008, BLOB: 0x0010, UNSIGNED: 0x0020, BINARY: 0x0080, AUTO_INCREMENT: 0x0200 } as const;
49
+
50
+ const CAP = {
51
+ LONG_PASSWORD: 0x1, FOUND_ROWS: 0x2, LONG_FLAG: 0x4, CONNECT_WITH_DB: 0x8, PROTOCOL_41: 0x200, TRANSACTIONS: 0x2000,
52
+ SECURE_CONNECTION: 0x8000, MULTI_STATEMENTS: 0x10000, MULTI_RESULTS: 0x20000, PS_MULTI_RESULTS: 0x40000,
53
+ PLUGIN_AUTH: 0x80000, CONNECT_ATTRS: 0x100000, PLUGIN_AUTH_LENENC: 0x200000,
54
+ } as const;
55
+ // what this server offers: no TLS, no compression, no deprecated-EOF (classic EOF packets), no session tracking
56
+ const SERVER_CAPS = CAP.LONG_PASSWORD | CAP.FOUND_ROWS | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 | CAP.TRANSACTIONS
57
+ | CAP.SECURE_CONNECTION | CAP.MULTI_STATEMENTS | CAP.MULTI_RESULTS | CAP.PS_MULTI_RESULTS | CAP.PLUGIN_AUTH | CAP.CONNECT_ATTRS | CAP.PLUGIN_AUTH_LENENC;
58
+ const STATUS = { IN_TRANS: 0x0001, AUTOCOMMIT: 0x0002, MORE_RESULTS: 0x0008 } as const;
59
+ const COM = { QUIT: 0x01, INIT_DB: 0x02, QUERY: 0x03, PING: 0x0e, STMT_PREPARE: 0x16, STMT_EXECUTE: 0x17, STMT_SEND_LONG_DATA: 0x18, STMT_CLOSE: 0x19, STMT_RESET: 0x1a } as const;
60
+ const UTF8MB4 = 45; // utf8mb4_general_ci
61
+ const BINARY_CHARSET = 63;
62
+ const NATIVE = 'mysql_native_password';
63
+ const MAX_PAYLOAD = 0xffffff;
64
+
65
+ const enc = new TextEncoder();
66
+ const dec = new TextDecoder();
67
+ const sha1 = (...parts: Uint8Array[]): Uint8Array => { const h = createHash('sha1'); for (const p of parts) h.update(p); return new Uint8Array(h.digest()); };
68
+
69
+ /** Whether the client's mysql_native_password answer proves a password: from the password itself, or from MySQL's
70
+ * stored form of it (hex SHA1(SHA1(password))), as a MySQL server checks it without holding the password. */
71
+ export type MysqlPasswordCheck = { password(password: string): boolean; storedHash(hex: string): boolean };
72
+
73
+ /** MySQL's stored form of a password for mysql_native_password: SHA1(SHA1(password)), hex. A pack keeps it when the
74
+ * password is made, beside whatever else it keeps, so the wire can admit the password without the pack holding it. */
75
+ export function nativePasswordHash(password: string): string {
76
+ return Array.from(sha1(sha1(enc.encode(password))), (b) => b.toString(16).padStart(2, '0')).join('');
77
+ }
78
+
79
+ /** mysql_native_password's token for `password` against `scramble`: SHA1(pw) XOR SHA1(scramble + SHA1(SHA1(pw))). */
80
+ export function nativePasswordToken(password: string, scramble: Uint8Array): Uint8Array {
81
+ if (!password) return new Uint8Array(0);
82
+ const stage1 = sha1(enc.encode(password));
83
+ const mix = sha1(scramble, sha1(stage1));
84
+ return stage1.map((b, i) => b ^ mix[i]!);
85
+ }
86
+
87
+ // ── bytes ───────────────────────────────────────────────────────────────────────────────────────────────────────────
88
+ class Writer {
89
+ private parts: number[] = [];
90
+ u8(v: number): this { this.parts.push(v & 0xff); return this; }
91
+ u16(v: number): this { return this.u8(v).u8(v >>> 8); }
92
+ u24(v: number): this { return this.u8(v).u8(v >>> 8).u8(v >>> 16); }
93
+ u32(v: number): this { return this.u16(v & 0xffff).u16(v >>> 16); }
94
+ u64(v: bigint): this { const n = BigInt.asUintN(64, v); for (let i = 0n; i < 8n; i++) this.u8(Number((n >> (8n * i)) & 0xffn)); return this; }
95
+ bytes(b: Uint8Array | number[]): this { for (const x of b) this.parts.push(x); return this; }
96
+ str(s: string): this { return this.bytes(enc.encode(s)); }
97
+ nul(s: string): this { return this.str(s).u8(0); }
98
+ lenenc(v: number | bigint): this {
99
+ const n = BigInt(v);
100
+ if (n < 251n) return this.u8(Number(n));
101
+ if (n < 0x10000n) return this.u8(0xfc).u16(Number(n));
102
+ if (n < 0x1000000n) return this.u8(0xfd).u24(Number(n));
103
+ return this.u8(0xfe).u64(n);
104
+ }
105
+ lenencBytes(b: Uint8Array): this { return this.lenenc(b.length).bytes(b); }
106
+ lenencStr(s: string): this { return this.lenencBytes(enc.encode(s)); }
107
+ done(): Uint8Array { return Uint8Array.from(this.parts); }
108
+ }
109
+
110
+ class Reader {
111
+ at = 0;
112
+ constructor(readonly b: Uint8Array) {}
113
+ left(): number { return this.b.length - this.at; }
114
+ u8(): number { if (this.at >= this.b.length) throw new Error('packet too short'); return this.b[this.at++]!; }
115
+ u16(): number { return this.u8() | (this.u8() << 8); }
116
+ u24(): number { return this.u16() | (this.u8() << 16); }
117
+ u32(): number { return (this.u16() | (this.u16() << 16)) >>> 0; }
118
+ u64(): bigint { let n = 0n; for (let i = 0n; i < 8n; i++) n |= BigInt(this.u8()) << (8n * i); return n; }
119
+ take(n: number): Uint8Array { if (this.at + n > this.b.length) throw new Error('packet too short'); const out = this.b.subarray(this.at, this.at + n); this.at += n; return out; }
120
+ rest(): Uint8Array { return this.take(this.left()); }
121
+ nul(): string { const end = this.b.indexOf(0, this.at); const stop = end < 0 ? this.b.length : end; const s = dec.decode(this.b.subarray(this.at, stop)); this.at = end < 0 ? stop : stop + 1; return s; }
122
+ lenenc(): bigint {
123
+ const first = this.u8();
124
+ if (first < 0xfb) return BigInt(first);
125
+ if (first === 0xfc) return BigInt(this.u16());
126
+ if (first === 0xfd) return BigInt(this.u24());
127
+ if (first === 0xfe) return this.u64();
128
+ throw new Error('bad length-encoded integer');
129
+ }
130
+ lenencBytes(): Uint8Array { return this.take(Number(this.lenenc())); }
131
+ }
132
+
133
+ // ── SQL text: statements, placeholders and literals (lexical only: what a statement means is the pack's) ──────────────
134
+ /** The offsets of `?` placeholders and `;` separators outside quotes, backticks and comments. */
135
+ function scan(sql: string): { placeholders: number[]; separators: number[] } {
136
+ const placeholders: number[] = []; const separators: number[] = [];
137
+ for (let i = 0; i < sql.length; i++) {
138
+ const c = sql[i]!;
139
+ if (c === "'" || c === '"' || c === '`') {
140
+ for (i++; i < sql.length; i++) {
141
+ if (sql[i] === '\\' && c !== '`') { i++; continue; }
142
+ if (sql[i] === c) { if (sql[i + 1] === c) { i++; continue; } break; }
143
+ }
144
+ } else if (c === '#' || (c === '-' && sql[i + 1] === '-' && /\s/.test(sql[i + 2] ?? ' '))) {
145
+ while (i < sql.length && sql[i] !== '\n') i++;
146
+ } else if (c === '/' && sql[i + 1] === '*') {
147
+ const end = sql.indexOf('*/', i + 2); i = end < 0 ? sql.length : end + 1;
148
+ } else if (c === '?') placeholders.push(i);
149
+ else if (c === ';') separators.push(i);
150
+ }
151
+ return { placeholders, separators };
152
+ }
153
+
154
+ /** A multi-statement script's statements, empty ones dropped. */
155
+ export function splitStatements(sql: string): string[] {
156
+ const { separators } = scan(sql);
157
+ const out: string[] = []; let from = 0;
158
+ for (const at of [...separators, sql.length]) { const s = sql.slice(from, at).trim(); if (s) out.push(s); from = at + 1; }
159
+ return out;
160
+ }
161
+
162
+ export function countPlaceholders(sql: string): number { return scan(sql).placeholders.length; }
163
+
164
+ /** A bound parameter: an integer, a float, text, bytes (a BLOB parameter, or text that is not UTF-8), a date or time. */
165
+ export type MysqlParam = null | bigint | number | string | Uint8Array | { date: string };
166
+ /** A parameter as a MySQL literal, written as PlanetScale's own driver formats one, so an engine that executes text
167
+ * reads both wires' statements alike. */
168
+ // source: https://github.com/planetscale/database-js/blob/v1.19.0/src/sanitization.ts "const re = /[\0\b\n\r\t\x1a\\"']/g"
169
+ // source: https://github.com/planetscale/database-js/blob/v1.19.0/src/text.ts "return `x'${digits.join('')}'`"
170
+ function literal(v: MysqlParam): string {
171
+ if (v === null) return 'null';
172
+ if (typeof v === 'bigint' || typeof v === 'number') return String(v);
173
+ if (v instanceof Uint8Array) return `x'${Array.from(v, (b) => b.toString(16).padStart(2, '0')).join('')}'`;
174
+ if (typeof v === 'object') return quote(v.date);
175
+ return quote(v);
176
+ }
177
+ const ESCAPES: Record<string, string> = { '"': '\\"', "'": "\\'", '\n': '\\n', '\r': '\\r', '\t': '\\t', '\\': '\\\\', '\0': '\\0', '\b': '\\b', '\x1a': '\\Z' };
178
+ function quote(text: string): string {
179
+ return `'${text.replace(/[\0\b\n\r\t\x1a\\"']/g, (c) => ESCAPES[c]!)}'`;
180
+ }
181
+ /** `sql` with each `?` placeholder replaced by its parameter's literal. */
182
+ export function bindParameters(sql: string, params: MysqlParam[]): string {
183
+ const { placeholders } = scan(sql);
184
+ if (placeholders.length !== params.length) throw new Error(`the statement has ${placeholders.length} placeholders and ${params.length} parameters`);
185
+ let out = ''; let from = 0;
186
+ placeholders.forEach((at, i) => { out += sql.slice(from, at) + literal(params[i]!); from = at + 1; });
187
+ return out + sql.slice(from);
188
+ }
189
+
190
+ // ── binary protocol values ──────────────────────────────────────────────────────────────────────────────────────────
191
+ const pad = (n: number, w = 2) => String(n).padStart(w, '0');
192
+ function readParam(r: Reader, type: number, unsigned: boolean): MysqlParam {
193
+ switch (type) {
194
+ case MysqlType.NULL: return null;
195
+ case MysqlType.TINY: { const v = r.u8(); return BigInt(unsigned ? v : (v << 24) >> 24); }
196
+ case MysqlType.SHORT: case MysqlType.YEAR: { const v = r.u16(); return BigInt(unsigned ? v : (v << 16) >> 16); }
197
+ case MysqlType.LONG: case MysqlType.INT24: { const v = r.u32(); return BigInt(unsigned ? v : v | 0); }
198
+ case MysqlType.LONGLONG: { const v = r.u64(); return unsigned ? v : BigInt.asIntN(64, v); }
199
+ case MysqlType.FLOAT: { const b = r.take(4); return new DataView(b.buffer, b.byteOffset, 4).getFloat32(0, true); }
200
+ case MysqlType.DOUBLE: { const b = r.take(8); return new DataView(b.buffer, b.byteOffset, 8).getFloat64(0, true); }
201
+ case MysqlType.DATE: case MysqlType.DATETIME: case MysqlType.TIMESTAMP: {
202
+ const len = r.u8();
203
+ if (len === 0) return { date: type === MysqlType.DATE ? '0000-00-00' : '0000-00-00 00:00:00' };
204
+ const y = r.u16(), m = r.u8(), d = r.u8();
205
+ const [h, i, s] = len >= 7 ? [r.u8(), r.u8(), r.u8()] : [0, 0, 0];
206
+ const us = len >= 11 ? r.u32() : 0;
207
+ const day = `${pad(y, 4)}-${pad(m)}-${pad(d)}`;
208
+ return { date: type === MysqlType.DATE ? day : `${day} ${pad(h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}` };
209
+ }
210
+ case MysqlType.TIME: {
211
+ const len = r.u8();
212
+ if (len === 0) return { date: '00:00:00' };
213
+ const neg = r.u8(), days = r.u32(), h = r.u8(), i = r.u8(), s = r.u8();
214
+ const us = len >= 12 ? r.u32() : 0;
215
+ return { date: `${neg ? '-' : ''}${pad(days * 24 + h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}` };
216
+ }
217
+ case MysqlType.TINY_BLOB: case MysqlType.MEDIUM_BLOB: case MysqlType.LONG_BLOB: case MysqlType.BLOB: case MysqlType.GEOMETRY:
218
+ return r.lenencBytes().slice();
219
+ default: {
220
+ // mysql_async sends text and bytes alike as VAR_STRING: text that is UTF-8 is a string (MySQL's string comparison and
221
+ // collation apply), anything else the bytes it is
222
+ const bytes = r.lenencBytes().slice();
223
+ try { return new TextDecoder('utf-8', { fatal: true }).decode(bytes); } catch { return bytes; }
224
+ }
225
+ }
226
+ }
227
+
228
+ /** A text-protocol value in a column's binary encoding. */
229
+ function writeBinaryValue(w: Writer, column: MysqlColumn, value: string | Uint8Array): void {
230
+ const text = typeof value === 'string' ? value : dec.decode(value);
231
+ const unsigned = ((column.flags ?? 0) & MysqlFlag.UNSIGNED) !== 0;
232
+ switch (column.type) {
233
+ case MysqlType.TINY: w.u8(Number(BigInt(text))); return;
234
+ case MysqlType.SHORT: case MysqlType.YEAR: w.u16(Number(BigInt(text))); return;
235
+ case MysqlType.LONG: case MysqlType.INT24: w.u32(Number(BigInt.asUintN(32, BigInt(text)))); return;
236
+ case MysqlType.LONGLONG: w.u64(unsigned ? BigInt(text) : BigInt.asUintN(64, BigInt(text))); return;
237
+ case MysqlType.FLOAT: { const b = new Uint8Array(4); new DataView(b.buffer).setFloat32(0, Number(text), true); w.bytes(b); return; }
238
+ case MysqlType.DOUBLE: { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, Number(text), true); w.bytes(b); return; }
239
+ case MysqlType.DATE: case MysqlType.DATETIME: case MysqlType.TIMESTAMP: {
240
+ const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?)?$/.exec(text);
241
+ if (!m) { w.u8(0); return; }
242
+ const [y, mo, d, h, i, s] = [m[1], m[2], m[3], m[4] ?? '0', m[5] ?? '0', m[6] ?? '0'].map(Number) as number[];
243
+ const us = m[7] ? Number(m[7].padEnd(6, '0')) : 0;
244
+ if (!y && !mo && !d && !h && !i && !s && !us) { w.u8(0); return; }
245
+ if (us) { w.u8(11).u16(y!).u8(mo!).u8(d!).u8(h!).u8(i!).u8(s!).u32(us); return; }
246
+ if (h || i || s) { w.u8(7).u16(y!).u8(mo!).u8(d!).u8(h!).u8(i!).u8(s!); return; }
247
+ w.u8(4).u16(y!).u8(mo!).u8(d!); return;
248
+ }
249
+ case MysqlType.TIME: {
250
+ const m = /^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/.exec(text);
251
+ if (!m) { w.u8(0); return; }
252
+ const hours = Number(m[2]); const us = m[5] ? Number(m[5].padEnd(6, '0')) : 0;
253
+ const days = Math.floor(hours / 24);
254
+ if (!hours && m[3] === '00' && m[4] === '00' && !us) { w.u8(0); return; }
255
+ w.u8(us ? 12 : 8).u8(m[1] ? 1 : 0).u32(days).u8(hours % 24).u8(Number(m[3])).u8(Number(m[4]));
256
+ if (us) w.u32(us);
257
+ return;
258
+ }
259
+ default: w.lenencBytes(typeof value === 'string' ? enc.encode(value) : value);
260
+ }
261
+ }
262
+
263
+ // ── packets ─────────────────────────────────────────────────────────────────────────────────────────────────────────
264
+ function columnDefinition(c: MysqlColumn): Uint8Array {
265
+ const binary = c.charset === BINARY_CHARSET;
266
+ return new Writer().lenencStr('def').lenencStr(c.schema ?? '').lenencStr(c.table ?? '').lenencStr(c.orgTable ?? c.table ?? '')
267
+ .lenencStr(c.name).lenencStr(c.orgName ?? c.name).u8(0x0c).u16(c.charset ?? (binary ? BINARY_CHARSET : UTF8MB4))
268
+ .u32(c.length ?? 0).u8(c.type).u16(c.flags ?? 0).u8(c.decimals ?? 0).u16(0).done();
269
+ }
270
+ const errPacket = (e: MysqlError): Uint8Array => new Writer().u8(0xff).u16(e.errno).str('#').str((e.sqlstate + '00000').slice(0, 5)).str(e.message).done();
271
+ const eofPacket = (status: number, warnings = 0): Uint8Array => new Writer().u8(0xfe).u16(warnings).u16(status).done();
272
+ const okPacket = (affected: number | bigint, insertId: number | bigint, status: number, warnings = 0, info = ''): Uint8Array =>
273
+ new Writer().u8(0x00).lenenc(affected).lenenc(insertId).u16(status).u16(warnings).str(info).done();
274
+ const unknownCommand: MysqlError = { errno: 1047, sqlstate: '08S01', message: 'Unknown command' };
275
+
276
+ type Prepared = { sql: string; params: number; types: number[]; long: Map<number, number[]> };
277
+
278
+ /** A MySQL server as a TwinStream: the protocol here, the meaning in `options`. */
279
+ export function createMysqlStream<Session>(options: MysqlStreamOptions<Session>): TwinStream {
280
+ let nextConnection = 0;
281
+ return (sink: TwinStreamSink) => {
282
+ const connectionId = ++nextConnection;
283
+ const scramble = new Uint8Array(randomBytes(20)).map((b) => (b & 0x7f) || 0x41); // printable, never NUL
284
+ let seq = 0;
285
+ let buffer = new Uint8Array(0);
286
+ let phase: 'handshake' | 'switch' | 'ready' | 'closed' = 'handshake';
287
+ let clientCaps = 0;
288
+ let pending: { user: string; database: string | null } | null = null;
289
+ let session: Session | null = null;
290
+ const statements = new Map<number, Prepared>();
291
+ let nextStatement = 0;
292
+ let chain: Promise<void> = Promise.resolve();
293
+
294
+ const send = (payload: Uint8Array): void => {
295
+ // a payload of 2^24-1 bytes or more is split, ending with a shorter packet (possibly empty)
296
+ let at = 0;
297
+ do {
298
+ const part = payload.subarray(at, at + MAX_PAYLOAD);
299
+ sink.write(Uint8Array.from([...new Writer().u24(part.length).u8(seq).done(), ...part]));
300
+ seq = (seq + 1) & 0xff; at += part.length;
301
+ if (part.length < MAX_PAYLOAD) break;
302
+ } while (true);
303
+ };
304
+ const status = (more = false): number => STATUS.AUTOCOMMIT | (session && options.inTransaction(session) ? STATUS.IN_TRANS : 0) | (more ? STATUS.MORE_RESULTS : 0);
305
+ const fail = (e: MysqlError): void => send(errPacket(e));
306
+
307
+ // the greeting: protocol v10, the scramble, mysql_native_password
308
+ send(new Writer().u8(10).nul(options.version).u32(connectionId).bytes(scramble.subarray(0, 8)).u8(0)
309
+ .u16(SERVER_CAPS & 0xffff).u8(UTF8MB4).u16(STATUS.AUTOCOMMIT).u16(SERVER_CAPS >>> 16).u8(21).bytes(new Array(10).fill(0))
310
+ .bytes(scramble.subarray(8)).u8(0).nul(NATIVE).done());
311
+
312
+ const admit = async (token: Uint8Array): Promise<void> => {
313
+ const { user, database } = pending!;
314
+ const check: MysqlPasswordCheck = {
315
+ password: (password) => {
316
+ const want = nativePasswordToken(password, scramble);
317
+ return want.length === token.length && want.every((b, i) => b === token[i]);
318
+ },
319
+ // SHA1(token XOR SHA1(scramble + stored)) is SHA1(SHA1(password)) exactly when the client knew the password
320
+ storedHash: (hex) => {
321
+ if (!/^[0-9a-f]{40}$/i.test(hex)) return false;
322
+ if (token.length === 0) return false; // an empty answer proves only an empty password, which a stored hash never is
323
+ if (token.length !== 20) return false;
324
+ const stored = Uint8Array.from(hex.match(/../g)!.map((h) => parseInt(h, 16)));
325
+ const mix = sha1(scramble, stored);
326
+ const stage1 = token.map((b, i) => b ^ mix[i]!);
327
+ return sha1(stage1).every((b, i) => b === stored[i]);
328
+ },
329
+ };
330
+ const answer = await options.connect(user, database, check);
331
+ if ('error' in answer) { fail(answer.error); phase = 'closed'; sink.end(); return; }
332
+ session = answer.session; phase = 'ready';
333
+ send(okPacket(0, 0, status()));
334
+ };
335
+
336
+ const handshakeResponse = async (payload: Uint8Array): Promise<void> => {
337
+ const r = new Reader(payload);
338
+ clientCaps = r.u32(); r.u32(); r.u8(); r.take(23);
339
+ const user = r.nul();
340
+ let token: Uint8Array;
341
+ if (clientCaps & CAP.PLUGIN_AUTH_LENENC) token = r.lenencBytes();
342
+ else if (clientCaps & CAP.SECURE_CONNECTION) token = r.take(r.u8());
343
+ else token = enc.encode(r.nul());
344
+ const database = (clientCaps & CAP.CONNECT_WITH_DB) && r.left() ? r.nul() || null : null;
345
+ const plugin = (clientCaps & CAP.PLUGIN_AUTH) && r.left() ? r.nul() : NATIVE;
346
+ pending = { user, database };
347
+ if (plugin !== NATIVE) {
348
+ // the client answered for another plugin: ask it to switch to mysql_native_password with the same scramble
349
+ phase = 'switch';
350
+ send(new Writer().u8(0xfe).nul(NATIVE).bytes(scramble).u8(0).done());
351
+ return;
352
+ }
353
+ await admit(token.slice());
354
+ };
355
+
356
+ const resultSet = (outcome: MysqlOutcome, binary: boolean, more: boolean): void => {
357
+ if ('error' in outcome) { fail(outcome.error); return; }
358
+ if (!('columns' in outcome)) {
359
+ send(okPacket(outcome.affectedRows, outcome.insertId ?? 0, status(more), outcome.warnings ?? 0, outcome.info ?? ''));
360
+ return;
361
+ }
362
+ send(new Writer().lenenc(outcome.columns.length).done());
363
+ for (const c of outcome.columns) send(columnDefinition(c));
364
+ send(eofPacket(status(more)));
365
+ for (const row of outcome.rows) {
366
+ const w = new Writer();
367
+ if (!binary) {
368
+ for (const v of row) { if (v === null) w.u8(0xfb); else if (typeof v === 'string') w.lenencStr(v); else w.lenencBytes(v); }
369
+ } else {
370
+ w.u8(0x00);
371
+ const bitmap = new Array(Math.floor((outcome.columns.length + 7 + 2) / 8)).fill(0);
372
+ row.forEach((v, i) => { if (v === null) bitmap[Math.floor((i + 2) / 8)] |= 1 << ((i + 2) % 8); });
373
+ w.bytes(bitmap);
374
+ row.forEach((v, i) => { if (v !== null) writeBinaryValue(w, outcome.columns[i]!, v); });
375
+ }
376
+ send(w.done());
377
+ }
378
+ send(eofPacket(status(more), outcome.warnings ?? 0));
379
+ };
380
+
381
+ const query = async (sql: string): Promise<void> => {
382
+ const parts = clientCaps & CAP.MULTI_STATEMENTS ? splitStatements(sql) : [sql.trim()];
383
+ if (!parts.length) { fail({ errno: 1065, sqlstate: '42000', message: 'Query was empty' }); return; }
384
+ for (let i = 0; i < parts.length; i++) {
385
+ const outcome = await options.execute(session!, parts[i]!);
386
+ const last = i === parts.length - 1 || 'error' in outcome;
387
+ resultSet(outcome, false, !last);
388
+ if ('error' in outcome) return; // a script stops at its first error, as MySQL's does
389
+ }
390
+ };
391
+
392
+ const prepare = (sql: string): void => {
393
+ const params = countPlaceholders(sql);
394
+ const id = ++nextStatement;
395
+ statements.set(id, { sql, params, types: [], long: new Map() });
396
+ // no result columns are named at prepare: the execute's result set carries them
397
+ send(new Writer().u8(0x00).u32(id).u16(0).u16(params).u8(0).u16(0).done());
398
+ if (params) {
399
+ for (let i = 0; i < params; i++) send(columnDefinition({ name: '?', type: MysqlType.VAR_STRING, charset: BINARY_CHARSET }));
400
+ send(eofPacket(status()));
401
+ }
402
+ };
403
+
404
+ const execute = async (r: Reader): Promise<void> => {
405
+ const id = r.u32(); r.u8(); r.u32(); // flags (no cursors), iteration count
406
+ const stmt = statements.get(id);
407
+ if (!stmt) { fail({ errno: 1243, sqlstate: 'HY000', message: `Unknown prepared statement handler (${id}) given to mysqld_stmt_execute` }); return; }
408
+ const values: MysqlParam[] = [];
409
+ if (stmt.params) {
410
+ const bitmap = r.take(Math.floor((stmt.params + 7) / 8));
411
+ if (r.u8() === 1) { stmt.types = []; for (let i = 0; i < stmt.params; i++) stmt.types.push(r.u16()); }
412
+ for (let i = 0; i < stmt.params; i++) {
413
+ const long = stmt.long.get(i);
414
+ if (long) { values.push(Uint8Array.from(long)); continue; }
415
+ if (bitmap[Math.floor(i / 8)]! & (1 << (i % 8))) { values.push(null); continue; }
416
+ const t = stmt.types[i] ?? MysqlType.VAR_STRING;
417
+ values.push(readParam(r, t & 0xff, (t & 0x8000) !== 0));
418
+ }
419
+ stmt.long.clear();
420
+ }
421
+ let sql: string;
422
+ try { sql = bindParameters(stmt.sql, values); }
423
+ catch (e) { fail({ errno: 1210, sqlstate: 'HY000', message: `Incorrect arguments to mysqld_stmt_execute: ${(e as Error).message}` }); return; }
424
+ resultSet(await options.execute(session!, sql), true, false);
425
+ };
426
+
427
+ const command = async (payload: Uint8Array): Promise<void> => {
428
+ seq = 1; // a command's packets begin a new sequence; the client's was 0
429
+ const r = new Reader(payload);
430
+ const code = r.u8();
431
+ switch (code) {
432
+ case COM.QUIT: phase = 'closed'; if (session) options.close?.(session); sink.end(); return;
433
+ case COM.PING: send(okPacket(0, 0, status())); return;
434
+ case COM.QUERY: await query(dec.decode(r.rest())); return;
435
+ case COM.INIT_DB: {
436
+ const err = options.useDatabase ? await options.useDatabase(session!, dec.decode(r.rest())) : unknownCommand;
437
+ if (err) fail(err); else send(okPacket(0, 0, status()));
438
+ return;
439
+ }
440
+ case COM.STMT_PREPARE: prepare(dec.decode(r.rest())); return;
441
+ case COM.STMT_EXECUTE: await execute(r); return;
442
+ case COM.STMT_SEND_LONG_DATA: { // no reply
443
+ const stmt = statements.get(r.u32()); const param = r.u16();
444
+ if (stmt) stmt.long.set(param, [...(stmt.long.get(param) ?? []), ...r.rest()]);
445
+ return;
446
+ }
447
+ case COM.STMT_CLOSE: statements.delete(r.u32()); return; // no reply
448
+ case COM.STMT_RESET: { const stmt = statements.get(r.u32()); stmt?.long.clear(); send(okPacket(0, 0, status())); return; }
449
+ default: fail(unknownCommand);
450
+ }
451
+ };
452
+
453
+ // whole packets out of the bytes, a split payload rejoined (2^24-1-byte parts end with a shorter one)
454
+ let assembling: number[] | null = null;
455
+ const packets = (): Uint8Array[] => {
456
+ const out: Uint8Array[] = [];
457
+ while (buffer.length >= 4) {
458
+ const len = buffer[0]! | (buffer[1]! << 8) | (buffer[2]! << 16);
459
+ if (buffer.length < 4 + len) break;
460
+ const part = buffer.subarray(4, 4 + len);
461
+ buffer = buffer.slice(4 + len);
462
+ if (len === MAX_PAYLOAD) { assembling = [...(assembling ?? []), ...part]; continue; }
463
+ out.push(assembling ? Uint8Array.from([...assembling, ...part]) : part.slice());
464
+ assembling = null;
465
+ }
466
+ return out;
467
+ };
468
+
469
+ const handle = async (payload: Uint8Array): Promise<void> => {
470
+ if (phase === 'closed') return;
471
+ try {
472
+ if (phase === 'handshake') { seq = 2; await handshakeResponse(payload); return; }
473
+ if (phase === 'switch') { seq = 4; await admit(payload.slice()); return; }
474
+ await command(payload);
475
+ } catch (e) {
476
+ fail({ errno: 1105, sqlstate: 'HY000', message: (e as Error).message });
477
+ }
478
+ };
479
+
480
+ return {
481
+ data(chunk) {
482
+ if (phase === 'closed') return;
483
+ const joined = new Uint8Array(buffer.length + chunk.length); joined.set(buffer); joined.set(chunk, buffer.length); buffer = joined;
484
+ for (const p of packets()) chain = chain.then(() => handle(p));
485
+ },
486
+ settled: () => chain,
487
+ close: () => { if (phase !== 'closed' && session) options.close?.(session); phase = 'closed'; },
488
+ };
489
+ };
490
+ }