@volter/twin-planetscale 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +473 -0
  3. package/api/src/fetch.ts +50 -0
  4. package/api/src/generated/surface.gen.json +1 -0
  5. package/api/src/generated/ui.gen.json +1 -0
  6. package/api/src/index.ts +19 -0
  7. package/api/src/manifest.ts +136 -0
  8. package/api/src/screens/deploy-request.tsx +111 -0
  9. package/api/src/screens/service-tokens.tsx +141 -0
  10. package/api/src/screens/session.tsx +117 -0
  11. package/api/src/semantics/audit.ts +82 -0
  12. package/api/src/semantics/backups.ts +258 -0
  13. package/api/src/semantics/branches.ts +201 -0
  14. package/api/src/semantics/deploy-requests.ts +493 -0
  15. package/api/src/semantics/index.ts +371 -0
  16. package/api/src/semantics/shared.ts +141 -0
  17. package/api/src/semantics/time.ts +77 -0
  18. package/api/src/token-gate.ts +96 -0
  19. package/dist/api/src/fetch.d.ts +8 -0
  20. package/dist/api/src/fetch.js +51 -0
  21. package/dist/api/src/fetch.ts +50 -0
  22. package/dist/api/src/generated/surface.gen.json +1 -0
  23. package/dist/api/src/generated/ui.gen.json +1 -0
  24. package/dist/api/src/index.ts +19 -0
  25. package/dist/api/src/manifest.d.ts +2 -0
  26. package/dist/api/src/manifest.js +113 -0
  27. package/dist/api/src/manifest.ts +136 -0
  28. package/dist/api/src/screens/deploy-request.d.ts +7 -0
  29. package/dist/api/src/screens/deploy-request.js +106 -0
  30. package/dist/api/src/screens/deploy-request.tsx +111 -0
  31. package/dist/api/src/screens/service-tokens.d.ts +3 -0
  32. package/dist/api/src/screens/service-tokens.js +134 -0
  33. package/dist/api/src/screens/service-tokens.tsx +141 -0
  34. package/dist/api/src/screens/session.d.ts +11 -0
  35. package/dist/api/src/screens/session.js +108 -0
  36. package/dist/api/src/screens/session.tsx +117 -0
  37. package/dist/api/src/semantics/audit.d.ts +31 -0
  38. package/dist/api/src/semantics/audit.js +80 -0
  39. package/dist/api/src/semantics/audit.ts +82 -0
  40. package/dist/api/src/semantics/backups.d.ts +37 -0
  41. package/dist/api/src/semantics/backups.js +264 -0
  42. package/dist/api/src/semantics/backups.ts +258 -0
  43. package/dist/api/src/semantics/branches.d.ts +53 -0
  44. package/dist/api/src/semantics/branches.js +197 -0
  45. package/dist/api/src/semantics/branches.ts +201 -0
  46. package/dist/api/src/semantics/deploy-requests.d.ts +47 -0
  47. package/dist/api/src/semantics/deploy-requests.js +491 -0
  48. package/dist/api/src/semantics/deploy-requests.ts +493 -0
  49. package/dist/api/src/semantics/index.d.ts +20 -0
  50. package/dist/api/src/semantics/index.js +381 -0
  51. package/dist/api/src/semantics/index.ts +371 -0
  52. package/dist/api/src/semantics/shared.d.ts +36 -0
  53. package/dist/api/src/semantics/shared.js +132 -0
  54. package/dist/api/src/semantics/shared.ts +141 -0
  55. package/dist/api/src/semantics/time.d.ts +2 -0
  56. package/dist/api/src/semantics/time.js +81 -0
  57. package/dist/api/src/semantics/time.ts +77 -0
  58. package/dist/api/src/token-gate.d.ts +9 -0
  59. package/dist/api/src/token-gate.js +97 -0
  60. package/dist/api/src/token-gate.ts +96 -0
  61. package/dist/src/cli.d.ts +2 -0
  62. package/dist/src/cli.js +61 -0
  63. package/dist/src/generated/surface.gen.json +1 -0
  64. package/dist/src/generated/ui.gen.json +1 -0
  65. package/dist/src/index.d.ts +26 -0
  66. package/dist/src/index.js +156 -0
  67. package/dist/src/manifest.d.ts +2 -0
  68. package/dist/src/manifest.js +41 -0
  69. package/dist/src/planetscale-budget.d.ts +78 -0
  70. package/dist/src/planetscale-budget.js +305 -0
  71. package/dist/src/planetscale-capabilities.d.ts +10 -0
  72. package/dist/src/planetscale-capabilities.js +3977 -0
  73. package/dist/src/planetscale-collation-weights.gen.d.ts +4 -0
  74. package/dist/src/planetscale-collation-weights.gen.js +12 -0
  75. package/dist/src/planetscale-collation.d.ts +70 -0
  76. package/dist/src/planetscale-collation.js +391 -0
  77. package/dist/src/planetscale-conformance.d.ts +8 -0
  78. package/dist/src/planetscale-conformance.js +213 -0
  79. package/dist/src/planetscale-connector.d.ts +150 -0
  80. package/dist/src/planetscale-connector.js +532 -0
  81. package/dist/src/planetscale-deploy.d.ts +26 -0
  82. package/dist/src/planetscale-deploy.js +235 -0
  83. package/dist/src/planetscale-information-schema.d.ts +32 -0
  84. package/dist/src/planetscale-information-schema.js +299 -0
  85. package/dist/src/planetscale-mysql.d.ts +33 -0
  86. package/dist/src/planetscale-mysql.js +547 -0
  87. package/dist/src/planetscale-roles.d.ts +11 -0
  88. package/dist/src/planetscale-roles.js +60 -0
  89. package/dist/src/planetscale-row.d.ts +12 -0
  90. package/dist/src/planetscale-row.js +39 -0
  91. package/dist/src/planetscale-server.d.ts +42 -0
  92. package/dist/src/planetscale-server.js +137 -0
  93. package/dist/src/planetscale-sql.d.ts +701 -0
  94. package/dist/src/planetscale-sql.js +7167 -0
  95. package/dist/src/planetscale-store.d.ts +126 -0
  96. package/dist/src/planetscale-store.js +827 -0
  97. package/dist/src/planetscale-twin.d.ts +48 -0
  98. package/dist/src/planetscale-twin.js +290 -0
  99. package/dist/src/planetscale-values.d.ts +139 -0
  100. package/dist/src/planetscale-values.js +719 -0
  101. package/dist/src/planetscale-wire.d.ts +110 -0
  102. package/dist/src/planetscale-wire.js +188 -0
  103. package/dist/src/semantics/psdb.d.ts +18 -0
  104. package/dist/src/semantics/psdb.js +30 -0
  105. package/package.json +58 -0
  106. package/src/cli.ts +58 -0
  107. package/src/generated/surface.gen.json +1 -0
  108. package/src/generated/ui.gen.json +1 -0
  109. package/src/index.ts +267 -0
  110. package/src/manifest.ts +60 -0
  111. package/src/planetscale-budget.ts +347 -0
  112. package/src/planetscale-capabilities.ts +3862 -0
  113. package/src/planetscale-collation-weights.gen.ts +13 -0
  114. package/src/planetscale-collation.ts +378 -0
  115. package/src/planetscale-conformance.ts +237 -0
  116. package/src/planetscale-connector.ts +571 -0
  117. package/src/planetscale-deploy.ts +197 -0
  118. package/src/planetscale-information-schema.ts +322 -0
  119. package/src/planetscale-mysql.ts +339 -0
  120. package/src/planetscale-roles.ts +71 -0
  121. package/src/planetscale-row.ts +43 -0
  122. package/src/planetscale-server.ts +162 -0
  123. package/src/planetscale-sql.ts +5957 -0
  124. package/src/planetscale-store.ts +869 -0
  125. package/src/planetscale-twin.ts +338 -0
  126. package/src/planetscale-values.ts +572 -0
  127. package/src/planetscale-wire.ts +274 -0
  128. package/src/semantics/psdb.ts +57 -0
@@ -0,0 +1,391 @@
1
+ // MySQL 8 COLLATIONS — how two strings compare, sort, group and collide. Collation changes
2
+ // comparison only: stored bytes and returned values stay exactly as written.
3
+ //
4
+ // Reference: MySQL 8.0 Reference Manual, chapter 10 "Character Sets, Collations, Unicode":
5
+ // 10.3.1 Collation Naming Conventions (_ai/_as, _ci/_cs, _bin; the 0900 = UCA 9.0.0 collations),
6
+ // 10.8.4 Collation Coercibility in Expressions (derivations and the aggregation rules below),
7
+ // 10.8.5 The binary Collation Compared to _bin Collations,
8
+ // 10.10.1 Unicode Character Sets (utf8mb4_general_ci, utf8mb4_unicode_ci = UCA 4.0.0,
9
+ // utf8mb4_0900_ai_ci = UCA 9.0.0; PAD SPACE for all but the 0900 family, which is NO PAD),
10
+ // 12.8.1 String Comparison Functions (LIKE compares per character under the collation).
11
+ // The weights are the published tables those collations are built from (planetscale-collation-
12
+ // weights.gen.ts): DUCET 9.0.0 for utf8mb4_0900_*, DUCET 4.0.0 primaries for *_unicode_ci and the
13
+ // MySQL 8.0 `my_unicase_default` sort table for *_general_ci.
14
+ //
15
+ // WHAT IS MODELLED OF THE WEIGHT TABLES: the DUCET entries for U+0000..U+052F (Latin, IPA, Greek,
16
+ // Cyrillic, combining diacritics), U+1E00..U+1FFF, U+2000..U+206F, U+20A0..U+20CF, U+2100..U+218F,
17
+ // U+FB00..U+FB06 and U+FF01..U+FF5E, including DUCET's expansions (ß = ss, æ = ae, fi = fi under
18
+ // utf8mb4_0900_ai_ci) and its contractions in those ranges. Every other character takes UCA's
19
+ // implicit weight derived from its code point, which is the weight MySQL gives an UNASSIGNED
20
+ // character: it equals only itself and sorts after the modelled ranges. For assigned characters
21
+ // outside the ranges (Arabic, Hebrew, Indic, kana, Hangul, emoji, …) that is not MySQL's answer —
22
+ // their case/width/accent equivalences and their order are not modelled
23
+ // (planetscale.collation.uca_full_weight_table).
24
+ import { GENERAL_CI, UCA_400, UCA_900 } from "./planetscale-collation-weights.gen.js";
25
+ let ducet900;
26
+ let uca400;
27
+ let general;
28
+ function loadDucet900() {
29
+ if (ducet900 !== undefined)
30
+ return ducet900;
31
+ const single = new Map();
32
+ const contractions = new Map();
33
+ for (const entry of UCA_900.split(' ')) {
34
+ const [keyText, body] = entry.split(':');
35
+ const cps = keyText.split('+').map((h) => parseInt(h, 16));
36
+ const ces = [];
37
+ for (const ce of body === '' ? [] : body.split(',')) {
38
+ const [p, s, t] = ce.split('.').map((h) => parseInt(h, 16));
39
+ ces.push(p, s ?? 0x20, t ?? 0x2);
40
+ }
41
+ if (cps.length === 1)
42
+ single.set(cps[0], ces);
43
+ else {
44
+ const list = contractions.get(cps[0]) ?? [];
45
+ list.push({ rest: cps.slice(1), ces });
46
+ list.sort((a, b) => b.rest.length - a.rest.length);
47
+ contractions.set(cps[0], list);
48
+ }
49
+ }
50
+ return (ducet900 = { single, contractions });
51
+ }
52
+ function loadUca400() {
53
+ if (uca400 !== undefined)
54
+ return uca400;
55
+ const map = new Map();
56
+ for (const entry of UCA_400.split(' ')) {
57
+ const [cp, body] = entry.split(':');
58
+ map.set(parseInt(cp, 16), body === '' ? [] : body.split(',').map((h) => parseInt(h, 16)));
59
+ }
60
+ return (uca400 = map);
61
+ }
62
+ function loadGeneral() {
63
+ if (general !== undefined)
64
+ return general;
65
+ const map = new Map();
66
+ for (const entry of GENERAL_CI.split(' ')) {
67
+ const [cp, sort] = entry.split(':');
68
+ map.set(parseInt(cp, 16), parseInt(sort, 16));
69
+ }
70
+ return (general = map);
71
+ }
72
+ const codePoints = (s) => { const out = []; for (const ch of s)
73
+ out.push(ch.codePointAt(0)); return out; };
74
+ /** The twelve CJK Compatibility Ideographs that are Unified_Ideograph (UCA "core Han"). */
75
+ const COMPAT_UNIFIED = new Set([0xfa0e, 0xfa0f, 0xfa11, 0xfa13, 0xfa14, 0xfa1f, 0xfa21, 0xfa23, 0xfa24, 0xfa27, 0xfa28, 0xfa29]);
76
+ /** UCA 9.0.0 implicit weights (UTS #10 §10.1.3): [AAAA.0020.0002][BBBB.0000.0000]. */
77
+ function implicit900(cp) {
78
+ if (cp >= 0x17000 && cp <= 0x18aff)
79
+ return [0xfb00, 0x20, 0x2, (cp - 0x17000) | 0x8000, 0, 0];
80
+ let base = 0xfbc0;
81
+ if ((cp >= 0x4e00 && cp <= 0x9fd5) || COMPAT_UNIFIED.has(cp))
82
+ base = 0xfb40;
83
+ else if ((cp >= 0x3400 && cp <= 0x4db5) || (cp >= 0x20000 && cp <= 0x2a6d6) || (cp >= 0x2a700 && cp <= 0x2b734)
84
+ || (cp >= 0x2b740 && cp <= 0x2b81d) || (cp >= 0x2b820 && cp <= 0x2cea1))
85
+ base = 0xfb80;
86
+ return [base + (cp >> 15), 0x20, 0x2, (cp & 0x7fff) | 0x8000, 0, 0];
87
+ }
88
+ /** The DUCET 9.0.0 collation elements of a string, flat [p, s, t, p, s, t, …]. */
89
+ function elements900(cps) {
90
+ const { single, contractions } = loadDucet900();
91
+ const out = [];
92
+ for (let i = 0; i < cps.length; i++) {
93
+ const cp = cps[i];
94
+ const candidates = contractions.get(cp);
95
+ let matched = false;
96
+ if (candidates !== undefined) {
97
+ for (const c of candidates) {
98
+ if (c.rest.every((r, k) => cps[i + 1 + k] === r)) {
99
+ out.push(...c.ces);
100
+ i += c.rest.length;
101
+ matched = true;
102
+ break;
103
+ }
104
+ }
105
+ }
106
+ if (matched)
107
+ continue;
108
+ out.push(...(single.get(cp) ?? implicit900(cp)));
109
+ }
110
+ return out;
111
+ }
112
+ /** A multi-level UCA sort key: each level's non-zero weights, levels separated by 0x0000. One
113
+ * weight is one UTF-16 code unit, so comparing keys as strings compares the weights. */
114
+ function ucaKey(ces, levels) {
115
+ let key = '';
116
+ for (let level = 0; level < levels; level++) {
117
+ if (level > 0)
118
+ key += '\u0000';
119
+ for (let i = level; i < ces.length; i += 3)
120
+ if (ces[i] !== 0)
121
+ key += String.fromCharCode(ces[i]);
122
+ }
123
+ return key;
124
+ }
125
+ /** utf8mb4_unicode_ci's primary weights (UCA 4.0.0). MySQL gives every supplementary character
126
+ * the weight of U+FFFD; unlisted BMP characters take the UCA 4.0.0 implicit weight. */
127
+ function weights400(cps) {
128
+ const table = loadUca400();
129
+ const out = [];
130
+ for (const cp of cps) {
131
+ if (cp > 0xffff) {
132
+ out.push(0xfffd);
133
+ continue;
134
+ }
135
+ const w = table.get(cp);
136
+ if (w !== undefined) {
137
+ out.push(...w);
138
+ continue;
139
+ }
140
+ const base = cp >= 0x3400 && cp <= 0x4db5 ? 0xfb80 : (cp >= 0x4e00 && cp <= 0x9fa5) || COMPAT_UNIFIED.has(cp) ? 0xfb40 : 0xfbc0;
141
+ out.push(base + (cp >> 15), (cp & 0x7fff) | 0x8000);
142
+ }
143
+ return out;
144
+ }
145
+ /** utf8mb4_general_ci: one weight per character from `my_unicase_default`; U+FFFD for supplementary. */
146
+ function weightsGeneral(cps) {
147
+ const table = loadGeneral();
148
+ return cps.map((cp) => (cp > 0xffff ? 0xfffd : table.get(cp) ?? cp));
149
+ }
150
+ /** PAD SPACE comparison of single-level weights: the shorter string is extended with spaces
151
+ * (MySQL's strnncollsp), so trailing spaces never matter and a trailing weight below the space
152
+ * weight (a TAB) sorts BEFORE the unpadded string. */
153
+ function padCompare(a, b, space) {
154
+ const n = Math.min(a.length, b.length);
155
+ for (let i = 0; i < n; i++)
156
+ if (a[i] !== b[i])
157
+ return a[i] < b[i] ? -1 : 1;
158
+ if (a.length === b.length)
159
+ return 0;
160
+ const longer = a.length > b.length ? a : b;
161
+ const sign = a.length > b.length ? 1 : -1;
162
+ for (let i = n; i < longer.length; i++)
163
+ if (longer[i] !== space)
164
+ return longer[i] < space ? -sign : sign;
165
+ return 0;
166
+ }
167
+ const padKey = (w, space) => {
168
+ let end = w.length;
169
+ while (end > 0 && w[end - 1] === space)
170
+ end--;
171
+ let key = '';
172
+ for (let i = 0; i < end; i++)
173
+ key += String.fromCodePoint(w[i]);
174
+ return key;
175
+ };
176
+ function codePointCompare(a, b) {
177
+ if (a === b)
178
+ return 0;
179
+ const x = codePoints(a);
180
+ const y = codePoints(b);
181
+ const n = Math.min(x.length, y.length);
182
+ for (let i = 0; i < n; i++)
183
+ if (x[i] !== y[i])
184
+ return x[i] < y[i] ? -1 : 1;
185
+ return x.length === y.length ? 0 : x.length < y.length ? -1 : 1;
186
+ }
187
+ /** Bounded per-collation memo of sort keys: sorts and group-bys key the same strings repeatedly. */
188
+ function memo(fn) {
189
+ const cache = new Map();
190
+ return (s) => {
191
+ const hit = cache.get(s);
192
+ if (hit !== undefined)
193
+ return hit;
194
+ const k = fn(s);
195
+ if (cache.size >= 50_000)
196
+ cache.clear();
197
+ cache.set(s, k);
198
+ return k;
199
+ };
200
+ }
201
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
202
+ function uca900(name, id, levels) {
203
+ const key = memo((s) => ucaKey(elements900(codePoints(s)), levels));
204
+ return { name, id, charset: 'utf8mb4', pad: false, binsort: false, key, compare: (a, b) => cmp(key(a), key(b)), charKey: (cp) => key(String.fromCodePoint(cp)) };
205
+ }
206
+ function padded(name, id, charset, weights, space, binsort) {
207
+ const w = memo((s) => weights(codePoints(s)));
208
+ const key = memo((s) => padKey(w(s), space));
209
+ return {
210
+ name, id, charset, pad: true, binsort, key,
211
+ compare: (a, b) => (a === b ? 0 : padCompare(w(a), w(b), space)),
212
+ charKey: (cp) => weights([cp]).map((w) => String.fromCodePoint(w)).join(''),
213
+ };
214
+ }
215
+ const identity = (cps) => cps;
216
+ /** The byte-wise `binary` collation of binary strings. Operands are byte strings (one char per byte). */
217
+ export const BINARY_COLLATION = {
218
+ name: 'binary', id: 63, charset: 'binary', pad: false, binsort: true,
219
+ key: (s) => s, compare: cmp, charKey: (cp) => String.fromCharCode(cp),
220
+ };
221
+ const COLLATIONS = [
222
+ BINARY_COLLATION,
223
+ uca900('utf8mb4_0900_ai_ci', 255, 1),
224
+ uca900('utf8mb4_0900_as_ci', 305, 2),
225
+ uca900('utf8mb4_0900_as_cs', 278, 3),
226
+ { name: 'utf8mb4_0900_bin', id: 309, charset: 'utf8mb4', pad: false, binsort: true, key: (s) => s, compare: codePointCompare, charKey: (cp) => String.fromCodePoint(cp) },
227
+ padded('utf8mb4_bin', 46, 'utf8mb4', identity, 0x20, true),
228
+ padded('utf8mb4_general_ci', 45, 'utf8mb4', weightsGeneral, 0x20, false),
229
+ padded('utf8mb4_unicode_ci', 224, 'utf8mb4', weights400, 0x209, false),
230
+ padded('utf8mb3_general_ci', 33, 'utf8mb3', weightsGeneral, 0x20, false),
231
+ padded('utf8mb3_bin', 83, 'utf8mb3', identity, 0x20, true),
232
+ padded('utf8mb3_unicode_ci', 192, 'utf8mb3', weights400, 0x209, false),
233
+ ];
234
+ const BY_NAME = new Map(COLLATIONS.map((c) => [c.name, c]));
235
+ /** Every collation the engine models (INFORMATION_SCHEMA.COLLATIONS lists them). */
236
+ export const modelledCollations = () => COLLATIONS;
237
+ const BY_ID = new Map(COLLATIONS.map((c) => [c.id, c]));
238
+ /** MySQL 8's default collation per character set (SHOW CHARACTER SET). */
239
+ export const DEFAULT_COLLATION_OF = { utf8mb4: 'utf8mb4_0900_ai_ci', utf8mb3: 'utf8mb3_general_ci', binary: 'binary' };
240
+ /** The database default, and the connection collation string literals take (`SET NAMES utf8mb4`
241
+ * selects utf8mb4's default collation, `default_collation_for_utf8mb4` = utf8mb4_0900_ai_ci). */
242
+ export const DATABASE_COLLATION = 'utf8mb4_0900_ai_ci';
243
+ /** The character sets MySQL 8 ships (SHOW CHARACTER SET). Names here that are not modelled are
244
+ * refused as unmodelled surface; any other name is MySQL's unknown charset/collation error. */
245
+ const MYSQL_CHARSETS = new Set(['armscii8', 'ascii', 'big5', 'binary', 'cp1250', 'cp1251', 'cp1256', 'cp1257', 'cp850', 'cp852', 'cp866', 'cp932',
246
+ 'dec8', 'eucjpms', 'euckr', 'gb18030', 'gb2312', 'gbk', 'geostd8', 'greek', 'hebrew', 'hp8', 'keybcs2', 'koi8r', 'koi8u', 'latin1', 'latin2',
247
+ 'latin5', 'latin7', 'macce', 'macroman', 'sjis', 'swe7', 'tis620', 'ucs2', 'ujis', 'utf16', 'utf16le', 'utf32', 'utf8', 'utf8mb3', 'utf8mb4']);
248
+ /** `utf8` is MySQL 8.0's alias for utf8mb3, in charset and collation names alike. */
249
+ const canonicalName = (name) => {
250
+ const n = name.toLowerCase();
251
+ return n === 'utf8' ? 'utf8mb3' : n.startsWith('utf8_') ? `utf8mb3_${n.slice(5)}` : n;
252
+ };
253
+ export function lookupCollation(name) {
254
+ const n = canonicalName(name);
255
+ const c = BY_NAME.get(n);
256
+ return c !== undefined ? { found: c } : notModelled(n, name);
257
+ }
258
+ /** A collation name the twin does not model: one of MySQL's (named for a MySQL character set) or no collation at all. */
259
+ function notModelled(n, name) {
260
+ const charset = n.split('_')[0];
261
+ return MYSQL_CHARSETS.has(charset) && n.includes('_') ? { unmodelled: n } : { unknown: name };
262
+ }
263
+ export function lookupCharset(name) {
264
+ const n = canonicalName(name);
265
+ if (n === 'utf8mb4' || n === 'utf8mb3' || n === 'binary')
266
+ return { found: n };
267
+ return MYSQL_CHARSETS.has(n) ? { unmodelled: n } : { unknown: name };
268
+ }
269
+ /** A modelled collation by name (canonical names only), or undefined. */
270
+ export const collationNamed = (name) => (name === undefined ? undefined : BY_NAME.get(canonicalName(name)));
271
+ export const collationById = (id) => (id === undefined ? undefined : BY_ID.get(id));
272
+ export const defaultCollation = (charset) => BY_NAME.get(DEFAULT_COLLATION_OF[charset]);
273
+ export const databaseCollation = () => BY_NAME.get(DATABASE_COLLATION);
274
+ /** The `_bin` collation of a charset (a column's `BINARY` attribute). */
275
+ export const binCollation = (charset) => (charset === 'binary' ? BINARY_COLLATION : BY_NAME.get(`${charset}_bin`));
276
+ // ── coercibility (10.8.4) ─────────────────────────────────────────────────────────────────────
277
+ export const EXPLICIT = 0;
278
+ export const NONE = 1;
279
+ export const IMPLICIT = 2;
280
+ export const SYSCONST = 3;
281
+ export const COERCIBLE = 4;
282
+ export const NUMERIC = 5;
283
+ export const IGNORABLE = 6;
284
+ export const DERIVATION_NAMES = ['EXPLICIT', 'NONE', 'IMPLICIT', 'SYSCONST', 'COERCIBLE', 'NUMERIC', 'IGNORABLE'];
285
+ const unicode = (c) => c.charset !== 'binary';
286
+ /** utf8mb4 is a superset of utf8mb3; a Unicode operand of lower derivation absorbs the other. */
287
+ function leftIsSuperset(l, r) {
288
+ return unicode(l.collation) && (l.derivation < r.derivation
289
+ || (l.derivation === r.derivation && (!unicode(r.collation) || (l.collation.charset === 'utf8mb4' && r.collation.charset === 'utf8mb3'))));
290
+ }
291
+ /**
292
+ * The collation two operands are compared under — MySQL's DTCollation::aggregate with the
293
+ * comparison flags (superset and coercible conversion allowed). A result of derivation NONE is
294
+ * "Illegal mix of collations": two different non-binary collations of equal derivation, or two
295
+ * different EXPLICIT ones. A binary string wins over a character string of equal or higher
296
+ * derivation, and a `_bin` collation wins a tie against a `_ci`/`_cs` one of the same charset.
297
+ */
298
+ export function aggregate(a, b) {
299
+ if (a.collation.charset !== b.collation.charset)
300
+ return binaryOperand(a, b) ?? acrossCharsets(a, b);
301
+ if (a.derivation < b.derivation)
302
+ return a;
303
+ if (b.derivation < a.derivation)
304
+ return b;
305
+ return a.collation === b.collation ? a : collationTie(a, b);
306
+ }
307
+ /** Of two operands of different character sets, the binary one: it wins over a character string of equal or higher derivation. */
308
+ const binaryOperand = (a, b) => (a.collation.charset === 'binary' ? (a.derivation <= b.derivation ? a : b) : b.collation.charset === 'binary' ? (b.derivation <= a.derivation ? b : a) : undefined);
309
+ /** Two different collations of one character set at equal derivation: `_bin` wins, two EXPLICIT ones are an illegal mix. */
310
+ function collationTie(a, b) {
311
+ if (a.derivation === EXPLICIT)
312
+ return { collation: a.collation, derivation: NONE };
313
+ if (a.collation.binsort)
314
+ return a;
315
+ if (b.collation.binsort)
316
+ return b;
317
+ return { collation: binCollation(a.collation.charset), derivation: NONE };
318
+ }
319
+ /** Two character strings of different character sets: the superset, else the coercible side gives way. */
320
+ function acrossCharsets(a, b) {
321
+ if (leftIsSuperset(a, b))
322
+ return a;
323
+ if (leftIsSuperset(b, a))
324
+ return b;
325
+ if (a.derivation < COERCIBLE && b.derivation === COERCIBLE)
326
+ return a;
327
+ if (b.derivation < COERCIBLE && a.derivation === COERCIBLE)
328
+ return b;
329
+ return { collation: BINARY_COLLATION, derivation: NONE };
330
+ }
331
+ /**
332
+ * LIKE under a collation: `%` matches any run of characters, `_` exactly one, `\` escapes, and
333
+ * every other pattern character matches one character of equal collation weight. Per character,
334
+ * so under utf8mb4_0900_ai_ci `'é' LIKE 'e'` holds while `'ß' LIKE 'ss'` does not (MySQL 8
335
+ * manual, 12.8.1: LIKE performs matching on a per-character basis). Trailing spaces are
336
+ * significant in LIKE whatever the collation's pad attribute.
337
+ */
338
+ export function likeMatch(value, pattern, collation) {
339
+ const toks = [];
340
+ const p = collation.charset === 'binary' ? [...pattern].map((c) => c.charCodeAt(0)) : codePoints(pattern);
341
+ for (let i = 0; i < p.length; i++) {
342
+ const c = p[i];
343
+ if (c === 0x5c && i + 1 < p.length) {
344
+ toks.push({ key: collation.charKey(p[++i]) });
345
+ continue;
346
+ }
347
+ if (c === 0x25) {
348
+ if (!('any' in (toks[toks.length - 1] ?? {})))
349
+ toks.push({ any: true });
350
+ continue;
351
+ }
352
+ if (c === 0x5f) {
353
+ toks.push({ one: true });
354
+ continue;
355
+ }
356
+ toks.push({ key: collation.charKey(c) });
357
+ }
358
+ const v = (collation.charset === 'binary' ? [...value].map((c) => c.charCodeAt(0)) : codePoints(value)).map((c) => collation.charKey(c));
359
+ // Iterative wildcard match with single-star backtracking (each `%` resets the anchor).
360
+ let vi = 0;
361
+ let ti = 0;
362
+ let starTi = -1;
363
+ let starVi = 0;
364
+ while (vi < v.length) {
365
+ const t = toks[ti];
366
+ if (t !== undefined && 'one' in t) {
367
+ vi++;
368
+ ti++;
369
+ continue;
370
+ }
371
+ if (t !== undefined && 'key' in t && t.key === v[vi]) {
372
+ vi++;
373
+ ti++;
374
+ continue;
375
+ }
376
+ if (t !== undefined && 'any' in t) {
377
+ starTi = ti++;
378
+ starVi = vi;
379
+ continue;
380
+ }
381
+ if (starTi >= 0) {
382
+ ti = starTi + 1;
383
+ vi = ++starVi;
384
+ continue;
385
+ }
386
+ return false;
387
+ }
388
+ while (ti < toks.length && 'any' in toks[ti])
389
+ ti++;
390
+ return ti === toks.length;
391
+ }
@@ -0,0 +1,8 @@
1
+ export type PlanetscaleConformanceReport = {
2
+ ok: boolean;
3
+ endpointsChecked: number;
4
+ routerSurfaceChecked: number;
5
+ resourceTypesChecked: number;
6
+ violations: string[];
7
+ };
8
+ export declare function checkPlanetscaleConformance(): Promise<PlanetscaleConformanceReport>;
@@ -0,0 +1,213 @@
1
+ // planetscale conformance (dev-only; lazy-imported by the CLI, NEVER from index.ts/runtime — E2).
2
+ //
3
+ // This is NOT a self-referential snapshot check. Two constants asserting about each other cannot
4
+ // detect a dead handler (§9 has refuted that pattern twice in this repo), so this check ISSUES REAL
5
+ // REQUESTS against a throwaway root and grades the OUTCOME a live handler produces — a status set
6
+ // plus a predicate over the body. A twin whose handler returned `{}` fails every probe.
7
+ //
8
+ // FOUR directions are closed, not two:
9
+ // 1. every PROBE has a matching entry in `implementedEndpoints` (no probe for unclaimed surface);
10
+ // 2. every claimed endpoint has a PROBE (no claim nothing exercises);
11
+ // 3. `ROUTER_SURFACE` — a HAND-WRITTEN census of the method/path pairs the router must REFUSE —
12
+ // is exercised, and any pair answering something other than the transport-level refusal
13
+ // envelope is a violation. Probe⇄snapshot alone is blind to SERVED-BUT-UNCLAIMED surface;
14
+ // this is what catches an endpoint that quietly exists.
15
+ // 3b. `MALFORMED_SURFACE` — the branches where dispatch reaches Execute but the request never
16
+ // becomes a query. They must answer `invalid_argument`, never a query error and never a result.
17
+ // 4. auth: a credential-less Execute must answer the vendor's 401 envelope. A twin that
18
+ // authenticated everything would pass every probe above while accepting an anonymous client.
19
+ //
20
+ // §9 round one rewrote direction 3 twice over: its census used to be BUILT FROM `PSDB_METHODS` (the
21
+ // router's own method gate) behind a comment claiming it was hand-enumerated, and its "claimed"
22
+ // half could not fire for any input. Both are documented at `ROUTER_SURFACE`.
23
+ import { mkdtempSync, rmSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { join } from 'node:path';
26
+ import { twinResources } from '@volter/world-core';
27
+ import { createPlanetscaleTwinFetch } from "./planetscale-server.js";
28
+ import { SERVICE } from "./planetscale-store.js";
29
+ import { handlePlanetscaleTwinRequest, planetscaleTwinSnapshot, PSDB_SERVICE, PLANETSCALE_RESOURCE_TYPES, } from "./planetscale-twin.js";
30
+ import { unpackRow } from "./planetscale-wire.js";
31
+ const AT = '2026-01-01T00:00:00.000Z';
32
+ /** `Basic base64('twin:conformance')` — a credential the twin accepts because it is well-formed. */
33
+ const AUTH = { authorization: `Basic ${btoa('twin:conformance')}` };
34
+ const exec = (query, session) => JSON.stringify({ query, ...(session !== undefined ? { session } : {}) });
35
+ /**
36
+ * The SERVED-BUT-UNCLAIMED census — every method/path pair the router must ANSWER WITH A REFUSAL.
37
+ *
38
+ * Probe⇄snapshot (directions 1 and 2) is blind in exactly one direction: surface the router quietly
39
+ * serves that no snapshot entry claims. This closes it. Anything here answering something other
40
+ * than the transport-level refusal envelope is a violation.
41
+ *
42
+ * HAND-WRITTEN LITERALS, deliberately. §9 round one, finding 8: the first version built five of its
43
+ * nine entries with `...PSDB_METHODS.map(...)` behind a comment insisting it was hand-enumerated.
44
+ * `PSDB_METHODS` IS the router's own method gate, so those five drifted in lockstep with the code
45
+ * they audit — adding a name to that array grew the census and produced no violation. Writing the
46
+ * paths out is what makes this an INDEPENDENT census; §9 still compares it against the real
47
+ * dispatch branches by reading.
48
+ *
49
+ * The CLAIMED half was deleted with it (finding 9): it could not fire. `Execute` with a `{}` body
50
+ * answers 400, which is not a refusal status, so the only entries the condition could reach were
51
+ * the two `unimplemented` ones it explicitly excluded. The PROBES above already assert each claimed
52
+ * endpoint's real OUTCOME, which is strictly stronger than "did not refuse".
53
+ */
54
+ const ROUTER_SURFACE = [
55
+ // Wrong verb: Connect unary RPCs are POST-only, and the refusal precedes authentication.
56
+ { method: 'GET', path: '/psdb.v1alpha1.Database/Execute' },
57
+ { method: 'HEAD', path: '/psdb.v1alpha1.Database/Execute' },
58
+ { method: 'PUT', path: '/psdb.v1alpha1.Database/CreateSession' },
59
+ { method: 'DELETE', path: '/psdb.v1alpha1.Database/CloseSession' },
60
+ // A method the service does not declare.
61
+ { method: 'POST', path: '/psdb.v1alpha1.Database/NoSuchMethod', body: '{}' },
62
+ { method: 'POST', path: '/psdb.v1alpha1.Database/DropDatabase', body: '{}' },
63
+ // A different service, and paths shaped like OTHER vendors' that must not be served here.
64
+ { method: 'POST', path: '/psdb.v2.Database/Execute', body: '{}' },
65
+ { method: 'POST', path: '/v1/query', body: '{}' },
66
+ { method: 'POST', path: '/query', body: '{}' },
67
+ { method: 'POST', path: '/', body: '{}' },
68
+ { method: 'GET', path: '/' },
69
+ ];
70
+ /** The malformed-input branches — see direction 3b below. */
71
+ const MALFORMED_SURFACE = [
72
+ { label: 'a body that is not JSON', body: '{not json' },
73
+ { label: 'a JSON array body', body: '[1,2,3]' },
74
+ { label: 'a non-string query', body: JSON.stringify({ query: 42 }) },
75
+ { label: 'a missing query', body: JSON.stringify({ session: null }) },
76
+ ];
77
+ export async function checkPlanetscaleConformance() {
78
+ const snapshot = planetscaleTwinSnapshot();
79
+ const violations = [];
80
+ const root = mkdtempSync(join(tmpdir(), 'planetscale-conf-'));
81
+ const call = (method, path, body, headers = AUTH) => handlePlanetscaleTwinRequest({ method, path, headers, root, occurredAt: AT, ...(body !== undefined ? { body } : {}) });
82
+ const probes = [
83
+ {
84
+ label: 'POST /psdb.v1alpha1.Database/CreateSession',
85
+ method: 'POST', path: `/${PSDB_SERVICE}/CreateSession`, body: '{}',
86
+ expect: (r) => {
87
+ const b = r.body;
88
+ return r.status === 200 && b.branch === 'main' && b.user?.username === 'twin'
89
+ && typeof b.session?.vitessSession?.sessionUUID === 'string' && b.session.vitessSession.sessionUUID !== '';
90
+ },
91
+ },
92
+ {
93
+ label: 'POST /psdb.v1alpha1.Database/Execute',
94
+ method: 'POST', path: `/${PSDB_SERVICE}/Execute`, body: exec('SELECT 1 AS one FROM dual'),
95
+ expect: (r) => {
96
+ const b = r.body;
97
+ if (r.status !== 200 || b.error !== undefined || b.result === undefined)
98
+ return false;
99
+ // Grade the DECODED VALUE, not the shape: a handler that returned an empty result set, or
100
+ // packed the row wrongly, fails here.
101
+ const fields = b.result.fields ?? [];
102
+ const rows = b.result.rows ?? [];
103
+ return fields.length === 1 && fields[0].name === 'one' && rows.length === 1
104
+ && unpackRow(rows[0])[0] === '1';
105
+ },
106
+ },
107
+ {
108
+ label: 'POST /psdb.v1alpha1.Database/CloseSession',
109
+ method: 'POST', path: `/${PSDB_SERVICE}/CloseSession`,
110
+ body: JSON.stringify({ session: { vitessSession: { sessionUUID: 'tws-1-0' } } }),
111
+ expect: (r) => {
112
+ const b = r.body;
113
+ return r.status === 200 && b.session?.vitessSession?.sessionUUID === 'tws-1-0';
114
+ },
115
+ },
116
+ {
117
+ label: 'POST /psdb.v1alpha1.Database/Prepare (vendor-faithful unimplemented)',
118
+ method: 'POST', path: `/${PSDB_SERVICE}/Prepare`, body: '{}',
119
+ expect: (r) => r.status === 501 && r.body.code === 'unimplemented',
120
+ },
121
+ {
122
+ label: 'POST /psdb.v1alpha1.Database/StreamExecute (the gap: Connect unimplemented)',
123
+ method: 'POST', path: `/${PSDB_SERVICE}/StreamExecute`, body: exec('SELECT 1'),
124
+ // this probe calls the request handler, which answers 501; through the pack's fetch it is the gap (psdbGap), 404
125
+ expect: (r) => r.status === 501 && r.body.code === 'unimplemented',
126
+ },
127
+ ];
128
+ try {
129
+ for (const probe of probes) {
130
+ if (!snapshot.implementedEndpoints.includes(probe.label)) {
131
+ violations.push(`probe '${probe.label}' has no matching entry in implementedEndpoints`);
132
+ continue;
133
+ }
134
+ const response = await call(probe.method, probe.path, probe.body, probe.headers ?? AUTH);
135
+ if (!probe.expect(response)) {
136
+ violations.push(`endpoint '${probe.label}' did not answer as declared (status ${response.status}, body ${JSON.stringify(response.body)})`);
137
+ }
138
+ }
139
+ for (const endpoint of snapshot.implementedEndpoints) {
140
+ if (!probes.some((p) => p.label === endpoint))
141
+ violations.push(`declared endpoint '${endpoint}' has no conformance probe`);
142
+ }
143
+ // Direction 3 — served-but-unclaimed surface. Every entry must answer the TRANSPORT-level
144
+ // refusal envelope (a Connect `{code, message}` at 404/405/501), never a query envelope and
145
+ // never a result.
146
+ for (const entry of ROUTER_SURFACE) {
147
+ const response = await call(entry.method, entry.path, entry.body);
148
+ const body = response.body;
149
+ const refused = (response.status === 404 || response.status === 405 || response.status === 501)
150
+ && typeof body.code === 'string' && body.error === undefined && body.result === undefined && body.session === undefined;
151
+ if (!refused) {
152
+ violations.push(`router surface '${entry.method} ${entry.path}' must answer the transport refusal envelope, got ${response.status} ${JSON.stringify(response.body)} — served-but-unclaimed surface`);
153
+ }
154
+ }
155
+ // Direction 3b — the MALFORMED-INPUT branches. Dispatch reaches Execute, but the request never
156
+ // becomes a query: it must answer `invalid_argument`, never a query error and never a result.
157
+ for (const entry of MALFORMED_SURFACE) {
158
+ const response = await call('POST', `/${PSDB_SERVICE}/Execute`, entry.body);
159
+ const body = response.body;
160
+ if (response.status !== 400 || body.code !== 'invalid_argument' || body.error !== undefined || body.result !== undefined) {
161
+ violations.push(`${entry.label} must answer 400 {code:'invalid_argument'}, got ${response.status} ${JSON.stringify(response.body)}`);
162
+ }
163
+ }
164
+ // Auth is a branch too, and it must refuse — a twin that authenticated everything would pass
165
+ // every probe above while accepting a credential-less client.
166
+ const unauth = await call('POST', `/${PSDB_SERVICE}/Execute`, exec('SELECT 1'), {});
167
+ if (unauth.status !== 401 || unauth.body.error?.code !== 'unauthenticated') {
168
+ violations.push(`a credential-less Execute must answer 401 {error:{code:'unauthenticated'}}, got ${unauth.status} ${JSON.stringify(unauth.body)}`);
169
+ }
170
+ // …and each declared type must be genuinely REACHABLE, not merely declared: DDL writes a table,
171
+ // DML writes a row, and the round-trip must be readable back through the same handler.
172
+ await call('POST', `/${PSDB_SERVICE}/Execute`, exec('CREATE TABLE conf (id BIGINT PRIMARY KEY AUTO_INCREMENT, label VARCHAR(64) NOT NULL)'));
173
+ await call('POST', `/${PSDB_SERVICE}/Execute`, exec("INSERT INTO conf (label) VALUES ('alpha')"));
174
+ const read = await call('POST', `/${PSDB_SERVICE}/Execute`, exec('SELECT id, label FROM conf'));
175
+ const result = read.body.result;
176
+ const values = result?.rows?.[0] === undefined ? [] : unpackRow(result.rows[0]);
177
+ if (values[0] !== '1' || values[1] !== 'alpha') {
178
+ violations.push(`the 'table'/'row' resource types are declared but a DDL+DML round trip did not project (got ${JSON.stringify(read.body)})`);
179
+ }
180
+ const sessions = await call('POST', `/${PSDB_SERVICE}/CreateSession`, '{}');
181
+ if (typeof sessions.body.session?.vitessSession?.sessionUUID !== 'string') {
182
+ violations.push(`the 'session' resource type is declared but CreateSession did not mint one (${JSON.stringify(sessions.body)})`);
183
+ }
184
+ // a branch's backup, through the management API (the `api` lane, api/src/)
185
+ const backup = await createPlanetscaleTwinFetch({ root })(new Request('http://api.planetscale.com/v1/organizations/o/databases/d/branches/main/backups', {
186
+ method: 'POST', headers: { authorization: 'id:token', 'content-type': 'application/json' }, body: JSON.stringify({ name: 'conf' }),
187
+ }));
188
+ if (backup.status !== 201)
189
+ violations.push(`the '_backup' resource type is declared but creating a backup answered ${backup.status}`);
190
+ // The inventory must match what the handlers WRITE, in BOTH directions — a missing type is a violation as much
191
+ // as an extra one. What was written is read back from the root the paths above wrote to, never listed by hand.
192
+ const written = [...new Set(twinResources(SERVICE, root).map((r) => r.type))];
193
+ for (const type of PLANETSCALE_RESOURCE_TYPES) {
194
+ if (!written.includes(type))
195
+ violations.push(`declared resource type '${type}' is not written by any handler path`);
196
+ }
197
+ for (const type of written) {
198
+ if (!PLANETSCALE_RESOURCE_TYPES.includes(type)) {
199
+ violations.push(`the handler writes subject type '${type}' but PLANETSCALE_RESOURCE_TYPES does not declare it`);
200
+ }
201
+ }
202
+ }
203
+ finally {
204
+ rmSync(root, { recursive: true, force: true });
205
+ }
206
+ return {
207
+ ok: violations.length === 0,
208
+ endpointsChecked: snapshot.implementedEndpoints.length,
209
+ routerSurfaceChecked: ROUTER_SURFACE.length + MALFORMED_SURFACE.length,
210
+ resourceTypesChecked: snapshot.resourceTypes.length,
211
+ violations,
212
+ };
213
+ }