@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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +473 -0
  3. package/api/src/fetch.ts +50 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/manifest.ts +136 -0
  8. package/api/src/screens/deploy-request.tsx +111 -0
  9. package/api/src/screens/service-tokens.tsx +141 -0
  10. package/api/src/screens/session.tsx +117 -0
  11. package/api/src/semantics/audit.ts +82 -0
  12. package/api/src/semantics/backups.ts +258 -0
  13. package/api/src/semantics/branches.ts +201 -0
  14. package/api/src/semantics/deploy-requests.ts +493 -0
  15. package/api/src/semantics/index.ts +371 -0
  16. package/api/src/semantics/shared.ts +141 -0
  17. package/api/src/semantics/time.ts +77 -0
  18. package/api/src/token-gate.ts +96 -0
  19. package/dist/api/src/fetch.d.ts +8 -0
  20. package/dist/api/src/fetch.js +51 -0
  21. package/dist/api/src/fetch.ts +50 -0
  22. package/dist/api/src/generated/surface.gen.json +1 -0
  23. package/dist/api/src/generated/ui.gen.json +1 -0
  24. package/dist/api/src/index.ts +19 -0
  25. package/dist/api/src/manifest.d.ts +2 -0
  26. package/dist/api/src/manifest.js +113 -0
  27. package/dist/api/src/manifest.ts +136 -0
  28. package/dist/api/src/screens/deploy-request.d.ts +7 -0
  29. package/dist/api/src/screens/deploy-request.js +106 -0
  30. package/dist/api/src/screens/deploy-request.tsx +111 -0
  31. package/dist/api/src/screens/service-tokens.d.ts +3 -0
  32. package/dist/api/src/screens/service-tokens.js +134 -0
  33. package/dist/api/src/screens/service-tokens.tsx +141 -0
  34. package/dist/api/src/screens/session.d.ts +11 -0
  35. package/dist/api/src/screens/session.js +108 -0
  36. package/dist/api/src/screens/session.tsx +117 -0
  37. package/dist/api/src/semantics/audit.d.ts +31 -0
  38. package/dist/api/src/semantics/audit.js +80 -0
  39. package/dist/api/src/semantics/audit.ts +82 -0
  40. package/dist/api/src/semantics/backups.d.ts +37 -0
  41. package/dist/api/src/semantics/backups.js +264 -0
  42. package/dist/api/src/semantics/backups.ts +258 -0
  43. package/dist/api/src/semantics/branches.d.ts +53 -0
  44. package/dist/api/src/semantics/branches.js +197 -0
  45. package/dist/api/src/semantics/branches.ts +201 -0
  46. package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
  47. package/dist/api/src/semantics/deploy-requests.js +491 -0
  48. package/dist/api/src/semantics/deploy-requests.ts +493 -0
  49. package/dist/api/src/semantics/index.d.ts +20 -0
  50. package/dist/api/src/semantics/index.js +381 -0
  51. package/dist/api/src/semantics/index.ts +371 -0
  52. package/dist/api/src/semantics/shared.d.ts +36 -0
  53. package/dist/api/src/semantics/shared.js +132 -0
  54. package/dist/api/src/semantics/shared.ts +141 -0
  55. package/dist/api/src/semantics/time.d.ts +2 -0
  56. package/dist/api/src/semantics/time.js +81 -0
  57. package/dist/api/src/semantics/time.ts +77 -0
  58. package/dist/api/src/token-gate.d.ts +9 -0
  59. package/dist/api/src/token-gate.js +97 -0
  60. package/dist/api/src/token-gate.ts +96 -0
  61. package/dist/src/cli.d.ts +2 -0
  62. package/dist/src/cli.js +61 -0
  63. package/dist/src/generated/surface.gen.json +1 -0
  64. package/dist/src/generated/ui.gen.json +1 -0
  65. package/dist/src/index.d.ts +26 -0
  66. package/dist/src/index.js +156 -0
  67. package/dist/src/manifest.d.ts +2 -0
  68. package/dist/src/manifest.js +41 -0
  69. package/dist/src/planetscale-budget.d.ts +78 -0
  70. package/dist/src/planetscale-budget.js +305 -0
  71. package/dist/src/planetscale-capabilities.d.ts +10 -0
  72. package/dist/src/planetscale-capabilities.js +3977 -0
  73. package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
  74. package/dist/src/planetscale-collation-weights.gen.js +12 -0
  75. package/dist/src/planetscale-collation.d.ts +70 -0
  76. package/dist/src/planetscale-collation.js +391 -0
  77. package/dist/src/planetscale-conformance.d.ts +8 -0
  78. package/dist/src/planetscale-conformance.js +213 -0
  79. package/dist/src/planetscale-connector.d.ts +150 -0
  80. package/dist/src/planetscale-connector.js +532 -0
  81. package/dist/src/planetscale-deploy.d.ts +26 -0
  82. package/dist/src/planetscale-deploy.js +235 -0
  83. package/dist/src/planetscale-information-schema.d.ts +32 -0
  84. package/dist/src/planetscale-information-schema.js +299 -0
  85. package/dist/src/planetscale-mysql.d.ts +33 -0
  86. package/dist/src/planetscale-mysql.js +547 -0
  87. package/dist/src/planetscale-roles.d.ts +11 -0
  88. package/dist/src/planetscale-roles.js +60 -0
  89. package/dist/src/planetscale-row.d.ts +12 -0
  90. package/dist/src/planetscale-row.js +39 -0
  91. package/dist/src/planetscale-server.d.ts +42 -0
  92. package/dist/src/planetscale-server.js +137 -0
  93. package/dist/src/planetscale-sql.d.ts +701 -0
  94. package/dist/src/planetscale-sql.js +7167 -0
  95. package/dist/src/planetscale-store.d.ts +126 -0
  96. package/dist/src/planetscale-store.js +827 -0
  97. package/dist/src/planetscale-twin.d.ts +48 -0
  98. package/dist/src/planetscale-twin.js +290 -0
  99. package/dist/src/planetscale-values.d.ts +139 -0
  100. package/dist/src/planetscale-values.js +719 -0
  101. package/dist/src/planetscale-wire.d.ts +110 -0
  102. package/dist/src/planetscale-wire.js +188 -0
  103. package/dist/src/semantics/psdb.d.ts +18 -0
  104. package/dist/src/semantics/psdb.js +30 -0
  105. package/package.json +58 -0
  106. package/src/cli.ts +58 -0
  107. package/src/generated/surface.gen.json +1 -0
  108. package/src/generated/ui.gen.json +1 -0
  109. package/src/index.ts +267 -0
  110. package/src/manifest.ts +60 -0
  111. package/src/planetscale-budget.ts +347 -0
  112. package/src/planetscale-capabilities.ts +3862 -0
  113. package/src/planetscale-collation-weights.gen.ts +13 -0
  114. package/src/planetscale-collation.ts +378 -0
  115. package/src/planetscale-conformance.ts +237 -0
  116. package/src/planetscale-connector.ts +571 -0
  117. package/src/planetscale-deploy.ts +197 -0
  118. package/src/planetscale-information-schema.ts +322 -0
  119. package/src/planetscale-mysql.ts +339 -0
  120. package/src/planetscale-roles.ts +71 -0
  121. package/src/planetscale-row.ts +43 -0
  122. package/src/planetscale-server.ts +162 -0
  123. package/src/planetscale-sql.ts +5957 -0
  124. package/src/planetscale-store.ts +869 -0
  125. package/src/planetscale-twin.ts +338 -0
  126. package/src/planetscale-values.ts +572 -0
  127. package/src/planetscale-wire.ts +274 -0
  128. package/src/semantics/psdb.ts +57 -0
@@ -0,0 +1,48 @@
1
+ import { PLANETSCALE_RESOURCE_TYPES } from './planetscale-store.js';
2
+ export type PlanetscaleRequest = {
3
+ method: string;
4
+ path: string;
5
+ body?: string;
6
+ headers?: Record<string, string>;
7
+ occurredAt?: string;
8
+ root?: string;
9
+ readOnly?: boolean;
10
+ /** The password this twin demands. Omit to accept any non-empty credential (still 401s a missing
11
+ * one) — the honest default for a local twin that holds no real secret. */
12
+ password?: string;
13
+ /** The username this twin demands. Omit to accept any non-empty username. */
14
+ username?: string;
15
+ /** The schema name the twin presents (`Field.database`, errno 1146). */
16
+ database?: string;
17
+ /** The branch name `CreateSession` reports. */
18
+ branch?: string;
19
+ };
20
+ export type PlanetscaleResponse = {
21
+ status: number;
22
+ body: unknown;
23
+ headers?: Record<string, string>;
24
+ };
25
+ /** The Connect service this twin serves. The path is `/<fully-qualified service>/<Method>`. */
26
+ export declare const PSDB_SERVICE = "psdb.v1alpha1.Database";
27
+ /** Every method the real service declares, so an unknown one is distinguishable from a known-but-
28
+ * unmodeled one — the difference between Connect's `unimplemented` and its `not_found`. */
29
+ export declare const PSDB_METHODS: readonly ["CreateSession", "Execute", "StreamExecute", "CloseSession", "Prepare"];
30
+ /** The literal 401 body the client's own suite fixtures. Identical for a missing, blank or wrong
31
+ * credential: the vendor draws no distinction between them, so neither does this twin. */
32
+ export declare const PLANETSCALE_UNAUTHENTICATED: {
33
+ readonly code: "unauthenticated";
34
+ readonly message: "invalid auth credentials";
35
+ };
36
+ /** The endpoint inventory `planetscale-conformance.ts` probes. */
37
+ export type PlanetscaleTwinSnapshot = {
38
+ service: string;
39
+ implementedEndpoints: string[];
40
+ resourceTypes: readonly string[];
41
+ };
42
+ export declare function planetscaleTwinSnapshot(): PlanetscaleTwinSnapshot;
43
+ export declare function extractBasicCredential(headers: Record<string, string> | undefined): {
44
+ username: string;
45
+ password: string;
46
+ } | null;
47
+ export declare function handlePlanetscaleTwinRequest(req: PlanetscaleRequest): Promise<PlanetscaleResponse>;
48
+ export { PLANETSCALE_RESOURCE_TYPES };
@@ -0,0 +1,290 @@
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 { closeSession as storeCloseSession, createSession as storeCreateSession, executeSql, scopeOf, sessionState, DEFAULT_BRANCH, DEFAULT_DATABASE, PLANETSCALE_RESOURCE_TYPES, SERVICE, } from "./planetscale-store.js";
39
+ import { createHash } from 'node:crypto';
40
+ import { parseStatement, SqlError } from "./planetscale-sql.js";
41
+ import { directDdlDisabled, isDdl, roleRefusal } from "./planetscale-roles.js";
42
+ import { ownFields, transitionFor, twinResourcesOfType } from '@volter/world-core';
43
+ import { manifest } from "./manifest.js";
44
+ import { encodeQueryResult, makeSession, sessionIdOf, sessionVarsOf, } from "./planetscale-wire.js";
45
+ /** The Connect service this twin serves. The path is `/<fully-qualified service>/<Method>`. */
46
+ export const PSDB_SERVICE = 'psdb.v1alpha1.Database';
47
+ /** Every method the real service declares, so an unknown one is distinguishable from a known-but-
48
+ * unmodeled one — the difference between Connect's `unimplemented` and its `not_found`. */
49
+ export const PSDB_METHODS = ['CreateSession', 'Execute', 'StreamExecute', 'CloseSession', 'Prepare'];
50
+ /** The literal 401 body the client's own suite fixtures. Identical for a missing, blank or wrong
51
+ * credential: the vendor draws no distinction between them, so neither does this twin. */
52
+ export const PLANETSCALE_UNAUTHENTICATED = { code: 'unauthenticated', message: 'invalid auth credentials' };
53
+ const JSON_HEADERS = { 'content-type': 'application/json' };
54
+ export function planetscaleTwinSnapshot() {
55
+ return {
56
+ service: PSDB_SERVICE,
57
+ implementedEndpoints: [
58
+ 'POST /psdb.v1alpha1.Database/CreateSession',
59
+ 'POST /psdb.v1alpha1.Database/Execute',
60
+ 'POST /psdb.v1alpha1.Database/CloseSession',
61
+ 'POST /psdb.v1alpha1.Database/Prepare (vendor-faithful unimplemented)',
62
+ 'POST /psdb.v1alpha1.Database/StreamExecute (the gap: Connect unimplemented)',
63
+ ],
64
+ resourceTypes: PLANETSCALE_RESOURCE_TYPES,
65
+ };
66
+ }
67
+ function lowerHeaders(h) {
68
+ const out = {};
69
+ for (const [k, v] of Object.entries(h ?? {}))
70
+ out[k.toLowerCase()] = v;
71
+ return out;
72
+ }
73
+ /**
74
+ * Decode `Authorization: Basic base64(user:pass)` — the ONLY scheme this wire uses
75
+ * (dist/index.js `postJSON` builds it with `btoa(`${username}:${password}`)`).
76
+ *
77
+ * The password may itself contain `:` (PlanetScale passwords are `pscale_pw_…`, but a
78
+ * connection-URL password is arbitrary), so the split is on the FIRST colon only. Anything that is
79
+ * not a well-formed Basic header resolves to null and gets the vendor's 401 — a malformed header
80
+ * must never fall through and be treated as a bare credential.
81
+ */
82
+ /** What `atob` decodes (forgiving-base64): base64 characters whose length is not 1 mod 4, with at most the padding that completes a quad. */
83
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}(?:==)?|[A-Za-z0-9+/]{3}=?)?$/;
84
+ /** A Basic credential that is not base64: the vendor's 401, as for any malformed header. */
85
+ function notBase64() {
86
+ return null;
87
+ }
88
+ export function extractBasicCredential(headers) {
89
+ const raw = lowerHeaders(headers).authorization;
90
+ if (raw === undefined)
91
+ return null;
92
+ const m = /^basic\s+(\S+)\s*$/i.exec(raw.trim());
93
+ if (m === null)
94
+ return null;
95
+ const decoded = BASE64.test(m[1]) ? atob(m[1]) : notBase64();
96
+ if (decoded === null)
97
+ return null;
98
+ const colon = decoded.indexOf(':');
99
+ if (colon < 0)
100
+ return null;
101
+ const username = decoded.slice(0, colon);
102
+ const password = decoded.slice(colon + 1);
103
+ if (username === '' || password === '')
104
+ return null;
105
+ return { username, password };
106
+ }
107
+ /** Connect's transport-level error body: a lowercase code and a message, with no `error` wrapper. */
108
+ const connectError = (status, code, message) => ({ status, body: { code, message }, headers: JSON_HEADERS });
109
+ const unauthenticated = (session) => ({ status: 401, body: { ...(session !== undefined ? { session } : {}), error: PLANETSCALE_UNAUTHENTICATED }, headers: JSON_HEADERS });
110
+ export async function handlePlanetscaleTwinRequest(req) {
111
+ const path = req.path.split('?')[0].replace(/\/+$/, '') || '/';
112
+ const prefix = `/${PSDB_SERVICE}/`;
113
+ if (!path.startsWith(prefix))
114
+ return unknownService(path);
115
+ const method = path.slice(prefix.length);
116
+ if (!PSDB_METHODS.includes(method))
117
+ return unknownMethod(method);
118
+ // Connect unary RPCs are POST-only; a GET is refused before authentication, as Connect does.
119
+ if (req.method.toUpperCase() !== 'POST')
120
+ return postOnly();
121
+ const credential = extractBasicCredential(req.headers);
122
+ if (credential === null)
123
+ return unauthenticated(undefined);
124
+ if (req.username !== undefined && credential.username !== req.username)
125
+ return unauthenticated(undefined);
126
+ if (req.password !== undefined && credential.password !== req.password)
127
+ return unauthenticated(undefined);
128
+ // a password the management lane issued authenticates by its secret and acts in its role; a deleted one (or one of a
129
+ // deleted database) is refused like any bad credential (planetscale-roles.ts)
130
+ const issued = issuedPassword(req.root, credential, req.occurredAt);
131
+ if (issued === 'refused')
132
+ return unauthenticated(undefined);
133
+ const payload = req.body === undefined || req.body.trim() === '' ? {} : payloadOf(req.body);
134
+ if (payload === undefined)
135
+ return malformedBody();
136
+ const ctx = {
137
+ ...(req.root !== undefined ? { root: req.root } : {}),
138
+ occurredAt: req.occurredAt ?? '1970-01-01T00:00:00.000Z',
139
+ ...(req.readOnly !== undefined ? { readOnly: req.readOnly } : {}),
140
+ database: req.database ?? DEFAULT_DATABASE,
141
+ // a password acts on the branch it was made on (M5: "Create new credentials to access a branch's data",
142
+ // planetscale.com/docs/cli/password); the World's own credential acts on the branch the server was given
143
+ branch: issued?.branch ?? req.branch ?? DEFAULT_BRANCH,
144
+ ...(issued?.scope ? { scope: issued.scope } : {}),
145
+ };
146
+ const branch = ctx.branch ?? DEFAULT_BRANCH;
147
+ if (method === 'CreateSession')
148
+ return createSession(ctx, credential.username, branch);
149
+ if (method === 'CloseSession')
150
+ return closeSession(req, ctx, payload, branch);
151
+ if (method === 'Prepare')
152
+ return prepareUnimplemented();
153
+ if (method === 'StreamExecute')
154
+ return streamExecuteUnmodelled();
155
+ // ── Execute ────────────────────────────────────────────────────────────────────────────────
156
+ const query = payload.query;
157
+ const incoming = sessionIdOf(payload.session);
158
+ // the session's user variables and SQL mode ride the session the client echoes (vtgate.proto Session
159
+ // user_defined_variables, system_variables), as Vitess keeps them
160
+ const vars = sessionVarsOf(payload.session);
161
+ ctx.vars = vars;
162
+ if (typeof query !== 'string')
163
+ return queryNotString();
164
+ try {
165
+ if (issued !== undefined) {
166
+ let stmt;
167
+ try {
168
+ stmt = parseStatement(query);
169
+ }
170
+ catch {
171
+ stmt = undefined;
172
+ } // a statement that does not parse is refused by executeSql
173
+ const refused = stmt === undefined ? undefined : roleRefusal(issued.role, stmt);
174
+ if (refused)
175
+ throw refused;
176
+ if (stmt !== undefined && issued.safeMigrations && isDdl(stmt))
177
+ throw directDdlDisabled();
178
+ }
179
+ const { outcome, session } = await executeSql(ctx, query, incoming);
180
+ const body = {
181
+ session: makeSession(session.id, { branch, inTransaction: session.inTransaction, vars: outcome.vars ?? vars }),
182
+ result: encodeQueryResult(outcome),
183
+ };
184
+ // `timing` is DELIBERATELY omitted: it is an elapsed-wall-clock measurement, and this repo's
185
+ // serve-path determinism rule forbids one. The client's own `timing ?? 0` handles its absence,
186
+ // so `ExecutedQuery.time` reads 0 — honestly "not measured" rather than an invented duration.
187
+ return { status: 200, body, headers: JSON_HEADERS };
188
+ }
189
+ catch (error) {
190
+ if (!(error instanceof SqlError))
191
+ return internalFault(error);
192
+ // HTTP 200 — see the header. The client turns this into `DatabaseError(message, 400, error)`.
193
+ //
194
+ // The echoed session must report the transaction's REAL state. §9 round one, finding 10: this
195
+ // used to answer a flat `inTransaction: false`, so a statement that failed INSIDE an open
196
+ // transaction told the caller the transaction had ended — while the server still held the
197
+ // buffer and a later COMMIT still landed everything. MySQL does not end a transaction on a
198
+ // failed statement, and neither does this twin, so the wire must not say otherwise.
199
+ const open = incoming === null ? undefined : sessionState(req.root, incoming, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
200
+ const body = {
201
+ session: makeSession(incoming ?? '', { branch, inTransaction: open?.inTransaction === true, vars }),
202
+ error: { code: error.code, message: error.message },
203
+ };
204
+ return { status: 200, body, headers: JSON_HEADERS };
205
+ }
206
+ }
207
+ const unknownService = (path) => connectError(404, 'not_found', `unknown service: ${path.replace(/^\//, '').split('/')[0] ?? ''}`);
208
+ const unknownMethod = (method) => connectError(404, 'unimplemented', `${PSDB_SERVICE}/${method} is not implemented`);
209
+ const postOnly = () => connectError(405, 'unimplemented', 'HTTP GET is not supported by this protocol');
210
+ const malformedBody = () => connectError(400, 'invalid_argument', 'malformed JSON request body');
211
+ const queryNotString = () => connectError(400, 'invalid_argument', 'query must be a string');
212
+ /** A request body as a JSON object; undefined when it is not one. */
213
+ function payloadOf(text) {
214
+ let parsed;
215
+ try {
216
+ parsed = JSON.parse(text);
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
222
+ }
223
+ /**
224
+ * A fault inside the twin that is not a `SqlError`, answered as Connect's `internal` transport error. The wire must never
225
+ * be the crash surface (a deeply nested expression's RangeError once escaped as an HTTP 500 and the client's
226
+ * `UnknownError`), so the boundary that owns the protocol answers it.
227
+ */
228
+ const internalFault = (error) => connectError(500, 'internal', `internal twin error: ${error instanceof Error ? error.message : String(error)}`);
229
+ /** VENDOR-FAITHFUL: ps-http-sim answers Prepare with Connect's Unimplemented, and the client never calls it. */
230
+ const prepareUnimplemented = () => connectError(501, 'unimplemented', 'psdb.v1alpha1.Database/Prepare is not implemented');
231
+ /**
232
+ * A TWIN GAP, stated as one: the real service implements StreamExecute; this twin does not model Connect's
233
+ * server-streaming envelope framing, and a unary body here would be a fabricated success.
234
+ */
235
+ const streamExecuteUnmodelled = () => connectError(501, 'unimplemented', 'psdb.v1alpha1.Database/StreamExecute is not modeled by this twin');
236
+ /** CreateSession: a fresh session on the branch, and the user the credential names. */
237
+ async function createSession(ctx, username, branch) {
238
+ const state = await storeCreateSession(ctx);
239
+ const body = {
240
+ branch,
241
+ user: { username, psid: username },
242
+ session: makeSession(state.id, { branch, inTransaction: false }),
243
+ };
244
+ return { status: 200, body, headers: JSON_HEADERS };
245
+ }
246
+ /**
247
+ * CloseSession: a session the twin issued moves by the declared machine (manifest.ts): open → closed, or stays closed.
248
+ * Connect's CloseSessionResponse carries the (now closed) session; a session the twin never issued is NOT an error, as
249
+ * the real service tolerates it.
250
+ */
251
+ async function closeSession(req, ctx, payload, branch) {
252
+ const id = sessionIdOf(payload.session);
253
+ const held = id === null ? undefined : sessionState(req.root, id, ctx.database ?? DEFAULT_DATABASE, ctx.scope);
254
+ if (held !== undefined) {
255
+ const { move } = transitionFor('closed', manifest.resources.Session.state.closed, 'CloseSession', held.closed, undefined, id);
256
+ if (move === undefined)
257
+ throw new Error(`CloseSession moves a session from closed=${String(held.closed)}, which the machine does not declare`);
258
+ }
259
+ if (id !== null)
260
+ await storeCloseSession(ctx, id);
261
+ return { status: 200, body: { session: makeSession(id ?? '', { branch, inTransaction: false }) }, headers: JSON_HEADERS };
262
+ }
263
+ /**
264
+ * The password a Basic credential names, when the management lane issued one with its username: its role when the secret
265
+ * matches and it is not deleted, `refused` otherwise. A credential no issued password names is the World's own, as every
266
+ * credential was before passwords were issued, and acts as admin: an application pointed at the World (Dub) carries
267
+ * whatever credential its own configuration holds. That rule is the twin's decision.
268
+ */
269
+ function issuedPassword(root, credential, occurredAt) {
270
+ const row = twinResourcesOfType(SERVICE, '_password', root).map((r) => ownFields(r))
271
+ .find((p) => p.username === credential.username);
272
+ if (row === undefined)
273
+ return undefined;
274
+ if (row.deleted_at !== null && row.deleted_at !== undefined)
275
+ return 'refused';
276
+ if (row._secret_sha256 !== createHash('sha256').update(credential.password).digest('hex'))
277
+ return 'refused';
278
+ // a password whose ttl has passed "will be invalid" (the spec's create_password), refused like any bad credential
279
+ if (typeof row.expires_at === 'string' && occurredAt !== undefined && Date.parse(row.expires_at) <= Date.parse(occurredAt))
280
+ return 'refused';
281
+ // the branch the password was made on (its `database_branch`), and that branch's scope in the store
282
+ const branch = String(row.database_branch?.name ?? DEFAULT_BRANCH);
283
+ const scope = scopeOf(String(row._database || DEFAULT_DATABASE), branch);
284
+ // the branch's safe migrations, as the management lane keeps them on its branch record
285
+ const database = String(row._database ?? '');
286
+ const record = twinResourcesOfType(SERVICE, '_branch', root).find((r) => ownFields(r)._database === database && ownFields(r).name === branch && ownFields(r).deleted_at === null);
287
+ const safeMigrations = record !== undefined && ownFields(record).safe_migrations === true;
288
+ return { role: row.role, branch, ...(scope ? { scope } : {}), safeMigrations };
289
+ }
290
+ export { PLANETSCALE_RESOURCE_TYPES };
@@ -0,0 +1,139 @@
1
+ /** An exact decimal: `m / 10^s`. Integers are decimals of scale 0. */
2
+ export type Dec = {
3
+ m: bigint;
4
+ s: number;
5
+ };
6
+ /** A plain decimal or exponent spelling (`12`, `-0.50`, `.5`, `1.5e3`) as an exact value; null for
7
+ * anything else. */
8
+ export declare function parseDec(text: string): Dec | null;
9
+ /** A double as the exact decimal of its shortest round-trip spelling. */
10
+ export declare function decFromNumber(n: number): Dec;
11
+ export declare const decFromBigInt: (n: bigint) => Dec;
12
+ export declare const decAdd: (a: Dec, b: Dec) => Dec;
13
+ export declare const decSub: (a: Dec, b: Dec) => Dec;
14
+ export declare const decMul: (a: Dec, b: Dec) => Dec;
15
+ export declare const decNeg: (a: Dec) => Dec;
16
+ export declare const decIsZero: (a: Dec) => boolean;
17
+ export declare function decCmp(a: Dec, b: Dec): number;
18
+ /** Round half away from zero to `scale` digits (MySQL's rounding for exact values). */
19
+ export declare function decRound(a: Dec, scale: number): Dec;
20
+ /** Truncate toward zero to an integer. */
21
+ export declare const decTrunc: (a: Dec) => bigint;
22
+ /** `a / b` rounded half away from zero to `scale` digits; null on division by zero. */
23
+ export declare function decDiv(a: Dec, b: Dec, scale: number): Dec | null;
24
+ /** The remainder of `a / b` with the sign of `a` (MySQL MOD); null on division by zero. */
25
+ export declare function decMod(a: Dec, b: Dec): Dec | null;
26
+ /** The text of an exact value at its own scale, or at `scale` when given (padded or rounded). */
27
+ export declare function decText(a: Dec, scale?: number): string;
28
+ /** The canonical key of a value: trailing fractional zeros removed, so 1.50 and 1.5 meet. */
29
+ export declare function decKey(a: Dec): string;
30
+ /** Digits before the decimal point (0 for a pure fraction). */
31
+ export declare function decIntDigits(a: Dec): number;
32
+ export declare const decToNumber: (a: Dec) => number;
33
+ /**
34
+ * A double as MySQL prints one: the shortest digits that round-trip, in positional notation while
35
+ * the decimal exponent stays within DBL_DIG (15) digits, else `d.ddde±x` without a plus sign
36
+ * (`1e20`, `1.2345678901234568e17`, `1e-7`).
37
+ */
38
+ export declare function formatDouble(n: number): string;
39
+ /** A FLOAT (single precision) as MySQL prints it: the shortest digits that round-trip as float32. */
40
+ export declare function formatFloat(n: number): string;
41
+ /** MySQL's string → number conversion: the longest numeric prefix after leading spaces (`'1abc'`
42
+ * is 1, `'abc'` is 0), with `complete` saying whether the whole string was that number. */
43
+ export declare function stringNumberPrefix(text: string): {
44
+ value: number;
45
+ text: string;
46
+ complete: boolean;
47
+ };
48
+ export type TemporalKind = 'DATE' | 'DATETIME' | 'TIMESTAMP' | 'TIME';
49
+ /** A temporal value's parts. A TIME uses `h` for its whole hours (up to 838) and `neg` for its sign. */
50
+ export type Temporal = {
51
+ y: number;
52
+ mo: number;
53
+ d: number;
54
+ h: number;
55
+ mi: number;
56
+ s: number;
57
+ us: number;
58
+ neg: boolean;
59
+ };
60
+ /** How a parse went: `truncated` when trailing text was ignored (a warning, an error on a strict
61
+ * write), `zero` for a date with a zero part (refused on a strict write by NO_ZERO_DATE and
62
+ * NO_ZERO_IN_DATE). */
63
+ export type TemporalParse = {
64
+ t: Temporal;
65
+ truncated: boolean;
66
+ zero: boolean;
67
+ };
68
+ /**
69
+ * A DATE / DATETIME / TIMESTAMP string in any form MySQL accepts (manual 11.1.3 "Date and Time
70
+ * Literals"): delimited `YYYY-MM-DD[ hh:mm:ss[.fraction]]` with any punctuation as the delimiter
71
+ * and a space or `T` between date and time (the `T` form is what @planetscale/database sends for a
72
+ * `Date`), two-digit years (70–99 → 19xx, 00–69 → 20xx), the undelimited `YYYYMMDD[hhmmss]` and
73
+ * `YYMMDD[hhmmss]` forms, and a trailing `+hh:mm`/`-hh:mm` offset (8.0.19+), converted to UTC, the
74
+ * session time zone. Null when the text is not a date.
75
+ */
76
+ export declare function parseDateTimeText(text: string): TemporalParse | null;
77
+ /** A TIME string (manual 11.1.3): `[-][D ]hh:mm[:ss][.fraction]`, `hh:mm`, or undelimited
78
+ * `[-]hhmmss`/`mmss`/`ss`. A full DATETIME string is taken as its time of day. Range ±838:59:59. */
79
+ export declare function parseTimeText(text: string): TemporalParse | null;
80
+ /** A number used as a temporal (`20240101`, `20240101103000.5`, `103000` for a TIME). */
81
+ export declare function parseTemporalNumber(text: string, kind: TemporalKind): TemporalParse | null;
82
+ /** The instant (ms) a DATETIME's wall time names in UTC, and the reverse; CONVERT_TZ's arithmetic. */
83
+ export declare const temporalUtcMs: (t: Temporal) => number;
84
+ export declare function temporalFromUtcMs(ms: number, us: number): Temporal;
85
+ /** Round a value's fraction to `fsp` digits, as MySQL does on store (a half rounds up and carries). */
86
+ export declare function roundFsp(t: Temporal, fsp: number, kind: TemporalKind): Temporal;
87
+ /** The canonical text MySQL returns for a value of `kind` with `fsp` fractional digits. */
88
+ export declare function formatTemporal(t: Temporal, kind: TemporalKind, fsp: number): string;
89
+ /** A totally ordered key of a temporal value: DATE/DATETIME/TIMESTAMP as their packed
90
+ * `YYYYMMDDhhmmss.ffffff`, a TIME as its signed microseconds. */
91
+ export declare function packTemporal(t: Temporal, kind: TemporalKind): bigint;
92
+ /** A temporal in numeric context: DATE → YYYYMMDD, DATETIME → YYYYMMDDhhmmss[.f], TIME → hhmmss[.f]. */
93
+ export declare function temporalNumberText(t: Temporal, kind: TemporalKind, fsp: number): string;
94
+ /** TIMESTAMP's range: '1970-01-01 00:00:01.000000' to '2038-01-19 03:14:07.999999' UTC. */
95
+ export declare function inTimestampRange(t: Temporal): boolean;
96
+ /** A JSON number. Integers are exact (MySQL keeps int64/uint64); a number with a fraction or an
97
+ * exponent, or beyond uint64, is a DOUBLE; a DECIMAL comes only from SQL (JSON_OBJECT('k', 1.50)). */
98
+ export declare class JNum {
99
+ readonly kind: 'int' | 'double' | 'decimal';
100
+ readonly int: bigint;
101
+ readonly dbl: number;
102
+ readonly dec: Dec | null;
103
+ private constructor();
104
+ static int(n: bigint): JNum;
105
+ static double(n: number): JNum;
106
+ static decimal(d: Dec): JNum;
107
+ exact(): Dec;
108
+ }
109
+ /** A JSON object: members in MySQL's normalized order (shorter keys first, then byte order). */
110
+ export declare class JObj {
111
+ readonly members: Map<string, JV>;
112
+ constructor(members: Map<string, JV>);
113
+ }
114
+ export type JV = null | boolean | string | JNum | JV[] | JObj;
115
+ export declare class JsonTextError extends Error {
116
+ readonly reason: string;
117
+ readonly position: number;
118
+ constructor(reason: string, position: number);
119
+ }
120
+ /** Parse JSON text (RFC 8259, what MySQL accepts). Throws `JsonTextError` with MySQL's reason. */
121
+ export declare function parseJson(text: string): JV;
122
+ /** A JSON object built from members in any order (JSON_OBJECT), normalized as MySQL stores it. */
123
+ export declare const jsonObject: (members: Map<string, JV>) => JObj;
124
+ /** A JSON value printed as MySQL prints one: `", "` and `": "` separators, normalized member order,
125
+ * doubles with a `.0` when they would otherwise read as integers. */
126
+ export declare function printJson(v: JV): string;
127
+ export declare function jsonTypeName(v: JV): string;
128
+ /** Compare two JSON values (manual 13.5, "Comparison and Ordering of JSON Values"). Values of
129
+ * different types order by type precedence; numbers compare by value whatever their kind; strings
130
+ * by their utf8mb4 bytes; arrays element by element, the shorter first on a tie; booleans false
131
+ * first; objects are equal when they have the same members, otherwise ordered deterministically. */
132
+ export declare function compareJson(a: JV, b: JV): number;
133
+ /** A key under which JSON values that compare equal meet (numbers by value, objects by members). */
134
+ export declare function jsonKey(v: JV): string;
135
+ /** JSON_CONTAINS (manual 14.17.3): a scalar contains an equal scalar; an array contains every
136
+ * element of a candidate array, or a candidate non-array equal to one of its elements; an object
137
+ * contains a candidate object whose every member it contains. */
138
+ export declare function jsonContains(target: JV, candidate: JV): boolean;
139
+ export declare function jsonDepth(v: JV): number;