terrascale 0.3.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 (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +143 -0
  3. package/package.json +159 -0
  4. package/sdk-current-contract.json +27 -0
  5. package/sdk-route-manifest.json +67 -0
  6. package/src/admin.js +9 -0
  7. package/src/better-auth.js +14 -0
  8. package/src/config.js +121 -0
  9. package/src/database-codec.js +845 -0
  10. package/src/database-types.js +237 -0
  11. package/src/database-view.js +422 -0
  12. package/src/database.js +420 -0
  13. package/src/discovery.js +374 -0
  14. package/src/http.js +887 -0
  15. package/src/index.js +79 -0
  16. package/src/local/authentication.js +47 -0
  17. package/src/local/better-auth.js +517 -0
  18. package/src/local/cli.js +51 -0
  19. package/src/local/context.js +23 -0
  20. package/src/local/environment.js +109 -0
  21. package/src/local/index.js +204 -0
  22. package/src/local/router.js +999 -0
  23. package/src/local/server.js +664 -0
  24. package/src/local/store.js +530 -0
  25. package/src/local/test-environment.js +74 -0
  26. package/src/management-contracts.js +72 -0
  27. package/src/management.js +12 -0
  28. package/src/native-origin.js +75 -0
  29. package/src/postgres.js +494 -0
  30. package/src/react/core.js +743 -0
  31. package/src/react/index.js +99 -0
  32. package/src/result.js +251 -0
  33. package/src/schema.js +366 -0
  34. package/src/sql.js +996 -0
  35. package/src/svelte/index.js +129 -0
  36. package/src/tanstack/index.js +511 -0
  37. package/src/ts-auth-discovery.js +190 -0
  38. package/src/ts-auth.js +3497 -0
  39. package/types/admin.d.ts +6 -0
  40. package/types/better-auth.d.ts +8 -0
  41. package/types/config.d.ts +58 -0
  42. package/types/database-codec.d.ts +111 -0
  43. package/types/database-types.d.ts +213 -0
  44. package/types/database-view.d.ts +183 -0
  45. package/types/database.d.ts +98 -0
  46. package/types/discovery.d.ts +114 -0
  47. package/types/http.d.ts +46 -0
  48. package/types/index.d.ts +52 -0
  49. package/types/local/authentication.d.ts +11 -0
  50. package/types/local/better-auth.d.ts +33 -0
  51. package/types/local/cli.d.ts +2 -0
  52. package/types/local/context.d.ts +14 -0
  53. package/types/local/environment.d.ts +23 -0
  54. package/types/local/index.d.ts +94 -0
  55. package/types/local/router.d.ts +66 -0
  56. package/types/local/server.d.ts +54 -0
  57. package/types/local/store.d.ts +106 -0
  58. package/types/local/test-environment.d.ts +25 -0
  59. package/types/management-contracts.d.ts +44 -0
  60. package/types/management.d.ts +6 -0
  61. package/types/native-origin.d.ts +23 -0
  62. package/types/postgres.d.ts +123 -0
  63. package/types/react/core.d.ts +366 -0
  64. package/types/react/index.d.ts +54 -0
  65. package/types/result.d.ts +161 -0
  66. package/types/schema.d.ts +145 -0
  67. package/types/sql.d.ts +288 -0
  68. package/types/svelte/index.d.ts +81 -0
  69. package/types/tanstack/index.d.ts +165 -0
  70. package/types/ts-auth-discovery.d.ts +11 -0
  71. package/types/ts-auth.d.ts +1826 -0
@@ -0,0 +1,999 @@
1
+ import {
2
+ createHash,
3
+ createHmac,
4
+ randomBytes,
5
+ randomUUID,
6
+ timingSafeEqual,
7
+ } from "node:crypto";
8
+ import {
9
+ decodeDatabaseAuthority,
10
+ decodeDatabasePoint,
11
+ decodeDatabaseStatus,
12
+ decodeDatabaseValue,
13
+ encodeDatabaseAuthority,
14
+ encodeDatabaseCommit,
15
+ encodeDatabaseQuery,
16
+ encodeDatabaseStatus,
17
+ encodeDatabaseSubscription,
18
+ encodeDatabaseValue,
19
+ parseDatabaseJson,
20
+ requireDatabaseU64,
21
+ requireDatabaseUuid,
22
+ } from "../database-codec.js";
23
+ import { matchesLocalBearerAuthorization } from "./authentication.js";
24
+ import { jsonResponse, problemResponse } from "./context.js";
25
+ /**
26
+ * @import {
27
+ * CollectionBinding,
28
+ * DatabaseCommit,
29
+ * DatabaseCondition,
30
+ * DatabaseField,
31
+ * DatabasePoint,
32
+ * DatabaseQueryPredicate,
33
+ * DatabaseScope,
34
+ * DatabaseValue,
35
+ * DatabaseWrite,
36
+ * } from "../database-types.js"
37
+ */
38
+ /** @import { FetchLike } from "../config.js" */
39
+ /** @import { LocalAuthenticationMode } from "./context.js" */
40
+ /** @import { TerraScaleSqliteStore } from "./store.js" */
41
+
42
+ /** @typedef {Record<string, unknown>} JsonObject */
43
+ /** @typedef {JsonObject} WirePoint */
44
+ /**
45
+ * @typedef {{
46
+ * readonly sequence: string;
47
+ * readonly entries: readonly {
48
+ * readonly collection: string;
49
+ * readonly document: string;
50
+ * readonly before: WirePoint;
51
+ * readonly after: WirePoint;
52
+ * }[];
53
+ * }} Change
54
+ */
55
+ /**
56
+ * @typedef {{
57
+ * readonly route: string;
58
+ * readonly predicate: string;
59
+ * readonly limit: number;
60
+ * readonly sequence: string;
61
+ * readonly rows: readonly {
62
+ * readonly document: string;
63
+ * readonly point: WirePoint;
64
+ * }[];
65
+ * readonly expires: number;
66
+ * }} Snapshot
67
+ */
68
+ const absent = {
69
+ version: 1,
70
+ state: "absent",
71
+ state_version: null,
72
+ context: [],
73
+ siblings: [],
74
+ };
75
+ const maximumBodyBytes = 4 * 1024 * 1024;
76
+
77
+ /** Development implementation of the admitted native grammar, never producer qualification. */
78
+ export class TerraScaleLocalRouter {
79
+ /** @type {TerraScaleSqliteStore} */
80
+ #store;
81
+ /** @type {DatabaseScope} */
82
+ #scope;
83
+ /** @type {string} */
84
+ #scopeKey;
85
+ /** @type {string} */
86
+ #path;
87
+ /** @type {string | undefined} */
88
+ #token;
89
+ #secret = randomBytes(32);
90
+ /** @type {{ readonly replica: string; readonly incarnation: string }} */
91
+ #writer;
92
+
93
+ /**
94
+ * @param {TerraScaleSqliteStore} store
95
+ * @param {DatabaseScope} scope
96
+ * @param {{
97
+ * readonly authentication: LocalAuthenticationMode;
98
+ * readonly bearerToken?: string;
99
+ * }} options
100
+ */
101
+ constructor(store, scope, options) {
102
+ this.#store = store;
103
+ this.#scope = Object.freeze({
104
+ tenant: requireDatabaseUuid(scope.tenant),
105
+ database: requireDatabaseUuid(scope.database),
106
+ shard: requireDatabaseU64(scope.shard),
107
+ });
108
+ this.#scopeKey = `${scope.tenant}/${scope.database}/${scope.shard}`;
109
+ this.#path = `/v1/tenants/${scope.tenant}/databases/${scope.database}/shards/${scope.shard}`;
110
+ this.#token =
111
+ options.authentication === "required" ? options.bearerToken : undefined;
112
+ const writer = /** @type {{ readonly replica: string; readonly incarnation: string } | undefined} */ (
113
+ store.getMeta(`native-writer:${this.#scopeKey}`)
114
+ ) ?? {
115
+ replica: randomUUID(),
116
+ incarnation: randomBytes(16).toString("hex"),
117
+ };
118
+ this.#writer = Object.freeze(writer);
119
+ store.setMeta(`native-writer:${this.#scopeKey}`, writer);
120
+ }
121
+
122
+ get authority() {
123
+ return Object.freeze({
124
+ mode: /** @type {const} */ ("single_writer"),
125
+ generation: 1n,
126
+ writer: this.#writer,
127
+ });
128
+ }
129
+ /**
130
+ * @param {CollectionBinding} binding
131
+ * @returns {void}
132
+ */
133
+ bindCollection(binding) {
134
+ requireDatabaseUuid(binding.collection);
135
+ requireDatabaseU64(binding.schema_generation, "schema generation", true);
136
+ if (!/^[0-9a-f]{64}$/u.test(binding.schema_digest))
137
+ throw new TypeError("Schema digest must be lowercase SHA256.");
138
+ this.#store.put("native-catalog", this.#scopeKey, binding.collection, {
139
+ ...binding,
140
+ schema_generation: binding.schema_generation.toString(),
141
+ });
142
+ }
143
+ /**
144
+ * @readonly
145
+ * @type {FetchLike}
146
+ */
147
+ fetch = async (input, init) => this.handle(new Request(input, init));
148
+
149
+ /**
150
+ * @param {Request} request
151
+ * @returns {Promise<Response>}
152
+ */
153
+ async handle(request) {
154
+ if (
155
+ this.#token !== undefined &&
156
+ !matchesLocalBearerAuthorization(
157
+ request.headers.get("authorization"),
158
+ this.#token,
159
+ )
160
+ )
161
+ return problemResponse(401, "invalid_key");
162
+ try {
163
+ request.signal.throwIfAborted();
164
+ const url = new URL(request.url);
165
+ if (url.search !== "" || !url.pathname.startsWith(`${this.#path}/`))
166
+ return problemResponse(403, "forbidden_scope");
167
+ const suffix = url.pathname.slice(this.#path.length);
168
+ if (request.method === "GET") {
169
+ const match = /^\/collections\/([^/]+)\/documents\/([^/]+)$/u.exec(
170
+ suffix,
171
+ );
172
+ if (match === null) return problemResponse(404, "unknown_route");
173
+ const collection = requireDatabaseUuid(match[1]);
174
+ const document = requireDatabaseUuid(match[2]);
175
+ return await this.#store.exclusive(() => {
176
+ this.#binding(collection);
177
+ return jsonResponse(this.#point(collection, document));
178
+ });
179
+ }
180
+ if (request.method !== "POST")
181
+ return problemResponse(405, "method_not_allowed");
182
+ if (
183
+ request.headers.get("content-type")?.split(";", 1)[0]?.trim() !==
184
+ "application/json"
185
+ )
186
+ return problemResponse(400, "invalid_request");
187
+ const raw = object(await readBody(request));
188
+ if (suffix === "/transactions") {
189
+ const commit = decodeCommit(raw);
190
+ const encoded = await encodeDatabaseCommit(this.#scope, commit);
191
+ if (encoded.effect_digest !== raw.effect_digest)
192
+ return problemResponse(400, "invalid_request");
193
+ return await this.#store.exclusive(() =>
194
+ this.#store.transaction(() => this.#commit(raw, commit)),
195
+ );
196
+ }
197
+ if (suffix === "/transactions/status") {
198
+ const identity = decodeDatabaseStatus({
199
+ ...raw,
200
+ state: "unknown",
201
+ outcome: null,
202
+ });
203
+ exactKeys(raw, [
204
+ "version",
205
+ "transaction_id",
206
+ "authority",
207
+ "effect_digest",
208
+ ]);
209
+ encodeDatabaseStatus({
210
+ transaction_id: identity.transaction_id,
211
+ authority: identity.authority,
212
+ effect_digest: identity.effect_digest,
213
+ });
214
+ return await this.#store.exclusive(() => {
215
+ const result = /** @type {JsonObject | undefined} */ (
216
+ this.#store.get(
217
+ "native-transactions",
218
+ this.#scopeKey,
219
+ transactionKey(raw),
220
+ )
221
+ );
222
+ if (result !== undefined) {
223
+ if (
224
+ result.effect_digest !== raw.effect_digest ||
225
+ JSON.stringify(result.authority) !== JSON.stringify(raw.authority)
226
+ )
227
+ return problemResponse(409, "idempotency_conflict");
228
+ if (
229
+ identity.transaction_id.expires_at_unix_ms <= BigInt(Date.now())
230
+ )
231
+ return jsonResponse({ ...raw, state: "expired", outcome: null });
232
+ return jsonResponse(result);
233
+ }
234
+ return jsonResponse({
235
+ ...raw,
236
+ state:
237
+ identity.transaction_id.expires_at_unix_ms <= BigInt(Date.now())
238
+ ? "expired"
239
+ : "unknown",
240
+ outcome: null,
241
+ });
242
+ });
243
+ }
244
+ const match =
245
+ /^\/collections\/([^/]+)\/documents\/(query|subscriptions)$/u.exec(
246
+ suffix,
247
+ );
248
+ if (match === null) return problemResponse(404, "unknown_route");
249
+ const collection = requireDatabaseUuid(match[1]);
250
+ return await this.#store.exclusive(() => {
251
+ this.#binding(collection);
252
+ return this.#query(collection, match[2] === "subscriptions", raw);
253
+ });
254
+ } catch (error) {
255
+ if (error instanceof NativeLocalError)
256
+ return problemResponse(error.status, error.code);
257
+ return problemResponse(400, "invalid_request");
258
+ }
259
+ }
260
+
261
+ /**
262
+ * @param {string} collection
263
+ * @returns {JsonObject}
264
+ */
265
+ #binding(collection) {
266
+ const value = /** @type {JsonObject | undefined} */ (
267
+ this.#store.get("native-catalog", this.#scopeKey, collection)
268
+ );
269
+ if (value === undefined) throw new NativeLocalError(409, "schema_changed");
270
+ return value;
271
+ }
272
+ /**
273
+ * @param {string} collection
274
+ * @param {string} document
275
+ * @returns {WirePoint}
276
+ */
277
+ #point(collection, document) {
278
+ return (
279
+ /** @type {WirePoint | undefined} */ (
280
+ this.#store.get(
281
+ "native-points",
282
+ `${this.#scopeKey}/${collection}`,
283
+ document,
284
+ )
285
+ ) ?? absent
286
+ );
287
+ }
288
+ /** @returns {bigint} */
289
+ #sequence() {
290
+ return BigInt(
291
+ /** @type {string | undefined} */ (
292
+ this.#store.getMeta(`native-sequence:${this.#scopeKey}`)
293
+ ) ?? "0",
294
+ );
295
+ }
296
+
297
+ /**
298
+ * @param {JsonObject} raw
299
+ * @param {DatabaseCommit} commit
300
+ * @returns {Response}
301
+ */
302
+ #commit(raw, commit) {
303
+ const key = transactionKey(raw);
304
+ const previous = /** @type {JsonObject | undefined} */ (
305
+ this.#store.get("native-transactions", this.#scopeKey, key)
306
+ );
307
+ if (previous !== undefined) {
308
+ if (
309
+ previous.effect_digest !== raw.effect_digest ||
310
+ JSON.stringify(previous.authority) !== JSON.stringify(raw.authority)
311
+ )
312
+ return problemResponse(409, "idempotency_conflict");
313
+ if (commit.transaction_id.expires_at_unix_ms <= BigInt(Date.now()))
314
+ return problemResponse(409, "idempotency_expired");
315
+ return previous.state === "rejected"
316
+ ? problemResponse(412, "precondition_failed")
317
+ : jsonResponse(object(previous.outcome).receipt);
318
+ }
319
+ if (commit.transaction_id.expires_at_unix_ms <= BigInt(Date.now()))
320
+ return problemResponse(409, "idempotency_expired");
321
+ if (
322
+ commit.authority.mode === "single_writer" &&
323
+ JSON.stringify(encodeDatabaseAuthority(commit.authority)) !==
324
+ JSON.stringify(encodeDatabaseAuthority(this.authority))
325
+ ) {
326
+ return problemResponse(409, "authority_changed");
327
+ }
328
+ for (const write of commit.writes) {
329
+ const binding = this.#binding(write.collection);
330
+ if (
331
+ binding.schema_digest !== write.schema_digest ||
332
+ binding.schema_generation !== write.schema_generation.toString()
333
+ )
334
+ return problemResponse(409, "schema_changed");
335
+ const point = this.#point(write.collection, write.document);
336
+ if (
337
+ (write.condition.kind === "absent" && point.state !== "absent") ||
338
+ (write.condition.kind === "version" &&
339
+ point.state_version !== write.condition.version)
340
+ ) {
341
+ this.#store.put("native-transactions", this.#scopeKey, key, {
342
+ version: 1,
343
+ transaction_id: raw.transaction_id,
344
+ authority: raw.authority,
345
+ effect_digest: raw.effect_digest,
346
+ state: "rejected",
347
+ outcome: { error: { code: "precondition_failed" } },
348
+ });
349
+ return problemResponse(412, "precondition_failed");
350
+ }
351
+ }
352
+ const sequence = this.#sequence() + 1n;
353
+ const event = {
354
+ database: this.#scope.database,
355
+ shard: this.#scope.shard.toString(),
356
+ ...this.#writer,
357
+ sequence: sequence.toString(),
358
+ };
359
+ /** @type {Change["entries"][number][]} */
360
+ const entries = [];
361
+ for (const write of commit.writes) {
362
+ const before = this.#point(write.collection, write.document);
363
+ const point = decodeDatabasePoint(before);
364
+ const retained = point.siblings.filter(
365
+ (sibling) =>
366
+ !write.observed.some(
367
+ (observed) =>
368
+ observed.replica === sibling.event.replica &&
369
+ observed.incarnation === sibling.event.incarnation &&
370
+ observed.sequence >= sibling.event.sequence,
371
+ ),
372
+ );
373
+ const sibling = {
374
+ event,
375
+ schema_generation: write.schema_generation.toString(),
376
+ schema_digest: write.schema_digest,
377
+ ...wireWriteValue(write.value),
378
+ };
379
+ const siblings = [...retained.map(wireSibling), sibling];
380
+ const context = new Map(
381
+ point.context.map((observed) => [
382
+ observed.replica + observed.incarnation,
383
+ observed,
384
+ ]),
385
+ );
386
+ const own = { ...this.#writer, sequence };
387
+ context.set(own.replica + own.incarnation, own);
388
+ for (const observed of write.observed) {
389
+ const old = context.get(observed.replica + observed.incarnation);
390
+ if (old === undefined || old.sequence < observed.sequence)
391
+ context.set(observed.replica + observed.incarnation, observed);
392
+ }
393
+ const after = {
394
+ version: 1,
395
+ state:
396
+ siblings.length > 1
397
+ ? "conflict"
398
+ : write.value.kind === "live"
399
+ ? "value"
400
+ : "deleted",
401
+ state_version:
402
+ siblings.length > 1
403
+ ? null
404
+ : createHash("sha256")
405
+ .update(JSON.stringify(sibling))
406
+ .digest("hex"),
407
+ context: [...context.values()]
408
+ .sort((a, b) =>
409
+ (a.replica + a.incarnation).localeCompare(
410
+ b.replica + b.incarnation,
411
+ ),
412
+ )
413
+ .map((observed) => ({
414
+ ...observed,
415
+ sequence: observed.sequence.toString(),
416
+ })),
417
+ siblings,
418
+ };
419
+ decodeDatabasePoint(after);
420
+ this.#store.put(
421
+ "native-points",
422
+ `${this.#scopeKey}/${write.collection}`,
423
+ write.document,
424
+ after,
425
+ );
426
+ entries.push({
427
+ collection: write.collection,
428
+ document: write.document,
429
+ before,
430
+ after,
431
+ });
432
+ }
433
+ const receipt = {
434
+ version: 1,
435
+ durability: "local_durable",
436
+ transaction_id: raw.transaction_id,
437
+ authority: raw.authority,
438
+ effect_digest: raw.effect_digest,
439
+ event,
440
+ commit_sequence: sequence.toString(),
441
+ };
442
+ this.#store.put("native-transactions", this.#scopeKey, key, {
443
+ version: 1,
444
+ transaction_id: raw.transaction_id,
445
+ authority: raw.authority,
446
+ effect_digest: raw.effect_digest,
447
+ state: "committed",
448
+ outcome: { receipt },
449
+ });
450
+ this.#store.put(
451
+ "native-changes",
452
+ this.#scopeKey,
453
+ sequence.toString().padStart(20, "0"),
454
+ { sequence: sequence.toString(), entries },
455
+ );
456
+ this.#store.setMeta(
457
+ `native-sequence:${this.#scopeKey}`,
458
+ sequence.toString(),
459
+ );
460
+ return jsonResponse(receipt);
461
+ }
462
+
463
+ /**
464
+ * @param {JsonObject} payload
465
+ * @returns {string}
466
+ */
467
+ #cursor(payload) {
468
+ const value = Buffer.from(JSON.stringify(payload)).toString("base64url");
469
+ const signed = `${value}_${createHmac("sha256", this.#secret).update(value).digest("base64url")}`;
470
+ return Buffer.from(signed).toString("base64url");
471
+ }
472
+ /**
473
+ * @param {unknown} value
474
+ * @param {string} route
475
+ * @param {string} predicate
476
+ * @returns {JsonObject}
477
+ */
478
+ #cursorPayload(value, route, predicate) {
479
+ if (typeof value !== "string" || value.length > 1024)
480
+ throw new NativeLocalError(400, "invalid_cursor");
481
+ const signed = Buffer.from(value, "base64url").toString("utf8");
482
+ if (Buffer.from(signed).toString("base64url") !== value)
483
+ throw new NativeLocalError(400, "invalid_cursor");
484
+ const separator = signed.length - 44;
485
+ if (separator < 1 || signed[separator] !== "_")
486
+ throw new NativeLocalError(400, "invalid_cursor");
487
+ const body = signed.slice(0, separator);
488
+ const signature = signed.slice(separator + 1);
489
+ const expected = createHmac("sha256", this.#secret)
490
+ .update(body)
491
+ .digest("base64url");
492
+ if (
493
+ signature.length !== expected.length ||
494
+ !timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
495
+ )
496
+ throw new NativeLocalError(400, "invalid_cursor");
497
+ const payload = object(
498
+ JSON.parse(Buffer.from(body, "base64url").toString("utf8")),
499
+ );
500
+ if (payload.route !== route || payload.predicate !== predicate)
501
+ throw new NativeLocalError(400, "invalid_cursor");
502
+ if (typeof payload.expires !== "number" || payload.expires <= Date.now())
503
+ throw new NativeLocalError(409, "resync_required");
504
+ return payload;
505
+ }
506
+ /**
507
+ * @param {string} collection
508
+ * @param {boolean} subscription
509
+ * @param {JsonObject} raw
510
+ * @returns {Response}
511
+ */
512
+ #query(collection, subscription, raw) {
513
+ for (const entry of /** @type {readonly { readonly scope: string; readonly id: string; readonly value: Snapshot }[]} */ (
514
+ this.#store.listNamespace("native-snapshots")
515
+ ))
516
+ if (entry.value.expires <= Date.now())
517
+ this.#store.delete("native-snapshots", entry.scope, entry.id);
518
+ const predicate = decodePredicate(raw.predicate);
519
+ const predicateKey = createHash("sha256")
520
+ .update(JSON.stringify(raw.predicate))
521
+ .digest("hex");
522
+ const binding = this.#binding(collection);
523
+ const route = `${collection}/${subscription ? "subscription" : "query"}/${binding.schema_generation}/${binding.schema_digest}`;
524
+ if (subscription && raw.phase === "poll") {
525
+ encodeDatabaseSubscription({
526
+ phase: "poll",
527
+ predicate,
528
+ poll_cursor: text(raw.poll_cursor),
529
+ });
530
+ exactKeys(raw, ["version", "phase", "predicate", "poll_cursor"]);
531
+ const cursor = this.#cursorPayload(raw.poll_cursor, route, predicateKey);
532
+ const after = decimal(cursor.sequence);
533
+ const changes = /** @type {Change[]} */ (
534
+ this.#store.list("native-changes", this.#scopeKey)
535
+ )
536
+ .filter((change) => BigInt(change.sequence) > after)
537
+ .slice(0, 8);
538
+ const groups = changes.map((change) => ({
539
+ sequence: change.sequence,
540
+ effects: change.entries.flatMap(
541
+ /** @returns {{ document: string; point: JsonObject | null }[]} */
542
+ (entry) => {
543
+ if (entry.collection !== collection) return [];
544
+ const before = matchesPoint(entry.before, predicate);
545
+ const after = matchesPoint(entry.after, predicate);
546
+ return after
547
+ ? [{ document: entry.document, point: entry.after }]
548
+ : before
549
+ ? [{ document: entry.document, point: null }]
550
+ : [];
551
+ },
552
+ ),
553
+ }));
554
+ if (groups.some((group) => group.effects.length > 128))
555
+ throw new NativeLocalError(409, "resync_required");
556
+ const sequence = changes.at(-1)?.sequence ?? cursor.sequence;
557
+ return jsonResponse({
558
+ version: 1,
559
+ phase: "poll",
560
+ groups,
561
+ poll_cursor: this.#cursor({
562
+ route,
563
+ predicate: predicateKey,
564
+ sequence,
565
+ expires: Date.now() + 60_000,
566
+ }),
567
+ });
568
+ }
569
+ const limit = raw.limit;
570
+ if (typeof limit !== "number") throw new TypeError("Missing limit.");
571
+ if (subscription) {
572
+ encodeDatabaseSubscription({
573
+ phase: "snapshot",
574
+ predicate,
575
+ limit,
576
+ ...(raw.snapshot_cursor === undefined
577
+ ? {}
578
+ : { snapshot_cursor: text(raw.snapshot_cursor) }),
579
+ });
580
+ exactKeys(
581
+ raw,
582
+ ["version", "phase", "predicate", "limit"],
583
+ ["snapshot_cursor"],
584
+ );
585
+ if (raw.phase !== "snapshot") throw new TypeError("Invalid phase.");
586
+ } else {
587
+ encodeDatabaseQuery({
588
+ predicate,
589
+ limit,
590
+ ...(raw.cursor === undefined ? {} : { cursor: text(raw.cursor) }),
591
+ });
592
+ exactKeys(raw, ["version", "predicate", "limit"], ["cursor"]);
593
+ }
594
+ if (raw.version !== 1) throw new TypeError("Invalid version.");
595
+ const cursorValue = subscription ? raw.snapshot_cursor : raw.cursor;
596
+ /** @type {Snapshot} */
597
+ let snapshot;
598
+ /** @type {string} */
599
+ let id;
600
+ let offset = 0;
601
+ if (cursorValue === undefined) {
602
+ id = randomUUID();
603
+ const rows = /** @type {readonly { readonly scope: string; readonly id: string; readonly value: WirePoint }[]} */ (
604
+ this.#store.listNamespace("native-points")
605
+ )
606
+ .filter(
607
+ (entry) =>
608
+ entry.scope === `${this.#scopeKey}/${collection}` &&
609
+ matchesPoint(entry.value, predicate),
610
+ )
611
+ .map((entry) => ({ document: entry.id, point: entry.value }));
612
+ snapshot = {
613
+ route,
614
+ predicate: predicateKey,
615
+ limit,
616
+ sequence: this.#sequence().toString(),
617
+ rows,
618
+ expires: Date.now() + 60_000,
619
+ };
620
+ this.#store.put("native-snapshots", this.#scopeKey, id, snapshot);
621
+ } else {
622
+ const cursor = this.#cursorPayload(cursorValue, route, predicateKey);
623
+ id = text(cursor.id);
624
+ offset = typeof cursor.offset === "number" ? cursor.offset : -1;
625
+ const existing = /** @type {Snapshot | undefined} */ (
626
+ this.#store.get("native-snapshots", this.#scopeKey, id)
627
+ );
628
+ if (
629
+ existing === undefined ||
630
+ existing.limit !== limit ||
631
+ existing.expires <= Date.now() ||
632
+ !Number.isSafeInteger(offset) ||
633
+ offset < 0
634
+ )
635
+ throw new NativeLocalError(409, "resync_required");
636
+ snapshot = existing;
637
+ }
638
+ /** @type {Snapshot["rows"][number][]} */
639
+ const rows = [];
640
+ // Signed cursor/envelope metadata is below 2 KiB. Count each row once;
641
+ // repeated serialization of a large discarded suffix would be quadratic.
642
+ let bytes = 2048;
643
+ for (const row of snapshot.rows.slice(offset, offset + limit)) {
644
+ const rowBytes = Buffer.byteLength(JSON.stringify(row)) + 1;
645
+ if (bytes + rowBytes > maximumBodyBytes) break;
646
+ bytes += rowBytes;
647
+ rows.push(row);
648
+ }
649
+ if (rows.length === 0 && offset < snapshot.rows.length)
650
+ throw new NativeLocalError(503, "capacity");
651
+ const next = offset + rows.length;
652
+ const continuation =
653
+ next < snapshot.rows.length
654
+ ? {
655
+ [subscription ? "next_snapshot_cursor" : "next_cursor"]:
656
+ this.#cursor({
657
+ route,
658
+ predicate: predicateKey,
659
+ id,
660
+ offset: next,
661
+ expires: snapshot.expires,
662
+ }),
663
+ }
664
+ : subscription
665
+ ? {
666
+ poll_cursor: this.#cursor({
667
+ route,
668
+ predicate: predicateKey,
669
+ sequence: snapshot.sequence,
670
+ expires: snapshot.expires,
671
+ }),
672
+ }
673
+ : {};
674
+ return jsonResponse({
675
+ version: 1,
676
+ ...(subscription ? { phase: "snapshot" } : {}),
677
+ rows,
678
+ ...continuation,
679
+ });
680
+ }
681
+ }
682
+
683
+ class NativeLocalError extends Error {
684
+ /**
685
+ * @readonly
686
+ * @type {number}
687
+ */
688
+ status;
689
+ /**
690
+ * @readonly
691
+ * @type {string}
692
+ */
693
+ code;
694
+
695
+ /**
696
+ * @param {number} status
697
+ * @param {string} code
698
+ */
699
+ constructor(status, code) {
700
+ super(code);
701
+ this.status = status;
702
+ this.code = code;
703
+ }
704
+ }
705
+ /**
706
+ * @param {unknown} value
707
+ * @returns {JsonObject}
708
+ */
709
+ function object(value) {
710
+ if (value === null || typeof value !== "object" || Array.isArray(value))
711
+ throw new TypeError("Expected object.");
712
+ return /** @type {JsonObject} */ (value);
713
+ }
714
+ /**
715
+ * @param {unknown} value
716
+ * @returns {string}
717
+ */
718
+ function text(value) {
719
+ if (typeof value !== "string") throw new TypeError("Expected string.");
720
+ return value;
721
+ }
722
+ /**
723
+ * @param {unknown} value
724
+ * @returns {bigint}
725
+ */
726
+ function decimal(value) {
727
+ const input = text(value);
728
+ if (!/^(0|[1-9][0-9]*)$/u.test(input))
729
+ throw new TypeError("Expected decimal.");
730
+ return requireDatabaseU64(BigInt(input));
731
+ }
732
+ /**
733
+ * @param {JsonObject} value
734
+ * @param {readonly string[]} required
735
+ * @param {readonly string[]} [optional]
736
+ * @returns {void}
737
+ */
738
+ function exactKeys(value, required, optional = []) {
739
+ if (
740
+ required.some((key) => !Object.hasOwn(value, key)) ||
741
+ Object.keys(value).some(
742
+ (key) => !required.includes(key) && !optional.includes(key),
743
+ )
744
+ )
745
+ throw new TypeError("Unexpected fields.");
746
+ }
747
+ /**
748
+ * @param {JsonObject} raw
749
+ * @returns {string}
750
+ */
751
+ function transactionKey(raw) {
752
+ const id = object(raw.transaction_id);
753
+ return `${text(id.expires_at_unix_ms)}:${text(id.nonce)}`;
754
+ }
755
+ /**
756
+ * @param {unknown} value
757
+ * @returns {readonly DatabaseField[]}
758
+ */
759
+ function decodeFields(value) {
760
+ if (!Array.isArray(value)) throw new TypeError("Invalid fields.");
761
+ return value.map((raw) => {
762
+ const field = object(raw);
763
+ exactKeys(field, ["id", "value"]);
764
+ if (typeof field.id !== "number") throw new TypeError("Invalid field ID.");
765
+ return { id: field.id, value: decodeDatabaseValue(field.value) };
766
+ });
767
+ }
768
+ /**
769
+ * @param {JsonObject} raw
770
+ * @returns {DatabaseCommit}
771
+ */
772
+ function decodeCommit(raw) {
773
+ exactKeys(raw, [
774
+ "version",
775
+ "transaction_id",
776
+ "authority",
777
+ "effect_digest",
778
+ "writes",
779
+ ]);
780
+ if (raw.version !== 1 || !Array.isArray(raw.writes))
781
+ throw new TypeError("Invalid commit.");
782
+ const identity = object(raw.transaction_id);
783
+ exactKeys(identity, ["expires_at_unix_ms", "nonce"]);
784
+ return {
785
+ transaction_id: {
786
+ expires_at_unix_ms: decimal(identity.expires_at_unix_ms),
787
+ nonce: text(identity.nonce),
788
+ },
789
+ authority: decodeDatabaseAuthority(raw.authority),
790
+ writes: raw.writes.map((rawWrite) => {
791
+ const write = object(rawWrite);
792
+ const value = object(write.value);
793
+ exactKeys(write, [
794
+ "collection",
795
+ "schema_generation",
796
+ "schema_digest",
797
+ "document",
798
+ "condition",
799
+ "observed",
800
+ "value",
801
+ ]);
802
+ const condition = object(write.condition);
803
+ exactKeys(
804
+ condition,
805
+ condition.kind === "version" ? ["kind", "version"] : ["kind"],
806
+ );
807
+ exactKeys(value, value.kind === "live" ? ["kind", "fields"] : ["kind"]);
808
+ const parsedCondition =
809
+ condition.kind === "version"
810
+ ? { kind: "version", version: text(condition.version) }
811
+ : { kind: condition.kind };
812
+ if (!Array.isArray(write.observed))
813
+ throw new TypeError("Invalid observed.");
814
+ return {
815
+ collection: requireDatabaseUuid(write.collection),
816
+ schema_generation: decimal(write.schema_generation),
817
+ schema_digest: text(write.schema_digest),
818
+ document: requireDatabaseUuid(write.document),
819
+ condition: /** @type {DatabaseCondition} */ (parsedCondition),
820
+ observed: write.observed.map((rawObserved) => {
821
+ const observed = object(rawObserved);
822
+ exactKeys(observed, ["replica", "incarnation", "sequence"]);
823
+ return {
824
+ replica: text(observed.replica),
825
+ incarnation: text(observed.incarnation),
826
+ sequence: decimal(observed.sequence),
827
+ };
828
+ }),
829
+ value:
830
+ value.kind === "live"
831
+ ? { kind: "live", fields: decodeFields(value.fields) }
832
+ : /** @type {DatabaseWrite["value"]} */ ({ kind: value.kind }),
833
+ };
834
+ }),
835
+ };
836
+ }
837
+ /**
838
+ * @param {DatabaseWrite["value"]} value
839
+ * @returns {JsonObject}
840
+ */
841
+ function wireWriteValue(value) {
842
+ return value.kind === "tombstone"
843
+ ? { kind: "tombstone" }
844
+ : {
845
+ kind: "live",
846
+ fields: value.fields.map((field) => ({
847
+ id: field.id,
848
+ value: encodeDatabaseValue(field.value),
849
+ })),
850
+ };
851
+ }
852
+ /**
853
+ * @param {DatabasePoint["siblings"][number]} sibling
854
+ * @returns {JsonObject}
855
+ */
856
+ function wireSibling(sibling) {
857
+ return {
858
+ event: {
859
+ ...sibling.event,
860
+ shard: sibling.event.shard.toString(),
861
+ sequence: sibling.event.sequence.toString(),
862
+ },
863
+ schema_generation: sibling.schema_generation.toString(),
864
+ schema_digest: sibling.schema_digest,
865
+ ...wireWriteValue(sibling),
866
+ };
867
+ }
868
+ /**
869
+ * @param {unknown} raw
870
+ * @returns {DatabaseQueryPredicate}
871
+ */
872
+ function decodePredicate(raw) {
873
+ const value = object(raw);
874
+ if (!Array.isArray(value.path)) throw new TypeError("Invalid path.");
875
+ const predicate = /** @type {DatabaseQueryPredicate} */ ({
876
+ ...value,
877
+ ...(value.value === undefined
878
+ ? {}
879
+ : { value: decodeDatabaseValue(value.value) }),
880
+ });
881
+ encodeDatabaseQuery({ predicate, limit: 1 });
882
+ return predicate;
883
+ }
884
+ /**
885
+ * @param {WirePoint} raw
886
+ * @param {DatabaseQueryPredicate} predicate
887
+ * @returns {boolean}
888
+ */
889
+ function matchesPoint(raw, predicate) {
890
+ const point = decodeDatabasePoint(raw);
891
+ return point.siblings.some((sibling) => {
892
+ if (sibling.kind !== "live") return false;
893
+ let fields = sibling.fields;
894
+ /** @type {DatabaseValue | undefined} */
895
+ let value;
896
+ for (const [index, id] of predicate.path.entries()) {
897
+ value = fields.find((field) => field.id === id)?.value;
898
+ if (index < predicate.path.length - 1) {
899
+ if (value?.type !== "map") {
900
+ value = undefined;
901
+ break;
902
+ }
903
+ fields = value.fields;
904
+ }
905
+ }
906
+ if (predicate.op === "is_missing") return value === undefined;
907
+ if (predicate.op === "is_null") return value?.type === "null";
908
+ if (!("value" in predicate)) return false;
909
+ if (
910
+ value === undefined ||
911
+ value.type !== predicate.value.type ||
912
+ !["bool", "i64", "utf8", "bytes"].includes(value.type)
913
+ )
914
+ return false;
915
+ const left = /** @type {Extract<DatabaseValue, { type: "bool" | "i64" | "utf8" | "bytes" }>} */ (
916
+ value
917
+ ).value;
918
+ const right = predicate.value.value;
919
+ const comparison =
920
+ left instanceof Uint8Array && right instanceof Uint8Array
921
+ ? Buffer.compare(left, right)
922
+ : left === right
923
+ ? 0
924
+ : left < right
925
+ ? -1
926
+ : 1;
927
+ switch (predicate.op) {
928
+ case "equal":
929
+ return comparison === 0;
930
+ case "less_than":
931
+ return comparison < 0;
932
+ case "less_than_or_equal":
933
+ return comparison <= 0;
934
+ case "greater_than":
935
+ return comparison > 0;
936
+ case "greater_than_or_equal":
937
+ return comparison >= 0;
938
+ }
939
+ });
940
+ }
941
+ /**
942
+ * @param {Request} request
943
+ * @returns {Promise<unknown>}
944
+ */
945
+ async function readBody(request) {
946
+ const reader = request.body?.getReader();
947
+ if (reader === undefined) throw new TypeError("Missing body.");
948
+ /** @type {Uint8Array[]} */
949
+ const parts = [];
950
+ let size = 0;
951
+ try {
952
+ while (true) {
953
+ request.signal.throwIfAborted();
954
+ const chunk = await awaitAbortable(reader.read(), request.signal);
955
+ if (chunk.done) break;
956
+ size += chunk.value.byteLength;
957
+ if (size > maximumBodyBytes) throw new TypeError("Body limit exceeded.");
958
+ parts.push(chunk.value);
959
+ }
960
+ } catch (error) {
961
+ void reader.cancel().catch(() => undefined);
962
+ throw error;
963
+ } finally {
964
+ reader.releaseLock();
965
+ }
966
+ const bytes = new Uint8Array(size);
967
+ let offset = 0;
968
+ for (const part of parts) {
969
+ bytes.set(part, offset);
970
+ offset += part.byteLength;
971
+ }
972
+ return parseDatabaseJson(bytes);
973
+ }
974
+
975
+ /**
976
+ * @template T
977
+ * @param {Promise<T>} work
978
+ * @param {AbortSignal} signal
979
+ * @returns {Promise<T>}
980
+ */
981
+ async function awaitAbortable(work, signal) {
982
+ if (signal.aborted) {
983
+ void work.catch(() => undefined);
984
+ throw signal.reason;
985
+ }
986
+ /** @type {() => void} */
987
+ let onAbort = () => {};
988
+ /** @type {Promise<never>} */
989
+ const aborted = new Promise((_, reject) => {
990
+ onAbort = () => reject(signal.reason);
991
+ signal.addEventListener("abort", onAbort, { once: true });
992
+ if (signal.aborted) onAbort();
993
+ });
994
+ try {
995
+ return await Promise.race([work, aborted]);
996
+ } finally {
997
+ signal.removeEventListener("abort", onAbort);
998
+ }
999
+ }