@volter/world-core 3.0.69 → 3.0.71
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/capability-decision.cjs +63 -9
- package/capability-decision.d.cts +12 -6
- package/dist/capability-decision.cjs +63 -9
- package/dist/capability-decision.d.cts +12 -6
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/mysql/client.d.ts +28 -0
- package/dist/src/mysql/client.js +220 -0
- package/dist/src/mysql/index.d.ts +2 -0
- package/dist/src/mysql/index.js +5 -0
- package/dist/src/mysql/stream.d.ts +113 -0
- package/dist/src/mysql/stream.js +648 -0
- package/package.json +5 -1
- package/src/index.ts +1 -0
- package/src/mysql/client.ts +159 -0
- package/src/mysql/index.ts +5 -0
- package/src/mysql/stream.ts +490 -0
|
@@ -0,0 +1,648 @@
|
|
|
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
|
+
/** MySQL's column type codes. */
|
|
12
|
+
export const MysqlType = {
|
|
13
|
+
DECIMAL: 0x00, TINY: 0x01, SHORT: 0x02, LONG: 0x03, FLOAT: 0x04, DOUBLE: 0x05, NULL: 0x06, TIMESTAMP: 0x07,
|
|
14
|
+
LONGLONG: 0x08, INT24: 0x09, DATE: 0x0a, TIME: 0x0b, DATETIME: 0x0c, YEAR: 0x0d, VARCHAR: 0x0f, BIT: 0x10,
|
|
15
|
+
JSON: 0xf5, NEWDECIMAL: 0xf6, ENUM: 0xf7, SET: 0xf8, TINY_BLOB: 0xf9, MEDIUM_BLOB: 0xfa, LONG_BLOB: 0xfb, BLOB: 0xfc,
|
|
16
|
+
VAR_STRING: 0xfd, STRING: 0xfe, GEOMETRY: 0xff,
|
|
17
|
+
};
|
|
18
|
+
export const MysqlFlag = { NOT_NULL: 0x0001, PRI_KEY: 0x0002, UNIQUE_KEY: 0x0004, MULTIPLE_KEY: 0x0008, BLOB: 0x0010, UNSIGNED: 0x0020, BINARY: 0x0080, AUTO_INCREMENT: 0x0200 };
|
|
19
|
+
const CAP = {
|
|
20
|
+
LONG_PASSWORD: 0x1, FOUND_ROWS: 0x2, LONG_FLAG: 0x4, CONNECT_WITH_DB: 0x8, PROTOCOL_41: 0x200, TRANSACTIONS: 0x2000,
|
|
21
|
+
SECURE_CONNECTION: 0x8000, MULTI_STATEMENTS: 0x10000, MULTI_RESULTS: 0x20000, PS_MULTI_RESULTS: 0x40000,
|
|
22
|
+
PLUGIN_AUTH: 0x80000, CONNECT_ATTRS: 0x100000, PLUGIN_AUTH_LENENC: 0x200000,
|
|
23
|
+
};
|
|
24
|
+
// what this server offers: no TLS, no compression, no deprecated-EOF (classic EOF packets), no session tracking
|
|
25
|
+
const SERVER_CAPS = CAP.LONG_PASSWORD | CAP.FOUND_ROWS | CAP.LONG_FLAG | CAP.CONNECT_WITH_DB | CAP.PROTOCOL_41 | CAP.TRANSACTIONS
|
|
26
|
+
| CAP.SECURE_CONNECTION | CAP.MULTI_STATEMENTS | CAP.MULTI_RESULTS | CAP.PS_MULTI_RESULTS | CAP.PLUGIN_AUTH | CAP.CONNECT_ATTRS | CAP.PLUGIN_AUTH_LENENC;
|
|
27
|
+
const STATUS = { IN_TRANS: 0x0001, AUTOCOMMIT: 0x0002, MORE_RESULTS: 0x0008 };
|
|
28
|
+
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 };
|
|
29
|
+
const UTF8MB4 = 45; // utf8mb4_general_ci
|
|
30
|
+
const BINARY_CHARSET = 63;
|
|
31
|
+
const NATIVE = 'mysql_native_password';
|
|
32
|
+
const MAX_PAYLOAD = 0xffffff;
|
|
33
|
+
const enc = new TextEncoder();
|
|
34
|
+
const dec = new TextDecoder();
|
|
35
|
+
const sha1 = (...parts) => { const h = createHash('sha1'); for (const p of parts)
|
|
36
|
+
h.update(p); return new Uint8Array(h.digest()); };
|
|
37
|
+
/** MySQL's stored form of a password for mysql_native_password: SHA1(SHA1(password)), hex. A pack keeps it when the
|
|
38
|
+
* password is made, beside whatever else it keeps, so the wire can admit the password without the pack holding it. */
|
|
39
|
+
export function nativePasswordHash(password) {
|
|
40
|
+
return Array.from(sha1(sha1(enc.encode(password))), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
41
|
+
}
|
|
42
|
+
/** mysql_native_password's token for `password` against `scramble`: SHA1(pw) XOR SHA1(scramble + SHA1(SHA1(pw))). */
|
|
43
|
+
export function nativePasswordToken(password, scramble) {
|
|
44
|
+
if (!password)
|
|
45
|
+
return new Uint8Array(0);
|
|
46
|
+
const stage1 = sha1(enc.encode(password));
|
|
47
|
+
const mix = sha1(scramble, sha1(stage1));
|
|
48
|
+
return stage1.map((b, i) => b ^ mix[i]);
|
|
49
|
+
}
|
|
50
|
+
// ── bytes ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
class Writer {
|
|
52
|
+
parts = [];
|
|
53
|
+
u8(v) { this.parts.push(v & 0xff); return this; }
|
|
54
|
+
u16(v) { return this.u8(v).u8(v >>> 8); }
|
|
55
|
+
u24(v) { return this.u8(v).u8(v >>> 8).u8(v >>> 16); }
|
|
56
|
+
u32(v) { return this.u16(v & 0xffff).u16(v >>> 16); }
|
|
57
|
+
u64(v) { const n = BigInt.asUintN(64, v); for (let i = 0n; i < 8n; i++)
|
|
58
|
+
this.u8(Number((n >> (8n * i)) & 0xffn)); return this; }
|
|
59
|
+
bytes(b) { for (const x of b)
|
|
60
|
+
this.parts.push(x); return this; }
|
|
61
|
+
str(s) { return this.bytes(enc.encode(s)); }
|
|
62
|
+
nul(s) { return this.str(s).u8(0); }
|
|
63
|
+
lenenc(v) {
|
|
64
|
+
const n = BigInt(v);
|
|
65
|
+
if (n < 251n)
|
|
66
|
+
return this.u8(Number(n));
|
|
67
|
+
if (n < 0x10000n)
|
|
68
|
+
return this.u8(0xfc).u16(Number(n));
|
|
69
|
+
if (n < 0x1000000n)
|
|
70
|
+
return this.u8(0xfd).u24(Number(n));
|
|
71
|
+
return this.u8(0xfe).u64(n);
|
|
72
|
+
}
|
|
73
|
+
lenencBytes(b) { return this.lenenc(b.length).bytes(b); }
|
|
74
|
+
lenencStr(s) { return this.lenencBytes(enc.encode(s)); }
|
|
75
|
+
done() { return Uint8Array.from(this.parts); }
|
|
76
|
+
}
|
|
77
|
+
class Reader {
|
|
78
|
+
b;
|
|
79
|
+
at = 0;
|
|
80
|
+
constructor(b) {
|
|
81
|
+
this.b = b;
|
|
82
|
+
}
|
|
83
|
+
left() { return this.b.length - this.at; }
|
|
84
|
+
u8() { if (this.at >= this.b.length)
|
|
85
|
+
throw new Error('packet too short'); return this.b[this.at++]; }
|
|
86
|
+
u16() { return this.u8() | (this.u8() << 8); }
|
|
87
|
+
u24() { return this.u16() | (this.u8() << 16); }
|
|
88
|
+
u32() { return (this.u16() | (this.u16() << 16)) >>> 0; }
|
|
89
|
+
u64() { let n = 0n; for (let i = 0n; i < 8n; i++)
|
|
90
|
+
n |= BigInt(this.u8()) << (8n * i); return n; }
|
|
91
|
+
take(n) { if (this.at + n > this.b.length)
|
|
92
|
+
throw new Error('packet too short'); const out = this.b.subarray(this.at, this.at + n); this.at += n; return out; }
|
|
93
|
+
rest() { return this.take(this.left()); }
|
|
94
|
+
nul() { 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; }
|
|
95
|
+
lenenc() {
|
|
96
|
+
const first = this.u8();
|
|
97
|
+
if (first < 0xfb)
|
|
98
|
+
return BigInt(first);
|
|
99
|
+
if (first === 0xfc)
|
|
100
|
+
return BigInt(this.u16());
|
|
101
|
+
if (first === 0xfd)
|
|
102
|
+
return BigInt(this.u24());
|
|
103
|
+
if (first === 0xfe)
|
|
104
|
+
return this.u64();
|
|
105
|
+
throw new Error('bad length-encoded integer');
|
|
106
|
+
}
|
|
107
|
+
lenencBytes() { return this.take(Number(this.lenenc())); }
|
|
108
|
+
}
|
|
109
|
+
// ── SQL text: statements, placeholders and literals (lexical only: what a statement means is the pack's) ──────────────
|
|
110
|
+
/** The offsets of `?` placeholders and `;` separators outside quotes, backticks and comments. */
|
|
111
|
+
function scan(sql) {
|
|
112
|
+
const placeholders = [];
|
|
113
|
+
const separators = [];
|
|
114
|
+
for (let i = 0; i < sql.length; i++) {
|
|
115
|
+
const c = sql[i];
|
|
116
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
117
|
+
for (i++; i < sql.length; i++) {
|
|
118
|
+
if (sql[i] === '\\' && c !== '`') {
|
|
119
|
+
i++;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (sql[i] === c) {
|
|
123
|
+
if (sql[i + 1] === c) {
|
|
124
|
+
i++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
else if (c === '#' || (c === '-' && sql[i + 1] === '-' && /\s/.test(sql[i + 2] ?? ' '))) {
|
|
132
|
+
while (i < sql.length && sql[i] !== '\n')
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
else if (c === '/' && sql[i + 1] === '*') {
|
|
136
|
+
const end = sql.indexOf('*/', i + 2);
|
|
137
|
+
i = end < 0 ? sql.length : end + 1;
|
|
138
|
+
}
|
|
139
|
+
else if (c === '?')
|
|
140
|
+
placeholders.push(i);
|
|
141
|
+
else if (c === ';')
|
|
142
|
+
separators.push(i);
|
|
143
|
+
}
|
|
144
|
+
return { placeholders, separators };
|
|
145
|
+
}
|
|
146
|
+
/** A multi-statement script's statements, empty ones dropped. */
|
|
147
|
+
export function splitStatements(sql) {
|
|
148
|
+
const { separators } = scan(sql);
|
|
149
|
+
const out = [];
|
|
150
|
+
let from = 0;
|
|
151
|
+
for (const at of [...separators, sql.length]) {
|
|
152
|
+
const s = sql.slice(from, at).trim();
|
|
153
|
+
if (s)
|
|
154
|
+
out.push(s);
|
|
155
|
+
from = at + 1;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
export function countPlaceholders(sql) { return scan(sql).placeholders.length; }
|
|
160
|
+
/** A parameter as a MySQL literal, written as PlanetScale's own driver formats one, so an engine that executes text
|
|
161
|
+
* reads both wires' statements alike. */
|
|
162
|
+
// source: https://github.com/planetscale/database-js/blob/v1.19.0/src/sanitization.ts "const re = /[\0\b\n\r\t\x1a\\"']/g"
|
|
163
|
+
// source: https://github.com/planetscale/database-js/blob/v1.19.0/src/text.ts "return `x'${digits.join('')}'`"
|
|
164
|
+
function literal(v) {
|
|
165
|
+
if (v === null)
|
|
166
|
+
return 'null';
|
|
167
|
+
if (typeof v === 'bigint' || typeof v === 'number')
|
|
168
|
+
return String(v);
|
|
169
|
+
if (v instanceof Uint8Array)
|
|
170
|
+
return `x'${Array.from(v, (b) => b.toString(16).padStart(2, '0')).join('')}'`;
|
|
171
|
+
if (typeof v === 'object')
|
|
172
|
+
return quote(v.date);
|
|
173
|
+
return quote(v);
|
|
174
|
+
}
|
|
175
|
+
const ESCAPES = { '"': '\\"', "'": "\\'", '\n': '\\n', '\r': '\\r', '\t': '\\t', '\\': '\\\\', '\0': '\\0', '\b': '\\b', '\x1a': '\\Z' };
|
|
176
|
+
function quote(text) {
|
|
177
|
+
return `'${text.replace(/[\0\b\n\r\t\x1a\\"']/g, (c) => ESCAPES[c])}'`;
|
|
178
|
+
}
|
|
179
|
+
/** `sql` with each `?` placeholder replaced by its parameter's literal. */
|
|
180
|
+
export function bindParameters(sql, params) {
|
|
181
|
+
const { placeholders } = scan(sql);
|
|
182
|
+
if (placeholders.length !== params.length)
|
|
183
|
+
throw new Error(`the statement has ${placeholders.length} placeholders and ${params.length} parameters`);
|
|
184
|
+
let out = '';
|
|
185
|
+
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
|
+
// ── binary protocol values ──────────────────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
|
191
|
+
function readParam(r, type, unsigned) {
|
|
192
|
+
switch (type) {
|
|
193
|
+
case MysqlType.NULL: return null;
|
|
194
|
+
case MysqlType.TINY: {
|
|
195
|
+
const v = r.u8();
|
|
196
|
+
return BigInt(unsigned ? v : (v << 24) >> 24);
|
|
197
|
+
}
|
|
198
|
+
case MysqlType.SHORT:
|
|
199
|
+
case MysqlType.YEAR: {
|
|
200
|
+
const v = r.u16();
|
|
201
|
+
return BigInt(unsigned ? v : (v << 16) >> 16);
|
|
202
|
+
}
|
|
203
|
+
case MysqlType.LONG:
|
|
204
|
+
case MysqlType.INT24: {
|
|
205
|
+
const v = r.u32();
|
|
206
|
+
return BigInt(unsigned ? v : v | 0);
|
|
207
|
+
}
|
|
208
|
+
case MysqlType.LONGLONG: {
|
|
209
|
+
const v = r.u64();
|
|
210
|
+
return unsigned ? v : BigInt.asIntN(64, v);
|
|
211
|
+
}
|
|
212
|
+
case MysqlType.FLOAT: {
|
|
213
|
+
const b = r.take(4);
|
|
214
|
+
return new DataView(b.buffer, b.byteOffset, 4).getFloat32(0, true);
|
|
215
|
+
}
|
|
216
|
+
case MysqlType.DOUBLE: {
|
|
217
|
+
const b = r.take(8);
|
|
218
|
+
return new DataView(b.buffer, b.byteOffset, 8).getFloat64(0, true);
|
|
219
|
+
}
|
|
220
|
+
case MysqlType.DATE:
|
|
221
|
+
case MysqlType.DATETIME:
|
|
222
|
+
case MysqlType.TIMESTAMP: {
|
|
223
|
+
const len = r.u8();
|
|
224
|
+
if (len === 0)
|
|
225
|
+
return { date: type === MysqlType.DATE ? '0000-00-00' : '0000-00-00 00:00:00' };
|
|
226
|
+
const y = r.u16(), m = r.u8(), d = r.u8();
|
|
227
|
+
const [h, i, s] = len >= 7 ? [r.u8(), r.u8(), r.u8()] : [0, 0, 0];
|
|
228
|
+
const us = len >= 11 ? r.u32() : 0;
|
|
229
|
+
const day = `${pad(y, 4)}-${pad(m)}-${pad(d)}`;
|
|
230
|
+
return { date: type === MysqlType.DATE ? day : `${day} ${pad(h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}` };
|
|
231
|
+
}
|
|
232
|
+
case MysqlType.TIME: {
|
|
233
|
+
const len = r.u8();
|
|
234
|
+
if (len === 0)
|
|
235
|
+
return { date: '00:00:00' };
|
|
236
|
+
const neg = r.u8(), days = r.u32(), h = r.u8(), i = r.u8(), s = r.u8();
|
|
237
|
+
const us = len >= 12 ? r.u32() : 0;
|
|
238
|
+
return { date: `${neg ? '-' : ''}${pad(days * 24 + h)}:${pad(i)}:${pad(s)}${us ? `.${pad(us, 6)}` : ''}` };
|
|
239
|
+
}
|
|
240
|
+
case MysqlType.TINY_BLOB:
|
|
241
|
+
case MysqlType.MEDIUM_BLOB:
|
|
242
|
+
case MysqlType.LONG_BLOB:
|
|
243
|
+
case MysqlType.BLOB:
|
|
244
|
+
case MysqlType.GEOMETRY:
|
|
245
|
+
return r.lenencBytes().slice();
|
|
246
|
+
default: {
|
|
247
|
+
// mysql_async sends text and bytes alike as VAR_STRING: text that is UTF-8 is a string (MySQL's string comparison and
|
|
248
|
+
// collation apply), anything else the bytes it is
|
|
249
|
+
const bytes = r.lenencBytes().slice();
|
|
250
|
+
try {
|
|
251
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return bytes;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
/** A text-protocol value in a column's binary encoding. */
|
|
260
|
+
function writeBinaryValue(w, column, value) {
|
|
261
|
+
const text = typeof value === 'string' ? value : dec.decode(value);
|
|
262
|
+
const unsigned = ((column.flags ?? 0) & MysqlFlag.UNSIGNED) !== 0;
|
|
263
|
+
switch (column.type) {
|
|
264
|
+
case MysqlType.TINY:
|
|
265
|
+
w.u8(Number(BigInt(text)));
|
|
266
|
+
return;
|
|
267
|
+
case MysqlType.SHORT:
|
|
268
|
+
case MysqlType.YEAR:
|
|
269
|
+
w.u16(Number(BigInt(text)));
|
|
270
|
+
return;
|
|
271
|
+
case MysqlType.LONG:
|
|
272
|
+
case MysqlType.INT24:
|
|
273
|
+
w.u32(Number(BigInt.asUintN(32, BigInt(text))));
|
|
274
|
+
return;
|
|
275
|
+
case MysqlType.LONGLONG:
|
|
276
|
+
w.u64(unsigned ? BigInt(text) : BigInt.asUintN(64, BigInt(text)));
|
|
277
|
+
return;
|
|
278
|
+
case MysqlType.FLOAT: {
|
|
279
|
+
const b = new Uint8Array(4);
|
|
280
|
+
new DataView(b.buffer).setFloat32(0, Number(text), true);
|
|
281
|
+
w.bytes(b);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
case MysqlType.DOUBLE: {
|
|
285
|
+
const b = new Uint8Array(8);
|
|
286
|
+
new DataView(b.buffer).setFloat64(0, Number(text), true);
|
|
287
|
+
w.bytes(b);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
case MysqlType.DATE:
|
|
291
|
+
case MysqlType.DATETIME:
|
|
292
|
+
case MysqlType.TIMESTAMP: {
|
|
293
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?)?$/.exec(text);
|
|
294
|
+
if (!m) {
|
|
295
|
+
w.u8(0);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const [y, mo, d, h, i, s] = [m[1], m[2], m[3], m[4] ?? '0', m[5] ?? '0', m[6] ?? '0'].map(Number);
|
|
299
|
+
const us = m[7] ? Number(m[7].padEnd(6, '0')) : 0;
|
|
300
|
+
if (!y && !mo && !d && !h && !i && !s && !us) {
|
|
301
|
+
w.u8(0);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (us) {
|
|
305
|
+
w.u8(11).u16(y).u8(mo).u8(d).u8(h).u8(i).u8(s).u32(us);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (h || i || s) {
|
|
309
|
+
w.u8(7).u16(y).u8(mo).u8(d).u8(h).u8(i).u8(s);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
w.u8(4).u16(y).u8(mo).u8(d);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case MysqlType.TIME: {
|
|
316
|
+
const m = /^(-)?(\d+):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/.exec(text);
|
|
317
|
+
if (!m) {
|
|
318
|
+
w.u8(0);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const hours = Number(m[2]);
|
|
322
|
+
const us = m[5] ? Number(m[5].padEnd(6, '0')) : 0;
|
|
323
|
+
const days = Math.floor(hours / 24);
|
|
324
|
+
if (!hours && m[3] === '00' && m[4] === '00' && !us) {
|
|
325
|
+
w.u8(0);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
w.u8(us ? 12 : 8).u8(m[1] ? 1 : 0).u32(days).u8(hours % 24).u8(Number(m[3])).u8(Number(m[4]));
|
|
329
|
+
if (us)
|
|
330
|
+
w.u32(us);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
default: w.lenencBytes(typeof value === 'string' ? enc.encode(value) : value);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// ── packets ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
337
|
+
function columnDefinition(c) {
|
|
338
|
+
const binary = c.charset === BINARY_CHARSET;
|
|
339
|
+
return new Writer().lenencStr('def').lenencStr(c.schema ?? '').lenencStr(c.table ?? '').lenencStr(c.orgTable ?? c.table ?? '')
|
|
340
|
+
.lenencStr(c.name).lenencStr(c.orgName ?? c.name).u8(0x0c).u16(c.charset ?? (binary ? BINARY_CHARSET : UTF8MB4))
|
|
341
|
+
.u32(c.length ?? 0).u8(c.type).u16(c.flags ?? 0).u8(c.decimals ?? 0).u16(0).done();
|
|
342
|
+
}
|
|
343
|
+
const errPacket = (e) => new Writer().u8(0xff).u16(e.errno).str('#').str((e.sqlstate + '00000').slice(0, 5)).str(e.message).done();
|
|
344
|
+
const eofPacket = (status, warnings = 0) => new Writer().u8(0xfe).u16(warnings).u16(status).done();
|
|
345
|
+
const okPacket = (affected, insertId, status, warnings = 0, info = '') => new Writer().u8(0x00).lenenc(affected).lenenc(insertId).u16(status).u16(warnings).str(info).done();
|
|
346
|
+
const unknownCommand = { errno: 1047, sqlstate: '08S01', message: 'Unknown command' };
|
|
347
|
+
/** A MySQL server as a TwinStream: the protocol here, the meaning in `options`. */
|
|
348
|
+
export function createMysqlStream(options) {
|
|
349
|
+
let nextConnection = 0;
|
|
350
|
+
return (sink) => {
|
|
351
|
+
const connectionId = ++nextConnection;
|
|
352
|
+
const scramble = new Uint8Array(randomBytes(20)).map((b) => (b & 0x7f) || 0x41); // printable, never NUL
|
|
353
|
+
let seq = 0;
|
|
354
|
+
let buffer = new Uint8Array(0);
|
|
355
|
+
let phase = 'handshake';
|
|
356
|
+
let clientCaps = 0;
|
|
357
|
+
let pending = null;
|
|
358
|
+
let session = null;
|
|
359
|
+
const statements = new Map();
|
|
360
|
+
let nextStatement = 0;
|
|
361
|
+
let chain = Promise.resolve();
|
|
362
|
+
const send = (payload) => {
|
|
363
|
+
// a payload of 2^24-1 bytes or more is split, ending with a shorter packet (possibly empty)
|
|
364
|
+
let at = 0;
|
|
365
|
+
do {
|
|
366
|
+
const part = payload.subarray(at, at + MAX_PAYLOAD);
|
|
367
|
+
sink.write(Uint8Array.from([...new Writer().u24(part.length).u8(seq).done(), ...part]));
|
|
368
|
+
seq = (seq + 1) & 0xff;
|
|
369
|
+
at += part.length;
|
|
370
|
+
if (part.length < MAX_PAYLOAD)
|
|
371
|
+
break;
|
|
372
|
+
} while (true);
|
|
373
|
+
};
|
|
374
|
+
const status = (more = false) => STATUS.AUTOCOMMIT | (session && options.inTransaction(session) ? STATUS.IN_TRANS : 0) | (more ? STATUS.MORE_RESULTS : 0);
|
|
375
|
+
const fail = (e) => send(errPacket(e));
|
|
376
|
+
// the greeting: protocol v10, the scramble, mysql_native_password
|
|
377
|
+
send(new Writer().u8(10).nul(options.version).u32(connectionId).bytes(scramble.subarray(0, 8)).u8(0)
|
|
378
|
+
.u16(SERVER_CAPS & 0xffff).u8(UTF8MB4).u16(STATUS.AUTOCOMMIT).u16(SERVER_CAPS >>> 16).u8(21).bytes(new Array(10).fill(0))
|
|
379
|
+
.bytes(scramble.subarray(8)).u8(0).nul(NATIVE).done());
|
|
380
|
+
const admit = async (token) => {
|
|
381
|
+
const { user, database } = pending;
|
|
382
|
+
const check = {
|
|
383
|
+
password: (password) => {
|
|
384
|
+
const want = nativePasswordToken(password, scramble);
|
|
385
|
+
return want.length === token.length && want.every((b, i) => b === token[i]);
|
|
386
|
+
},
|
|
387
|
+
// SHA1(token XOR SHA1(scramble + stored)) is SHA1(SHA1(password)) exactly when the client knew the password
|
|
388
|
+
storedHash: (hex) => {
|
|
389
|
+
if (!/^[0-9a-f]{40}$/i.test(hex))
|
|
390
|
+
return false;
|
|
391
|
+
if (token.length === 0)
|
|
392
|
+
return false; // an empty answer proves only an empty password, which a stored hash never is
|
|
393
|
+
if (token.length !== 20)
|
|
394
|
+
return false;
|
|
395
|
+
const stored = Uint8Array.from(hex.match(/../g).map((h) => parseInt(h, 16)));
|
|
396
|
+
const mix = sha1(scramble, stored);
|
|
397
|
+
const stage1 = token.map((b, i) => b ^ mix[i]);
|
|
398
|
+
return sha1(stage1).every((b, i) => b === stored[i]);
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
const answer = await options.connect(user, database, check);
|
|
402
|
+
if ('error' in answer) {
|
|
403
|
+
fail(answer.error);
|
|
404
|
+
phase = 'closed';
|
|
405
|
+
sink.end();
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
session = answer.session;
|
|
409
|
+
phase = 'ready';
|
|
410
|
+
send(okPacket(0, 0, status()));
|
|
411
|
+
};
|
|
412
|
+
const handshakeResponse = async (payload) => {
|
|
413
|
+
const r = new Reader(payload);
|
|
414
|
+
clientCaps = r.u32();
|
|
415
|
+
r.u32();
|
|
416
|
+
r.u8();
|
|
417
|
+
r.take(23);
|
|
418
|
+
const user = r.nul();
|
|
419
|
+
let token;
|
|
420
|
+
if (clientCaps & CAP.PLUGIN_AUTH_LENENC)
|
|
421
|
+
token = r.lenencBytes();
|
|
422
|
+
else if (clientCaps & CAP.SECURE_CONNECTION)
|
|
423
|
+
token = r.take(r.u8());
|
|
424
|
+
else
|
|
425
|
+
token = enc.encode(r.nul());
|
|
426
|
+
const database = (clientCaps & CAP.CONNECT_WITH_DB) && r.left() ? r.nul() || null : null;
|
|
427
|
+
const plugin = (clientCaps & CAP.PLUGIN_AUTH) && r.left() ? r.nul() : NATIVE;
|
|
428
|
+
pending = { user, database };
|
|
429
|
+
if (plugin !== NATIVE) {
|
|
430
|
+
// the client answered for another plugin: ask it to switch to mysql_native_password with the same scramble
|
|
431
|
+
phase = 'switch';
|
|
432
|
+
send(new Writer().u8(0xfe).nul(NATIVE).bytes(scramble).u8(0).done());
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
await admit(token.slice());
|
|
436
|
+
};
|
|
437
|
+
const resultSet = (outcome, binary, more) => {
|
|
438
|
+
if ('error' in outcome) {
|
|
439
|
+
fail(outcome.error);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (!('columns' in outcome)) {
|
|
443
|
+
send(okPacket(outcome.affectedRows, outcome.insertId ?? 0, status(more), outcome.warnings ?? 0, outcome.info ?? ''));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
send(new Writer().lenenc(outcome.columns.length).done());
|
|
447
|
+
for (const c of outcome.columns)
|
|
448
|
+
send(columnDefinition(c));
|
|
449
|
+
send(eofPacket(status(more)));
|
|
450
|
+
for (const row of outcome.rows) {
|
|
451
|
+
const w = new Writer();
|
|
452
|
+
if (!binary) {
|
|
453
|
+
for (const v of row) {
|
|
454
|
+
if (v === null)
|
|
455
|
+
w.u8(0xfb);
|
|
456
|
+
else if (typeof v === 'string')
|
|
457
|
+
w.lenencStr(v);
|
|
458
|
+
else
|
|
459
|
+
w.lenencBytes(v);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
w.u8(0x00);
|
|
464
|
+
const bitmap = new Array(Math.floor((outcome.columns.length + 7 + 2) / 8)).fill(0);
|
|
465
|
+
row.forEach((v, i) => { if (v === null)
|
|
466
|
+
bitmap[Math.floor((i + 2) / 8)] |= 1 << ((i + 2) % 8); });
|
|
467
|
+
w.bytes(bitmap);
|
|
468
|
+
row.forEach((v, i) => { if (v !== null)
|
|
469
|
+
writeBinaryValue(w, outcome.columns[i], v); });
|
|
470
|
+
}
|
|
471
|
+
send(w.done());
|
|
472
|
+
}
|
|
473
|
+
send(eofPacket(status(more), outcome.warnings ?? 0));
|
|
474
|
+
};
|
|
475
|
+
const query = async (sql) => {
|
|
476
|
+
const parts = clientCaps & CAP.MULTI_STATEMENTS ? splitStatements(sql) : [sql.trim()];
|
|
477
|
+
if (!parts.length) {
|
|
478
|
+
fail({ errno: 1065, sqlstate: '42000', message: 'Query was empty' });
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
for (let i = 0; i < parts.length; i++) {
|
|
482
|
+
const outcome = await options.execute(session, parts[i]);
|
|
483
|
+
const last = i === parts.length - 1 || 'error' in outcome;
|
|
484
|
+
resultSet(outcome, false, !last);
|
|
485
|
+
if ('error' in outcome)
|
|
486
|
+
return; // a script stops at its first error, as MySQL's does
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
const prepare = (sql) => {
|
|
490
|
+
const params = countPlaceholders(sql);
|
|
491
|
+
const id = ++nextStatement;
|
|
492
|
+
statements.set(id, { sql, params, types: [], long: new Map() });
|
|
493
|
+
// no result columns are named at prepare: the execute's result set carries them
|
|
494
|
+
send(new Writer().u8(0x00).u32(id).u16(0).u16(params).u8(0).u16(0).done());
|
|
495
|
+
if (params) {
|
|
496
|
+
for (let i = 0; i < params; i++)
|
|
497
|
+
send(columnDefinition({ name: '?', type: MysqlType.VAR_STRING, charset: BINARY_CHARSET }));
|
|
498
|
+
send(eofPacket(status()));
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const execute = async (r) => {
|
|
502
|
+
const id = r.u32();
|
|
503
|
+
r.u8();
|
|
504
|
+
r.u32(); // flags (no cursors), iteration count
|
|
505
|
+
const stmt = statements.get(id);
|
|
506
|
+
if (!stmt) {
|
|
507
|
+
fail({ errno: 1243, sqlstate: 'HY000', message: `Unknown prepared statement handler (${id}) given to mysqld_stmt_execute` });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const values = [];
|
|
511
|
+
if (stmt.params) {
|
|
512
|
+
const bitmap = r.take(Math.floor((stmt.params + 7) / 8));
|
|
513
|
+
if (r.u8() === 1) {
|
|
514
|
+
stmt.types = [];
|
|
515
|
+
for (let i = 0; i < stmt.params; i++)
|
|
516
|
+
stmt.types.push(r.u16());
|
|
517
|
+
}
|
|
518
|
+
for (let i = 0; i < stmt.params; i++) {
|
|
519
|
+
const long = stmt.long.get(i);
|
|
520
|
+
if (long) {
|
|
521
|
+
values.push(Uint8Array.from(long));
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (bitmap[Math.floor(i / 8)] & (1 << (i % 8))) {
|
|
525
|
+
values.push(null);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const t = stmt.types[i] ?? MysqlType.VAR_STRING;
|
|
529
|
+
values.push(readParam(r, t & 0xff, (t & 0x8000) !== 0));
|
|
530
|
+
}
|
|
531
|
+
stmt.long.clear();
|
|
532
|
+
}
|
|
533
|
+
let sql;
|
|
534
|
+
try {
|
|
535
|
+
sql = bindParameters(stmt.sql, values);
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
fail({ errno: 1210, sqlstate: 'HY000', message: `Incorrect arguments to mysqld_stmt_execute: ${e.message}` });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
resultSet(await options.execute(session, sql), true, false);
|
|
542
|
+
};
|
|
543
|
+
const command = async (payload) => {
|
|
544
|
+
seq = 1; // a command's packets begin a new sequence; the client's was 0
|
|
545
|
+
const r = new Reader(payload);
|
|
546
|
+
const code = r.u8();
|
|
547
|
+
switch (code) {
|
|
548
|
+
case COM.QUIT:
|
|
549
|
+
phase = 'closed';
|
|
550
|
+
if (session)
|
|
551
|
+
options.close?.(session);
|
|
552
|
+
sink.end();
|
|
553
|
+
return;
|
|
554
|
+
case COM.PING:
|
|
555
|
+
send(okPacket(0, 0, status()));
|
|
556
|
+
return;
|
|
557
|
+
case COM.QUERY:
|
|
558
|
+
await query(dec.decode(r.rest()));
|
|
559
|
+
return;
|
|
560
|
+
case COM.INIT_DB: {
|
|
561
|
+
const err = options.useDatabase ? await options.useDatabase(session, dec.decode(r.rest())) : unknownCommand;
|
|
562
|
+
if (err)
|
|
563
|
+
fail(err);
|
|
564
|
+
else
|
|
565
|
+
send(okPacket(0, 0, status()));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
case COM.STMT_PREPARE:
|
|
569
|
+
prepare(dec.decode(r.rest()));
|
|
570
|
+
return;
|
|
571
|
+
case COM.STMT_EXECUTE:
|
|
572
|
+
await execute(r);
|
|
573
|
+
return;
|
|
574
|
+
case COM.STMT_SEND_LONG_DATA: { // no reply
|
|
575
|
+
const stmt = statements.get(r.u32());
|
|
576
|
+
const param = r.u16();
|
|
577
|
+
if (stmt)
|
|
578
|
+
stmt.long.set(param, [...(stmt.long.get(param) ?? []), ...r.rest()]);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
case COM.STMT_CLOSE:
|
|
582
|
+
statements.delete(r.u32());
|
|
583
|
+
return; // no reply
|
|
584
|
+
case COM.STMT_RESET: {
|
|
585
|
+
const stmt = statements.get(r.u32());
|
|
586
|
+
stmt?.long.clear();
|
|
587
|
+
send(okPacket(0, 0, status()));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
default: fail(unknownCommand);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
// whole packets out of the bytes, a split payload rejoined (2^24-1-byte parts end with a shorter one)
|
|
594
|
+
let assembling = null;
|
|
595
|
+
const packets = () => {
|
|
596
|
+
const out = [];
|
|
597
|
+
while (buffer.length >= 4) {
|
|
598
|
+
const len = buffer[0] | (buffer[1] << 8) | (buffer[2] << 16);
|
|
599
|
+
if (buffer.length < 4 + len)
|
|
600
|
+
break;
|
|
601
|
+
const part = buffer.subarray(4, 4 + len);
|
|
602
|
+
buffer = buffer.slice(4 + len);
|
|
603
|
+
if (len === MAX_PAYLOAD) {
|
|
604
|
+
assembling = [...(assembling ?? []), ...part];
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
out.push(assembling ? Uint8Array.from([...assembling, ...part]) : part.slice());
|
|
608
|
+
assembling = null;
|
|
609
|
+
}
|
|
610
|
+
return out;
|
|
611
|
+
};
|
|
612
|
+
const handle = async (payload) => {
|
|
613
|
+
if (phase === 'closed')
|
|
614
|
+
return;
|
|
615
|
+
try {
|
|
616
|
+
if (phase === 'handshake') {
|
|
617
|
+
seq = 2;
|
|
618
|
+
await handshakeResponse(payload);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (phase === 'switch') {
|
|
622
|
+
seq = 4;
|
|
623
|
+
await admit(payload.slice());
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
await command(payload);
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
fail({ errno: 1105, sqlstate: 'HY000', message: e.message });
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
return {
|
|
633
|
+
data(chunk) {
|
|
634
|
+
if (phase === 'closed')
|
|
635
|
+
return;
|
|
636
|
+
const joined = new Uint8Array(buffer.length + chunk.length);
|
|
637
|
+
joined.set(buffer);
|
|
638
|
+
joined.set(chunk, buffer.length);
|
|
639
|
+
buffer = joined;
|
|
640
|
+
for (const p of packets())
|
|
641
|
+
chain = chain.then(() => handle(p));
|
|
642
|
+
},
|
|
643
|
+
settled: () => chain,
|
|
644
|
+
close: () => { if (phase !== 'closed' && session)
|
|
645
|
+
options.close?.(session); phase = 'closed'; },
|
|
646
|
+
};
|
|
647
|
+
};
|
|
648
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-core",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.71",
|
|
4
4
|
"description": "The kernel of Volter World: one log per twin, branches as pointers, checkpoints, the fold that keeps a twin current, the head that performs a write against the vendor, references, and the git plane. A twin package builds on it; the runtime serves it.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -120,6 +120,10 @@
|
|
|
120
120
|
"types": "./dist/src/redis/index.d.ts",
|
|
121
121
|
"default": "./dist/src/redis/index.js"
|
|
122
122
|
},
|
|
123
|
+
"./mysql": {
|
|
124
|
+
"types": "./dist/src/mysql/index.d.ts",
|
|
125
|
+
"default": "./dist/src/mysql/index.js"
|
|
126
|
+
},
|
|
123
127
|
"./clickhouse": {
|
|
124
128
|
"types": "./dist/src/clickhouse/index.d.ts",
|
|
125
129
|
"default": "./dist/src/clickhouse/index.js"
|
package/src/index.ts
CHANGED
|
@@ -87,6 +87,7 @@ export { acceptH2Connection, type H2Handler, type Http1Handler } from './h2.ts';
|
|
|
87
87
|
export { placeholderPng } from './placeholder-image.ts';
|
|
88
88
|
export { presignSigV4, signSigV4, verifySigV4, type SigV4Verdict } from './sigv4.ts';
|
|
89
89
|
export * as redis from './redis/index.ts';
|
|
90
|
+
export * as mysql from './mysql/index.ts';
|
|
90
91
|
export * as git from './git/index.ts';
|
|
91
92
|
|
|
92
93
|
// ── the scenario grammar (architecture, "Behaviour") ───────────────────────────────────────────
|