@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
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.
@@ -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
+ }