@terrascale/sdk 0.0.2 → 0.0.4

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 (84) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +193 -56
  3. package/package.json +14 -5
  4. package/sdk-public-v1-route-manifest.json +81 -0
  5. package/sdk-v1-route-manifest.json +8996 -1697
  6. package/src/database-codec.js +21 -26
  7. package/src/database-types.js +11 -11
  8. package/src/database-view.js +6 -6
  9. package/src/http.js +13 -48
  10. package/src/ids.js +22 -0
  11. package/src/index.js +77 -2
  12. package/src/local/better-auth.js +17 -6
  13. package/src/local/environment.js +0 -1
  14. package/src/local/index.js +2 -2
  15. package/src/local/legacy-client.js +5 -5
  16. package/src/local/router.js +12 -12
  17. package/src/local/store.js +29 -28
  18. package/src/local/test-environment.js +3 -2
  19. package/src/postgres.js +5 -4
  20. package/src/react/core.js +2 -2
  21. package/src/ts-auth-ids.js +122 -0
  22. package/src/ts-auth.js +75 -77
  23. package/src/v1/backups.js +111 -0
  24. package/src/v1/catalog.js +210 -0
  25. package/src/v1/changes.js +225 -0
  26. package/src/v1/database-schema.js +45 -0
  27. package/src/v1/decode.js +20 -8
  28. package/src/v1/errors.js +23 -3
  29. package/src/v1/ids.js +9 -59
  30. package/src/v1/imports.js +72 -0
  31. package/src/v1/indexes.js +76 -0
  32. package/src/v1/json.js +4 -3
  33. package/src/v1/management-decode.js +24 -0
  34. package/src/v1/management-routes.js +67 -0
  35. package/src/v1/management-validation.js +25 -0
  36. package/src/v1/routes.js +36 -11
  37. package/src/v1/saved-queries.js +58 -0
  38. package/src/v1/shape-crypto.js +125 -0
  39. package/src/v1/shape-manifest.js +31 -0
  40. package/src/v1/shape-reader.js +212 -0
  41. package/src/v1/shape-subscription.js +208 -0
  42. package/src/v1/shapes.js +15 -10
  43. package/src/v1/sql.js +98 -0
  44. package/src/v1/system.js +80 -0
  45. package/src/v1/terrabase-v1.js +192 -33
  46. package/src/v1/transport.js +80 -34
  47. package/src/v1/usage.js +101 -0
  48. package/types/database-codec.d.ts +1 -6
  49. package/types/database-types.d.ts +11 -11
  50. package/types/http.d.ts +2 -8
  51. package/types/ids.d.ts +10 -0
  52. package/types/index.d.ts +70 -1
  53. package/types/local/environment.d.ts +1 -1
  54. package/types/local/store.d.ts +10 -0
  55. package/types/local/test-environment.d.ts +2 -2
  56. package/types/postgres.d.ts +4 -4
  57. package/types/ts-auth-ids.d.ts +14 -0
  58. package/types/ts-auth.d.ts +99 -101
  59. package/types/v1/backups.d.ts +128 -0
  60. package/types/v1/catalog.d.ts +92 -0
  61. package/types/v1/changes.d.ts +53 -0
  62. package/types/v1/database-schema.d.ts +82 -0
  63. package/types/v1/decode.d.ts +17 -4
  64. package/types/v1/errors.d.ts +16 -1
  65. package/types/v1/ids.d.ts +4 -16
  66. package/types/v1/imports.d.ts +132 -0
  67. package/types/v1/indexes.d.ts +75 -0
  68. package/types/v1/management-decode.d.ts +15 -0
  69. package/types/v1/management-routes.d.ts +3 -0
  70. package/types/v1/management-validation.d.ts +8 -0
  71. package/types/v1/routes.d.ts +5 -2
  72. package/types/v1/saved-queries.d.ts +33 -0
  73. package/types/v1/shape-crypto.d.ts +39 -0
  74. package/types/v1/shape-manifest.d.ts +28 -0
  75. package/types/v1/shape-reader.d.ts +45 -0
  76. package/types/v1/shape-subscription.d.ts +35 -0
  77. package/types/v1/shapes.d.ts +14 -0
  78. package/types/v1/sql.d.ts +47 -0
  79. package/types/v1/system.d.ts +85 -0
  80. package/types/v1/terrabase-v1.d.ts +116 -7
  81. package/types/v1/transport.d.ts +27 -10
  82. package/types/v1/usage.d.ts +171 -0
  83. package/src/ts-auth-discovery.js +0 -207
  84. package/types/ts-auth-discovery.d.ts +0 -19
@@ -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,
package/src/postgres.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { isValidId } from "./ids.js";
1
2
  /** @deprecated The C# TerraBase has no PostgreSQL listener.
2
3
  * Node-only PostgreSQL client for the signed INT8/TEXT point SQL listener. */
3
4
  import { lookup } from "node:dns/promises";
@@ -38,8 +39,8 @@ const MAX_INT8 = (1n << 63n) - 1n;
38
39
  * readonly port: number;
39
40
  * readonly tlsServerName: string;
40
41
  * readonly caCertificatePem: string;
41
- * readonly database: string;
42
- * readonly keyId: string;
42
+ * readonly database: import("./ids.js").EntityId;
43
+ * readonly keyId: import("./ids.js").EntityId;
43
44
  * readonly password: string;
44
45
  * readonly connectTimeoutMs?: number;
45
46
  * }} PostgresConfig
@@ -168,8 +169,8 @@ function configuration(config) {
168
169
  !Number.isInteger(timeout) ||
169
170
  timeout < 1 ||
170
171
  timeout > 10000 ||
171
- !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(config.database) ||
172
- !canonicalBase64(config.keyId, 16) ||
172
+ !isValidId(config.database) ||
173
+ !isValidId(config.keyId) ||
173
174
  parts.length !== 3 ||
174
175
  parts[0] !== "tbk1" ||
175
176
  parts[1] !== config.keyId ||
package/src/react/core.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { generateId } from "../ids.js";
1
2
  /** @import { TerraScaleError, TerraScaleResult } from "../result.js" */
2
3
  /** @import { DatabaseResult } from "../database-types.js" */
3
4
 
@@ -733,8 +734,7 @@ function normalizeIdempotencyKey(value) {
733
734
 
734
735
  /** @returns {string} */
735
736
  function createIdempotencyKey() {
736
- const randomUuid = globalThis.crypto?.randomUUID;
737
- return randomUuid === undefined ? `tsm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` : randomUuid.call(globalThis.crypto);
737
+ return generateId();
738
738
  }
739
739
 
740
740
  /** @returns {ReactiveMutationError} */
@@ -0,0 +1,122 @@
1
+ import { requireId } from "./ids.js";
2
+
3
+ const singleEntityRoutes = new Set([
4
+ "tsauth.users.get", "tsauth.users.create", "tsauth.users.update",
5
+ "tsauth.apiKeys.create",
6
+ "tsauth.organizations.get", "tsauth.organizations.create", "tsauth.organizations.update",
7
+ "tsauth.organizations.createMembership", "tsauth.organizations.updateMembership",
8
+ "tsauth.organizations.createInvitation", "tsauth.organizations.revokeInvitation", "tsauth.organizations.createDomain",
9
+ "tsauth.connections.create", "tsauth.connections.update",
10
+ "tsauth.directories.create", "tsauth.directories.rotate",
11
+ "tsauth.jwtTemplates.create", "tsauth.jwtTemplates.update",
12
+ "tsauth.webhooks.create",
13
+ "tsauth.instance.get", "tsauth.instance.update",
14
+ ]);
15
+
16
+ const requiredRootReferences = new Map([
17
+ ["tsauth.oidc.userinfo", "sub"],
18
+ ["tsauth.organizations.enrollDomain", "org_id"],
19
+ ]);
20
+
21
+ /** @type {ReadonlyMap<string, readonly string[]>} */
22
+ const entityReferenceFields = new Map([
23
+ ["tsauth.organizations.createMembership", ["organization_id", "user_id"]],
24
+ ["tsauth.organizations.updateMembership", ["organization_id", "user_id"]],
25
+ ["tsauth.organizations.listMemberships", ["organization_id", "user_id"]],
26
+ ["tsauth.organizations.createInvitation", ["organization_id"]],
27
+ ["tsauth.organizations.revokeInvitation", ["organization_id"]],
28
+ ["tsauth.organizations.listInvitations", ["organization_id"]],
29
+ ["tsauth.organizations.createDomain", ["organization_id"]],
30
+ ["tsauth.organizations.listDomains", ["organization_id"]],
31
+ ["tsauth.auditEvents.list", ["instance_id"]],
32
+ ["tsauth.webhooks.deadLetter", ["endpoint_id", "event_id"]],
33
+ ["tsauth.oauthClients.create", ["client_id"]],
34
+ ["tsauth.oauthClients.update", ["client_id"]],
35
+ ["tsauth.oauthClients.list", ["client_id"]],
36
+ ["tsauth.oauthResources.create", ["introspection_client_id"]],
37
+ ["tsauth.oauthResources.update", ["introspection_client_id"]],
38
+ ["tsauth.oauthResources.list", ["introspection_client_id"]],
39
+ ["tsauth.connections.create", ["org_id"]],
40
+ ["tsauth.connections.update", ["org_id"]],
41
+ ["tsauth.connections.list", ["org_id"]],
42
+ ["tsauth.directories.create", ["org_id"]],
43
+ ["tsauth.directories.rotate", ["org_id"]],
44
+ ["tsauth.directories.list", ["org_id"]],
45
+ ]);
46
+
47
+ /**
48
+ * Validate the documented endpointId:eventId delivery selector.
49
+ * @param {unknown} value
50
+ * @returns {string}
51
+ */
52
+ export function requireDeadLetterId(value) {
53
+ if (typeof value !== "string" || value.split(":").length !== 2)
54
+ throw new TypeError("Dead-letter selector must contain endpoint and event ULIDs separated by a colon.");
55
+ const [endpoint, event] = value.split(":");
56
+ requireId(endpoint, "endpoint id");
57
+ requireId(event, "event id");
58
+ return value;
59
+ }
60
+
61
+ /**
62
+ * Validate identity fields in the hosted-auth DTOs without inspecting customer metadata.
63
+ * @param {unknown} value
64
+ * @param {string} route
65
+ * @param {boolean} [entity]
66
+ * @returns {void}
67
+ */
68
+ export function validateTsAuthIds(value, route, entity = singleEntityRoutes.has(route)) {
69
+ const requiredReference = requiredRootReferences.get(route);
70
+ if ((entity || requiredReference !== undefined) && (typeof value !== "object" || value === null || Array.isArray(value)))
71
+ throw new TypeError("Hosted-auth response must be an object with its required ULID.");
72
+ if (Array.isArray(value)) {
73
+ if (route === "tsauth.users.regenerateRecoveryCodes") {
74
+ if (!value.every(code => typeof code === "string"))
75
+ throw new TypeError("Recovery codes must be strings.");
76
+ return;
77
+ }
78
+ for (const entry of value) validateTsAuthIds(entry, route, true);
79
+ return;
80
+ }
81
+ if (typeof value !== "object" || value === null) return;
82
+ if (requiredReference !== undefined) requireId(Reflect.get(value, requiredReference), requiredReference);
83
+ const deadLetter = route === "tsauth.webhooks.deadLetter";
84
+ if (entity) {
85
+ if (deadLetter) requireDeadLetterId(Reflect.get(value, "id"));
86
+ else requireId(Reflect.get(value, "id"), "entity id");
87
+ }
88
+ const fields = ["id", "user_id", "organization_id", "org_id", "endpoint_id", "event_id", "instance_id", "actor_id"];
89
+ const nullableOrgId = route.startsWith("tsauth.connections.") || route.startsWith("tsauth.directories.");
90
+ if (/^tsauth\.(oauthClients|oauthResources|oidc)\./u.test(route))
91
+ fields.push("client_id", "introspection_client_id", "sub", "sid", "jti");
92
+ const requiredFields = entityReferenceFields.get(route) ?? [];
93
+ const validateReferences = entity || Object.hasOwn(value, "id");
94
+ for (const field of fields) {
95
+ if (Object.hasOwn(value, field) || (validateReferences && requiredFields.includes(field))) {
96
+ const id = Reflect.get(value, field);
97
+ if (field === "id" && deadLetter) requireDeadLetterId(id);
98
+ else if (id !== null || !(field === "sid" || (field === "org_id" && nullableOrgId))) requireId(id, field);
99
+ }
100
+ }
101
+ // `data` objects in audit events are customer payloads; only page arrays contain DTOs.
102
+ if (Object.hasOwn(value, "data") && Array.isArray(Reflect.get(value, "data")))
103
+ validateTsAuthIds(Reflect.get(value, "data"), route);
104
+ for (const field of ["passkeys", "clients", "resources"])
105
+ if (Object.hasOwn(value, field)) validateTsAuthIds(Reflect.get(value, field), route);
106
+ for (const field of ["organization", "user"]) {
107
+ if (Object.hasOwn(value, field) && Reflect.get(value, field) !== null) {
108
+ const relatedRoute = field === "organization" ? "tsauth.organizations.get" : "tsauth.users.get";
109
+ validateTsAuthIds(Reflect.get(value, field), relatedRoute, true);
110
+ }
111
+ }
112
+ let oauthEntity;
113
+ if (route.startsWith("tsauth.oauthClients.")) oauthEntity = "client";
114
+ else if (route.startsWith("tsauth.oauthResources.")) oauthEntity = "resource";
115
+ if (oauthEntity !== undefined && Object.hasOwn(value, oauthEntity))
116
+ validateTsAuthIds(Reflect.get(value, oauthEntity), route, true);
117
+ if (Object.hasOwn(value, "allowed_client_ids")) {
118
+ const ids = Reflect.get(value, "allowed_client_ids");
119
+ if (!Array.isArray(ids)) throw new TypeError("allowed_client_ids must be an array of ULIDs.");
120
+ for (const id of ids) requireId(id, "allowed_client_ids");
121
+ }
122
+ }