@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
package/README.md
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
# @volter/twin-planetscale
|
|
2
|
+
|
|
3
|
+
A local, stateful, vendor-faithful twin of **PlanetScale's serverless driver API** — the
|
|
4
|
+
`psdb.v1alpha1.Database` HTTP wire protocol that `@planetscale/database` speaks.
|
|
5
|
+
|
|
6
|
+
PlanetScale's HTTP driver is not MySQL's binary protocol and it is not REST. It is a **Connect RPC
|
|
7
|
+
service addressed with unary JSON POSTs**:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
POST /psdb.v1alpha1.Database/CreateSession {} -> { branch, user, session }
|
|
11
|
+
POST /psdb.v1alpha1.Database/Execute { query, session } -> { session, result | error }
|
|
12
|
+
POST /psdb.v1alpha1.Database/CloseSession { session } -> { session }
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
with `Authorization: Basic base64(username:password)`, and results packed as protojson
|
|
16
|
+
`QueryResult`: base64-concatenated row bytes plus per-column **byte** lengths, `-1` for NULL, and
|
|
17
|
+
every zero-valued field **omitted** (a DDL statement's whole result is `{}`).
|
|
18
|
+
|
|
19
|
+
Behind that protocol is a **real database**: a stateful MySQL-subset engine folded out of the
|
|
20
|
+
`@volter/world-core` kernel action log. `SELECT` returns what `INSERT` actually wrote; a duplicate key
|
|
21
|
+
raises MySQL's own `errno 1062`; `AUTO_INCREMENT` survives deletes; and `conn.transaction()` — which
|
|
22
|
+
is nothing but `BEGIN`/…/`COMMIT` over the client's own session — genuinely commits or rolls back.
|
|
23
|
+
|
|
24
|
+
## Run it
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun run packages/twin/planetscale/src/cli.ts serve --port 8080 --root /tmp/psdb
|
|
28
|
+
# then, from an app, with the CLIENT'S OWN public option — no patching:
|
|
29
|
+
PLANETSCALE_DATABASE_URL="http://user:pass@127.0.0.1:8080"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`http://` is honoured by the client itself (`protocol()` in `dist/index.js` passes `http:` through
|
|
33
|
+
and forces every other scheme to `https:`), so pointing an unmodified `connect({ url })` at the twin
|
|
34
|
+
is *configuration*, not modification. That is exactly the shape
|
|
35
|
+
[dub](https://github.com/dubinc/dub) reads in `apps/web/lib/planetscale/connection.ts`, which is the
|
|
36
|
+
demand this pack was built for (volter-ai/twin#255).
|
|
37
|
+
|
|
38
|
+
Under `volter-world`, no env var is needed at all: the pack declares the `*.psdb.cloud` host suffix
|
|
39
|
+
on its descriptor, so the injector redirects an unmodified client's traffic to the twin.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bun run packages/twin/planetscale/src/cli.ts conformance # endpoint probes + router-surface census
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Native MySQL frontend (`serve --mysql`)
|
|
46
|
+
|
|
47
|
+
Dub reaches PlanetScale through **two clients over one database**: the HTTP driver above from its
|
|
48
|
+
redirect middleware, and Prisma over MySQL's binary wire protocol from its API routes and its
|
|
49
|
+
deleted-link fallback page (volter-ai/twin#255 named both). The pack serves that second interface
|
|
50
|
+
as a **frontend of the same state owner**, never a second database:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# the HTTP twin owns the state; the frontend forwards every native connection to it
|
|
54
|
+
bun run packages/twin/planetscale/src/cli.ts serve --port 8080 --database planetscale
|
|
55
|
+
PLANETSCALE_TWIN_URL=http://127.0.0.1:8080 \
|
|
56
|
+
bun run packages/twin/planetscale/src/cli.ts serve --mysql --port 3306 --database planetscale
|
|
57
|
+
DATABASE_URL="mysql://twin:twin@127.0.0.1:3306/planetscale" # an unmodified Prisma/mysql2 client
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`volter init` emits exactly this pair for an app whose Prisma datasource is MySQL beside the
|
|
61
|
+
HTTP client (Dub), instead of a managed MySQL container. By hand, declare both as services, the
|
|
62
|
+
HTTP twin first: services see the env of the
|
|
63
|
+
services declared before them, so the `--mysql` service reads the HTTP twin's `PLANETSCALE_TWIN_URL`
|
|
64
|
+
(the descriptor's `nativeTransport.upstreamEnv`) and refuses to start without it. `covers` credits
|
|
65
|
+
the native `mysql:` connection to the `--mysql` service only, never to the HTTP twin by vendor name.
|
|
66
|
+
`--username`/`--password` on the frontend are what native clients present AND what it presents to
|
|
67
|
+
the HTTP twin unless the upstream URL carries its own credentials, so give both services the same
|
|
68
|
+
pair (or none).
|
|
69
|
+
|
|
70
|
+
Each native connection becomes one real psdb session (`CreateSession` on handshake, `CloseSession`
|
|
71
|
+
on `COM_QUIT`/disconnect, so an unfinished transaction rolls back exactly as the HTTP driver's
|
|
72
|
+
would). SQL text, results and errors come from the same engine: the frontend owns only framing,
|
|
73
|
+
`mysql_native_password` authentication, `COM_STMT_PREPARE`/`EXECUTE`/`CLOSE`/`RESET` with typed
|
|
74
|
+
binary parameters and binary result rows, `COM_PING`, `COM_INIT_DB`, `SET NAMES utf8mb4`,
|
|
75
|
+
`SET autocommit` (an implicit transaction the frontend opens and closes through the same `BEGIN`/
|
|
76
|
+
`COMMIT` the HTTP driver would send) and the `@@`/`@@session.` variables a driver handshake reads
|
|
77
|
+
(the engine's own table, which the HTTP driver reads too: `@@sql_mode` is MySQL 8's default,
|
|
78
|
+
`@@lower_case_table_names` is 2, `@@transaction_isolation` is `READ-COMMITTED`).
|
|
79
|
+
A prepare never executes a mutation and opens no transaction. Everything else a native client can
|
|
80
|
+
send is refused with a MySQL error, never silently accepted: other `SET`s (`SET [SESSION]
|
|
81
|
+
TRANSACTION …` reaches the engine, below), other `@@` variables, cursors, multi-statements,
|
|
82
|
+
`COM_STMT_SEND_LONG_DATA`, packets above 16 MiB and binary `TIME` results; the `native` area of
|
|
83
|
+
the capability manifest lists them. If the HTTP twin replaces a connection's session (it was
|
|
84
|
+
restarted), the frontend drops that connection instead of continuing on a different session. Prisma's `schema.table.column` qualification is accepted by the shared
|
|
85
|
+
parser; the twin owns one schema.
|
|
86
|
+
|
|
87
|
+
`prisma db push`, `prisma db pull` and Prisma's drift check read the database through
|
|
88
|
+
`INFORMATION_SCHEMA` (below), so Prisma Migrate works against the frontend: Dub's full multi-file
|
|
89
|
+
schema pushes into a fresh twin, a second push reports it already in sync, and `db pull` prints the
|
|
90
|
+
same models back. The one field `db pull` cannot recover is a `Json` `@default`, which Prisma
|
|
91
|
+
applies client-side on MySQL and never writes into the database.
|
|
92
|
+
|
|
93
|
+
### `INFORMATION_SCHEMA`
|
|
94
|
+
|
|
95
|
+
`INFORMATION_SCHEMA.<view>` (any case) resolves to MySQL 8's catalog views **derived on every read
|
|
96
|
+
from the engine's own catalog** — never stored, so they cannot drift from the tables they describe
|
|
97
|
+
(`src/planetscale-information-schema.ts`). `TABLES`, `COLUMNS` (MySQL 8.0.19+ `COLUMN_TYPE`
|
|
98
|
+
rendering such as `int`, `tinyint(1)`, `datetime(3)`, `decimal(10,0)`; `COLUMN_KEY`; `EXTRA`
|
|
99
|
+
`auto_increment`/`DEFAULT_GENERATED`; each character column's resolved charset and collation),
|
|
100
|
+
`STATISTICS` (every `PRIMARY`/`UNIQUE`/`KEY`/`FULLTEXT` index with prefix length and direction),
|
|
101
|
+
`TABLE_CONSTRAINTS` and `KEY_COLUMN_USAGE` are derived. The engine has no views, routines,
|
|
102
|
+
triggers, `CHECK` constraints or foreign keys, so `VIEWS`, `ROUTINES`, `TRIGGERS`,
|
|
103
|
+
`CHECK_CONSTRAINTS` and `REFERENTIAL_CONSTRAINTS` answer the truthful empty set; any other view is
|
|
104
|
+
MySQL's errno 1109. The views are read by the same `SELECT` as stored tables (joins, `DISTINCT`,
|
|
105
|
+
the `BINARY` prefix) and may be joined with them. `CREATE_TIME` is `NULL`; the size columns carry a
|
|
106
|
+
fresh InnoDB table's one 16 KiB page per index. Table names keep their declared lettercase and are
|
|
107
|
+
looked up case-insensitively (MySQL's `lower_case_table_names = 2`), and the catalog's identifier
|
|
108
|
+
columns (`TABLE_NAME`, `COLUMN_NAME`, `INDEX_NAME`, …) compare the same way, so `FROM EV` and
|
|
109
|
+
`WHERE TABLE_NAME = 'EV'` agree about a table declared `ev`. MySQL compares them under
|
|
110
|
+
`utf8mb3_tolower_ci`; the twin uses `utf8mb3_general_ci`, which also folds accents.
|
|
111
|
+
|
|
112
|
+
### Collations
|
|
113
|
+
|
|
114
|
+
Strings compare the way MySQL 8 compares them: under a collation
|
|
115
|
+
([Character Sets, Collations, Unicode](https://dev.mysql.com/doc/refman/8.0/en/charset.html)),
|
|
116
|
+
implemented in `src/planetscale-collation.ts`. Collation changes comparison only; stored bytes and
|
|
117
|
+
returned values stay exactly as written.
|
|
118
|
+
|
|
119
|
+
- **Resolution** (§10.3.4–10.3.5). `CREATE TABLE` resolves the table's charset and collation from
|
|
120
|
+
its `CHARACTER SET`/`COLLATE` options, else the database default `utf8mb4` /
|
|
121
|
+
`utf8mb4_0900_ai_ci`; each character column (`CHAR`, `VARCHAR`, the `TEXT`s, `ENUM`, `SET`) from
|
|
122
|
+
its own `CHARACTER SET`/`COLLATE`/`BINARY` attribute, else the table's.
|
|
123
|
+
`INFORMATION_SCHEMA.COLUMNS.COLLATION_NAME` and `TABLES.TABLE_COLLATION` report the result, so a
|
|
124
|
+
Prisma schema (whose tables declare `utf8mb4_unicode_ci`) still reports in sync.
|
|
125
|
+
- **Coercibility** (§10.8.4). A comparison runs under one collation chosen from its operands:
|
|
126
|
+
`COLLATE` (explicit) beats a column (implicit) beats a system constant beats a string literal,
|
|
127
|
+
which takes the connection collation `utf8mb4_0900_ai_ci` (`@@collation_connection`), beats a
|
|
128
|
+
number or temporal beats `NULL`. A binary string (`BINARY x`, `CAST(x AS BINARY)`, a
|
|
129
|
+
`VARBINARY`/`BLOB` column, a hex literal, `_binary'…'`) makes the comparison byte-wise; `utf8mb4`
|
|
130
|
+
absorbs `utf8mb3`; a `_bin` collation wins a tie; two different non-binary collations of equal
|
|
131
|
+
derivation are errno 1267 `Illegal mix of collations`. Unknown names are errno 1273/1115.
|
|
132
|
+
Numbers, temporals and JSON keep their own comparison.
|
|
133
|
+
- **Where it applies**: `=`, `<>`, `<=>`, `<`/`>`/`BETWEEN`, `IN` (lists, row constructors,
|
|
134
|
+
subqueries), `LIKE`, simple `CASE`, `NULLIF`, join conditions (including the hash join),
|
|
135
|
+
`ORDER BY`, `GROUP BY`, `DISTINCT`, `MIN`/`MAX`, `COUNT(DISTINCT)` and the other `DISTINCT`
|
|
136
|
+
aggregates, the clustered primary-key order, and `PRIMARY`/`UNIQUE` enforcement and row locks: a
|
|
137
|
+
case variant of a stored email in a `_ci` column is errno 1062 (Prisma's P2002), and
|
|
138
|
+
`findUnique` by a mixed-case email finds the lowercase row.
|
|
139
|
+
- **PAD attribute** (§10.10.1). The `0900` collations and `binary` are NO PAD (`'a ' = 'a'` is 0);
|
|
140
|
+
`utf8mb4_bin`, `utf8mb4_general_ci` and `utf8mb4_unicode_ci` are PAD SPACE. `LIKE` matches per
|
|
141
|
+
character under the collation and trailing spaces always count (§12.8.1): `'é' LIKE 'e'` is 1
|
|
142
|
+
under `utf8mb4_0900_ai_ci`, `'ß' LIKE 'ss'` is 0 although `'ß' = 'ss'` is 1.
|
|
143
|
+
|
|
144
|
+
Modelled collations: `utf8mb4_0900_ai_ci`, `utf8mb4_0900_as_ci`, `utf8mb4_0900_as_cs`,
|
|
145
|
+
`utf8mb4_0900_bin`, `utf8mb4_bin`, `utf8mb4_general_ci`, `utf8mb4_unicode_ci`,
|
|
146
|
+
`utf8mb3_general_ci`, `utf8mb3_bin`, `utf8mb3_unicode_ci` and `binary` (`utf8` names are
|
|
147
|
+
`utf8mb3`). Any other MySQL charset or collation is refused as `unsupported:`
|
|
148
|
+
(`planetscale.collation.other_collations`).
|
|
149
|
+
|
|
150
|
+
The weights are the published tables, generated into `src/planetscale-collation-weights.gen.ts` by
|
|
151
|
+
`scripts/generate-collation-weights.ts`: DUCET 9.0.0 (all three levels, with its expansions and
|
|
152
|
+
contractions) for the `0900` family, DUCET 4.0.0 primaries for `unicode_ci`, and MySQL 8.0's
|
|
153
|
+
`my_unicase_default` sort table for `general_ci`. **What of the UCA weight tables is modelled:**
|
|
154
|
+
U+0000–U+052F (Latin, IPA, spacing modifiers, combining diacritics, Greek, Cyrillic),
|
|
155
|
+
U+1E00–U+1FFF, U+2000–U+206F, U+20A0–U+20CF, U+2100–U+218F, U+FB00–U+FB06 and U+FF01–U+FF5E. So
|
|
156
|
+
case, accents, `ß` = `ss`, `æ` = `ae`, `ø` = `o` (`0900` only; `unicode_ci` keeps `ø` and `æ`
|
|
157
|
+
distinct, as UCA 4.0.0 does), ligatures (`fi` = `fi`) and fullwidth ASCII fold as MySQL folds them.
|
|
158
|
+
**What is not:** every other character (Armenian, Hebrew, Arabic, Indic scripts, kana, Hangul,
|
|
159
|
+
CJK, emoji, …) takes the UCA implicit weight of its code point, which is MySQL's weight for an
|
|
160
|
+
*unassigned* character: it equals only itself and sorts after the modelled ranges, so those
|
|
161
|
+
scripts' case, width and accent equivalences and their relative order are not MySQL's
|
|
162
|
+
(`planetscale.collation.uca_full_weight_table`, todo). `unicode_ci` and `general_ci` give every
|
|
163
|
+
supplementary character the weight of U+FFFD, as MySQL does. The connector's refresh reads
|
|
164
|
+
`DESCRIBE`, which carries no collation, so a pulled table's character columns compare under
|
|
165
|
+
`utf8mb4_0900_ai_ci` (`planetscale.connector.column_collations`, todo).
|
|
166
|
+
|
|
167
|
+
### Types and conversions
|
|
168
|
+
|
|
169
|
+
A cell is stored as MySQL's text form of its value, and every expression has a MySQL type derived
|
|
170
|
+
from its operands without reading a value (`src/planetscale-values.ts` holds the value arithmetic).
|
|
171
|
+
The type decides how a value compares, sorts, groups and is computed, whether it comes from a
|
|
172
|
+
column, `COALESCE`/`CASE`/`IF`/`GREATEST`, a subquery or arithmetic
|
|
173
|
+
([Type Conversion in Expression Evaluation](https://dev.mysql.com/doc/refman/8.0/en/type-conversion.html)):
|
|
174
|
+
|
|
175
|
+
- **Numbers.** Integers are exact at any size (BIGINT and BIGINT UNSIGNED beyond 2^53 included, in
|
|
176
|
+
keys, `DISTINCT`, `GROUP BY` and JSON). Arithmetic over exact operands is exact
|
|
177
|
+
([Precision Math](https://dev.mysql.com/doc/refman/8.0/en/precision-math.html)): `+ - *` keep
|
|
178
|
+
BIGINT or DECIMAL with the manual's scales, `/` adds `div_precision_increment` (4) digits
|
|
179
|
+
(`9 / 2` is `4.5000`), and an overflow is errno 1690. A DOUBLE or string operand makes the
|
|
180
|
+
expression DOUBLE, printed as MySQL prints one (`0.1e0 + 0.2e0` is `0.30000000000000004`).
|
|
181
|
+
Division by zero is `NULL`, and errno 1365 in a value an `INSERT`/`UPDATE` stores.
|
|
182
|
+
- **Mixed comparisons.** Strings under their collation, exact numbers exactly, a temporal against
|
|
183
|
+
a string as points in time, JSON against anything as JSON, every other mix as DOUBLE with a
|
|
184
|
+
string read by its numeric prefix: `'abc' = 0` and `'1abc' = 1` are 1, `'abc' + 1` is 1.
|
|
185
|
+
- **Temporals.** `DATE`, `DATETIME(fsp)`, `TIMESTAMP(fsp)`, `TIME` and `YEAR` accept every literal
|
|
186
|
+
form MySQL accepts (any delimiter, the `T` separator @planetscale/database sends, the
|
|
187
|
+
undelimited `YYYYMMDD[hhmmss]` forms, a `+hh:mm` offset) and are stored canonically, rounded to
|
|
188
|
+
the column's fsp; they compare and sort in time.
|
|
189
|
+
- **JSON.** A JSON column is validated and stored normalized (members in MySQL's order, integers
|
|
190
|
+
exact, a fractional number a DOUBLE); JSON compares per
|
|
191
|
+
[Comparison and Ordering of JSON Values](https://dev.mysql.com/doc/refman/8.0/en/json.html#json-comparison),
|
|
192
|
+
a non-JSON operand converted to JSON (`j->'$.name' = 'bob'` matches, JSON `2` is not `'2'`).
|
|
193
|
+
- **Writes** convert to the column's type under MySQL 8's default `sql_mode`
|
|
194
|
+
(`ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION`):
|
|
195
|
+
a non-number is errno 1366 (1265 when only a prefix is numeric), a value out of range 1264,
|
|
196
|
+
DECIMAL is rounded to its scale and padded (`1.5` reads back `1.50`), an invalid or zero date
|
|
197
|
+
1292, invalid JSON 3140, a string or binary string longer than its column 1406, an ENUM/SET
|
|
198
|
+
value that is not a member 1265. `INSERT IGNORE` adjusts instead (a `NULL` into `NOT NULL` takes
|
|
199
|
+
the type's implicit default). A column `DEFAULT` is converted the same way at `CREATE TABLE`
|
|
200
|
+
(errno 1067 when it does not fit).
|
|
201
|
+
- **`CAST(… AS type)` / `CONVERT(…, type)`** convert with MySQL's non-strict `CAST` rules.
|
|
202
|
+
|
|
203
|
+
### The `SELECT` surface
|
|
204
|
+
|
|
205
|
+
One executor serves stored tables, `INFORMATION_SCHEMA` views and derived tables:
|
|
206
|
+
`[INNER|CROSS] JOIN`, `LEFT [OUTER] JOIN … ON` and comma joins with table aliases and `t.*`;
|
|
207
|
+
scalar, `IN`/`NOT IN` and `[NOT] EXISTS` subqueries, correlated or not (Prisma's cursor
|
|
208
|
+
pagination and relation filters); row constructors such as `(a, b) IN ((…), (…))` (Prisma's
|
|
209
|
+
composite-key relation loads); `GROUP BY` (columns, select aliases, positions) with `HAVING` and
|
|
210
|
+
`COUNT([DISTINCT])`, `SUM`, `AVG`, `MIN`, `MAX`, `JSON_ARRAYAGG` and `GROUP_CONCAT` typed as MySQL
|
|
211
|
+
types them (`SUM`/`AVG` of an integer is `DECIMAL`); `DISTINCT`; `CASE`; `JSON_CONTAINS`,
|
|
212
|
+
`JSON_EXTRACT`, `JSON_UNQUOTE`, `->`/`->>`, `JSON_TYPE`, `JSON_LENGTH`, `JSON_ARRAY`, `JSON_OBJECT`.
|
|
213
|
+
A computed column is typed as MySQL 8 types the expression, and that type is its `Field.type`
|
|
214
|
+
on psdb and its column type on the native wire: JSON functions and `JSON_ARRAYAGG` are `JSON`
|
|
215
|
+
(charset 63, flags `BLOB|BINARY`); a scalar subquery takes its column's type; `COALESCE`, `IFNULL`,
|
|
216
|
+
`IF` and `CASE` aggregate their operands' types by the manual's `CASE` rules (literal `NULL`s
|
|
217
|
+
ignored, all-JSON stays `JSON`, JSON mixed with a string is `VARCHAR`, mixed temporals are
|
|
218
|
+
`DATETIME`, a `DECIMAL` widens integers); `NULLIF` takes its first argument's type; a derived-table
|
|
219
|
+
column keeps its inner expression's type. So `COALESCE((SELECT JSON_ARRAYAGG(…) …), JSON_ARRAY())`
|
|
220
|
+
comes back parsed. Arithmetic, comparisons and every other function are typed the same way, so a
|
|
221
|
+
column's type never depends on the rows it happened to return.
|
|
222
|
+
Name resolution is MySQL's: innermost query first, errno 1052 for an ambiguous column, select
|
|
223
|
+
aliases before columns in `ORDER BY`/`HAVING` and after them in `GROUP BY`. A read without
|
|
224
|
+
`ORDER BY` returns rows in primary-key order, the order a full scan of InnoDB's clustered index
|
|
225
|
+
returns; MySQL promises no order there, and an index scan in production may differ. The default
|
|
226
|
+
`sql_mode`'s refusals are MySQL's: a grouped query's column that is not functionally dependent on
|
|
227
|
+
the `GROUP BY` columns (through a table's `PRIMARY KEY` or `NOT NULL UNIQUE` key, or an equality
|
|
228
|
+
in `WHERE` or `ON`) is errno 1055, or 1140 without `GROUP BY`; `SELECT DISTINCT … ORDER BY` a
|
|
229
|
+
column not selected is 3065; `LIMIT` inside an `IN` subquery is 1235; an `UPDATE`/`DELETE` whose
|
|
230
|
+
subquery reads its own table (not through a derived table) is 1093. `UNION`, `RIGHT`/`NATURAL`
|
|
231
|
+
joins, `JOIN … USING`, window functions and `WITH ROLLUP` answer `unsupported:`.
|
|
232
|
+
|
|
233
|
+
### Transactions and row locks
|
|
234
|
+
|
|
235
|
+
A transaction buffers its writes in its session and applies them as one action at `COMMIT`;
|
|
236
|
+
`ROLLBACK`, a dropped native connection or `CloseSession` discards them. Until it ends, the
|
|
237
|
+
transaction holds exclusive row locks on every row it wrote and every row a locking read
|
|
238
|
+
(`FOR UPDATE`, `FOR SHARE`, `LOCK IN SHARE MODE`) returned, and on the `PRIMARY`/`UNIQUE` values it
|
|
239
|
+
claimed. Another session's write or locking read that needs one of them waits, then runs against
|
|
240
|
+
what the holder committed, as a blocked InnoDB statement does; plain reads never wait. A wait that
|
|
241
|
+
would close a cycle fails with errno 1213 and rolls the waiting transaction back; a wait past 50 s
|
|
242
|
+
(`innodb_lock_wait_timeout`) fails with errno 1205; that wait is measured in real elapsed time,
|
|
243
|
+
not on the World clock, which only advances between requests. `SET [SESSION] TRANSACTION READ
|
|
244
|
+
ONLY` and `START TRANSACTION READ ONLY` are honoured (a write is errno 1792; `SET TRANSACTION`
|
|
245
|
+
inside a transaction is 1568). `SET [SESSION] TRANSACTION ISOLATION LEVEL …` is accepted, but every
|
|
246
|
+
level reads the latest committed rows plus the transaction's own writes (no `REPEATABLE READ`
|
|
247
|
+
snapshot); `FOR SHARE` is held exclusively, and gap locks are not modeled. Row locks live in the
|
|
248
|
+
serving process: a transaction left open when that process ends is rolled back, as MySQL rolls
|
|
249
|
+
back a dead connection's, and its session's next statement fails with errno 2013 (a `ROLLBACK`
|
|
250
|
+
succeeds), so its buffer can never commit over rows other sessions wrote meanwhile.
|
|
251
|
+
|
|
252
|
+
`planetscale-mysql.test.ts` drives an unmodified `mysql2` client and the HTTP driver against one
|
|
253
|
+
root: writes, updates and deletes through either are visible through the other, native transaction
|
|
254
|
+
commit/rollback has the same shared visibility, and `SET autocommit = 0`'s implicit transaction
|
|
255
|
+
and a prepare's non-execution are pinned. `planetscale.native.shared_state` holds the cross-client
|
|
256
|
+
proof in the capability manifest too. The motivating #255 scenario — Dub's unmodified Next.js
|
|
257
|
+
server with Prisma (native) serving the fallback page and the link create/delete API while the
|
|
258
|
+
HTTP driver serves the redirect, all against one World — is proven by the story kept with the
|
|
259
|
+
project's Dub evidence, the same place the redirect journey lives; the repo does not carry the
|
|
260
|
+
app checkout.
|
|
261
|
+
|
|
262
|
+
## The store doors: `GET /twin/store/tables` and `GET /twin/store/rows`
|
|
263
|
+
|
|
264
|
+
The psdb wire is **Connect-JSON, and unary Connect is POST-only** — `Execute` included, so even a
|
|
265
|
+
`SELECT` is a POST, and the handler refuses a GET on the RPC path before it authenticates (which
|
|
266
|
+
is what ps-http-sim does too). Nothing in the vendor surface observes stored state with a GET.
|
|
267
|
+
|
|
268
|
+
`GET /twin/store/tables` and `GET /twin/store/rows` are the twin's own **named, read-only,
|
|
269
|
+
deterministic projections** over the same image `Execute` runs against (the twin programming
|
|
270
|
+
model's store door; `GET /twin` names them under `stores`) — the schema with its persisted
|
|
271
|
+
`AUTO_INCREMENT` watermark, and the rows with the engine's internal `rowid`. Both are sorted
|
|
272
|
+
rather than left in `Map` insertion order, so the bytes are a function of the state and not of
|
|
273
|
+
the order the log happened to arrive in. They are twin doors, not vendor surface: keyless, and
|
|
274
|
+
deliberately **absent from the capability manifest**, since counting scaffolding PlanetScale does
|
|
275
|
+
not publish would pad the coverage denominator. They are also what makes this pack's determinism
|
|
276
|
+
checkable at the resource level — the gate's R9 replay reads them back on two fresh roots.
|
|
277
|
+
|
|
278
|
+
## Refresh
|
|
279
|
+
|
|
280
|
+
Refresh reads schema and bounded primary-key pages, then folds one observation through the kernel.
|
|
281
|
+
`rowLimit` caps each returned page (default 500, maximum 10,000); `maxPages` caps pages per table
|
|
282
|
+
(default 10, maximum 100). The result names `completeTables` and `partialTables`, with `removed`
|
|
283
|
+
and `deltasAppended` counts. A full final page is partial even if it happens to contain the last row.
|
|
284
|
+
`pullPlanetscaleSnapshot` exposes the same completion metadata alongside resources; the older
|
|
285
|
+
`pullPlanetscaleDatabase` returns resources only.
|
|
286
|
+
|
|
287
|
+
Like an incremental database scan, this is not a global point-in-time snapshot. Vitess documents
|
|
288
|
+
[different isolation guarantees for single-shard and multi-shard transactions](https://vitess.io/docs/24.0/reference/compatibility/mysql-compatibility/).
|
|
289
|
+
Before removing a missing keyed row from a completed table, refresh checks its primary key again.
|
|
290
|
+
A row found by that check must agree with an already-scanned replacement identity; otherwise
|
|
291
|
+
the refresh fails and should be retried. This handles case-insensitive key spelling changes.
|
|
292
|
+
A completed table with a changed primary-key definition retires its old row addresses, including
|
|
293
|
+
when a new key column was absent from the old rows. An incomplete scan that changes the primary-key
|
|
294
|
+
definition is refused before recording anything; increase the scan bounds to complete that change. A keyless
|
|
295
|
+
table can complete only in one short page. Partial tables never lose rows based on absence.
|
|
296
|
+
An unfiltered successful table listing can also establish a dropped table's absence; selecting
|
|
297
|
+
specific tables leaves all other tables untouched. Local edits remain in the kernel's overlay.
|
|
298
|
+
|
|
299
|
+
All schema, page and absence-check queries must succeed before observation. Invalid limits,
|
|
300
|
+
malformed responses, unsupported paging keys, collisions, and exhausted budgets fail without
|
|
301
|
+
changing local observed state. Missing-row checks are capped separately by `maxMissingChecks`
|
|
302
|
+
(default 100, maximum 1,000). Integer/decimal, text and binary primary keys support continuation;
|
|
303
|
+
other key types can be read in a short first page but cannot be used for continuation or absence
|
|
304
|
+
checks. Every query uses the rate guard. SQL `LIMIT` bounds returned rows, not rows examined or
|
|
305
|
+
billed by the storage engine; this is not a metered-cost guarantee.
|
|
306
|
+
|
|
307
|
+
## Deployment
|
|
308
|
+
|
|
309
|
+
Local SQL statements and committed transactions are recorded as single kernel actions with
|
|
310
|
+
their complete row changes. Deployment applies captured rows, rather than repeating the original
|
|
311
|
+
predicate against a different database. This follows the distinction in MySQL's
|
|
312
|
+
[row-based replication](https://dev.mysql.com/doc/refman/8.0/en/replication-sbr-rbr.html).
|
|
313
|
+
Explicit primary-key values keep inserted rows addressable after refresh. Updates and deletes
|
|
314
|
+
compare captured text using `CAST(... AS BINARY)`, so case and trailing spaces remain significant.
|
|
315
|
+
Numeric fields use validated, unquoted numeric literals to avoid lossy string-to-number comparison. A conflicting or missing row rolls back the transaction. Deployment reserves the whole
|
|
316
|
+
plan's call cost before starting, including session setup and rollback; large plans exceeding the
|
|
317
|
+
current budget are refused before any vendor call.
|
|
318
|
+
|
|
319
|
+
Session state is local bookkeeping. Schema changes and writes to tables without a primary key
|
|
320
|
+
cannot be deployed through this adapter. Prepare the vendor schema and refresh it before authoring
|
|
321
|
+
data changes; schema deployment remains a separate PlanetScale workflow. Deployment also refuses
|
|
322
|
+
primary-key values whose stored representation is not preserved (such as integer `'01'`, automatic IDs requested as zero, `ZEROFILL`,
|
|
323
|
+
padded binary keys, or decimal/float keys). Hash collisions are refused before local writes or refresh
|
|
324
|
+
can collapse distinct rows. Commit also rechecks buffered identities against both the latest upstream and local
|
|
325
|
+
views, so an intervening refresh or another session cannot hide a collision. Integer and decimal values must fit their declared range and scale
|
|
326
|
+
without rounding. Values needing unmodeled storage conversion—including floating-point, temporal,
|
|
327
|
+
JSON, or padding changes—are refused before any part of the plan executes.
|
|
328
|
+
|
|
329
|
+
## Coverage
|
|
330
|
+
|
|
331
|
+
**Partial, and honestly so.** The manifest
|
|
332
|
+
(`src/planetscale-capabilities.ts`) is the *real vendor surface* as the denominator — MySQL's own
|
|
333
|
+
statement families crossed with the psdb protocol **and with PlanetScale's management API, of which
|
|
334
|
+
this pack models the slice its `api` lane serves** (below). Coverage reads low on purpose; growing the denominator later is
|
|
335
|
+
success, not regression. Run `bun scripts/manifest-baseline-one.ts planetscale` for the live count.
|
|
336
|
+
|
|
337
|
+
### What is modeled
|
|
338
|
+
|
|
339
|
+
- **The psdb protocol** — CreateSession / Execute / CloseSession, HTTP Basic auth with the vendor's
|
|
340
|
+
own `401 {error:{code:'unauthenticated'}}` envelope, `Prepare` answering Connect's
|
|
341
|
+
`unimplemented` (which is the *vendor's* behaviour, reproduced), and the two distinct error
|
|
342
|
+
channels: a **query** error is HTTP **200** carrying `{error:{code,message}}` (the client
|
|
343
|
+
synthesizes status 400 from it), an **auth** failure is HTTP **401** carrying the same envelope.
|
|
344
|
+
- **The wire codec** — byte-accurate row packing (a length is the BYTE count; getting this wrong
|
|
345
|
+
silently shifts every later column), `-1` NULL lengths, omitted `values` on an all-NULL row,
|
|
346
|
+
protojson zero-value omission, and the `Field` metadata (`type`/`charset`/`flags`/`columnType`)
|
|
347
|
+
the client's `cast` branches on. INT64/DECIMAL stay strings; INT32 becomes a number; JSON is
|
|
348
|
+
parsed; charset 63 becomes a `Uint8Array`.
|
|
349
|
+
- **DDL** — `CREATE TABLE` (columns, types, `NOT NULL`, `DEFAULT`, `DEFAULT CURRENT_TIMESTAMP(fsp)`
|
|
350
|
+
read from the World clock at insert, `AUTO_INCREMENT`, `PRIMARY KEY`, `UNIQUE` (a composite one
|
|
351
|
+
constrains the tuple), secondary `KEY`/`INDEX` and `FULLTEXT` recorded in the catalog, the table's
|
|
352
|
+
default charset/collation), `IF NOT EXISTS` / `IF EXISTS`, `DROP TABLE`, `TRUNCATE`
|
|
353
|
+
(which resets `AUTO_INCREMENT`, unlike `DELETE`), `SHOW TABLES`, `DESCRIBE` / `SHOW COLUMNS`.
|
|
354
|
+
- **DML** — `INSERT` (multi-row, `IGNORE`, `ON DUPLICATE KEY UPDATE` with MySQL's 1/2/0
|
|
355
|
+
`rowsAffected`), `SELECT` (`*`, projections, aliases, `WHERE` with the comparison/logical
|
|
356
|
+
operators plus `IN`/`LIKE`/`BETWEEN`/`IS NULL`, `ORDER BY`, `LIMIT`/`OFFSET`, `COUNT(*)`,
|
|
357
|
+
`FROM dual`), `UPDATE` (expression assignments, `LIMIT`, changed-rows-only counting), `DELETE`.
|
|
358
|
+
- **Real transactions** — `BEGIN`/`COMMIT`/`ROLLBACK` as a per-session write buffer: statements
|
|
359
|
+
inside see their own uncommitted writes, another session sees none of them, and `ROLLBACK`
|
|
360
|
+
discards everything including buffered deletes and DDL.
|
|
361
|
+
- **MySQL errors** — `errno`/`sqlstate` under the Vitess vtrpc code: 1146 no-such-table, 1050
|
|
362
|
+
table-exists, 1062 duplicate entry, 1048 not-null, 1054 unknown column, 1064 parse error, 1136
|
|
363
|
+
column-count mismatch, 1364 no default, 1290 read-only.
|
|
364
|
+
- **Published system limits** — the 2048-table and 1017-column caps from
|
|
365
|
+
[planetscale.com/docs/reference/planetscale-system-limits](https://planetscale.com/docs/reference/planetscale-system-limits)
|
|
366
|
+
(read 2026-08-31), with the 100k-row and 64 MiB figures recorded alongside.
|
|
367
|
+
- **A connector** — `pull` over an **injected** client (`SHOW TABLES` → `DESCRIBE` →
|
|
368
|
+
`SELECT * … ORDER BY … LIMIT n`), idempotent through the kernel observation path, with bounded
|
|
369
|
+
pagination and confirmed upstream deletions; and deployment of captured row changes as guarded inserts, updates and deletes.
|
|
370
|
+
- **A fail-closed rate budget** — see below.
|
|
371
|
+
- **A native MySQL frontend** — `serve --mysql`, the same state through MySQL's binary protocol for
|
|
372
|
+
Prisma/`mysql2` clients (see above).
|
|
373
|
+
- **`INFORMATION_SCHEMA`** — the catalog views Prisma's MySQL describer reads (see above).
|
|
374
|
+
- **Branches** — a password acts on the branch it was made on. `main` is the World's one image; every other branch
|
|
375
|
+
is a scope of its own (`_branch_table`/`_branch_row`, never pushed to a real branch), made with its parent's schema
|
|
376
|
+
and no rows, or with a backup's schema and rows. A branch with safe migrations refuses DDL (`ERROR 1105 (HY000):
|
|
377
|
+
direct DDL is disabled`). The twin's limit: every database's `main` shares the one image.
|
|
378
|
+
- **The management API** — `api.planetscale.com/v1/organizations/…`, the pack's `api` lane (`api/src/`, derived from
|
|
379
|
+
PlanetScale's Swagger document): databases (create, read, settings, delete), branches (create from a parent or a
|
|
380
|
+
backup, read, list, delete; safe migrations on and off), a branch's passwords (create with a role and a `ttl`, list,
|
|
381
|
+
read, delete; the psdb data plane authenticates them and enforces their role), deploy requests (the branch's schema
|
|
382
|
+
diffed against its parent's, reviews, a serial queue, deploy into the base, a 30-minute revert, close), backups
|
|
383
|
+
(create, list with its filters, read, delete), backup policies (the Base plan's 12-hourly schedule and added ones),
|
|
384
|
+
the organization's audit log, and service tokens (create, list, delete; their accesses are granted on the
|
|
385
|
+
organization's settings page), each operation gated by the service token accesses its spec names. Its pages: the
|
|
386
|
+
sign-in, the service tokens settings and a deploy request's page with "Approve changes". Its records are the twin's
|
|
387
|
+
bookkeeping (`_database`, `_branch`, `_password`, `_deploy_request`, `_backup`, `_backup_policy`, `_audit_event`,
|
|
388
|
+
`_service_token`): never pushed to a real branch — and, like every bookkeeping entry, they do not show in `world log`
|
|
389
|
+
or `world diff`.
|
|
390
|
+
|
|
391
|
+
### What is NOT modeled (filed as `todo`, not hidden)
|
|
392
|
+
|
|
393
|
+
Entropy reads — `RAND()`/`UUID()` — and `ON UPDATE CURRENT_TIMESTAMP`, the 20 s transaction timeout
|
|
394
|
+
and `ExecuteResponse.timing` are todos to be served from seeded entropy and the World's clock; today
|
|
395
|
+
the functions are refused and `timing` is omitted. (The clock functions — `NOW(fsp)` and its
|
|
396
|
+
synonyms, `UTC_TIMESTAMP()`, `CURDATE()`, no-argument `UNIX_TIMESTAMP()` — are served from the World
|
|
397
|
+
clock's instant for the statement: `planetscale.functions.clock`.)
|
|
398
|
+
|
|
399
|
+
`UNION`, window functions, `EXPLAIN`, index hints (`FORCE`/`USE`/`IGNORE INDEX`) and `PARTITION`
|
|
400
|
+
selection, `ALTER TABLE` beyond `ADD COLUMN` and `DROP COLUMN`, foreign keys, views, generated columns, `SAVEPOINT`, the isolation
|
|
401
|
+
levels' distinct read semantics, the rest of the date, string and JSON-modification function
|
|
402
|
+
libraries, Connect's binary codecs and `StreamExecute`, and **PlanetScale's management API beyond the
|
|
403
|
+
`api` lane's slice** (organizations, members, teams, webhooks, keyspaces, insights, workflows and the rest; every
|
|
404
|
+
other path under `/v1/organizations` answers PlanetScale's 404). Every one has a manifest entry so the denominator stays honest.
|
|
405
|
+
|
|
406
|
+
**Unmodeled surface is refused, never faked** — and it takes the *right* channel. Legal SQL this
|
|
407
|
+
twin has not built (joins, `GROUP BY`, subqueries, aggregates, `DISTINCT`, set operations, index
|
|
408
|
+
hints, `PARTITION`, `EXPLAIN`, `INTERVAL`, unknown functions, `ALTER TABLE … DROP INDEX`, `CREATE
|
|
409
|
+
VIEW`, …) answers Vitess's own `unsupported: …` `UNIMPLEMENTED`; a genuinely *malformed* statement
|
|
410
|
+
answers MySQL's `errno 1064`. Neither ever answers an empty result set, which would read to a caller
|
|
411
|
+
as "ran fine, matched nothing".
|
|
412
|
+
`planetscale.select.unmodeled_clauses_take_the_right_channel` and
|
|
413
|
+
`planetscale.errors.unmodeled_statement` assert both directions.
|
|
414
|
+
|
|
415
|
+
Three behaviours differ from real MySQL and are filed rather than papered over: this twin **buffers
|
|
416
|
+
DDL inside a transaction** (MySQL implicitly commits before DDL), it **reuses an `AUTO_INCREMENT` id
|
|
417
|
+
after a rollback** (InnoDB leaves a permanent gap), and it types an integer **literal** `INT64` and
|
|
418
|
+
names the column by its literal text, where real Vitess normalizes the literal into the bind
|
|
419
|
+
variable `:vtg1` and names the column after it. Real error messages also carry a **vttablet routing
|
|
420
|
+
prefix** (`target: <keyspace>.<shard>.<tablet_type>: vttablet: rpc error: …`) that a twin with no
|
|
421
|
+
keyspace or shard deliberately does not fabricate.
|
|
422
|
+
|
|
423
|
+
### Adversarial review
|
|
424
|
+
|
|
425
|
+
This pack was built under `../../../docs/contributing/adding-a-twin.md` §9. Round one refuted seven `done` claims and
|
|
426
|
+
found five further defects by reading; every fix earned a **named pin capability**, listed in
|
|
427
|
+
`planetscale-capabilities.test.ts` under *"every §9 round-one refutation has a pin capability"*, so
|
|
428
|
+
re-introducing any of those bugs reddens something specific. The two sharpest were a legal `INSERT`
|
|
429
|
+
of non-ASCII text into a `VARBINARY` column that made the table return HTTP 500 **forever**, and a
|
|
430
|
+
connector that assigned row ids by their position in an unordered `SELECT` — which silently dropped
|
|
431
|
+
a newly-observed real row onto a locally-created one on the second pull.
|
|
432
|
+
|
|
433
|
+
### No UI mirror
|
|
434
|
+
|
|
435
|
+
**The API is the product.** When someone does PlanetScale's core job they *write SQL from an
|
|
436
|
+
application*, not click a dashboard: the whole reason `@planetscale/database` exists is that the
|
|
437
|
+
database is reached from serverless code. PlanetScale's console is a provisioning, branching and
|
|
438
|
+
insights dashboard — the *management* surface this pack does not model — not where the data work
|
|
439
|
+
happens and not an agent navigation target. So this pack ships **no mirror and no UI capabilities**;
|
|
440
|
+
coverage is API + connector. (Its `ui-scope.json` entry records `needsUi: false` for the same
|
|
441
|
+
reason, and `planetscale-capabilities.test.ts` asserts that no `dimension: 'ui'` capability exists,
|
|
442
|
+
so the decision is enforced rather than merely stated.)
|
|
443
|
+
|
|
444
|
+
## Rate budget
|
|
445
|
+
|
|
446
|
+
PlanetScale publishes **no** requests-per-second or per-minute limit for the psdb HTTP API — its
|
|
447
|
+
system-limits page publishes only per-query and per-schema bounds. Per the repo's rule for exactly
|
|
448
|
+
that case, the declaration says so plainly and stays **at** the kernel's austere fallback (60 units
|
|
449
|
+
/ 60s at weight 2 = 30 calls per minute) rather than inventing a number, so it owes no
|
|
450
|
+
`VENDOR_BURST_ANCHOR` figure. `transaction` is priced 4× because one guarded call runs the caller's
|
|
451
|
+
entire callback behind it.
|
|
452
|
+
|
|
453
|
+
The real cost driver is metered **rows read**, which a request-count ceiling can only bound crudely.
|
|
454
|
+
That limitation is disclosed rather than hidden; the connector answers it separately by putting a
|
|
455
|
+
hard `LIMIT` in every statement it issues.
|
|
456
|
+
|
|
457
|
+
## Grounding
|
|
458
|
+
|
|
459
|
+
- the **installed `@planetscale/database@1.20.1`** package source (`npm pack`) — the authoritative
|
|
460
|
+
wire contract, and its own test fixtures for the 401 and query-error envelopes;
|
|
461
|
+
- [`github.com/mattrobenolt/ps-http-sim`](https://github.com/mattrobenolt/ps-http-sim) and the
|
|
462
|
+
`psdb.v1alpha1.Database` Connect service it implements (the simulator upstream dub devs run);
|
|
463
|
+
- [planetscale.com/docs/reference/planetscale-system-limits](https://planetscale.com/docs/reference/planetscale-system-limits).
|
|
464
|
+
|
|
465
|
+
See `spec-sources.json` for the full provenance record.
|
|
466
|
+
|
|
467
|
+
## Fidelity
|
|
468
|
+
|
|
469
|
+
`src/planetscale-sdk.integration.test.ts` drives the **real, unmodified** `@planetscale/database`
|
|
470
|
+
client against the twin over real HTTP — constructed through its own `connect({ url })` and nothing
|
|
471
|
+
else. No `fetch` override, no `cast`/`format` override, no monkey-patching. The client's own
|
|
472
|
+
argument interpolation, base64 row decoder and type casting all run, so everything the test asserts
|
|
473
|
+
is the client's own output.
|
package/api/src/fetch.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The management API lane of the planetscale pack (`api.planetscale.com/v1`), the lane's own dispatch: the pack's router
|
|
2
|
+
// (../../src/planetscale-server.ts) sends every `/v1/organizations` request here, and the organization's service tokens
|
|
3
|
+
// page (`app.planetscale.com/{organization}/settings/service-tokens`, screens/). The generated surface is PlanetScale's
|
|
4
|
+
// own Swagger document; the service-token gate sits in front of the dispatch (token-gate.ts), then the kernel's
|
|
5
|
+
// cross-cutting checks (read-only, a malformed body), then the handlers (semantics/). An operation this lane does not
|
|
6
|
+
// serve answers PlanetScale's 404: the gap.
|
|
7
|
+
import { bindSemantics, compileSurface, coreFor, createDerivedFetch, crossCutting, matchOperation, semanticsContext, type DerivedFetch, type DerivedOperation, type DerivedSurface } from '@volter/world-core';
|
|
8
|
+
import surface from './generated/surface.gen.json' with { type: 'json' };
|
|
9
|
+
import { manifest } from './manifest.ts';
|
|
10
|
+
import { apiSemantics } from './semantics/index.ts';
|
|
11
|
+
import { catchUp } from './semantics/time.ts';
|
|
12
|
+
import { tokenGate } from './token-gate.ts';
|
|
13
|
+
import { serviceTokensPage } from './screens/service-tokens.tsx';
|
|
14
|
+
import { sessionFlow } from './screens/session.tsx';
|
|
15
|
+
import { deployRequestPage } from './screens/deploy-request.tsx';
|
|
16
|
+
|
|
17
|
+
const SCREEN: DerivedOperation = { id: 'ServiceTokensPage', method: 'POST', path: '/{organization}/settings/service-tokens', class: 'action' };
|
|
18
|
+
const GATE: DerivedOperation = { id: 'TwinTokenGate', method: 'GET', path: '/v1/organizations', class: 'retrieve' };
|
|
19
|
+
|
|
20
|
+
export function createPlanetscaleApiLaneFetch(options: { root?: string; readOnly?: boolean; clock?: () => string } = {}): DerivedFetch & { catchUp: (request: Request) => Promise<void> } {
|
|
21
|
+
const scope = { ...(options.root !== undefined ? { root: options.root } : {}), ...(options.clock ? { clock: options.clock } : {}) };
|
|
22
|
+
const derived = createDerivedFetch({
|
|
23
|
+
surface: surface as DerivedSurface,
|
|
24
|
+
handlers: bindSemantics(manifest, apiSemantics, scope),
|
|
25
|
+
core: coreFor(manifest, scope),
|
|
26
|
+
around: crossCutting(manifest, { readOnly: options.readOnly ?? false, ...scope }),
|
|
27
|
+
gap: () => Response.json({ code: 'not_found', message: 'Not Found' }, { status: 404 }),
|
|
28
|
+
});
|
|
29
|
+
const routes = compileSurface(surface as DerivedSurface);
|
|
30
|
+
const contextFor = (request: Request) => semanticsContext(manifest, request, SCREEN, scope);
|
|
31
|
+
return Object.assign(async (request: Request): Promise<Response> => {
|
|
32
|
+
// what the World clock has made due (a database turning ready, a backup running and completing) happens first, so
|
|
33
|
+
// every door reads the lane as it stands now (semantics/time.ts)
|
|
34
|
+
if (!options.readOnly) await catchUp(await contextFor(new Request(request.url)));
|
|
35
|
+
// the organization's settings page, where a person makes a token and grants its accesses (screens/service-tokens.tsx)
|
|
36
|
+
const screen = (await sessionFlow(request.clone(), await contextFor(new Request(request.url))))
|
|
37
|
+
?? (await serviceTokensPage(request.clone(), contextFor)) ?? (await deployRequestPage(request, contextFor));
|
|
38
|
+
if (screen) return screen;
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
const matched = matchOperation(routes, request.method, url.pathname, url.searchParams, request.headers);
|
|
41
|
+
const ctx = await semanticsContext(manifest, new Request(request.url, { headers: request.headers }), matched?.operation ?? GATE, scope);
|
|
42
|
+
const call = { ...ctx.call, params: matched?.params ?? {} };
|
|
43
|
+
return tokenGate({ ...ctx, call }, request, matched?.operation.id) ?? derived(request);
|
|
44
|
+
}, {
|
|
45
|
+
owners: () => derived.owners(),
|
|
46
|
+
/** What the World clock has made due, caught up for a request another wire of the pack answers (psdb's Execute reads
|
|
47
|
+
* the branch a deploy has just changed): a move time makes happens when any request next arrives. */
|
|
48
|
+
catchUp: async (request: Request): Promise<void> => { if (!options.readOnly) await catchUp(await contextFor(new Request(request.url))); },
|
|
49
|
+
});
|
|
50
|
+
}
|