@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,547 @@
|
|
|
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.js";
|
|
7
|
+
const MAX_PACKET = 16 * 1024 * 1024 - 2;
|
|
8
|
+
const MAX_STATEMENTS = 1024;
|
|
9
|
+
const CAPABILITIES = 1 | 4 | 8 | 512 | 8192 | 32768 | 131072 | 262144 | 524288;
|
|
10
|
+
class MysqlError extends Error {
|
|
11
|
+
errno;
|
|
12
|
+
state;
|
|
13
|
+
constructor(message, errno = 1235, state = '42000') {
|
|
14
|
+
super(message);
|
|
15
|
+
this.errno = errno;
|
|
16
|
+
this.state = state;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const integer = (value, bytes) => {
|
|
20
|
+
const out = Buffer.alloc(bytes);
|
|
21
|
+
if (bytes === 8)
|
|
22
|
+
out.writeBigUInt64LE(BigInt.asUintN(64, BigInt(value)));
|
|
23
|
+
else
|
|
24
|
+
out.writeUIntLE(Number(value), 0, bytes);
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
const length = (n) => n < 251 ? integer(n, 1) : n < 65536 ? Buffer.concat([Buffer.from([252]), integer(n, 2)])
|
|
28
|
+
: n < 16777216 ? Buffer.concat([Buffer.from([253]), integer(n, 3)]) : Buffer.concat([Buffer.from([254]), integer(n, 8)]);
|
|
29
|
+
const string = (value) => { const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value); return Buffer.concat([length(bytes.length), bytes]); };
|
|
30
|
+
class Reader {
|
|
31
|
+
buffer;
|
|
32
|
+
offset = 0;
|
|
33
|
+
constructor(buffer) {
|
|
34
|
+
this.buffer = buffer;
|
|
35
|
+
}
|
|
36
|
+
bytes(n) {
|
|
37
|
+
if (!Number.isSafeInteger(n) || n < 0 || this.offset + n > this.buffer.length)
|
|
38
|
+
throw new MysqlError('Malformed MySQL packet', 1835, 'HY000');
|
|
39
|
+
const value = this.buffer.subarray(this.offset, this.offset + n);
|
|
40
|
+
this.offset += n;
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
uint(n) { return this.bytes(n).readUIntLE(0, n); }
|
|
44
|
+
len() { 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); })(); }
|
|
45
|
+
str() { return this.bytes(this.len()); }
|
|
46
|
+
zero() { const end = this.buffer.indexOf(0, this.offset); if (end < 0)
|
|
47
|
+
throw new MysqlError('Unterminated string', 1835); const result = this.bytes(end - this.offset).toString(); this.bytes(1); return result; }
|
|
48
|
+
}
|
|
49
|
+
/** Placeholders only in executable SQL. A prepare never executes a mutation. */
|
|
50
|
+
export function mysqlPlaceholders(sql) {
|
|
51
|
+
const positions = [];
|
|
52
|
+
let quote = '';
|
|
53
|
+
let comment = '';
|
|
54
|
+
for (let i = 0; i < sql.length; i++) {
|
|
55
|
+
const c = sql[i];
|
|
56
|
+
const next = sql[i + 1];
|
|
57
|
+
if (comment === 'line') {
|
|
58
|
+
if (c === '\n')
|
|
59
|
+
comment = '';
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (comment === 'block') {
|
|
63
|
+
if (c === '*' && next === '/') {
|
|
64
|
+
comment = '';
|
|
65
|
+
i++;
|
|
66
|
+
}
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (quote) {
|
|
70
|
+
if (c === '\\' && quote !== '`') {
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (c === quote) {
|
|
75
|
+
if (next === quote)
|
|
76
|
+
i++;
|
|
77
|
+
else
|
|
78
|
+
quote = '';
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (c === "'" || c === '"' || c === '`')
|
|
83
|
+
quote = c;
|
|
84
|
+
else if (c === '#' || (c === '-' && next === '-')) {
|
|
85
|
+
comment = 'line';
|
|
86
|
+
}
|
|
87
|
+
else if (c === '/' && next === '*') {
|
|
88
|
+
comment = 'block';
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
else if (c === '?')
|
|
92
|
+
positions.push(i);
|
|
93
|
+
}
|
|
94
|
+
return positions;
|
|
95
|
+
}
|
|
96
|
+
function bind(sql, positions, values) {
|
|
97
|
+
let result = '';
|
|
98
|
+
let previous = 0;
|
|
99
|
+
positions.forEach((position, i) => { result += sql.slice(previous, position) + values[i]; previous = position + 1; });
|
|
100
|
+
return result + sql.slice(previous);
|
|
101
|
+
}
|
|
102
|
+
const quote = (value) => {
|
|
103
|
+
const text = value.toString('utf8');
|
|
104
|
+
if (!Buffer.from(text).equals(value))
|
|
105
|
+
return `X'${value.toString('hex')}'`;
|
|
106
|
+
return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "''").replace(/\0/g, '\\0')}'`;
|
|
107
|
+
};
|
|
108
|
+
const TYPES = { NULL_TYPE: 6, INT8: 1, UINT8: 1, INT16: 2, UINT16: 2, INT24: 9, UINT24: 9,
|
|
109
|
+
INT32: 3, UINT32: 3, INT64: 8, UINT64: 8, FLOAT32: 4, FLOAT64: 5, TIMESTAMP: 7, DATE: 10, TIME: 11,
|
|
110
|
+
DATETIME: 12, YEAR: 13, DECIMAL: 246, TEXT: 252, BLOB: 252, VARCHAR: 253, VARBINARY: 253, CHAR: 254,
|
|
111
|
+
BINARY: 254, BIT: 16, ENUM: 254, SET: 254, JSON: 245 };
|
|
112
|
+
function column(field) {
|
|
113
|
+
const fixed = Buffer.concat([integer(field.charset ?? 45, 2), integer(field.columnLength ?? 1024, 4),
|
|
114
|
+
integer(TYPES[field.type ?? 'NULL_TYPE'] ?? 253, 1), integer(field.flags ?? 0, 2), Buffer.from([0, 0, 0])]);
|
|
115
|
+
return Buffer.concat([string('def'), string(field.database ?? ''), string(field.table ?? ''), string(field.orgTable ?? ''),
|
|
116
|
+
string(field.name), string(field.orgName ?? field.name), Buffer.from([12]), fixed]);
|
|
117
|
+
}
|
|
118
|
+
function dateValue(reader, type) {
|
|
119
|
+
const size = reader.uint(1);
|
|
120
|
+
if (size === 0)
|
|
121
|
+
return "'0000-00-00 00:00:00'";
|
|
122
|
+
if (type === 11) {
|
|
123
|
+
if (size !== 8 && size !== 12)
|
|
124
|
+
throw new MysqlError('Invalid TIME parameter', 1835);
|
|
125
|
+
const sign = reader.uint(1);
|
|
126
|
+
const days = reader.uint(4);
|
|
127
|
+
const hours = days * 24 + reader.uint(1);
|
|
128
|
+
const minute = reader.uint(1);
|
|
129
|
+
const second = reader.uint(1);
|
|
130
|
+
const micro = size === 12 ? reader.uint(4) : 0;
|
|
131
|
+
return `'${sign ? '-' : ''}${hours}:${String(minute).padStart(2, '0')}:${String(second).padStart(2, '0')}.${String(micro).padStart(6, '0')}'`;
|
|
132
|
+
}
|
|
133
|
+
if (![4, 7, 11].includes(size))
|
|
134
|
+
throw new MysqlError('Invalid date parameter', 1835);
|
|
135
|
+
const year = reader.uint(2);
|
|
136
|
+
const month = reader.uint(1);
|
|
137
|
+
const day = reader.uint(1);
|
|
138
|
+
const h = size >= 7 ? reader.uint(1) : 0;
|
|
139
|
+
const m = size >= 7 ? reader.uint(1) : 0;
|
|
140
|
+
const s = size >= 7 ? reader.uint(1) : 0;
|
|
141
|
+
const micro = size === 11 ? reader.uint(4) : 0;
|
|
142
|
+
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')}'`;
|
|
143
|
+
}
|
|
144
|
+
function parameters(reader, statement) {
|
|
145
|
+
const n = statement.positions.length;
|
|
146
|
+
if (n === 0)
|
|
147
|
+
return [];
|
|
148
|
+
const nulls = reader.bytes(Math.ceil(n / 8));
|
|
149
|
+
const fresh = reader.uint(1);
|
|
150
|
+
if (fresh === 1)
|
|
151
|
+
statement.types = Array.from({ length: n }, () => ({ type: reader.uint(1), unsigned: (reader.uint(1) & 128) !== 0 }));
|
|
152
|
+
else if (fresh !== 0)
|
|
153
|
+
throw new MysqlError('Invalid parameter bind flag', 1835);
|
|
154
|
+
if (!statement.types)
|
|
155
|
+
throw new MysqlError('Parameter types were not bound', 2031, 'HY000');
|
|
156
|
+
return statement.types.map(({ type, unsigned }, i) => {
|
|
157
|
+
if ((nulls[Math.floor(i / 8)] & (1 << (i % 8))) || type === 6)
|
|
158
|
+
return 'NULL';
|
|
159
|
+
const width = type === 1 ? 1 : type === 2 || type === 13 ? 2 : type === 3 || type === 9 ? 4 : 0;
|
|
160
|
+
if (width) {
|
|
161
|
+
const bytes = reader.bytes(width);
|
|
162
|
+
return String(unsigned ? bytes.readUIntLE(0, width) : bytes.readIntLE(0, width));
|
|
163
|
+
}
|
|
164
|
+
if (type === 8) {
|
|
165
|
+
const bytes = reader.bytes(8);
|
|
166
|
+
return String(unsigned ? bytes.readBigUInt64LE() : bytes.readBigInt64LE());
|
|
167
|
+
}
|
|
168
|
+
if (type === 4 || type === 5) {
|
|
169
|
+
const bytes = reader.bytes(type === 4 ? 4 : 8);
|
|
170
|
+
const value = type === 4 ? bytes.readFloatLE() : bytes.readDoubleLE();
|
|
171
|
+
if (!Number.isFinite(value))
|
|
172
|
+
throw new MysqlError('Non-finite parameter', 1366);
|
|
173
|
+
return String(value);
|
|
174
|
+
}
|
|
175
|
+
if ([7, 10, 11, 12].includes(type))
|
|
176
|
+
return dateValue(reader, type);
|
|
177
|
+
if ([0, 15, 16, 245, 246, 249, 250, 251, 252, 253, 254].includes(type))
|
|
178
|
+
return quote(reader.str());
|
|
179
|
+
throw new MysqlError(`Parameter type ${type} is not supported`);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
function binaryCell(field, bytes) {
|
|
183
|
+
const type = TYPES[field.type ?? 'NULL_TYPE'] ?? 253;
|
|
184
|
+
const text = bytes.toString();
|
|
185
|
+
const unsigned = ((field.flags ?? 0) & 32) !== 0;
|
|
186
|
+
const width = type === 1 ? 1 : type === 2 || type === 13 ? 2 : type === 3 || type === 9 ? 4 : 0;
|
|
187
|
+
if (width) {
|
|
188
|
+
const out = Buffer.alloc(width);
|
|
189
|
+
if (unsigned)
|
|
190
|
+
out.writeUIntLE(Number(text), 0, width);
|
|
191
|
+
else
|
|
192
|
+
out.writeIntLE(Number(text), 0, width);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
if (type === 8)
|
|
196
|
+
return integer(BigInt(text), 8);
|
|
197
|
+
if (type === 4 || type === 5) {
|
|
198
|
+
const out = Buffer.alloc(type === 4 ? 4 : 8);
|
|
199
|
+
if (type === 4)
|
|
200
|
+
out.writeFloatLE(Number(text));
|
|
201
|
+
else
|
|
202
|
+
out.writeDoubleLE(Number(text));
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
if ([7, 10, 12].includes(type)) {
|
|
206
|
+
const parts = text.match(/^(\d{4})-(\d\d)-(\d\d)(?:[ T](\d\d):(\d\d):(\d\d)(?:\.(\d+))?)?/);
|
|
207
|
+
if (!parts)
|
|
208
|
+
throw new MysqlError('Invalid stored datetime', 1292, '22007');
|
|
209
|
+
const values = parts.slice(1, 7).map((part) => Number(part ?? 0));
|
|
210
|
+
const micro = Number((parts[7] ?? '').padEnd(6, '0').slice(0, 6));
|
|
211
|
+
return Buffer.concat([Buffer.from([type === 10 ? 4 : 11]), integer(values[0], 2), Buffer.from(values.slice(1, 3)),
|
|
212
|
+
...(type === 10 ? [] : [Buffer.from(values.slice(3)), integer(micro, 4)])]);
|
|
213
|
+
}
|
|
214
|
+
if (type === 11)
|
|
215
|
+
throw new MysqlError('Binary TIME results are not modeled');
|
|
216
|
+
return string(bytes);
|
|
217
|
+
}
|
|
218
|
+
export function createPlanetscaleMysqlStream(options) {
|
|
219
|
+
const authorization = options.authorization ?? `Basic ${Buffer.from(`${options.username || 'twin'}:${options.password || 'twin'}`).toString('base64')}`;
|
|
220
|
+
let connectionId = 0;
|
|
221
|
+
return (socket) => {
|
|
222
|
+
let buffer = Buffer.alloc(0);
|
|
223
|
+
let sequence = 0;
|
|
224
|
+
let authenticated = false;
|
|
225
|
+
let closing = false;
|
|
226
|
+
let gone = false;
|
|
227
|
+
let session;
|
|
228
|
+
let autocommit = true;
|
|
229
|
+
let queue = Promise.resolve();
|
|
230
|
+
let queued = 0;
|
|
231
|
+
let nextStatement = 0;
|
|
232
|
+
const statements = new Map();
|
|
233
|
+
let longData;
|
|
234
|
+
const salt = randomBytes(20);
|
|
235
|
+
const end = () => { if (!gone) {
|
|
236
|
+
gone = true;
|
|
237
|
+
socket.end();
|
|
238
|
+
} };
|
|
239
|
+
const inTransaction = () => session?.vitessSession.inTransaction === true;
|
|
240
|
+
const status = () => (autocommit ? 2 : 0) | (inTransaction() ? 1 : 0);
|
|
241
|
+
const send = (payload) => {
|
|
242
|
+
if (payload.length > MAX_PACKET)
|
|
243
|
+
throw new MysqlError('Result packet too large', 1153, '08S01');
|
|
244
|
+
socket.write(Buffer.concat([integer(payload.length, 3), integer(sequence++ % 256, 1), payload]));
|
|
245
|
+
};
|
|
246
|
+
const ok = (rows = 0, insert = 0) => send(Buffer.concat([Buffer.from([0]), length(rows), length(insert), integer(status(), 2), integer(0, 2)]));
|
|
247
|
+
const eof = () => send(Buffer.concat([Buffer.from([254]), integer(0, 2), integer(status(), 2)]));
|
|
248
|
+
const fail = (error) => {
|
|
249
|
+
// A statement the frontend parses itself (a prepare) fails with the engine's own errno.
|
|
250
|
+
const e = error instanceof MysqlError ? error : error instanceof SqlError ? new MysqlError(error.message, error.errno, error.sqlState) : new MysqlError(String(error), 1105, 'HY000');
|
|
251
|
+
send(Buffer.concat([Buffer.from([255]), integer(e.errno, 2), Buffer.from(`#${e.state}${e.message}`)]));
|
|
252
|
+
};
|
|
253
|
+
const rpc = async (method, body) => {
|
|
254
|
+
const response = await options.upstream(new Request(`http://twin/psdb.v1alpha1.Database/${method}`, {
|
|
255
|
+
method: 'POST', headers: { authorization, 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30000),
|
|
256
|
+
}));
|
|
257
|
+
const data = await response.json();
|
|
258
|
+
const returned = data.session;
|
|
259
|
+
if (returned && session && returned.vitessSession.sessionUUID !== session.vitessSession.sessionUUID) {
|
|
260
|
+
// The upstream twin no longer knows this session (it restarted or the session was closed):
|
|
261
|
+
// it minted a fresh one, so any buffered transaction is gone. A real server would have
|
|
262
|
+
// dropped the connection; do the same rather than continue on a silently different session.
|
|
263
|
+
session = undefined;
|
|
264
|
+
end();
|
|
265
|
+
throw new MysqlError('Lost connection to the PlanetScale twin during query: the upstream session was replaced', 2013, 'HY000');
|
|
266
|
+
}
|
|
267
|
+
if (returned)
|
|
268
|
+
session = returned;
|
|
269
|
+
const error = data.error;
|
|
270
|
+
if (!response.ok || error) {
|
|
271
|
+
const message = error?.message ?? String(data.message ?? `HTTP ${response.status}`);
|
|
272
|
+
throw new MysqlError(message, Number(message.match(/errno (\d+)/)?.[1] ?? 1105), message.match(/sqlstate ([A-Z0-9]+)/)?.[1] ?? 'HY000');
|
|
273
|
+
}
|
|
274
|
+
return data;
|
|
275
|
+
};
|
|
276
|
+
const normalize = (query) => {
|
|
277
|
+
// The engine's own system variables (the psdb wire reads the same table), with this
|
|
278
|
+
// connection's autocommit state.
|
|
279
|
+
const variables = Object.fromEntries(Object.entries({ ...SYSTEM_VARIABLES, max_allowed_packet: MAX_PACKET, autocommit: autocommit ? 1 : 0 })
|
|
280
|
+
.map(([k, v]) => [k, typeof v === 'number' ? String(v) : `'${String(v).replace(/'/g, "''")}'`]));
|
|
281
|
+
let out = query;
|
|
282
|
+
const tokens = tokenize(query);
|
|
283
|
+
for (let i = tokens.length - 1; i >= 0; i--) {
|
|
284
|
+
const token = tokens[i];
|
|
285
|
+
if (token.kind !== 'ident' || !token.value.startsWith('@@'))
|
|
286
|
+
continue;
|
|
287
|
+
let name = token.value.slice(2).toLowerCase();
|
|
288
|
+
let end = token.pos - 1 + token.value.length;
|
|
289
|
+
// @@session.x / @@global.x / @@local.x: the tokenizer yields the scope, a '.', then the name.
|
|
290
|
+
const scoped = ['session', 'global', 'local'].includes(name) && tokens[i + 1]?.kind === 'punct' && tokens[i + 1].value === '.' && tokens[i + 2]?.kind === 'ident';
|
|
291
|
+
if (scoped) {
|
|
292
|
+
const target = tokens[i + 2];
|
|
293
|
+
name = target.value.toLowerCase();
|
|
294
|
+
end = target.pos - 1 + target.value.length;
|
|
295
|
+
}
|
|
296
|
+
if (!(name in variables))
|
|
297
|
+
throw new MysqlError(`Unknown system variable '${name}'`, 1193, 'HY000');
|
|
298
|
+
out = out.slice(0, token.pos - 1) + variables[name] + out.slice(end);
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
};
|
|
302
|
+
const execute = async (query) => {
|
|
303
|
+
options.onQuery?.(query);
|
|
304
|
+
// Connection encoding is UTF-8. Other session settings stay loud instead of being no-ops.
|
|
305
|
+
if (/^SET NAMES (?:utf8mb4|utf8)\s*;?$/i.test(query.trim()))
|
|
306
|
+
return {};
|
|
307
|
+
const auto = query.trim().match(/^SET (?:@@(?:session\.)?)?autocommit\s*=\s*([01])\s*;?$/i);
|
|
308
|
+
if (auto) {
|
|
309
|
+
if (auto[1] === '1' && inTransaction())
|
|
310
|
+
await rpc('Execute', { query: 'COMMIT', session });
|
|
311
|
+
autocommit = auto[1] === '1';
|
|
312
|
+
return {};
|
|
313
|
+
}
|
|
314
|
+
if (!autocommit && !inTransaction() && !/^\s*(?:BEGIN|START TRANSACTION|COMMIT|ROLLBACK)\b/i.test(query))
|
|
315
|
+
await rpc('Execute', { query: 'BEGIN', session });
|
|
316
|
+
return read(query);
|
|
317
|
+
};
|
|
318
|
+
// A statement run for its result only: no autocommit bookkeeping (MySQL opens no transaction
|
|
319
|
+
// on a prepare), so the prepare probe below cannot start one.
|
|
320
|
+
const read = async (query) => (await rpc('Execute', { query: normalize(query), session })).result ?? {};
|
|
321
|
+
const result = (value, binary) => {
|
|
322
|
+
const fields = value.fields ?? [];
|
|
323
|
+
if (!fields.length) {
|
|
324
|
+
ok(Number(value.rowsAffected ?? 0), Number(value.insertId ?? 0));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
send(length(fields.length));
|
|
328
|
+
fields.forEach((field) => send(column(field)));
|
|
329
|
+
eof();
|
|
330
|
+
for (const row of value.rows ?? []) {
|
|
331
|
+
const data = Buffer.from(row.values ?? '', 'base64');
|
|
332
|
+
let offset = 0;
|
|
333
|
+
const nulls = Buffer.alloc(Math.ceil((fields.length + 2) / 8));
|
|
334
|
+
const values = row.lengths.map((n, i) => {
|
|
335
|
+
const count = Number(n);
|
|
336
|
+
if (count < 0) {
|
|
337
|
+
nulls[Math.floor((i + 2) / 8)] |= 1 << ((i + 2) % 8);
|
|
338
|
+
return binary ? Buffer.alloc(0) : Buffer.from([251]);
|
|
339
|
+
}
|
|
340
|
+
const bytes = data.subarray(offset, offset + count);
|
|
341
|
+
offset += count;
|
|
342
|
+
return binary ? binaryCell(fields[i], bytes) : string(bytes);
|
|
343
|
+
});
|
|
344
|
+
send(Buffer.concat(binary ? [Buffer.from([0]), nulls, ...values] : values));
|
|
345
|
+
}
|
|
346
|
+
eof();
|
|
347
|
+
};
|
|
348
|
+
const closeSession = async () => { if (session) {
|
|
349
|
+
try {
|
|
350
|
+
await rpc('CloseSession', { session });
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
session = undefined;
|
|
354
|
+
}
|
|
355
|
+
} };
|
|
356
|
+
const handshake = Buffer.concat([Buffer.from([10]), Buffer.from(`${SERVER_VERSION}\0`), integer(++connectionId, 4), salt.subarray(0, 8), Buffer.from([0]),
|
|
357
|
+
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')]);
|
|
358
|
+
send(handshake);
|
|
359
|
+
const command = async (payload, incomingSequence) => {
|
|
360
|
+
sequence = (incomingSequence + 1) % 256;
|
|
361
|
+
const r = new Reader(payload);
|
|
362
|
+
if (!authenticated) {
|
|
363
|
+
const capabilities = r.uint(4);
|
|
364
|
+
r.bytes(4);
|
|
365
|
+
const charset = r.uint(1);
|
|
366
|
+
r.bytes(23);
|
|
367
|
+
if ((capabilities & 512) === 0 || ![33, 45, 46, 224, 255].includes(charset))
|
|
368
|
+
throw new MysqlError('Unsupported handshake or character set', 1047, '08S01');
|
|
369
|
+
const username = r.zero();
|
|
370
|
+
const auth = capabilities & 32768 ? r.bytes(r.uint(1)) : Buffer.from(r.zero(), 'binary');
|
|
371
|
+
const database = capabilities & 8 ? r.zero() : '';
|
|
372
|
+
if (!username || !auth.length || options.username && username !== options.username)
|
|
373
|
+
throw new MysqlError('Access denied', 1045, '28000');
|
|
374
|
+
if (options.password) {
|
|
375
|
+
const sha = (bytes) => createHash('sha1').update(bytes).digest();
|
|
376
|
+
const first = sha(Buffer.from(options.password));
|
|
377
|
+
const scramble = sha(Buffer.concat([salt, sha(first)]));
|
|
378
|
+
const expected = Buffer.from(first.map((byte, i) => byte ^ scramble[i]));
|
|
379
|
+
if (auth.length !== expected.length || !timingSafeEqual(auth, expected))
|
|
380
|
+
throw new MysqlError('Access denied', 1045, '28000');
|
|
381
|
+
}
|
|
382
|
+
if (options.database && database && database !== options.database)
|
|
383
|
+
throw new MysqlError(`Unknown database '${database}'`, 1049, '42000');
|
|
384
|
+
await rpc('CreateSession', {});
|
|
385
|
+
authenticated = true;
|
|
386
|
+
ok();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const op = r.uint(1);
|
|
390
|
+
if (op === 1) {
|
|
391
|
+
closing = true;
|
|
392
|
+
await closeSession();
|
|
393
|
+
end();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (op === 14) {
|
|
397
|
+
ok();
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
if (op === 2) {
|
|
401
|
+
const database = payload.subarray(1).toString();
|
|
402
|
+
if (options.database && database !== options.database)
|
|
403
|
+
throw new MysqlError(`Unknown database '${database}'`, 1049);
|
|
404
|
+
ok();
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (op === 3) {
|
|
408
|
+
result(await execute(payload.subarray(1).toString()), false);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (op === 22) {
|
|
412
|
+
if (statements.size >= MAX_STATEMENTS)
|
|
413
|
+
throw new MysqlError('Too many prepared statements', 1461);
|
|
414
|
+
const sql = payload.subarray(1).toString();
|
|
415
|
+
const positions = mysqlPlaceholders(sql);
|
|
416
|
+
const probe = normalize(bind(sql, positions, positions.map(() => '0')));
|
|
417
|
+
const parsed = parseStatement(probe);
|
|
418
|
+
const fields = parsed.kind === 'select' ? (await read(probe)).fields ?? [] : [];
|
|
419
|
+
const id = ++nextStatement;
|
|
420
|
+
statements.set(id, { sql, positions });
|
|
421
|
+
send(Buffer.concat([Buffer.from([0]), integer(id, 4), integer(fields.length, 2), integer(positions.length, 2), Buffer.from([0]), integer(0, 2)]));
|
|
422
|
+
positions.forEach(() => send(column({ name: '?', type: 'VARCHAR' })));
|
|
423
|
+
if (positions.length)
|
|
424
|
+
eof();
|
|
425
|
+
fields.forEach((field) => send(column(field)));
|
|
426
|
+
if (fields.length)
|
|
427
|
+
eof();
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (op === 25) {
|
|
431
|
+
statements.delete(r.uint(4));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (op === 26) {
|
|
435
|
+
const statement = statements.get(r.uint(4));
|
|
436
|
+
if (!statement)
|
|
437
|
+
throw new MysqlError('Unknown prepared statement', 1243);
|
|
438
|
+
statement.types = undefined;
|
|
439
|
+
ok();
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (op === 24) {
|
|
443
|
+
longData = new MysqlError('COM_STMT_SEND_LONG_DATA is not modeled');
|
|
444
|
+
return;
|
|
445
|
+
} // the protocol defines no reply; the next execute answers
|
|
446
|
+
if (op === 23) {
|
|
447
|
+
if (longData) {
|
|
448
|
+
const error = longData;
|
|
449
|
+
longData = undefined;
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
const statement = statements.get(r.uint(4));
|
|
453
|
+
if (!statement)
|
|
454
|
+
throw new MysqlError('Unknown prepared statement', 1243);
|
|
455
|
+
if (r.uint(1) !== 0 || r.uint(4) !== 1)
|
|
456
|
+
throw new MysqlError('Prepared cursors/iterations are not modeled');
|
|
457
|
+
const values = parameters(r, statement); // mysql2 reserves allocation padding for NULL parameters; it is not another parameter.
|
|
458
|
+
result(await execute(bind(statement.sql, statement.positions, values)), true);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
throw new MysqlError(`MySQL command ${op} is not modeled`, 1047, '08S01');
|
|
462
|
+
};
|
|
463
|
+
const connection = {
|
|
464
|
+
settled: () => queue,
|
|
465
|
+
data(bytes) {
|
|
466
|
+
if (closing || gone)
|
|
467
|
+
return;
|
|
468
|
+
buffer = Buffer.concat([buffer, Buffer.from(bytes)]);
|
|
469
|
+
if (buffer.length > MAX_PACKET + 4) {
|
|
470
|
+
closing = true;
|
|
471
|
+
end();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
while (buffer.length >= 4) {
|
|
475
|
+
const size = buffer.readUIntLE(0, 3);
|
|
476
|
+
if (size > MAX_PACKET || ++queued > 64) {
|
|
477
|
+
closing = true;
|
|
478
|
+
end();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (buffer.length < size + 4) {
|
|
482
|
+
queued--;
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
const seq = buffer[3];
|
|
486
|
+
const payload = Buffer.from(buffer.subarray(4, size + 4));
|
|
487
|
+
buffer = buffer.subarray(size + 4);
|
|
488
|
+
queue = queue.then(async () => { if (!closing)
|
|
489
|
+
await command(payload, seq); }).catch((error) => { if (!gone)
|
|
490
|
+
fail(error); if (!authenticated)
|
|
491
|
+
end(); }).finally(() => { queued--; });
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
close() {
|
|
495
|
+
closing = true;
|
|
496
|
+
gone = true;
|
|
497
|
+
queue = queue.then(closeSession).catch(() => { });
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
return connection;
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
export async function startPlanetscaleMysql(options) {
|
|
504
|
+
const { createServer } = await import('node:net');
|
|
505
|
+
const upstream = new URL(options.upstream);
|
|
506
|
+
if (!['http:', 'https:'].includes(upstream.protocol) || !['127.0.0.1', 'localhost', '[::1]'].includes(upstream.hostname))
|
|
507
|
+
throw new Error('PlanetScale native frontend requires a local HTTP twin URL');
|
|
508
|
+
const authorization = `Basic ${Buffer.from(`${decodeURIComponent(upstream.username) || options.username || 'twin'}:${decodeURIComponent(upstream.password) || options.password || 'twin'}`).toString('base64')}`;
|
|
509
|
+
upstream.username = '';
|
|
510
|
+
upstream.password = '';
|
|
511
|
+
const stream = createPlanetscaleMysqlStream({
|
|
512
|
+
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 }),
|
|
513
|
+
authorization,
|
|
514
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
515
|
+
...(options.username !== undefined ? { username: options.username } : {}),
|
|
516
|
+
...(options.password !== undefined ? { password: options.password } : {}),
|
|
517
|
+
...(options.onQuery ? { onQuery: options.onQuery } : {}),
|
|
518
|
+
});
|
|
519
|
+
const connections = new Map();
|
|
520
|
+
const pending = new Set();
|
|
521
|
+
const server = createServer((socket) => {
|
|
522
|
+
socket.setTimeout(28_800_000, () => socket.destroy());
|
|
523
|
+
const connection = stream({ write: (bytes) => { if (!socket.destroyed)
|
|
524
|
+
socket.write(bytes); }, end: () => { socket.end(); } }, socket.remoteAddress ?? '');
|
|
525
|
+
connections.set(socket, connection);
|
|
526
|
+
socket.on('data', (chunk) => connection.data(chunk));
|
|
527
|
+
socket.on('error', () => { socket.destroy(); });
|
|
528
|
+
socket.on('close', () => {
|
|
529
|
+
connections.delete(socket);
|
|
530
|
+
connection.close();
|
|
531
|
+
const cleanup = connection.settled();
|
|
532
|
+
pending.add(cleanup);
|
|
533
|
+
void cleanup.finally(() => pending.delete(cleanup));
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(options.port ?? 0, '127.0.0.1', () => { server.off('error', reject); resolve(); }); });
|
|
537
|
+
return { port: server.address().port, stop: async () => {
|
|
538
|
+
await new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); for (const socket of connections.keys())
|
|
539
|
+
socket.destroy(); });
|
|
540
|
+
await Promise.all([...pending]);
|
|
541
|
+
} };
|
|
542
|
+
}
|
|
543
|
+
/** The MySQL wire a hosted World serves on its stream door: answered by the twin's own wire, whose
|
|
544
|
+
* World token already admitted the stream. */
|
|
545
|
+
export function createPlanetscaleTwinStream(options) {
|
|
546
|
+
return createPlanetscaleMysqlStream({ upstream: options.wire });
|
|
547
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SqlError, type Statement } from './planetscale-sql.js';
|
|
2
|
+
export type PasswordRole = 'reader' | 'writer' | 'readwriter' | 'admin';
|
|
3
|
+
/** Whether a statement changes schema: what a branch with safe migrations refuses. */
|
|
4
|
+
export declare const isDdl: (stmt: Statement) => boolean;
|
|
5
|
+
/** A branch with safe migrations refuses DDL: "You'll see a `ERROR 1105 (HY000): direct DDL is disabled` message if you
|
|
6
|
+
* attempt to make schema changes in a branch with safe migrations enabled"
|
|
7
|
+
* (https://planetscale.com/docs/vitess/schema-changes/branching). Where the page stops: the message as the HTTP driver
|
|
8
|
+
* reports it; the twin answers the page's errno, SQLSTATE and words. */
|
|
9
|
+
export declare const directDdlDisabled: () => SqlError;
|
|
10
|
+
/** The refusal a role gives a statement, or undefined when the role allows it. */
|
|
11
|
+
export declare function roleRefusal(role: PasswordRole, stmt: Statement): SqlError | undefined;
|
|
@@ -0,0 +1,60 @@
|
|
|
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 } from "./planetscale-sql.js";
|
|
21
|
+
const ROLE_USER = { reader: 'planetscale-reader', writer: 'planetscale-writer-only', readwriter: 'planetscale-writer' };
|
|
22
|
+
const ALLOWED = {
|
|
23
|
+
reader: new Set(['Select']),
|
|
24
|
+
writer: new Set(['Insert', 'Update', 'Delete']),
|
|
25
|
+
readwriter: new Set(['Select', 'Insert', 'Update', 'Delete']),
|
|
26
|
+
admin: new Set(['Select', 'Insert', 'Update', 'Delete', 'DDL']),
|
|
27
|
+
};
|
|
28
|
+
const baseTable = (factor) => factor && 'table' in factor && factor.schema?.toLowerCase() !== 'information_schema' ? factor.table : undefined;
|
|
29
|
+
/** The command a statement is and the table it names first, or undefined for one no role is refused. */
|
|
30
|
+
function commandOf(stmt) {
|
|
31
|
+
if (stmt.kind === 'select' || stmt.kind === 'update-multi') {
|
|
32
|
+
const t = baseTable(stmt.from?.[0]?.factor);
|
|
33
|
+
return t === undefined ? undefined : { command: stmt.kind === 'select' ? 'Select' : 'Update', table: t };
|
|
34
|
+
}
|
|
35
|
+
const command = TABLE_COMMAND[stmt.kind];
|
|
36
|
+
return command === undefined || !('table' in stmt) ? undefined : { command, table: stmt.table };
|
|
37
|
+
}
|
|
38
|
+
/** The command each single-table statement is, by kind. */
|
|
39
|
+
const TABLE_COMMAND = {
|
|
40
|
+
describe: 'Select', 'show-create-table': 'Select', insert: 'Insert', update: 'Update', delete: 'Delete',
|
|
41
|
+
'create-table': 'DDL', 'create-table-select': 'DDL', 'drop-table': 'DDL', truncate: 'DDL', 'alter-add-columns': 'DDL', 'alter-drop-columns': 'DDL',
|
|
42
|
+
};
|
|
43
|
+
/** Whether a statement changes schema: what a branch with safe migrations refuses. */
|
|
44
|
+
export const isDdl = (stmt) => TABLE_COMMAND[stmt.kind] === 'DDL';
|
|
45
|
+
/** A branch with safe migrations refuses DDL: "You'll see a `ERROR 1105 (HY000): direct DDL is disabled` message if you
|
|
46
|
+
* attempt to make schema changes in a branch with safe migrations enabled"
|
|
47
|
+
* (https://planetscale.com/docs/vitess/schema-changes/branching). Where the page stops: the message as the HTTP driver
|
|
48
|
+
* reports it; the twin answers the page's errno, SQLSTATE and words. */
|
|
49
|
+
export const directDdlDisabled = () => new SqlError('direct DDL is disabled', 'FAILED_PRECONDITION', 1105, 'HY000');
|
|
50
|
+
/** The refusal a role gives a statement, or undefined when the role allows it. */
|
|
51
|
+
export function roleRefusal(role, stmt) {
|
|
52
|
+
if (role === 'admin')
|
|
53
|
+
return undefined;
|
|
54
|
+
const c = commandOf(stmt);
|
|
55
|
+
return c === undefined || ALLOWED[role].has(c.command) ? undefined : commandDenied(role, c);
|
|
56
|
+
}
|
|
57
|
+
/** MySQL's ER_TABLEACCESS_DENIED_ERROR (1142), as the password-roles page prints it. */
|
|
58
|
+
function commandDenied(role, c) {
|
|
59
|
+
return new SqlError(`${c.command} command denied to user '${ROLE_USER[role]}' for table '${c.table}' (ACL check error)`, 'PERMISSION_DENIED', 1142, '42000');
|
|
60
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Cell } from './planetscale-sql.js';
|
|
2
|
+
export declare function pulledRowId(table: string, identity: string[], ordinal?: number): number;
|
|
3
|
+
export declare function keyedRowIdentity(table: {
|
|
4
|
+
name: string;
|
|
5
|
+
primaryKey: string[];
|
|
6
|
+
}, cells: Record<string, Cell>): string[];
|
|
7
|
+
export declare function quoteSqlLiteral(value: Cell): string;
|
|
8
|
+
export declare const quoteIdent: (name: string) => string;
|
|
9
|
+
export declare function keyedRowId(table: {
|
|
10
|
+
name: string;
|
|
11
|
+
primaryKey: string[];
|
|
12
|
+
}, cells: Record<string, Cell>): number;
|