@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,110 @@
|
|
|
1
|
+
import type { Cell, Field, SessionVars } from './planetscale-sql.js';
|
|
2
|
+
/** One packed row, as `ExecuteResponse.result.rows[]` carries it. */
|
|
3
|
+
export type WireRow = {
|
|
4
|
+
lengths: string[];
|
|
5
|
+
values?: string;
|
|
6
|
+
};
|
|
7
|
+
/** `ExecuteResponse.result` — every key omitted when it holds its protojson zero value. */
|
|
8
|
+
export type WireQueryResult = {
|
|
9
|
+
fields?: WireField[];
|
|
10
|
+
rows?: WireRow[];
|
|
11
|
+
rowsAffected?: string;
|
|
12
|
+
insertId?: string;
|
|
13
|
+
};
|
|
14
|
+
/** `query.Field`, protojson-shaped. Optional keys are omitted, never null. */
|
|
15
|
+
export type WireField = {
|
|
16
|
+
name: string;
|
|
17
|
+
type?: string;
|
|
18
|
+
table?: string;
|
|
19
|
+
orgTable?: string;
|
|
20
|
+
database?: string;
|
|
21
|
+
orgName?: string;
|
|
22
|
+
columnLength?: number;
|
|
23
|
+
charset?: number;
|
|
24
|
+
flags?: number;
|
|
25
|
+
columnType?: string;
|
|
26
|
+
};
|
|
27
|
+
/** `vtrpc.RPCError` as it appears on `ExecuteResponse.error` (and in a Connect error body). */
|
|
28
|
+
export type WireError = {
|
|
29
|
+
code: string;
|
|
30
|
+
message: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function encodeCell(value: string, charset: number | undefined): Uint8Array;
|
|
33
|
+
/** Base64 of a byte sequence laid out one char per byte — the layout `atob` reproduces. */
|
|
34
|
+
export declare function packBytes(bytes: Uint8Array): string;
|
|
35
|
+
/** Pack one result row: per-column byte lengths plus the concatenated payload. */
|
|
36
|
+
export declare function packRow(fields: Field[], cells: Cell[]): WireRow;
|
|
37
|
+
/** The inverse — used by this pack's own tests and by the connector to read a real response. */
|
|
38
|
+
export declare function unpackRow(row: WireRow): Array<string | null>;
|
|
39
|
+
/** Field → protojson, dropping every key at its zero value (see the header). */
|
|
40
|
+
export declare function encodeField(field: Field): WireField;
|
|
41
|
+
/** A whole result set → `ExecuteResponse.result`. `{}` for a DDL statement, by construction. */
|
|
42
|
+
export declare function encodeQueryResult(input: {
|
|
43
|
+
fields: Field[];
|
|
44
|
+
rows: Cell[][];
|
|
45
|
+
rowsAffected: number;
|
|
46
|
+
insertId: number;
|
|
47
|
+
}): WireQueryResult;
|
|
48
|
+
/**
|
|
49
|
+
* `psdb.v1alpha1.Session`, as far as this protocol can be observed.
|
|
50
|
+
*
|
|
51
|
+
* HONESTY NOTE: the client treats the session as COMPLETELY OPAQUE — `Connection.execute` stores
|
|
52
|
+
* whatever came back and echoes it on the next request, and reads no field of it (dist/index.js,
|
|
53
|
+
* `this.session = session`). So the only facts about its shape that this pack can assert are the
|
|
54
|
+
* ones its own protocol needs: an identity that survives a round trip, and the transaction state
|
|
55
|
+
* that makes `Connection.transaction()` work. The real Vitess `Session` proto carries many more
|
|
56
|
+
* fields (shard sessions, options, warnings, DDL strategy); this twin does NOT fabricate them, and
|
|
57
|
+
* `planetscale.session.vitess_session_fields` files that as a todo rather than inventing a shape.
|
|
58
|
+
*/
|
|
59
|
+
export type WireSession = {
|
|
60
|
+
signature: string;
|
|
61
|
+
vitessSession: {
|
|
62
|
+
autocommit: boolean;
|
|
63
|
+
targetString: string;
|
|
64
|
+
sessionUUID: string;
|
|
65
|
+
inTransaction: boolean;
|
|
66
|
+
/** vtgate.proto Session.user_defined_variables: "all the @variables defined for this session", each a BindVariable */
|
|
67
|
+
userDefinedVariables?: Record<string, {
|
|
68
|
+
type?: string;
|
|
69
|
+
value?: string;
|
|
70
|
+
}>;
|
|
71
|
+
/** vtgate.proto Session.system_variables: "all session variables set for this connection" (the twin keeps sql_mode) */
|
|
72
|
+
systemVariables?: Record<string, string>;
|
|
73
|
+
/** vtgate.proto Session.warnings: "non-fatal warnings from the previous query", each a QueryWarning (code, message) */
|
|
74
|
+
warnings?: Array<{
|
|
75
|
+
code?: number;
|
|
76
|
+
message?: string;
|
|
77
|
+
}>;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The session variables a client's session carries, read back from what it echoes: user variables from
|
|
82
|
+
* `user_defined_variables` (a BindVariable each: its type, and its value as bytes, base64 in protojson; a NULL value is
|
|
83
|
+
* the zero type, NULL_TYPE, which protojson omits) and the SQL mode from `system_variables.sql_mode`. The session is the
|
|
84
|
+
* client's to echo, as Vitess keeps them; the twin stores none of it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function sessionVarsOf(session: unknown): SessionVars;
|
|
87
|
+
export type WireUser = {
|
|
88
|
+
username: string;
|
|
89
|
+
psid: string;
|
|
90
|
+
};
|
|
91
|
+
/** `CreateSessionResponse`. `branch` and `user` come straight from ps-http-sim's own response. */
|
|
92
|
+
export type WireCreateSessionResponse = {
|
|
93
|
+
branch: string;
|
|
94
|
+
user: WireUser;
|
|
95
|
+
session: WireSession;
|
|
96
|
+
};
|
|
97
|
+
export type WireExecuteResponse = {
|
|
98
|
+
session?: WireSession;
|
|
99
|
+
result?: WireQueryResult;
|
|
100
|
+
error?: WireError;
|
|
101
|
+
/** seconds, as a float — the client multiplies by 1000 for `ExecutedQuery.time`. */
|
|
102
|
+
timing?: number;
|
|
103
|
+
};
|
|
104
|
+
export declare function makeSession(id: string, opts: {
|
|
105
|
+
branch: string;
|
|
106
|
+
inTransaction: boolean;
|
|
107
|
+
vars?: SessionVars;
|
|
108
|
+
}): WireSession;
|
|
109
|
+
/** Read the session id a client echoed back. Returns null for a missing/unrecognisable session. */
|
|
110
|
+
export declare function sessionIdOf(session: unknown): string | null;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { BINARY_CHARSET } from "./planetscale-sql.js";
|
|
2
|
+
/**
|
|
3
|
+
* Encode one cell to bytes.
|
|
4
|
+
*
|
|
5
|
+
* The charset decides the encoding, exactly as it decides the client's decoding: charset 63 is
|
|
6
|
+
* MySQL's binary collation, for which `cast` returns a `Uint8Array` built one byte per character
|
|
7
|
+
* (dist/cast.js `isBinary` → dist/text.js `uint8Array`), so a binary cell is laid out LATIN-1 —
|
|
8
|
+
* one char, one byte. Everything else is UTF-8, which is what `decodeUtf8` decodes.
|
|
9
|
+
*/
|
|
10
|
+
/** A binary cell holding a code point above 0xFF, which no write of the engine stores: its UTF-8 bytes, never a throw. */
|
|
11
|
+
function wideBinaryCell(value) {
|
|
12
|
+
return new TextEncoder().encode(value);
|
|
13
|
+
}
|
|
14
|
+
export function encodeCell(value, charset) {
|
|
15
|
+
if (charset === BINARY_CHARSET) {
|
|
16
|
+
// A binary cell is expected to already be BYTE-SHAPED (one char per byte) — the engine's
|
|
17
|
+
// `storeValue` guarantees that on every write path. If a code point above 0xFF reaches here
|
|
18
|
+
// anyway (state written by an older build, or a caller folding resources in by hand), the
|
|
19
|
+
// answer is to UTF-8-encode it, NOT to throw.
|
|
20
|
+
//
|
|
21
|
+
// §9 round one, finding 3: this used to throw a plain `Error`. Because it is not a `SqlError`,
|
|
22
|
+
// it escaped `handlePlanetscaleTwinRequest` entirely, the server's catch-all turned it into an
|
|
23
|
+
// HTTP 500, and the client raised `UnknownError` — so one legal `INSERT INTO b (p) VALUES
|
|
24
|
+
// ('日')` made that table UNREADABLE FOREVER. Real MySQL just stores the UTF-8 bytes. A twin's
|
|
25
|
+
// read path must never be the thing that crashes; bytes are bytes.
|
|
26
|
+
let byteShaped = true;
|
|
27
|
+
for (let i = 0; i < value.length; i++)
|
|
28
|
+
if (value.charCodeAt(i) > 0xff) {
|
|
29
|
+
byteShaped = false;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
if (!byteShaped)
|
|
33
|
+
return wideBinaryCell(value);
|
|
34
|
+
const out = new Uint8Array(value.length);
|
|
35
|
+
for (let i = 0; i < value.length; i++)
|
|
36
|
+
out[i] = value.charCodeAt(i);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
return new TextEncoder().encode(value);
|
|
40
|
+
}
|
|
41
|
+
/** Base64 of a byte sequence laid out one char per byte — the layout `atob` reproduces. */
|
|
42
|
+
export function packBytes(bytes) {
|
|
43
|
+
let binary = '';
|
|
44
|
+
for (let i = 0; i < bytes.length; i++)
|
|
45
|
+
binary += String.fromCharCode(bytes[i]);
|
|
46
|
+
return btoa(binary);
|
|
47
|
+
}
|
|
48
|
+
/** Pack one result row: per-column byte lengths plus the concatenated payload. */
|
|
49
|
+
export function packRow(fields, cells) {
|
|
50
|
+
const lengths = [];
|
|
51
|
+
const chunks = [];
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (let i = 0; i < fields.length; i++) {
|
|
54
|
+
const cell = cells[i] ?? null;
|
|
55
|
+
if (cell === null) {
|
|
56
|
+
lengths.push('-1');
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// A JSON column reports charset 63 as MySQL's does, but its text is utf8mb4 (MySQL 8 manual,
|
|
60
|
+
// "The JSON Data Type": JSON strings use utf8mb4), so it packs as text, never as a byte string.
|
|
61
|
+
const bytes = encodeCell(cell, fields[i]?.type === 'JSON' ? undefined : fields[i]?.charset);
|
|
62
|
+
lengths.push(String(bytes.length));
|
|
63
|
+
chunks.push(bytes);
|
|
64
|
+
total += bytes.length;
|
|
65
|
+
}
|
|
66
|
+
if (total === 0)
|
|
67
|
+
return { lengths };
|
|
68
|
+
const joined = new Uint8Array(total);
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const chunk of chunks) {
|
|
71
|
+
joined.set(chunk, offset);
|
|
72
|
+
offset += chunk.length;
|
|
73
|
+
}
|
|
74
|
+
return { lengths, values: packBytes(joined) };
|
|
75
|
+
}
|
|
76
|
+
/** The inverse — used by this pack's own tests and by the connector to read a real response. */
|
|
77
|
+
export function unpackRow(row) {
|
|
78
|
+
const raw = row.values === undefined ? '' : atob(row.values);
|
|
79
|
+
let offset = 0;
|
|
80
|
+
return row.lengths.map((size) => {
|
|
81
|
+
const width = parseInt(size, 10);
|
|
82
|
+
if (width < 0)
|
|
83
|
+
return null;
|
|
84
|
+
const slice = raw.substring(offset, offset + width);
|
|
85
|
+
offset += width;
|
|
86
|
+
return slice;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Field → protojson, dropping every key at its zero value (see the header). */
|
|
90
|
+
export function encodeField(field) {
|
|
91
|
+
const out = { name: field.name };
|
|
92
|
+
if (field.type !== 'NULL')
|
|
93
|
+
out.type = field.type;
|
|
94
|
+
if (field.table !== undefined)
|
|
95
|
+
out.table = field.table;
|
|
96
|
+
if (field.orgTable !== undefined)
|
|
97
|
+
out.orgTable = field.orgTable;
|
|
98
|
+
if (field.database !== undefined)
|
|
99
|
+
out.database = field.database;
|
|
100
|
+
if (field.orgName !== undefined)
|
|
101
|
+
out.orgName = field.orgName;
|
|
102
|
+
if (field.columnLength !== undefined && field.columnLength !== 0)
|
|
103
|
+
out.columnLength = field.columnLength;
|
|
104
|
+
if (field.charset !== undefined && field.charset !== 0)
|
|
105
|
+
out.charset = field.charset;
|
|
106
|
+
if (field.flags !== undefined && field.flags !== 0)
|
|
107
|
+
out.flags = field.flags;
|
|
108
|
+
if (field.columnType !== undefined)
|
|
109
|
+
out.columnType = field.columnType;
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
/** A whole result set → `ExecuteResponse.result`. `{}` for a DDL statement, by construction. */
|
|
113
|
+
export function encodeQueryResult(input) {
|
|
114
|
+
const out = {};
|
|
115
|
+
if (input.fields.length > 0)
|
|
116
|
+
out.fields = input.fields.map(encodeField);
|
|
117
|
+
if (input.rows.length > 0)
|
|
118
|
+
out.rows = input.rows.map((r) => packRow(input.fields, r));
|
|
119
|
+
if (input.rowsAffected !== 0)
|
|
120
|
+
out.rowsAffected = String(input.rowsAffected);
|
|
121
|
+
if (input.insertId !== 0)
|
|
122
|
+
out.insertId = String(input.insertId);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The session variables a client's session carries, read back from what it echoes: user variables from
|
|
127
|
+
* `user_defined_variables` (a BindVariable each: its type, and its value as bytes, base64 in protojson; a NULL value is
|
|
128
|
+
* the zero type, NULL_TYPE, which protojson omits) and the SQL mode from `system_variables.sql_mode`. The session is the
|
|
129
|
+
* client's to echo, as Vitess keeps them; the twin stores none of it.
|
|
130
|
+
*/
|
|
131
|
+
export function sessionVarsOf(session) {
|
|
132
|
+
const vs = session?.vitessSession;
|
|
133
|
+
const user = {};
|
|
134
|
+
for (const [name, bv] of Object.entries(vs?.userDefinedVariables ?? {})) {
|
|
135
|
+
if (bv === null || typeof bv !== 'object')
|
|
136
|
+
continue;
|
|
137
|
+
const type = typeof bv.type === 'string' && bv.type !== 'NULL_TYPE' ? bv.type : undefined;
|
|
138
|
+
const bytes = atob(bv.value ?? '');
|
|
139
|
+
// a binary string keeps its bytes (one character per byte, as the engine holds binary cells); text is UTF-8
|
|
140
|
+
user[name.toLowerCase()] = type === undefined ? { value: null, type: 'NULL' } : { value: type === 'VARBINARY' ? bytes : new TextDecoder().decode(Uint8Array.from(bytes, (c) => c.charCodeAt(0))), type };
|
|
141
|
+
}
|
|
142
|
+
const sqlMode = vs?.systemVariables?.sql_mode;
|
|
143
|
+
const collationConnection = vs?.systemVariables?.collation_connection;
|
|
144
|
+
const timeZone = vs?.systemVariables?.time_zone;
|
|
145
|
+
const warnings = Array.isArray(vs?.warnings) ? vs.warnings.map((w) => ({ level: 'Warning', code: Number(w?.code ?? 0), message: String(w?.message ?? '') })) : [];
|
|
146
|
+
return { user, ...(typeof sqlMode === 'string' ? { sqlMode } : {}), ...(typeof collationConnection === 'string' ? { collationConnection } : {}), ...(typeof timeZone === 'string' ? { timeZone } : {}), ...(warnings.length ? { warnings } : {}) };
|
|
147
|
+
}
|
|
148
|
+
function encodeVars(vars) {
|
|
149
|
+
if (vars === undefined)
|
|
150
|
+
return {};
|
|
151
|
+
const names = Object.keys(vars.user);
|
|
152
|
+
return {
|
|
153
|
+
...(names.length ? { userDefinedVariables: Object.fromEntries(names.sort().map((n) => {
|
|
154
|
+
const v = vars.user[n];
|
|
155
|
+
const bytes = v.value === null ? undefined : v.type === 'VARBINARY' ? Uint8Array.from(v.value, (c) => c.charCodeAt(0)) : new TextEncoder().encode(v.value);
|
|
156
|
+
return [n, bytes === undefined ? {} : { type: v.type, value: packBytes(bytes) }];
|
|
157
|
+
})) } : {}),
|
|
158
|
+
...(vars.sqlMode !== undefined || vars.collationConnection !== undefined || vars.timeZone !== undefined ? { systemVariables: {
|
|
159
|
+
...(vars.sqlMode !== undefined ? { sql_mode: vars.sqlMode } : {}), ...(vars.collationConnection !== undefined ? { collation_connection: vars.collationConnection } : {}),
|
|
160
|
+
...(vars.timeZone !== undefined ? { time_zone: vars.timeZone } : {}),
|
|
161
|
+
} } : {}),
|
|
162
|
+
...(vars.warnings?.length ? { warnings: vars.warnings.map((w) => ({ code: w.code, message: w.message })) } : {}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
export function makeSession(id, opts) {
|
|
166
|
+
return {
|
|
167
|
+
// A stable, derivable signature — NOT entropy: the served response must be a pure function of
|
|
168
|
+
// (request, stored state), so the same session id always renders the same signature.
|
|
169
|
+
signature: packBytes(new TextEncoder().encode(`twin:${opts.branch}:${id}`)),
|
|
170
|
+
vitessSession: {
|
|
171
|
+
autocommit: !opts.inTransaction,
|
|
172
|
+
targetString: `${opts.branch}@primary`,
|
|
173
|
+
sessionUUID: id,
|
|
174
|
+
inTransaction: opts.inTransaction,
|
|
175
|
+
...encodeVars(opts.vars),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
/** Read the session id a client echoed back. Returns null for a missing/unrecognisable session. */
|
|
180
|
+
export function sessionIdOf(session) {
|
|
181
|
+
if (session === null || typeof session !== 'object')
|
|
182
|
+
return null;
|
|
183
|
+
const vs = session.vitessSession;
|
|
184
|
+
if (vs === null || typeof vs !== 'object')
|
|
185
|
+
return null;
|
|
186
|
+
const id = vs.sessionUUID;
|
|
187
|
+
return typeof id === 'string' && id !== '' ? id : null;
|
|
188
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DerivedHandler } from '@volter/world-core';
|
|
2
|
+
import { type PlanetscaleRequest, type PlanetscaleResponse } from '../planetscale-twin.js';
|
|
3
|
+
/** What a psdb request is answered against: the server's options and the instant it happens at. */
|
|
4
|
+
export type PsdbOptions = {
|
|
5
|
+
root?: string;
|
|
6
|
+
readOnly?: boolean;
|
|
7
|
+
username?: string;
|
|
8
|
+
password?: string;
|
|
9
|
+
database?: string;
|
|
10
|
+
branch?: string;
|
|
11
|
+
};
|
|
12
|
+
/** A fetch Request as the psdb request handler reads it. */
|
|
13
|
+
export declare function psdbRequest(request: Request, options: PsdbOptions, occurredAt: string): Promise<PlanetscaleRequest>;
|
|
14
|
+
/** The handler's answer as a fetch Response: only its own headers, none added. */
|
|
15
|
+
export declare const psdbResponse: ({ status, body, headers }: PlanetscaleResponse) => Response;
|
|
16
|
+
/** A fault inside the twin, in Connect's transport shape; never an exception out of the fetch. */
|
|
17
|
+
export declare const psdbInternal: (error: unknown) => Response;
|
|
18
|
+
export declare function psdbHandlers(options: PsdbOptions, now: () => string): Record<string, DerivedHandler>;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { handlePlanetscaleTwinRequest } from "../planetscale-twin.js";
|
|
2
|
+
/** A fetch Request as the psdb request handler reads it. */
|
|
3
|
+
export async function psdbRequest(request, options, occurredAt) {
|
|
4
|
+
const url = new URL(request.url);
|
|
5
|
+
const body = request.method !== 'GET' && request.method !== 'HEAD' ? await request.text() : '';
|
|
6
|
+
const headers = {};
|
|
7
|
+
request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
|
8
|
+
return {
|
|
9
|
+
method: request.method,
|
|
10
|
+
path: url.pathname + (url.search || ''),
|
|
11
|
+
body,
|
|
12
|
+
headers,
|
|
13
|
+
readOnly: options.readOnly ?? false,
|
|
14
|
+
occurredAt,
|
|
15
|
+
...(options.root !== undefined ? { root: options.root } : {}),
|
|
16
|
+
...(options.username !== undefined ? { username: options.username } : {}),
|
|
17
|
+
...(options.password !== undefined ? { password: options.password } : {}),
|
|
18
|
+
...(options.database !== undefined ? { database: options.database } : {}),
|
|
19
|
+
...(options.branch !== undefined ? { branch: options.branch } : {}),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** The handler's answer as a fetch Response: only its own headers, none added. */
|
|
23
|
+
export const psdbResponse = ({ status, body, headers }) => new Response(JSON.stringify(body), { status, headers: { ...(headers ?? {}) } });
|
|
24
|
+
/** A fault inside the twin, in Connect's transport shape; never an exception out of the fetch. */
|
|
25
|
+
export const psdbInternal = (error) => new Response(JSON.stringify({ code: 'internal', message: String(error) }), { status: 500, headers: { 'content-type': 'application/json' } });
|
|
26
|
+
export function psdbHandlers(options, now) {
|
|
27
|
+
const answer = async (request) => psdbResponse(await handlePlanetscaleTwinRequest(await psdbRequest(request, options, now())));
|
|
28
|
+
const serve = ({ request }) => answer(request).catch(psdbInternal);
|
|
29
|
+
return { CreateSession: serve, Execute: serve, CloseSession: serve, Prepare: serve };
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-planetscale",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local PlanetScale twin: the psdb HTTP wire protocol (POST /psdb.v1alpha1.Database/CreateSession | Execute | CloseSession, Basic auth, protojson QueryResult with base64 row packing) served over a real stateful MySQL-subset engine folded out of the @volter/world-core kernel action log — so an unmodified @planetscale/database client executes DDL/DML, reads its own writes, and runs transactions against it.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"api/src",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"!**/*.test.ts",
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
18
|
+
"directory": "packages/twin/planetscale"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/planetscale#readme",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/src/index.d.ts",
|
|
25
|
+
"default": "./dist/src/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"world-planetscale": "dist/src/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test src/*.test.ts",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
35
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
36
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@volter/world-ui": "0.1.0",
|
|
40
|
+
"react": "^19.2.7",
|
|
41
|
+
"react-dom": "^19.2.7"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@volter/world-core": "2.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/bun": "^1.2.20",
|
|
48
|
+
"@types/node": "^24.0.0",
|
|
49
|
+
"@volter/world-core": "2.0.0",
|
|
50
|
+
"@volter/world-tooling": "0.1.0",
|
|
51
|
+
"@planetscale/database": "1.20.1",
|
|
52
|
+
"typescript": "^5.9.0",
|
|
53
|
+
"mysql2": "3.14.1"
|
|
54
|
+
},
|
|
55
|
+
"engines": {
|
|
56
|
+
"node": ">=22.3"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
// world-planetscale CLI: serve the KERNEL-BACKED PlanetScale psdb twin, or run conformance.
|
|
4
|
+
// State lives in the @volter/world-core action log (no in-memory side-store). Conformance is dev-only +
|
|
5
|
+
// lazy-imported so the bin runs without @volter/world-tooling (E2).
|
|
6
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
7
|
+
import { createPlanetscaleTwinServer } from './planetscale-server.ts';
|
|
8
|
+
import { pack } from './index.ts';
|
|
9
|
+
|
|
10
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
11
|
+
const port = Number(optionValue(rest, '--port', String(process.env.PORT ?? '0'))) || undefined;
|
|
12
|
+
const root = optionValue(rest, '--root') || undefined;
|
|
13
|
+
// The credentials this twin will DEMAND. Omit them and any non-empty user:password pair is
|
|
14
|
+
// accepted (a missing or malformed Authorization header is still a vendor-shaped 401) — the right
|
|
15
|
+
// default for a local twin that holds no real secret.
|
|
16
|
+
const username = optionValue(rest, '--username') || undefined;
|
|
17
|
+
const password = optionValue(rest, '--password') || undefined;
|
|
18
|
+
const database = optionValue(rest, '--database') || undefined;
|
|
19
|
+
const branch = optionValue(rest, '--branch') || undefined;
|
|
20
|
+
const readOnly = hasFlag(rest, '--read-only'); // a twin accepts writes unless started read-only
|
|
21
|
+
|
|
22
|
+
if ((cmd === 'serve' || cmd === undefined) && hasFlag(rest, '--mysql')) {
|
|
23
|
+
if (readOnly) throw new Error('Set read-only on the upstream HTTP twin; native connections share its policy');
|
|
24
|
+
const { startPlanetscaleMysql } = await import('./planetscale-mysql.ts');
|
|
25
|
+
const upstreamEnv = pack.nativeTransport!.upstreamEnv;
|
|
26
|
+
const upstream = optionValue(rest, '--upstream') || process.env[upstreamEnv];
|
|
27
|
+
if (!upstream) throw new Error(`--mysql requires ${upstreamEnv} or --upstream pointing at the World HTTP twin (declare the HTTP twin service first)`);
|
|
28
|
+
const server = await startPlanetscaleMysql({ upstream, port, username, password, database, ...(hasFlag(rest, '--trace-sql') ? { onQuery: (query: string) => process.stderr.write(`mysql: ${query}\n`) } : {}) });
|
|
29
|
+
process.stdout.write(`planetscale MySQL frontend at mysql://127.0.0.1:${server.port} (shared HTTP state)\n`);
|
|
30
|
+
let closing = false;
|
|
31
|
+
const close = async () => { if (closing) return; closing = true; await server.stop(); process.exit(0); };
|
|
32
|
+
process.once('SIGTERM', () => { void close(); });
|
|
33
|
+
process.once('SIGINT', () => { void close(); });
|
|
34
|
+
await keepProcessAlive();
|
|
35
|
+
} else if (cmd === 'serve' || cmd === undefined) {
|
|
36
|
+
const s = await createPlanetscaleTwinServer({
|
|
37
|
+
readOnly,
|
|
38
|
+
...(root ? { root } : {}),
|
|
39
|
+
...(port ? { port } : {}),
|
|
40
|
+
...(username ? { username } : {}),
|
|
41
|
+
...(password ? { password } : {}),
|
|
42
|
+
...(database ? { database } : {}),
|
|
43
|
+
...(branch ? { branch } : {}),
|
|
44
|
+
});
|
|
45
|
+
process.stdout.write(
|
|
46
|
+
`planetscale twin (psdb.v1alpha1.Database over Connect-JSON: CreateSession, Execute, CloseSession)${readOnly ? ' [read-only]' : ''} at http://127.0.0.1:${s.port}\n`
|
|
47
|
+
+ ` point a client at it with PLANETSCALE_DATABASE_URL=http://user:pass@127.0.0.1:${s.port}\n`
|
|
48
|
+
+ ' (the http: scheme is honoured by @planetscale/database itself — every other scheme is forced to https)\n',
|
|
49
|
+
);
|
|
50
|
+
await keepProcessAlive();
|
|
51
|
+
} else if (cmd === 'conformance') {
|
|
52
|
+
const { checkPlanetscaleConformance } = await import('./planetscale-conformance.ts');
|
|
53
|
+
const report = await checkPlanetscaleConformance();
|
|
54
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
55
|
+
if (!report.ok) process.exitCode = 1;
|
|
56
|
+
} else {
|
|
57
|
+
process.stdout.write('Usage: world-planetscale serve|conformance [--port N] [--root DIR] [--username U] [--password P] [--database D] [--branch B] [--read-only] [--mysql --upstream URL]\n');
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","spec":"proto","format":"proto","version":"psdb.v1alpha1.Database","resources":[{"name":"CloseSessionResponse","schema":"CloseSessionResponse","fields":[{"name":"session","type":"Session","nullable":true},{"name":"error","type":"RPCError","nullable":true}],"stateCandidates":[],"expandable":[]},{"name":"CreateSessionResponse","schema":"CreateSessionResponse","fields":[{"name":"branch","type":"string","nullable":false},{"name":"user","type":"User","nullable":true},{"name":"session","type":"Session","nullable":true}],"stateCandidates":[],"expandable":[]},{"name":"ExecuteResponse","schema":"ExecuteResponse","fields":[{"name":"session","type":"Session","nullable":true},{"name":"result","type":"QueryResult","nullable":true},{"name":"error","type":"RPCError","nullable":true},{"name":"timing","type":"number","nullable":false}],"stateCandidates":[],"expandable":[]},{"name":"PrepareResponse","schema":"PrepareResponse","fields":[{"name":"session","type":"Session","nullable":true},{"name":"fields","type":"array","nullable":false},{"name":"error","type":"RPCError","nullable":true},{"name":"paramsCount","type":"integer","nullable":false}],"stateCandidates":[],"expandable":[]}],"operations":[{"id":"CreateSession","method":"post","path":"/psdb.v1alpha1.Database/CreateSession","pathParams":[],"query":[],"body":[],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"CreateSessionResponse","list":false},"class":"action"},{"id":"Execute","method":"post","path":"/psdb.v1alpha1.Database/Execute","pathParams":[],"query":[],"body":[{"name":"bindVariables","type":"object","required":false},{"name":"prepared","type":"boolean","required":false},{"name":"query","type":"string","required":false},{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"ExecuteResponse","list":false},"class":"action"},{"id":"StreamExecute","method":"post","path":"/psdb.v1alpha1.Database/StreamExecute","pathParams":[],"query":[],"body":[{"name":"bindVariables","type":"object","required":false},{"name":"prepared","type":"boolean","required":false},{"name":"query","type":"string","required":false},{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"streams":true,"answers":{"resource":"ExecuteResponse","list":false},"class":"action"},{"id":"Prepare","method":"post","path":"/psdb.v1alpha1.Database/Prepare","pathParams":[],"query":[],"body":[{"name":"bindVariables","type":"object","required":false},{"name":"query","type":"string","required":false},{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"PrepareResponse","list":false},"class":"action"},{"id":"CloseSession","method":"post","path":"/psdb.v1alpha1.Database/CloseSession","pathParams":[],"query":[],"body":[{"name":"session","type":"Session","required":false}],"bodyEncoding":"json","successStatus":200,"answers":{"resource":"CloseSessionResponse","list":false},"class":"action"}]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"generatedBy":"scripts/derive-pack.ts","resources":{"Session":{"actions":[],"states":{"closed":["false","true"]},"embeds":{}}}}
|