@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,338 @@
|
|
|
1
|
+
// PLANETSCALE psdb TWIN — THE REQUEST HANDLER. Contract:
|
|
2
|
+
// handlePlanetscaleTwinRequest({ method, path, body, headers, root, occurredAt, readOnly, ... })
|
|
3
|
+
// -> { status, body, headers }
|
|
4
|
+
//
|
|
5
|
+
// This file is the TRANSPORT half. Every MySQL semantic lives in `planetscale-sql.ts`, every kernel
|
|
6
|
+
// interaction in `planetscale-store.ts`, and every byte-level encoding rule in
|
|
7
|
+
// `planetscale-wire.ts`.
|
|
8
|
+
//
|
|
9
|
+
// ── THE PROTOCOL, AND WHY IT LOOKS LIKE THIS ──────────────────────────────────────────────────
|
|
10
|
+
// PlanetScale's HTTP driver does not speak MySQL's binary protocol and it is not REST. It is a
|
|
11
|
+
// Connect RPC service — `psdb.v1alpha1.Database` — addressed with unary JSON POSTs:
|
|
12
|
+
//
|
|
13
|
+
// POST /psdb.v1alpha1.Database/CreateSession {} -> {branch, user, session}
|
|
14
|
+
// POST /psdb.v1alpha1.Database/Execute {query, session} -> {session, result|error}
|
|
15
|
+
// POST /psdb.v1alpha1.Database/CloseSession {session} -> {session}
|
|
16
|
+
// POST /psdb.v1alpha1.Database/Prepare … -> unimplemented (real!)
|
|
17
|
+
//
|
|
18
|
+
// with `Authorization: Basic base64(username:password)` and `User-Agent: database-js/<version>`.
|
|
19
|
+
// GROUNDED in the installed `@planetscale/database@1.20.1` source (dist/index.js builds exactly
|
|
20
|
+
// these two URLs and this header set) and in `github.com/mattrobenolt/ps-http-sim`, the simulator
|
|
21
|
+
// upstream integrators run, whose handler implements CreateSession/Execute/StreamExecute/
|
|
22
|
+
// CloseSession and answers Prepare with Connect's `unimplemented`.
|
|
23
|
+
//
|
|
24
|
+
// ── TWO ERROR CHANNELS, AND CONFUSING THEM BREAKS EVERY CONSUMER ──────────────────────────────
|
|
25
|
+
// 1. A QUERY error (unknown table, duplicate key, parse error) is HTTP **200** carrying
|
|
26
|
+
// `{session, error:{code, message}}`. The client's `execute` sees `error` on an OK response and
|
|
27
|
+
// throws `DatabaseError(message, 400, error)` — note the **400 it synthesizes itself**, which is
|
|
28
|
+
// why serving an actual HTTP 400 here would change the status every consumer's tests assert on.
|
|
29
|
+
// 2. An AUTH failure is HTTP **401** with the SAME `{session, error:{code, message}}` envelope —
|
|
30
|
+
// the client's `postJSON` looks for `parsed?.error` on a non-ok response and throws
|
|
31
|
+
// `DatabaseError` with the REAL status. This shape is not a guess: it is the literal fixture in
|
|
32
|
+
// the client's own test suite (`__tests__/index.test.ts`,
|
|
33
|
+
// `{ code: 'unauthenticated', message: 'invalid auth credentials' }` replied at 401).
|
|
34
|
+
// Anything else — an unknown method, a wrong HTTP verb — takes Connect's own transport-level error
|
|
35
|
+
// body `{code, message}` with a LOWERCASE code, which the client reports as `UnknownError`
|
|
36
|
+
// ("Expected JSON response from database API, got HTTP …"). That is the vendor's behaviour too, and
|
|
37
|
+
// it is deliberately NOT dressed up in the `{error}` envelope to look like a query failure.
|
|
38
|
+
import {
|
|
39
|
+
closeSession as storeCloseSession,
|
|
40
|
+
createSession as storeCreateSession,
|
|
41
|
+
executeSql,
|
|
42
|
+
scopeOf,
|
|
43
|
+
sessionState,
|
|
44
|
+
DEFAULT_BRANCH,
|
|
45
|
+
DEFAULT_DATABASE,
|
|
46
|
+
PLANETSCALE_RESOURCE_TYPES,
|
|
47
|
+
SERVICE,
|
|
48
|
+
type ExecuteContext,
|
|
49
|
+
} from './planetscale-store.ts';
|
|
50
|
+
import { createHash } from 'node:crypto';
|
|
51
|
+
import { parseStatement, SqlError, type Statement } from './planetscale-sql.ts';
|
|
52
|
+
import { directDdlDisabled, isDdl, roleRefusal, type PasswordRole } from './planetscale-roles.ts';
|
|
53
|
+
import { ownFields, transitionFor, twinResourcesOfType } from '@volter/world-core';
|
|
54
|
+
import { manifest } from './manifest.ts';
|
|
55
|
+
import {
|
|
56
|
+
encodeQueryResult,
|
|
57
|
+
makeSession,
|
|
58
|
+
sessionIdOf,
|
|
59
|
+
sessionVarsOf,
|
|
60
|
+
type WireCreateSessionResponse,
|
|
61
|
+
type WireExecuteResponse,
|
|
62
|
+
} from './planetscale-wire.ts';
|
|
63
|
+
|
|
64
|
+
export type PlanetscaleRequest = {
|
|
65
|
+
method: string;
|
|
66
|
+
path: string;
|
|
67
|
+
body?: string;
|
|
68
|
+
headers?: Record<string, string>;
|
|
69
|
+
occurredAt?: string;
|
|
70
|
+
root?: string;
|
|
71
|
+
readOnly?: boolean;
|
|
72
|
+
/** The password this twin demands. Omit to accept any non-empty credential (still 401s a missing
|
|
73
|
+
* one) — the honest default for a local twin that holds no real secret. */
|
|
74
|
+
password?: string;
|
|
75
|
+
/** The username this twin demands. Omit to accept any non-empty username. */
|
|
76
|
+
username?: string;
|
|
77
|
+
/** The schema name the twin presents (`Field.database`, errno 1146). */
|
|
78
|
+
database?: string;
|
|
79
|
+
/** The branch name `CreateSession` reports. */
|
|
80
|
+
branch?: string;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export type PlanetscaleResponse = { status: number; body: unknown; headers?: Record<string, string> };
|
|
84
|
+
|
|
85
|
+
/** The Connect service this twin serves. The path is `/<fully-qualified service>/<Method>`. */
|
|
86
|
+
export const PSDB_SERVICE = 'psdb.v1alpha1.Database';
|
|
87
|
+
|
|
88
|
+
/** Every method the real service declares, so an unknown one is distinguishable from a known-but-
|
|
89
|
+
* unmodeled one — the difference between Connect's `unimplemented` and its `not_found`. */
|
|
90
|
+
export const PSDB_METHODS = ['CreateSession', 'Execute', 'StreamExecute', 'CloseSession', 'Prepare'] as const;
|
|
91
|
+
|
|
92
|
+
/** The literal 401 body the client's own suite fixtures. Identical for a missing, blank or wrong
|
|
93
|
+
* credential: the vendor draws no distinction between them, so neither does this twin. */
|
|
94
|
+
export const PLANETSCALE_UNAUTHENTICATED = { code: 'unauthenticated', message: 'invalid auth credentials' } as const;
|
|
95
|
+
|
|
96
|
+
const JSON_HEADERS: Record<string, string> = { 'content-type': 'application/json' };
|
|
97
|
+
|
|
98
|
+
/** The endpoint inventory `planetscale-conformance.ts` probes. */
|
|
99
|
+
export type PlanetscaleTwinSnapshot = {
|
|
100
|
+
service: string;
|
|
101
|
+
implementedEndpoints: string[];
|
|
102
|
+
resourceTypes: readonly string[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export function planetscaleTwinSnapshot(): PlanetscaleTwinSnapshot {
|
|
106
|
+
return {
|
|
107
|
+
service: PSDB_SERVICE,
|
|
108
|
+
implementedEndpoints: [
|
|
109
|
+
'POST /psdb.v1alpha1.Database/CreateSession',
|
|
110
|
+
'POST /psdb.v1alpha1.Database/Execute',
|
|
111
|
+
'POST /psdb.v1alpha1.Database/CloseSession',
|
|
112
|
+
'POST /psdb.v1alpha1.Database/Prepare (vendor-faithful unimplemented)',
|
|
113
|
+
'POST /psdb.v1alpha1.Database/StreamExecute (the gap: Connect unimplemented)',
|
|
114
|
+
],
|
|
115
|
+
resourceTypes: PLANETSCALE_RESOURCE_TYPES,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function lowerHeaders(h: Record<string, string> | undefined): Record<string, string> {
|
|
120
|
+
const out: Record<string, string> = {};
|
|
121
|
+
for (const [k, v] of Object.entries(h ?? {})) out[k.toLowerCase()] = v;
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Decode `Authorization: Basic base64(user:pass)` — the ONLY scheme this wire uses
|
|
127
|
+
* (dist/index.js `postJSON` builds it with `btoa(`${username}:${password}`)`).
|
|
128
|
+
*
|
|
129
|
+
* The password may itself contain `:` (PlanetScale passwords are `pscale_pw_…`, but a
|
|
130
|
+
* connection-URL password is arbitrary), so the split is on the FIRST colon only. Anything that is
|
|
131
|
+
* not a well-formed Basic header resolves to null and gets the vendor's 401 — a malformed header
|
|
132
|
+
* must never fall through and be treated as a bare credential.
|
|
133
|
+
*/
|
|
134
|
+
/** What `atob` decodes (forgiving-base64): base64 characters whose length is not 1 mod 4, with at most the padding that completes a quad. */
|
|
135
|
+
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/;
|
|
136
|
+
|
|
137
|
+
/** A Basic credential that is not base64: the vendor's 401, as for any malformed header. */
|
|
138
|
+
function notBase64(): null {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function extractBasicCredential(headers: Record<string, string> | undefined): { username: string; password: string } | null {
|
|
143
|
+
const raw = lowerHeaders(headers).authorization;
|
|
144
|
+
if (raw === undefined) return null;
|
|
145
|
+
const m = /^basic\s+(\S+)\s*$/i.exec(raw.trim());
|
|
146
|
+
if (m === null) return null;
|
|
147
|
+
const decoded = BASE64.test(m[1]!) ? atob(m[1]!) : notBase64();
|
|
148
|
+
if (decoded === null) return null;
|
|
149
|
+
const colon = decoded.indexOf(':');
|
|
150
|
+
if (colon < 0) return null;
|
|
151
|
+
const username = decoded.slice(0, colon);
|
|
152
|
+
const password = decoded.slice(colon + 1);
|
|
153
|
+
if (username === '' || password === '') return null;
|
|
154
|
+
return { username, password };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Connect's transport-level error body: a lowercase code and a message, with no `error` wrapper. */
|
|
158
|
+
const connectError = (status: number, code: string, message: string): PlanetscaleResponse =>
|
|
159
|
+
({ status, body: { code, message }, headers: JSON_HEADERS });
|
|
160
|
+
|
|
161
|
+
const unauthenticated = (session: unknown): PlanetscaleResponse =>
|
|
162
|
+
({ status: 401, body: { ...(session !== undefined ? { session } : {}), error: PLANETSCALE_UNAUTHENTICATED }, headers: JSON_HEADERS });
|
|
163
|
+
|
|
164
|
+
export async function handlePlanetscaleTwinRequest(req: PlanetscaleRequest): Promise<PlanetscaleResponse> {
|
|
165
|
+
const path = req.path.split('?')[0]!.replace(/\/+$/, '') || '/';
|
|
166
|
+
const prefix = `/${PSDB_SERVICE}/`;
|
|
167
|
+
|
|
168
|
+
if (!path.startsWith(prefix)) return unknownService(path);
|
|
169
|
+
const method = path.slice(prefix.length);
|
|
170
|
+
if (!(PSDB_METHODS as readonly string[]).includes(method)) return unknownMethod(method);
|
|
171
|
+
// Connect unary RPCs are POST-only; a GET is refused before authentication, as Connect does.
|
|
172
|
+
if (req.method.toUpperCase() !== 'POST') return postOnly();
|
|
173
|
+
|
|
174
|
+
const credential = extractBasicCredential(req.headers);
|
|
175
|
+
if (credential === null) return unauthenticated(undefined);
|
|
176
|
+
if (req.username !== undefined && credential.username !== req.username) return unauthenticated(undefined);
|
|
177
|
+
if (req.password !== undefined && credential.password !== req.password) return unauthenticated(undefined);
|
|
178
|
+
// a password the management lane issued authenticates by its secret and acts in its role; a deleted one (or one of a
|
|
179
|
+
// deleted database) is refused like any bad credential (planetscale-roles.ts)
|
|
180
|
+
const issued = issuedPassword(req.root, credential, req.occurredAt);
|
|
181
|
+
if (issued === 'refused') return unauthenticated(undefined);
|
|
182
|
+
|
|
183
|
+
const payload = req.body === undefined || req.body.trim() === '' ? {} : payloadOf(req.body);
|
|
184
|
+
if (payload === undefined) return malformedBody();
|
|
185
|
+
|
|
186
|
+
const ctx: ExecuteContext = {
|
|
187
|
+
...(req.root !== undefined ? { root: req.root } : {}),
|
|
188
|
+
occurredAt: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
|
|
189
|
+
...(req.readOnly !== undefined ? { readOnly: req.readOnly } : {}),
|
|
190
|
+
database: req.database ?? DEFAULT_DATABASE,
|
|
191
|
+
// a password acts on the branch it was made on (M5: "Create new credentials to access a branch's data",
|
|
192
|
+
// planetscale.com/docs/cli/password); the World's own credential acts on the branch the server was given
|
|
193
|
+
branch: issued?.branch ?? req.branch ?? DEFAULT_BRANCH,
|
|
194
|
+
...(issued?.scope ? { scope: issued.scope } : {}),
|
|
195
|
+
};
|
|
196
|
+
const branch = ctx.branch ?? DEFAULT_BRANCH;
|
|
197
|
+
|
|
198
|
+
if (method === 'CreateSession') return createSession(ctx, credential.username, branch);
|
|
199
|
+
if (method === 'CloseSession') return closeSession(req, ctx, payload, branch);
|
|
200
|
+
if (method === 'Prepare') return prepareUnimplemented();
|
|
201
|
+
if (method === 'StreamExecute') return streamExecuteUnmodelled();
|
|
202
|
+
|
|
203
|
+
// ── Execute ────────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
const query = payload.query;
|
|
205
|
+
const incoming = sessionIdOf(payload.session);
|
|
206
|
+
// the session's user variables and SQL mode ride the session the client echoes (vtgate.proto Session
|
|
207
|
+
// user_defined_variables, system_variables), as Vitess keeps them
|
|
208
|
+
const vars = sessionVarsOf(payload.session);
|
|
209
|
+
ctx.vars = vars;
|
|
210
|
+
if (typeof query !== 'string') return queryNotString();
|
|
211
|
+
try {
|
|
212
|
+
if (issued !== undefined) {
|
|
213
|
+
let stmt: Statement | undefined;
|
|
214
|
+
try { stmt = parseStatement(query); } catch { stmt = undefined; } // a statement that does not parse is refused by executeSql
|
|
215
|
+
const refused = stmt === undefined ? undefined : roleRefusal(issued.role, stmt);
|
|
216
|
+
if (refused) throw refused;
|
|
217
|
+
if (stmt !== undefined && issued.safeMigrations && isDdl(stmt)) throw directDdlDisabled();
|
|
218
|
+
}
|
|
219
|
+
const { outcome, session } = await executeSql(ctx, query, incoming);
|
|
220
|
+
const body: WireExecuteResponse = {
|
|
221
|
+
session: makeSession(session.id, { branch, inTransaction: session.inTransaction, vars: outcome.vars ?? vars }),
|
|
222
|
+
result: encodeQueryResult(outcome),
|
|
223
|
+
};
|
|
224
|
+
// `timing` is DELIBERATELY omitted: it is an elapsed-wall-clock measurement, and this repo's
|
|
225
|
+
// serve-path determinism rule forbids one. The client's own `timing ?? 0` handles its absence,
|
|
226
|
+
// so `ExecutedQuery.time` reads 0 — honestly "not measured" rather than an invented duration.
|
|
227
|
+
return { status: 200, body, headers: JSON_HEADERS };
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (!(error instanceof SqlError)) return internalFault(error);
|
|
230
|
+
// HTTP 200 — see the header. The client turns this into `DatabaseError(message, 400, error)`.
|
|
231
|
+
//
|
|
232
|
+
// The echoed session must report the transaction's REAL state. §9 round one, finding 10: this
|
|
233
|
+
// used to answer a flat `inTransaction: false`, so a statement that failed INSIDE an open
|
|
234
|
+
// transaction told the caller the transaction had ended — while the server still held the
|
|
235
|
+
// buffer and a later COMMIT still landed everything. MySQL does not end a transaction on a
|
|
236
|
+
// failed statement, and neither does this twin, so the wire must not say otherwise.
|
|
237
|
+
const open = incoming === null ? undefined : sessionState(req.root, incoming, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
|
|
238
|
+
const body: WireExecuteResponse = {
|
|
239
|
+
session: makeSession(incoming ?? '', { branch, inTransaction: open?.inTransaction === true, vars }),
|
|
240
|
+
error: { code: error.code, message: error.message },
|
|
241
|
+
};
|
|
242
|
+
return { status: 200, body, headers: JSON_HEADERS };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const unknownService = (path: string): PlanetscaleResponse =>
|
|
247
|
+
connectError(404, 'not_found', `unknown service: ${path.replace(/^\//, '').split('/')[0] ?? ''}`);
|
|
248
|
+
|
|
249
|
+
const unknownMethod = (method: string): PlanetscaleResponse =>
|
|
250
|
+
connectError(404, 'unimplemented', `${PSDB_SERVICE}/${method} is not implemented`);
|
|
251
|
+
|
|
252
|
+
const postOnly = (): PlanetscaleResponse =>
|
|
253
|
+
connectError(405, 'unimplemented', 'HTTP GET is not supported by this protocol');
|
|
254
|
+
|
|
255
|
+
const malformedBody = (): PlanetscaleResponse =>
|
|
256
|
+
connectError(400, 'invalid_argument', 'malformed JSON request body');
|
|
257
|
+
|
|
258
|
+
const queryNotString = (): PlanetscaleResponse =>
|
|
259
|
+
connectError(400, 'invalid_argument', 'query must be a string');
|
|
260
|
+
|
|
261
|
+
/** A request body as a JSON object; undefined when it is not one. */
|
|
262
|
+
function payloadOf(text: string): Record<string, unknown> | undefined {
|
|
263
|
+
let parsed: unknown;
|
|
264
|
+
try { parsed = JSON.parse(text); } catch { return undefined; }
|
|
265
|
+
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* A fault inside the twin that is not a `SqlError`, answered as Connect's `internal` transport error. The wire must never
|
|
270
|
+
* be the crash surface (a deeply nested expression's RangeError once escaped as an HTTP 500 and the client's
|
|
271
|
+
* `UnknownError`), so the boundary that owns the protocol answers it.
|
|
272
|
+
*/
|
|
273
|
+
const internalFault = (error: unknown): PlanetscaleResponse =>
|
|
274
|
+
connectError(500, 'internal', `internal twin error: ${error instanceof Error ? error.message : String(error)}`);
|
|
275
|
+
|
|
276
|
+
/** VENDOR-FAITHFUL: ps-http-sim answers Prepare with Connect's Unimplemented, and the client never calls it. */
|
|
277
|
+
const prepareUnimplemented = (): PlanetscaleResponse =>
|
|
278
|
+
connectError(501, 'unimplemented', 'psdb.v1alpha1.Database/Prepare is not implemented');
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* A TWIN GAP, stated as one: the real service implements StreamExecute; this twin does not model Connect's
|
|
282
|
+
* server-streaming envelope framing, and a unary body here would be a fabricated success.
|
|
283
|
+
*/
|
|
284
|
+
const streamExecuteUnmodelled = (): PlanetscaleResponse =>
|
|
285
|
+
connectError(501, 'unimplemented', 'psdb.v1alpha1.Database/StreamExecute is not modeled by this twin');
|
|
286
|
+
|
|
287
|
+
/** CreateSession: a fresh session on the branch, and the user the credential names. */
|
|
288
|
+
async function createSession(ctx: ExecuteContext, username: string, branch: string): Promise<PlanetscaleResponse> {
|
|
289
|
+
const state = await storeCreateSession(ctx);
|
|
290
|
+
const body: WireCreateSessionResponse = {
|
|
291
|
+
branch,
|
|
292
|
+
user: { username, psid: username },
|
|
293
|
+
session: makeSession(state.id, { branch, inTransaction: false }),
|
|
294
|
+
};
|
|
295
|
+
return { status: 200, body, headers: JSON_HEADERS };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* CloseSession: a session the twin issued moves by the declared machine (manifest.ts): open → closed, or stays closed.
|
|
300
|
+
* Connect's CloseSessionResponse carries the (now closed) session; a session the twin never issued is NOT an error, as
|
|
301
|
+
* the real service tolerates it.
|
|
302
|
+
*/
|
|
303
|
+
async function closeSession(req: PlanetscaleRequest, ctx: ExecuteContext, payload: Record<string, unknown>, branch: string): Promise<PlanetscaleResponse> {
|
|
304
|
+
const id = sessionIdOf(payload.session);
|
|
305
|
+
const held = id === null ? undefined : sessionState(req.root, id, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
|
|
306
|
+
if (held !== undefined) {
|
|
307
|
+
const { move } = transitionFor('closed', manifest.resources.Session!.state!.closed!, 'CloseSession', held.closed, undefined, id!);
|
|
308
|
+
if (move === undefined) throw new Error(`CloseSession moves a session from closed=${String(held.closed)}, which the machine does not declare`);
|
|
309
|
+
}
|
|
310
|
+
if (id !== null) await storeCloseSession(ctx, id);
|
|
311
|
+
return { status: 200, body: { session: makeSession(id ?? '', { branch, inTransaction: false }) }, headers: JSON_HEADERS };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The password a Basic credential names, when the management lane issued one with its username: its role when the secret
|
|
316
|
+
* matches and it is not deleted, `refused` otherwise. A credential no issued password names is the World's own, as every
|
|
317
|
+
* credential was before passwords were issued, and acts as admin: an application pointed at the World (Dub) carries
|
|
318
|
+
* whatever credential its own configuration holds. That rule is the twin's decision.
|
|
319
|
+
*/
|
|
320
|
+
function issuedPassword(root: string | undefined, credential: { username: string; password: string }, occurredAt?: string): { role: PasswordRole; branch: string; scope?: string; safeMigrations: boolean } | 'refused' | undefined {
|
|
321
|
+
const row = twinResourcesOfType(SERVICE, '_password', root).map((r) => ownFields(r) as Record<string, unknown>)
|
|
322
|
+
.find((p) => p.username === credential.username);
|
|
323
|
+
if (row === undefined) return undefined;
|
|
324
|
+
if (row.deleted_at !== null && row.deleted_at !== undefined) return 'refused';
|
|
325
|
+
if (row._secret_sha256 !== createHash('sha256').update(credential.password).digest('hex')) return 'refused';
|
|
326
|
+
// a password whose ttl has passed "will be invalid" (the spec's create_password), refused like any bad credential
|
|
327
|
+
if (typeof row.expires_at === 'string' && occurredAt !== undefined && Date.parse(row.expires_at) <= Date.parse(occurredAt)) return 'refused';
|
|
328
|
+
// the branch the password was made on (its `database_branch`), and that branch's scope in the store
|
|
329
|
+
const branch = String((row.database_branch as { name?: unknown } | undefined)?.name ?? DEFAULT_BRANCH);
|
|
330
|
+
const scope = scopeOf(String(row._database || DEFAULT_DATABASE), branch);
|
|
331
|
+
// the branch's safe migrations, as the management lane keeps them on its branch record
|
|
332
|
+
const database = String(row._database ?? '');
|
|
333
|
+
const record = twinResourcesOfType(SERVICE, '_branch', root).find((r) => (ownFields(r) as Record<string, unknown>)._database === database && (ownFields(r) as Record<string, unknown>).name === branch && (ownFields(r) as Record<string, unknown>).deleted_at === null);
|
|
334
|
+
const safeMigrations = record !== undefined && (ownFields(record) as Record<string, unknown>).safe_migrations === true;
|
|
335
|
+
return { role: row.role as PasswordRole, branch, ...(scope ? { scope } : {}), safeMigrations };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export { PLANETSCALE_RESOURCE_TYPES };
|