@terrascale/sdk 0.0.2 → 0.0.3

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.
@@ -1,3 +1,5 @@
1
+ import { requireId as requireDatabaseId } from "./ids.js";
2
+ export { requireId as requireDatabaseId } from "./ids.js";
1
3
  /**
2
4
  * @import {
3
5
  * DatabaseAuthority, DatabaseCommit, DatabaseCommitReceipt, DatabaseCondition, DatabaseEvent,
@@ -20,18 +22,6 @@ const maximumBodyBytes = 4 * 1024 * 1024;
20
22
  const maximumValueBytes = 1024 * 1024;
21
23
  const scalarTypes = new Set(["bool", "i64", "utf8", "bytes"]);
22
24
 
23
- /**
24
- * @param {unknown} value
25
- * @param {string} [name]
26
- * @returns {string}
27
- */
28
- export function requireDatabaseUuid(value, name = "UUID") {
29
- if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value)) {
30
- throw new TypeError(`${name} must be a canonical lowercase UUID.`);
31
- }
32
- return value;
33
- }
34
-
35
25
  /**
36
26
  * @param {unknown} value
37
27
  * @param {string} [name]
@@ -295,7 +285,7 @@ function observedToWire(entries) {
295
285
  let previous = "";
296
286
  return array(entries).map(raw => {
297
287
  const entry = object(raw, ["replica", "incarnation", "sequence"]);
298
- const replica = requireDatabaseUuid(entry.replica, "replica");
288
+ const replica = requireDatabaseId(entry.replica, "replica");
299
289
  const incarnation = hex(entry.incarnation, 16, "incarnation");
300
290
  const order = replica + incarnation;
301
291
  if (order <= previous) throw new TypeError("Observed origins must be strictly increasing.");
@@ -311,7 +301,7 @@ function observedToWire(entries) {
311
301
  function observedFromWire(raw) {
312
302
  const entries = array(raw).map(value => {
313
303
  const entry = object(value, ["replica", "incarnation", "sequence"]);
314
- return { replica: requireDatabaseUuid(entry.replica, "replica"), incarnation: hex(entry.incarnation, 16, "incarnation"), sequence: decimal(entry.sequence, "sequence", true) };
304
+ return { replica: requireDatabaseId(entry.replica, "replica"), incarnation: hex(entry.incarnation, 16, "incarnation"), sequence: decimal(entry.sequence, "sequence", true) };
315
305
  });
316
306
  observedToWire(entries);
317
307
  return entries;
@@ -324,8 +314,8 @@ function observedFromWire(raw) {
324
314
  function eventFromWire(raw) {
325
315
  const entry = object(raw, ["database", "shard", "replica", "incarnation", "sequence"]);
326
316
  return {
327
- database: requireDatabaseUuid(entry.database, "database"), shard: decimal(entry.shard, "shard"),
328
- replica: requireDatabaseUuid(entry.replica, "replica"), incarnation: hex(entry.incarnation, 16, "incarnation"),
317
+ database: requireDatabaseId(entry.database, "database"), shard: decimal(entry.shard, "shard"),
318
+ replica: requireDatabaseId(entry.replica, "replica"), incarnation: hex(entry.incarnation, 16, "incarnation"),
329
319
  sequence: decimal(entry.sequence, "sequence", true),
330
320
  };
331
321
  }
@@ -430,7 +420,7 @@ function rowsFromWire(raw) {
430
420
  let previous = "";
431
421
  return array(raw, 128).map(rawRow => {
432
422
  const entry = object(rawRow, ["document", "point"]);
433
- const document = requireDatabaseUuid(entry.document, "document");
423
+ const document = requireDatabaseId(entry.document, "document");
434
424
  if (document <= previous) throw new TypeError("Query documents must be strictly increasing.");
435
425
  previous = document;
436
426
  return { document, point: decodeDatabasePoint(entry.point) };
@@ -486,7 +476,7 @@ export function decodeDatabaseSubscription(raw) {
486
476
  previous = sequence;
487
477
  return { sequence, effects: array(group.effects, 128).map(rawEffect => {
488
478
  const effect = object(rawEffect, ["document", "point"]);
489
- return { document: requireDatabaseUuid(effect.document, "document"), point: effect.point === null ? null : decodeDatabasePoint(effect.point) };
479
+ return { document: requireDatabaseId(effect.document, "document"), point: effect.point === null ? null : decodeDatabasePoint(effect.point) };
490
480
  }) };
491
481
  });
492
482
  return { version: 1, phase: "poll", groups, poll_cursor: cursor(entry.poll_cursor) };
@@ -501,7 +491,7 @@ export function encodeDatabaseAuthority(authority) {
501
491
  object(authority, ["mode", "generation", "writer"]);
502
492
  if (authority.mode !== "single_writer") throw new TypeError("Unsupported authority mode.");
503
493
  const writer = object(authority.writer, ["replica", "incarnation"]);
504
- return { mode: "single_writer", generation: requireDatabaseU64(authority.generation, "authority generation", true).toString(), writer: { replica: requireDatabaseUuid(writer.replica, "writer"), incarnation: hex(writer.incarnation, 16, "incarnation") } };
494
+ return { mode: "single_writer", generation: requireDatabaseU64(authority.generation, "authority generation", true).toString(), writer: { replica: requireDatabaseId(writer.replica, "writer"), incarnation: hex(writer.incarnation, 16, "incarnation") } };
505
495
  }
506
496
 
507
497
  /**
@@ -514,7 +504,7 @@ export function decodeDatabaseAuthority(raw) {
514
504
  const entry = object(raw, ["mode", "generation", "writer"]);
515
505
  if (entry.mode !== "single_writer") throw new TypeError("Unsupported authority mode.");
516
506
  const writer = object(entry.writer, ["replica", "incarnation"]);
517
- return { mode: "single_writer", generation: decimal(entry.generation, "authority generation", true), writer: { replica: requireDatabaseUuid(writer.replica, "writer"), incarnation: hex(writer.incarnation, 16, "incarnation") } };
507
+ return { mode: "single_writer", generation: decimal(entry.generation, "authority generation", true), writer: { replica: requireDatabaseId(writer.replica, "writer"), incarnation: hex(writer.incarnation, 16, "incarnation") } };
518
508
  }
519
509
 
520
510
  /**
@@ -569,7 +559,7 @@ function writeToWire(write, authority) {
569
559
  : (object(write.value, ["kind", "fields"]), { kind: "live", fields: fieldsToWire(write.value.fields, 1, { nodes: 0 }) });
570
560
  if (write.value.kind !== "live" && write.value.kind !== "tombstone") throw new TypeError("Unsupported write kind.");
571
561
  if (value.kind === "live") validateNativeDocument(/** @type {readonly Json[]} */ (value.fields));
572
- return { collection: requireDatabaseUuid(write.collection, "collection"), document: requireDatabaseUuid(write.document, "document"), schema_generation: requireDatabaseU64(write.schema_generation, "schema generation", true).toString(), schema_digest: hex(write.schema_digest, 32, "schema digest"), condition: conditionToWire(write.condition), observed: observedToWire(write.observed), value };
562
+ return { collection: requireDatabaseId(write.collection, "collection"), document: requireDatabaseId(write.document, "document"), schema_generation: requireDatabaseU64(write.schema_generation, "schema generation", true).toString(), schema_digest: hex(write.schema_digest, 32, "schema digest"), condition: conditionToWire(write.condition), observed: observedToWire(write.observed), value };
573
563
  }
574
564
 
575
565
  class Bytes {
@@ -603,7 +593,12 @@ class Bytes {
603
593
  * @param {string} value
604
594
  * @returns {void}
605
595
  */
606
- uuid(value) { this.hex(value.replaceAll("-", "")); }
596
+ id(value) {
597
+ const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
598
+ let bits = 0n;
599
+ for (const char of requireDatabaseId(value)) bits = (bits << 5n) | BigInt(alphabet.indexOf(char));
600
+ this.hex(bits.toString(16).padStart(32, "0"));
601
+ }
607
602
  /** @returns {Uint8Array} */
608
603
  finish() { const bytes = new Uint8Array(this.length); let offset = 0; for (const chunk of this.chunks) { bytes.set(chunk, offset); offset += chunk.length; } return bytes; }
609
604
  }
@@ -667,22 +662,22 @@ function validateNativeDocument(fields) {
667
662
  function effectBytes(scope, authority, writes) {
668
663
  const bytes = new Bytes();
669
664
  bytes.put(encoder.encode("TB-DOC-EFFECT-v1\0"));
670
- bytes.uuid(requireDatabaseUuid(scope.tenant, "tenant")); bytes.uuid(requireDatabaseUuid(scope.database, "database")); bytes.integer(requireDatabaseU64(scope.shard, "shard"), 8);
665
+ bytes.id(requireDatabaseId(scope.tenant, "tenant")); bytes.id(requireDatabaseId(scope.database, "database")); bytes.integer(requireDatabaseU64(scope.shard, "shard"), 8);
671
666
  const singleWriter = authority.mode === "single_writer";
672
667
  bytes.integer(singleWriter ? 1n : 0n, 1);
673
668
  if (singleWriter) {
674
669
  const writer = object(authority.writer, ["replica", "incarnation"]);
675
- bytes.integer(BigInt(/** @type {string} */ (authority.generation)), 8); bytes.uuid(/** @type {string} */ (writer.replica)); bytes.hex(/** @type {string} */ (writer.incarnation));
670
+ bytes.integer(BigInt(/** @type {string} */ (authority.generation)), 8); bytes.id(/** @type {string} */ (writer.replica)); bytes.hex(/** @type {string} */ (writer.incarnation));
676
671
  }
677
672
  bytes.integer(BigInt(writes.length), 8);
678
673
  for (const write of writes) {
679
- bytes.uuid(/** @type {string} */ (write.collection)); bytes.uuid(/** @type {string} */ (write.document)); bytes.integer(BigInt(/** @type {string} */ (write.schema_generation)), 8); bytes.hex(/** @type {string} */ (write.schema_digest));
674
+ bytes.id(/** @type {string} */ (write.collection)); bytes.id(/** @type {string} */ (write.document)); bytes.integer(BigInt(/** @type {string} */ (write.schema_generation)), 8); bytes.hex(/** @type {string} */ (write.schema_digest));
680
675
  const condition = object(write.condition, ["kind"], ["version"]);
681
676
  bytes.integer(condition.kind === "none" ? 0n : condition.kind === "absent" ? 1n : 2n, 1);
682
677
  if (condition.kind === "version") bytes.hex(/** @type {string} */ (condition.version));
683
678
  const observed = array(write.observed);
684
679
  bytes.integer(BigInt(observed.length), 8);
685
- for (const raw of observed) { const entry = object(raw, ["replica", "incarnation", "sequence"]); bytes.uuid(/** @type {string} */ (entry.replica)); bytes.hex(/** @type {string} */ (entry.incarnation)); bytes.integer(BigInt(/** @type {string} */ (entry.sequence)), 8); }
680
+ for (const raw of observed) { const entry = object(raw, ["replica", "incarnation", "sequence"]); bytes.id(/** @type {string} */ (entry.replica)); bytes.hex(/** @type {string} */ (entry.incarnation)); bytes.integer(BigInt(/** @type {string} */ (entry.sequence)), 8); }
686
681
  const value = object(write.value, ["kind"], ["fields"]);
687
682
  bytes.integer(value.kind === "tombstone" ? 0n : 1n, 1);
688
683
  if (value.kind === "live") {
@@ -1,20 +1,20 @@
1
1
  /**
2
- * The current TerraBase document protocol uses canonical UUID entity IDs.
3
- * @typedef {string} CanonicalUuid
2
+ * Entity identities are canonical uppercase ULIDs, including explicit local development.
3
+ * @typedef {import("./ids.js").EntityId} EntityId
4
4
  */
5
5
 
6
6
  /**
7
7
  * All 64-bit quantities stay bigint in JavaScript and decimal strings on the wire.
8
8
  * @typedef {{
9
- * readonly tenant: CanonicalUuid;
10
- * readonly database: CanonicalUuid;
9
+ * readonly tenant: EntityId;
10
+ * readonly database: EntityId;
11
11
  * readonly shard: bigint;
12
12
  * }} DatabaseScope
13
13
  */
14
14
 
15
15
  /**
16
16
  * @typedef {{
17
- * readonly collection: CanonicalUuid;
17
+ * readonly collection: EntityId;
18
18
  * readonly schema_generation: bigint;
19
19
  * readonly schema_digest: string;
20
20
  * }} CollectionBinding
@@ -39,7 +39,7 @@
39
39
 
40
40
  /**
41
41
  * @typedef {{
42
- * readonly replica: CanonicalUuid;
42
+ * readonly replica: EntityId;
43
43
  * readonly incarnation: string;
44
44
  * readonly sequence: bigint;
45
45
  * }} DatabaseObserved
@@ -47,7 +47,7 @@
47
47
 
48
48
  /**
49
49
  * @typedef {DatabaseObserved & {
50
- * readonly database: CanonicalUuid;
50
+ * readonly database: EntityId;
51
51
  * readonly shard: bigint;
52
52
  * }} DatabaseEvent
53
53
  */
@@ -58,7 +58,7 @@
58
58
  * | {
59
59
  * readonly mode: "single_writer";
60
60
  * readonly generation: bigint;
61
- * readonly writer: { readonly replica: CanonicalUuid; readonly incarnation: string };
61
+ * readonly writer: { readonly replica: EntityId; readonly incarnation: string };
62
62
  * }
63
63
  * )} DatabaseAuthority
64
64
  */
@@ -80,7 +80,7 @@
80
80
 
81
81
  /**
82
82
  * @typedef {CollectionBinding & {
83
- * readonly document: CanonicalUuid;
83
+ * readonly document: EntityId;
84
84
  * readonly condition: DatabaseCondition;
85
85
  * readonly observed: readonly DatabaseObserved[];
86
86
  * readonly value: DatabaseWriteValue;
@@ -135,7 +135,7 @@
135
135
  * }} DatabaseQuery
136
136
  */
137
137
 
138
- /** @typedef {{ readonly document: CanonicalUuid; readonly point: DatabasePoint }} DatabaseQueryRow */
138
+ /** @typedef {{ readonly document: EntityId; readonly point: DatabasePoint }} DatabaseQueryRow */
139
139
 
140
140
  /**
141
141
  * @typedef {{
@@ -202,7 +202,7 @@
202
202
  * )} DatabaseSubscriptionRequest
203
203
  */
204
204
 
205
- /** @typedef {{ readonly document: CanonicalUuid; readonly point: DatabasePoint | null }} DatabaseSubscriptionEffect */
205
+ /** @typedef {{ readonly document: EntityId; readonly point: DatabasePoint | null }} DatabaseSubscriptionEffect */
206
206
 
207
207
  /**
208
208
  * @typedef {{
@@ -1,4 +1,4 @@
1
- import { encodeDatabaseSubscription, requireDatabaseUuid, requireDatabaseU64 } from "./database-codec.js";
1
+ import { encodeDatabaseSubscription, requireDatabaseId, requireDatabaseU64 } from "./database-codec.js";
2
2
  /**
3
3
  * @import { TerraBaseClient } from "./local/legacy-client.js"
4
4
  * @import {
@@ -213,14 +213,14 @@ export class DatabaseSubscriptionView {
213
213
  */
214
214
  constructor(scope, options) {
215
215
  const capturedScope = Object.freeze({
216
- tenant: requireDatabaseUuid(scope.tenant, "tenant"),
217
- database: requireDatabaseUuid(scope.database, "database"),
216
+ tenant: requireDatabaseId(scope.tenant, "tenant"),
217
+ database: requireDatabaseId(scope.database, "database"),
218
218
  shard: requireDatabaseU64(scope.shard, "shard"),
219
219
  });
220
220
  const predicate = predicateCopy(options.predicate);
221
221
  const limit = boundedInteger(options.limit, 128, 128, "Snapshot page size");
222
222
  encodeDatabaseSubscription({ phase: "snapshot", predicate, limit });
223
- this.binding = Object.freeze({ scope: capturedScope, collection: requireDatabaseUuid(options.collection, "collection"), predicate, limit });
223
+ this.binding = Object.freeze({ scope: capturedScope, collection: requireDatabaseId(options.collection, "collection"), predicate, limit });
224
224
  this.#maximumRows = boundedInteger(options.maximumRows, 10_000, 1_000_000, "Maximum rows");
225
225
  }
226
226
 
@@ -258,7 +258,7 @@ export class DatabaseSubscriptionView {
258
258
  if (response.rows.length > this.binding.limit) throw new DatabaseSubscriptionError("page_limit_exceeded");
259
259
  let previous = this.#snapshotRows.at(-1)?.document ?? "";
260
260
  const page = response.rows.map(row => {
261
- const document = requireDatabaseUuid(row.document, "document");
261
+ const document = requireDatabaseId(row.document, "document");
262
262
  if (document <= previous) throw new DatabaseSubscriptionError("snapshot_order_invalid");
263
263
  previous = document;
264
264
  return Object.freeze({ document, point: pointCopy(row.point, this.binding.scope) });
@@ -293,7 +293,7 @@ export class DatabaseSubscriptionView {
293
293
  /** @type {Set<string>} */
294
294
  const documents = new Set();
295
295
  for (const effect of group.effects) {
296
- const document = requireDatabaseUuid(effect.document, "document");
296
+ const document = requireDatabaseId(effect.document, "document");
297
297
  if (documents.has(document)) throw new DatabaseSubscriptionError("duplicate_group_document");
298
298
  documents.add(document);
299
299
  if (effect.point === null) rows.delete(document);
package/src/http.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  TsAuthEndpointDiscovery,
6
6
  } from "./ts-auth-discovery.js";
7
7
  import { parseDatabaseJson } from "./database-codec.js";
8
+ import { validateTsAuthIds } from "./ts-auth-ids.js";
8
9
  /** @import { ResolvedTsAuthRuntimeConfig, TsAuthRuntimeConfig, FetchLike } from "./config.js" */
9
10
  /** @import { TerraScaleError, TerraScaleErrorCategory, TerraScaleResult, TerraScaleValidationIssue } from "./result.js" */
10
11
 
@@ -227,6 +228,15 @@ export class TsAuthHttp {
227
228
  response.status,
228
229
  response.headers,
229
230
  );
231
+ try {
232
+ validateTsAuthIds(parsed.value, options.routeName);
233
+ } catch {
234
+ return err(options.routeName, {
235
+ kind: "invalid-response", code: "invalid_response",
236
+ message: "ts-auth returned a noncanonical entity ID.", status: response.status,
237
+ requestId: requestIdFrom(response.headers),
238
+ }, response.status, response.headers);
239
+ }
230
240
  return ok(
231
241
  options.routeName,
232
242
  response.status,
package/src/ids.js ADDED
@@ -0,0 +1,22 @@
1
+ import { isValid, monotonicFactory } from "ulid";
2
+
3
+ /** A plain 26-character, uppercase Crockford ULID. @typedef {string} EntityId */
4
+
5
+ const nextId = monotonicFactory();
6
+
7
+ /** @returns {EntityId} Monotonic within a millisecond, including clock rollback. */
8
+ export function generateId() {
9
+ return nextId();
10
+ }
11
+
12
+ /** @param {unknown} value @returns {value is EntityId} */
13
+ export function isValidId(value) {
14
+ // The library also accepts lowercase and 130-bit overflow; the public contract does not.
15
+ return typeof value === "string" && isValid(value) && value === value.toUpperCase() && /^[0-7]/u.test(value);
16
+ }
17
+
18
+ /** @param {unknown} value @param {string} [name] @returns {EntityId} */
19
+ export function requireId(value, name = "Entity ID") {
20
+ if (!isValidId(value)) throw new TypeError(`${name} must be a plain 26-character canonical uppercase Crockford ULID.`);
21
+ return value;
22
+ }
package/src/index.js CHANGED
@@ -19,7 +19,8 @@ export {
19
19
  isTerraBaseV1Error,
20
20
  } from "./v1/errors.js";
21
21
  export { v1Filter } from "./v1/filter.js";
22
- export { documentIdFor } from "./v1/ids.js";
22
+ export { generateId, isValidId, requireId } from "./ids.js";
23
+ /** @typedef {import("./ids.js").EntityId} EntityId */
23
24
  export { ExactDecimal } from "./v1/json.js";
24
25
 
25
26
  /** @typedef {import("./v1/transport.js").TerraBaseV1ClientConfig} TerraBaseV1ClientConfig */
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { generateId, requireId } from "../ids.js";
2
2
  import { createAdapterFactory } from "@better-auth/core/db/adapter";
3
3
  /** @import { CleanedWhere, CustomAdapter, DBAdapter, JoinConfig } from "@better-auth/core/db/adapter" */
4
4
  /** @import { BetterAuthOptions } from "@better-auth/core" */
@@ -29,6 +29,8 @@ export function createLocalBetterAuthAdapter(store) {
29
29
  * @returns {DBAdapter<BetterAuthOptions>}
30
30
  */
31
31
  function make(options, scoped) {
32
+ if (options.advanced?.database?.generateId === "uuid")
33
+ throw new TypeError("Better Auth entity IDs must be ULIDs; UUID generation is unavailable.");
32
34
  /** @type {ReturnType<typeof createAdapterFactory<BetterAuthOptions>>} */
33
35
  const factory = createAdapterFactory({
34
36
  config: {
@@ -38,7 +40,7 @@ export function createLocalBetterAuthAdapter(store) {
38
40
  supportsArrays: true,
39
41
  supportsBooleans: true,
40
42
  supportsNumericIds: false,
41
- supportsUUIDs: true,
43
+ customIdGenerator: generateId,
42
44
  transaction: async (callback) => {
43
45
  if (scoped)
44
46
  throw new Error("Nested local auth transactions are unavailable.");
@@ -65,7 +67,8 @@ function customAdapter(store, config, scoped) {
65
67
  * @param {string} model
66
68
  * @returns {Row[]}
67
69
  */
68
- const rows = (model) => store.list("local-auth", model);
70
+ const rows = (model) => store.listEntries("local-auth", model)
71
+ .map(entry => ({ .../** @type {Row} */ (entry.value), id: requireId(entry.id) }));
69
72
  /**
70
73
  * @param {string} model
71
74
  * @param {readonly CleanedWhere[]} clauses
@@ -150,13 +153,21 @@ function customAdapter(store, config, scoped) {
150
153
  */
151
154
  const save = (model, input, creating = false) => {
152
155
  const row = jsonRow(input);
153
- const id =
154
- typeof row.id === "string" && row.id.length > 0 ? row.id : randomUUID();
156
+ const id = row.id === undefined ? generateId() : requireId(row.id);
157
+ for (const [logical, table] of Object.entries(config.schema)) {
158
+ if (config.getModelName(logical) !== model) continue;
159
+ for (const [field, schema] of Object.entries(table.fields)) {
160
+ if (schema.references?.field !== "id") continue;
161
+ const value = row[config.getFieldName({ model: logical, field })];
162
+ if (value !== undefined && value !== null) requireId(value, field);
163
+ }
164
+ }
155
165
  const candidate = { ...row, id };
156
166
  if (creating && store.get("local-auth", model, id) !== undefined)
157
167
  throw new LocalAuthConstraintError();
158
168
  ensureUnique(model, candidate);
159
- store.put("local-auth", model, id, candidate);
169
+ delete row.id;
170
+ store.put("local-auth", model, id, row);
160
171
  return candidate;
161
172
  };
162
173
  /**
@@ -1,5 +1,5 @@
1
+ import { generateId } from "../ids.js";
1
2
  import { isAbsolute } from "node:path";
2
- import { randomUUID } from "node:crypto";
3
3
  import { createTerraBaseClient } from "./legacy-client.js";
4
4
  import { normalizeLocalBearerToken } from "./authentication.js";
5
5
  import { assertLocalSqliteEnvironment } from "./environment.js";
@@ -86,7 +86,7 @@ export class TerraScaleSqliteRuntime {
86
86
  const scope =
87
87
  options.scope ??
88
88
  (stored === undefined
89
- ? { tenant: randomUUID(), database: randomUUID(), shard: 0n }
89
+ ? { tenant: generateId(), database: generateId(), shard: 0n }
90
90
  : { ...stored, shard: BigInt(stored.shard) });
91
91
  this.#router = new TerraScaleLocalRouter(this.#store, scope, {
92
92
  authentication,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  decodeDatabaseCommitReceipt, decodeDatabasePoint, decodeDatabaseQueryPage,
4
4
  decodeDatabaseSubscription, decodeDatabaseStatus, encodeDatabaseStatus, encodeDatabaseCommit, encodeDatabaseQuery,
5
- encodeDatabaseSubscription, encodeDatabaseAuthority, parseDatabaseJson, requireDatabaseU64, requireDatabaseUuid,
5
+ encodeDatabaseSubscription, encodeDatabaseAuthority, parseDatabaseJson, requireDatabaseU64, requireDatabaseId,
6
6
  } from "../database-codec.js";
7
7
  /**
8
8
  * @import {
@@ -61,7 +61,7 @@ function identityAuthority(authority) {
61
61
  * @returns {string}
62
62
  */
63
63
  function scopePath(scope) {
64
- return `/v1/tenants/${requireDatabaseUuid(scope.tenant, "tenant")}/databases/${requireDatabaseUuid(scope.database, "database")}/shards/${requireDatabaseU64(scope.shard, "shard")}`;
64
+ return `/v1/tenants/${requireDatabaseId(scope.tenant, "tenant")}/databases/${requireDatabaseId(scope.database, "database")}/shards/${requireDatabaseU64(scope.shard, "shard")}`;
65
65
  }
66
66
 
67
67
  /**
@@ -195,7 +195,7 @@ export class TerraBaseClient {
195
195
 
196
196
  /** @param {TerraBaseClientConfig} config */
197
197
  constructor(config) {
198
- this.#scope = Object.freeze({ tenant: requireDatabaseUuid(config.tenant, "tenant"), database: requireDatabaseUuid(config.database, "database"), shard: requireDatabaseU64(config.shard, "shard") });
198
+ this.#scope = Object.freeze({ tenant: requireDatabaseId(config.tenant, "tenant"), database: requireDatabaseId(config.database, "database"), shard: requireDatabaseU64(config.shard, "shard") });
199
199
  this.#config = { ...config };
200
200
  this.#fetch = config.fetch ?? globalThis.fetch.bind(globalThis);
201
201
  this.#path = scopePath(this.#scope);
@@ -214,7 +214,7 @@ export class TerraBaseClient {
214
214
  * @returns {Promise<DatabaseResult<DatabasePoint>>}
215
215
  */
216
216
  read(collection, document, options = {}) {
217
- const path = `${this.#collectionPath(collection)}/documents/${requireDatabaseUuid(document, "document")}`;
217
+ const path = `${this.#collectionPath(collection)}/documents/${requireDatabaseId(document, "document")}`;
218
218
  return this.#request(path, "GET", undefined, raw => this.#scopedPoint(decodeDatabasePoint(raw)), options);
219
219
  }
220
220
 
@@ -312,7 +312,7 @@ export class TerraBaseClient {
312
312
  * @param {string} collection
313
313
  * @returns {string}
314
314
  */
315
- #collectionPath(collection) { return `${this.#path}/collections/${requireDatabaseUuid(collection, "collection")}`; }
315
+ #collectionPath(collection) { return `${this.#path}/collections/${requireDatabaseId(collection, "collection")}`; }
316
316
 
317
317
  /**
318
318
  * @param {DatabasePoint} point
@@ -1,8 +1,8 @@
1
+ import { generateId } from "../ids.js";
1
2
  import {
2
3
  createHash,
3
4
  createHmac,
4
5
  randomBytes,
5
- randomUUID,
6
6
  timingSafeEqual,
7
7
  } from "node:crypto";
8
8
  import {
@@ -18,7 +18,7 @@ import {
18
18
  encodeDatabaseValue,
19
19
  parseDatabaseJson,
20
20
  requireDatabaseU64,
21
- requireDatabaseUuid,
21
+ requireDatabaseId,
22
22
  } from "../database-codec.js";
23
23
  import { matchesLocalBearerAuthorization } from "./authentication.js";
24
24
  import { jsonResponse, problemResponse } from "./context.js";
@@ -101,8 +101,8 @@ export class TerraScaleLocalRouter {
101
101
  constructor(store, scope, options) {
102
102
  this.#store = store;
103
103
  this.#scope = Object.freeze({
104
- tenant: requireDatabaseUuid(scope.tenant),
105
- database: requireDatabaseUuid(scope.database),
104
+ tenant: requireDatabaseId(scope.tenant),
105
+ database: requireDatabaseId(scope.database),
106
106
  shard: requireDatabaseU64(scope.shard),
107
107
  });
108
108
  this.#scopeKey = `${scope.tenant}/${scope.database}/${scope.shard}`;
@@ -112,7 +112,7 @@ export class TerraScaleLocalRouter {
112
112
  const writer = /** @type {{ readonly replica: string; readonly incarnation: string } | undefined} */ (
113
113
  store.getMeta(`native-writer:${this.#scopeKey}`)
114
114
  ) ?? {
115
- replica: randomUUID(),
115
+ replica: generateId(),
116
116
  incarnation: randomBytes(16).toString("hex"),
117
117
  };
118
118
  this.#writer = Object.freeze(writer);
@@ -131,7 +131,7 @@ export class TerraScaleLocalRouter {
131
131
  * @returns {void}
132
132
  */
133
133
  bindCollection(binding) {
134
- requireDatabaseUuid(binding.collection);
134
+ requireDatabaseId(binding.collection);
135
135
  requireDatabaseU64(binding.schema_generation, "schema generation", true);
136
136
  if (!/^[0-9a-f]{64}$/u.test(binding.schema_digest))
137
137
  throw new TypeError("Schema digest must be lowercase SHA256.");
@@ -170,8 +170,8 @@ export class TerraScaleLocalRouter {
170
170
  suffix,
171
171
  );
172
172
  if (match === null) return problemResponse(404, "unknown_route");
173
- const collection = requireDatabaseUuid(match[1]);
174
- const document = requireDatabaseUuid(match[2]);
173
+ const collection = requireDatabaseId(match[1]);
174
+ const document = requireDatabaseId(match[2]);
175
175
  return await this.#store.exclusive(() => {
176
176
  this.#binding(collection);
177
177
  return jsonResponse(this.#point(collection, document));
@@ -246,7 +246,7 @@ export class TerraScaleLocalRouter {
246
246
  suffix,
247
247
  );
248
248
  if (match === null) return problemResponse(404, "unknown_route");
249
- const collection = requireDatabaseUuid(match[1]);
249
+ const collection = requireDatabaseId(match[1]);
250
250
  return await this.#store.exclusive(() => {
251
251
  this.#binding(collection);
252
252
  return this.#query(collection, match[2] === "subscriptions", raw);
@@ -599,7 +599,7 @@ export class TerraScaleLocalRouter {
599
599
  let id;
600
600
  let offset = 0;
601
601
  if (cursorValue === undefined) {
602
- id = randomUUID();
602
+ id = generateId();
603
603
  const rows = /** @type {readonly { readonly scope: string; readonly id: string; readonly value: WirePoint }[]} */ (
604
604
  this.#store.listNamespace("native-points")
605
605
  )
@@ -812,10 +812,10 @@ function decodeCommit(raw) {
812
812
  if (!Array.isArray(write.observed))
813
813
  throw new TypeError("Invalid observed.");
814
814
  return {
815
- collection: requireDatabaseUuid(write.collection),
815
+ collection: requireDatabaseId(write.collection),
816
816
  schema_generation: decimal(write.schema_generation),
817
817
  schema_digest: text(write.schema_digest),
818
- document: requireDatabaseUuid(write.document),
818
+ document: requireDatabaseId(write.document),
819
819
  condition: /** @type {DatabaseCondition} */ (parsedCondition),
820
820
  observed: write.observed.map((rawObserved) => {
821
821
  const observed = object(rawObserved);
@@ -2,7 +2,7 @@ import { mkdirSync } from "node:fs";
2
2
  import { DatabaseSync } from "node:sqlite";
3
3
  import { dirname, isAbsolute } from "node:path";
4
4
 
5
- const SCHEMA_VERSION = 1;
5
+ const FORMAT_VERSION = 2;
6
6
 
7
7
  /**
8
8
  * A small synchronous JSON record store for Node local adapters.
@@ -55,7 +55,7 @@ export class TerraScaleSqliteStore {
55
55
  if (databasePath !== ":memory:") {
56
56
  this.#database.exec("PRAGMA journal_mode = WAL;");
57
57
  }
58
- this.#migrate();
58
+ this.#initialize();
59
59
  } catch (error) {
60
60
  this.#database.close();
61
61
  this.#closed = true;
@@ -128,6 +128,25 @@ export class TerraScaleSqliteStore {
128
128
  return rows.map((row) => /** @type {T} */ (parseJson(row.value, "record")));
129
129
  }
130
130
 
131
+ /**
132
+ * @template T
133
+ * @param {string} namespace
134
+ * @param {string} scope
135
+ * @returns {readonly { readonly id: string; readonly value: T }[]}
136
+ */
137
+ listEntries(namespace, scope) {
138
+ this.#assertOpen();
139
+ assertKey(namespace, "namespace");
140
+ assertKey(scope, "scope");
141
+ const rows = this.#database
142
+ .prepare("SELECT id, value FROM records WHERE namespace = ? AND scope = ? ORDER BY id ASC")
143
+ .all(namespace, scope);
144
+ return rows.map(row => ({
145
+ id: String(row.id),
146
+ value: /** @type {T} */ (parseJson(row.value, "record")),
147
+ }));
148
+ }
149
+
131
150
  /**
132
151
  * @template T
133
152
  * @param {string} namespace
@@ -339,32 +358,18 @@ export class TerraScaleSqliteStore {
339
358
  }
340
359
 
341
360
  /** @returns {void} */
342
- #migrate() {
361
+ #initialize() {
343
362
  this.#database.exec("BEGIN IMMEDIATE;");
344
363
  try {
345
- this.#database.exec(`
346
- CREATE TABLE IF NOT EXISTS schema_migrations (
347
- version INTEGER NOT NULL PRIMARY KEY,
348
- applied_at TEXT NOT NULL
349
- ) WITHOUT ROWID;
350
- `);
351
-
352
- const row = this.#database
353
- .prepare(
354
- "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1",
355
- )
356
- .get();
357
- const version = row?.version;
358
- if (version !== undefined && typeof version !== "number") {
359
- throw new Error("Invalid schema migration version.");
360
- }
361
- if (version !== undefined && version > SCHEMA_VERSION) {
364
+ const version = this.#database.prepare("PRAGMA user_version").get()?.user_version;
365
+ const table = this.#database.prepare("SELECT name FROM sqlite_schema WHERE type = 'table' AND name NOT LIKE 'sqlite_%' LIMIT 1").get();
366
+ if (version !== FORMAT_VERSION && !(version === 0 && table === undefined)) {
362
367
  throw new Error(
363
- `Database schema version ${version} is newer than supported version ${SCHEMA_VERSION}.`,
368
+ `TS-754: unsupported local SQLite format. Stop the local runtime, delete ${this.databasePath} and its -wal/-shm files, then restart with fresh ULID storage.`,
364
369
  );
365
370
  }
366
371
 
367
- if (version === undefined) {
372
+ if (version === 0) {
368
373
  this.#database.exec(`
369
374
  CREATE TABLE records (
370
375
  namespace TEXT NOT NULL,
@@ -379,11 +384,7 @@ export class TerraScaleSqliteStore {
379
384
  value TEXT NOT NULL CHECK (json_valid(value) = 1)
380
385
  ) WITHOUT ROWID;
381
386
  `);
382
- this.#database
383
- .prepare(
384
- "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)",
385
- )
386
- .run(SCHEMA_VERSION, new Date().toISOString());
387
+ this.#database.exec(`PRAGMA user_version = ${FORMAT_VERSION}`);
387
388
  }
388
389
  this.#database.exec("COMMIT;");
389
390
  } catch (error) {
@@ -406,7 +407,7 @@ export class TerraScaleSqliteStore {
406
407
  );
407
408
  }
408
409
  } catch {
409
- // Preserve the original work/migration exception. SQLite will normally
410
+ // Preserve the original operation exception. SQLite will normally
410
411
  // have already rolled back a failed transaction automatically.
411
412
  }
412
413
  }
@@ -1,3 +1,4 @@
1
+ import { generateId } from "../ids.js";
1
2
  import { assertLocalSqliteEnvironment } from "./environment.js";
2
3
  /** Local development tests never relax CI/deployment/qualification guards. */
3
4
  export const localSqliteUnitTestsAllowed = (() => {
@@ -11,7 +12,7 @@ export const localSqliteUnitTestsAllowed = (() => {
11
12
  import { mkdtemp, rm } from "node:fs/promises";
12
13
  import { join } from "node:path";
13
14
  import { tmpdir } from "node:os";
14
- import { randomBytes, randomUUID } from "node:crypto";
15
+ import { randomBytes } from "node:crypto";
15
16
  import { createTerraScaleSqliteRuntime } from "./index.js";
16
17
  /** @import { TerraScaleSqliteRuntimeOptions } from "./index.js" */
17
18
  /** @import { DatabaseCommit, DatabaseField, DatabasePoint } from "../database-types.js" */
@@ -25,7 +26,7 @@ export async function localFixture(options = {}) {
25
26
  ...options,
26
27
  sqlite: { enabled: true, databasePath },
27
28
  });
28
- const collection = randomUUID();
29
+ const collection = generateId();
29
30
  const binding = {
30
31
  collection,
31
32
  schema_generation: 1n,