@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
package/src/v1/sql.js ADDED
@@ -0,0 +1,98 @@
1
+ import { array, boolean, malformed, object, string } from "./decode.js";
2
+ import { requireDocumentId, requireName } from "./ids.js";
3
+ /**
4
+ * @import { JsonObject, JsonValue } from "./json.js"
5
+ * @import { V1RequestOptions } from "./terrabase-v1.js"
6
+ * @import { V1Transport } from "./transport.js"
7
+ */
8
+
9
+ /** @typedef {{ readonly id: string; readonly value: JsonObject }} V1SqlRow */
10
+ /**
11
+ * SQL returns one bounded result, without a pagination cursor.
12
+ * @typedef {{ readonly matched: number; readonly truncated: boolean; readonly rows: readonly V1SqlRow[] }} V1SqlResult
13
+ */
14
+ /** @typedef {{ readonly field: string; readonly reason: string }} V1SqlIndexSuggestion */
15
+ /**
16
+ * @typedef {{
17
+ * readonly ok: boolean;
18
+ * readonly error: string | null;
19
+ * readonly collection: string | null;
20
+ * readonly hasLimit: boolean;
21
+ * readonly plan: readonly string[];
22
+ * readonly indexes: readonly string[];
23
+ * readonly suggestions: readonly V1SqlIndexSuggestion[];
24
+ * }} V1SqlCheck
25
+ */
26
+
27
+ /** @param {string} sql @param {V1RequestOptions} options @returns {string} */
28
+ function requestBody(sql, options) {
29
+ if (typeof sql !== "string" || sql.length > 16_384)
30
+ throw new TypeError("SQL must be a string of at most 16384 UTF-16 code units.");
31
+ if (options === null || typeof options !== "object" || Array.isArray(options))
32
+ throw new TypeError("SQL request options must be an object.");
33
+ for (const member of Object.keys(options))
34
+ if (member !== "signal") throw new TypeError(`Unknown SQL request option ${member}.`);
35
+ // SQL syntax, including empty statements, stays producer-owned: check returns ok:false.
36
+ return JSON.stringify({ sql });
37
+ }
38
+
39
+ /** @param {JsonValue | undefined} value @returns {V1SqlResult} */
40
+ function decodeResult(value) {
41
+ const raw = object(value, "SQL result");
42
+ const rows = array(raw.rows, "SQL rows").map(entry => {
43
+ const row = object(entry, "SQL row");
44
+ return Object.freeze({ id: requireDocumentId(row.id), value: object(row.value, "SQL row value") });
45
+ });
46
+ const matched = raw.matched;
47
+ if (typeof matched !== "number" || !Number.isInteger(matched) || matched < 0 || matched > 1000 || matched !== rows.length)
48
+ malformed("SQL matched count");
49
+ if (new Set(rows.map(row => row.id)).size !== rows.length) malformed("SQL row identities");
50
+ return Object.freeze({ matched, truncated: boolean(raw.truncated, "SQL truncation flag"), rows: Object.freeze(rows) });
51
+ }
52
+
53
+ /** @param {JsonValue | undefined} value @param {"checkSql" | "explainSql"} operation @returns {V1SqlCheck} */
54
+ function decodeCheck(value, operation) {
55
+ const raw = object(value, "SQL check");
56
+ const ok = boolean(raw.ok, "SQL check flag");
57
+ const error = raw.error === null ? null : string(raw.error, "SQL check error");
58
+ const collection = raw.collection === null ? null : requireName("collection", raw.collection);
59
+ const hasLimit = boolean(raw.hasLimit, "SQL limit flag");
60
+ const plan = array(raw.plan, "SQL plan").map(step => string(step, "SQL plan step"));
61
+ const indexes = array(raw.indexes, "SQL indexes").map(index => string(index, "SQL index"));
62
+ const suggestions = array(raw.suggestions, "SQL index suggestions").map(entry => {
63
+ const suggestion = object(entry, "SQL index suggestion");
64
+ return Object.freeze({ field: string(suggestion.field, "SQL index field"), reason: string(suggestion.reason, "SQL index reason") });
65
+ });
66
+ if (ok ? error !== null || collection === null : error === null || error === "" || collection !== null || hasLimit || plan.length > 0 || indexes.length > 0 || suggestions.length > 0)
67
+ malformed("SQL check outcome");
68
+ if (operation === "explainSql" && !ok) malformed("SQL explain outcome");
69
+ return Object.freeze({ ok, error, collection, hasLimit, plan: Object.freeze(plan), indexes: Object.freeze(indexes), suggestions: Object.freeze(suggestions) });
70
+ }
71
+
72
+ /**
73
+ * Runs the producer's read-only SQL subset; it never converts a legacy SQL builder.
74
+ * @param {V1Transport} transport
75
+ * @param {string} database
76
+ * @param {string} sql
77
+ * @param {V1RequestOptions} [options]
78
+ * @returns {Promise<V1SqlResult>}
79
+ */
80
+ export async function runSql(transport, database, sql, options = {}) {
81
+ const body = requestBody(sql, options);
82
+ return decodeResult((await transport.call("runSql", { database: requireName("database", database) }, { body, signal: options.signal })).value);
83
+ }
84
+
85
+ /**
86
+ * Check reports invalid SQL in a successful body; explain reports a producer HTTP error.
87
+ * @param {V1Transport} transport
88
+ * @param {string} database
89
+ * @param {string} sql
90
+ * @param {V1RequestOptions} [options]
91
+ * @param {"checkSql" | "explainSql"} [operation]
92
+ * @returns {Promise<V1SqlCheck>}
93
+ */
94
+ export async function checkSql(transport, database, sql, options = {}, operation = "checkSql") {
95
+ if (operation !== "checkSql" && operation !== "explainSql") throw new TypeError("SQL checks use checkSql or explainSql.");
96
+ const body = requestBody(sql, options);
97
+ return decodeCheck((await transport.call(operation, { database: requireName("database", database) }, { body, signal: options.signal })).value, operation);
98
+ }
@@ -0,0 +1,80 @@
1
+ import { decodeDocument } from "./decode.js";
2
+ import { requireId } from "../ids.js";
3
+ import { requireDocumentId, requireName } from "./ids.js";
4
+ import { flag, list, nullable, numeric, record, text } from "./management-decode.js";
5
+ import { members } from "./management-validation.js";
6
+ import { decodeCollection, decodeQueryPage } from "./terrabase-v1.js";
7
+ import { V1Transport } from "./transport.js";
8
+ /** @import { V1RequestOptions } from "./terrabase-v1.js" */
9
+ const status = record({ status: text, version: text, region: text, storageFormat: text, revision: nullable(text), machine: text });
10
+ const replication = record({ region: text, status: text, maxLagSeconds: nullable(numeric),
11
+ peers: list(record({ region: text, status: text, lagSeconds: nullable(numeric) })) });
12
+ const databases = record({ items: list(record({ name: text, id: requireId, homeRegion: text, replicaRegions: list(text), allRegions: flag })) });
13
+ /** @typedef {ReturnType<typeof status> & {readonly httpStatus: number}} V1ProbeStatus */
14
+ /** @typedef {ReturnType<typeof replication>} V1ReplicationHealth */
15
+ /** @typedef {ReturnType<typeof databases>} V1PlatformDatabases */
16
+ /** @typedef {{ readonly baseUrl: string; readonly token?: string; readonly fetch?: import("../config.js").FetchLike; readonly maxResponseBytes?: number }} TerraBaseSystemClientConfig */
17
+
18
+ /** Public anonymous probes; 503 readiness/backup bodies remain typed probe outcomes. */
19
+ export class V1Health {
20
+ /** @type {V1Transport} */
21
+ #transport;
22
+ /** @param {V1Transport} transport */
23
+ constructor(transport) { this.#transport = transport; Object.freeze(this); }
24
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ProbeStatus>} */
25
+ async liveness(options = {}) {
26
+ const response = await this.#transport.call("healthz", {}, { signal: options.signal });
27
+ return Object.freeze({ ...status(response.value), httpStatus: response.status });
28
+ }
29
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ProbeStatus>} */
30
+ async readiness(options = {}) {
31
+ const response = await this.#transport.call("readyz", {}, { signal: options.signal });
32
+ return Object.freeze({ ...status(response.value), httpStatus: response.status });
33
+ }
34
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ProbeStatus>} */
35
+ async backup(options = {}) {
36
+ const response = await this.#transport.call("backupz", {}, { signal: options.signal });
37
+ return Object.freeze({ ...status(response.value), httpStatus: response.status });
38
+ }
39
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ReplicationHealth>} */
40
+ async replication(options = {}) { return replication((await this.#transport.call("replicationHealth", {}, { signal: options.signal })).value); }
41
+ }
42
+ /** The public read-only explore API requires a user JWT carrying platform_role=admin. */
43
+ export class V1Platform {
44
+ /** @type {V1Transport} */
45
+ #transport;
46
+ /** @param {V1Transport} transport */
47
+ constructor(transport) { this.#transport = transport; Object.freeze(this); }
48
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1PlatformDatabases>} */
49
+ async databases(options = {}) { return databases((await this.#transport.call("listPlatformDatabases", {}, { signal: options.signal })).value); }
50
+ /** @param {"ts-auth" | "ts-dashboard"} database @param {V1RequestOptions} [options] */
51
+ async collections(database, options = {}) {
52
+ const response = await this.#transport.call("listPlatformCollections", { database: requireName("database", database) }, { signal: options.signal });
53
+ return record({ items: list(decodeCollection), next_cursor: nullable(text) })(response.value);
54
+ }
55
+ /** @param {"ts-auth" | "ts-dashboard"} database @param {string} collection @param {{ readonly limit?: number; readonly cursor?: string }} [request] @param {V1RequestOptions} [options] */
56
+ async documents(database, collection, request = {}, options = {}) {
57
+ members(request, ["limit", "cursor"]);
58
+ const query = new URLSearchParams();
59
+ if (request.limit !== undefined) {
60
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 1000) throw new TypeError("limit must be 1-1000.");
61
+ query.set("limit", String(request.limit));
62
+ }
63
+ if (request.cursor !== undefined) {
64
+ if (typeof request.cursor !== "string" || request.cursor === "") throw new TypeError("cursor must be non-empty.");
65
+ query.set("cursor", request.cursor);
66
+ }
67
+ return decodeQueryPage((await this.#transport.call("listPlatformDocuments", { database: requireName("database", database), collection: requireName("collection", collection) }, { query, signal: options.signal })).value);
68
+ }
69
+ /** @param {"ts-auth" | "ts-dashboard"} database @param {string} collection @param {string} id @param {V1RequestOptions} [options] */
70
+ async document(database, collection, id, options = {}) {
71
+ return decodeDocument((await this.#transport.call("getPlatformDocument", { database: requireName("database", database), collection: requireName("collection", collection) }, {
72
+ query: new URLSearchParams({ id: requireDocumentId(id) }), signal: options.signal,
73
+ })).value);
74
+ }
75
+ }
76
+ /** @param {TerraBaseSystemClientConfig} config */
77
+ export function createTerraBaseSystemClient(config) {
78
+ const transport = new V1Transport(config);
79
+ return Object.freeze({ health: new V1Health(transport), platform: new V1Platform(transport) });
80
+ }
@@ -1,13 +1,26 @@
1
1
  // The TerraBase v1 client: documents, transactions, queries and shapes over
2
2
  // the project-scoped C# Documents API (decision 0004 and shape-reads.md).
3
3
  import {
4
- array, boolean, decodeDocument, decodeWrite, durability, malformed, object, u64,
4
+ array, boolean, decodeDocument, decodeWrite, documentObject, durability, malformed, object, u64,
5
5
  } from "./decode.js";
6
+ import { requireId } from "../ids.js";
6
7
  import { isJsonPointer, requireFilter, requirePath } from "./filter.js";
7
8
  import { requireDocumentId, requireName, requireVersion } from "./ids.js";
8
9
  import { assertJsonObject, assertJsonValue, ExactDecimal, stringifyExactJson } from "./json.js";
9
- import { readShape } from "./shapes.js";
10
+ import { readShapeManifest } from "./shape-manifest.js";
11
+ import { V1ShapeSubscription } from "./shape-subscription.js";
12
+ import { decryptSealedShapePage, readShapePage, readShapeKey } from "./shape-reader.js";
13
+ import { runSql, checkSql } from "./sql.js";
14
+ import { streamChanges } from "./changes.js";
15
+ import { V1Databases, getCollectionSchema, putCollectionSchema, deleteCollectionSchema } from "./catalog.js";
10
16
  import { V1Transport } from "./transport.js";
17
+ import { V1Health, V1Platform } from "./system.js";
18
+ import { V1Imports } from "./imports.js";
19
+ import { V1Backups, V1BackupDestinationClient } from "./backups.js";
20
+ import { V1ProjectUsageClient, V1DatabaseUsageClient } from "./usage.js";
21
+ import { V1Indexes } from "./indexes.js";
22
+ import { V1DatabaseSchemaClient } from "./database-schema.js";
23
+ import { V1SavedQueries } from "./saved-queries.js";
11
24
  /**
12
25
  * @import { V1Document, V1Durability, V1WriteResult } from "./decode.js"
13
26
  * @import { V1Filter, V1Path } from "./filter.js"
@@ -37,6 +50,7 @@ import { V1Transport } from "./transport.js";
37
50
  * @typedef {{ readonly op: "set"; readonly path: string; readonly value: JsonInput }
38
51
  * | { readonly op: "unset"; readonly path: string }
39
52
  * | { readonly op: "inc"; readonly path: string; readonly by: number | bigint | ExactDecimal }
53
+ * | { readonly op: "add" | "remove"; readonly path: string; readonly value: import("./filter.js").V1Scalar }
40
54
  * | { readonly op: "append"; readonly path: string; readonly value: JsonInput }} V1PatchOp
41
55
  */
42
56
  /**
@@ -67,7 +81,7 @@ import { V1Transport } from "./transport.js";
67
81
  /**
68
82
  * One op's result: `id` and `version` for writes, `asserted` for asserts, and
69
83
  * also `count` (decimal u64) for `assertCount`.
70
- * @typedef {{ readonly id: string; readonly version: string } | { readonly asserted: true; readonly count?: string }} V1TransactionOpResult
84
+ * @typedef {{ readonly id: string; readonly version: string; readonly document?: import("./json.js").JsonObject } | { readonly asserted: true; readonly count?: string }} V1TransactionOpResult
71
85
  */
72
86
  /** @typedef {{ readonly gte?: number | undefined; readonly lte?: number | undefined }} V1CountBounds */
73
87
  /**
@@ -99,6 +113,9 @@ function patchOp(op) {
99
113
  case "unset": allowed = ["op", "path"]; break;
100
114
  case "inc": allowed = ["op", "path", "by"]; break;
101
115
  }
116
+ const path = pointer(op.path);
117
+ if (["inc", "add", "remove"].includes(op.op) && path.split("/").length !== 2)
118
+ throw new TypeError("inc, add and remove require a top-level field.");
102
119
  for (const key of Object.keys(op)) if (!allowed.includes(key)) throw new TypeError(`Unknown patch op member ${key}.`);
103
120
  switch (op.op) {
104
121
  case "set":
@@ -107,14 +124,30 @@ function patchOp(op) {
107
124
  return { op: op.op, path: pointer(op.path), value: op.value };
108
125
  case "unset":
109
126
  return { op: "unset", path: pointer(op.path) };
127
+ case "add":
128
+ case "remove": {
129
+ const value = op.value;
130
+ if (value !== null && typeof value !== "boolean" && typeof value !== "string" &&
131
+ typeof value !== "number" && typeof value !== "bigint" && !(value instanceof ExactDecimal))
132
+ throw new TypeError("add and remove require a JSON scalar of at most 1 KiB.");
133
+ assertJsonValue(value, "set element");
134
+ if (new TextEncoder().encode(stringifyExactJson(value, "set element")).byteLength > 1024)
135
+ throw new TypeError("add and remove require a JSON scalar of at most 1 KiB.");
136
+ return { op: op.op, path, value };
137
+ }
110
138
  case "inc": {
111
139
  const by = op.by;
112
- if (!(typeof by === "bigint" || by instanceof ExactDecimal || (typeof by === "number" && Number.isFinite(by))))
113
- throw new TypeError("inc takes a finite number, bigint or ExactDecimal.");
114
- return { op: "inc", path: pointer(op.path), by };
140
+ if (!(typeof by === "bigint" || by instanceof ExactDecimal || (typeof by === "number" && Number.isSafeInteger(by))))
141
+ throw new TypeError("inc requires a non-zero signed 64-bit integer.");
142
+ const text = String(by);
143
+ if (!/^-?(?:0|[1-9]\d*)$/u.test(text)) throw new TypeError("inc requires an integer JSON literal.");
144
+ const integer = BigInt(text);
145
+ if (integer === 0n || integer < -(1n << 63n) || integer >= (1n << 63n))
146
+ throw new TypeError("inc requires a non-zero signed 64-bit integer.");
147
+ return { op: "inc", path, by };
115
148
  }
116
149
  default:
117
- throw new TypeError("Patch ops are set, unset, inc or append.");
150
+ throw new TypeError("Patch ops are set, unset, inc, append, add or remove.");
118
151
  }
119
152
  }
120
153
 
@@ -184,7 +217,7 @@ function encodeQuery(query) {
184
217
  * @param {JsonValue | undefined} value
185
218
  * @returns {V1QueryPage}
186
219
  */
187
- function decodeQueryPage(value) {
220
+ export function decodeQueryPage(value) {
188
221
  const raw = object(value, "query response");
189
222
  const next = raw.next_cursor;
190
223
  if (next !== null && (typeof next !== "string" || next === "")) malformed("next_cursor");
@@ -258,11 +291,12 @@ export class V1Collection {
258
291
  }
259
292
 
260
293
  /**
294
+ * The by-key route takes the raw document ID in the query, not the path.
261
295
  * @param {string} id
262
- * @returns {Readonly<Record<string, string>>}
296
+ * @returns {URLSearchParams}
263
297
  */
264
- #document(id) {
265
- return { ...this.#params, id: requireDocumentId(id) };
298
+ #documentKey(id) {
299
+ return new URLSearchParams({ id: requireDocumentId(id) });
266
300
  }
267
301
 
268
302
  /**
@@ -276,7 +310,7 @@ export class V1Collection {
276
310
  /** @type {Record<string, string>} */
277
311
  const headers = {};
278
312
  if (options.ifNoneMatch !== undefined) headers["if-none-match"] = `"${requireVersion(options.ifNoneMatch)}"`;
279
- const response = await this.#transport.call("getDocument", this.#document(id), { headers, signal: options.signal });
313
+ const response = await this.#transport.call("getDocument", this.#params, { query: this.#documentKey(id), headers, signal: options.signal });
280
314
  return decodeDocument(response.value);
281
315
  }
282
316
 
@@ -288,15 +322,14 @@ export class V1Collection {
288
322
  * @returns {Promise<V1WriteResult>}
289
323
  */
290
324
  async put(id, document, options = {}) {
291
- const params = this.#document(id);
292
325
  const headers = preconditionHeaders(options, true);
293
- return decodeWrite(await this.#transport.call("putDocument", params, {
294
- body: encodeDocument(document), headers, signal: options.signal,
326
+ return decodeWrite(await this.#transport.call("putDocument", this.#params, {
327
+ query: this.#documentKey(id), body: encodeDocument(document), headers, signal: options.signal,
295
328
  }));
296
329
  }
297
330
 
298
331
  /**
299
- * Creates a document under a server-generated UUIDv7.
332
+ * Creates a document under a server-generated ULID.
300
333
  * @param {JsonInputObject} document
301
334
  * @param {V1CreateOptions} [options]
302
335
  * @returns {Promise<V1WriteResult>}
@@ -315,14 +348,13 @@ export class V1Collection {
315
348
  * @returns {Promise<V1WriteResult>}
316
349
  */
317
350
  async patch(id, patch, options = {}) {
318
- const params = this.#document(id);
319
351
  const headers = preconditionHeaders(options, false);
320
352
  const normalized = normalizePatch(patch);
321
353
  const [contentType, body] = "merge" in normalized
322
354
  ? ["application/merge-patch+json", stringifyExactJson(normalized.merge, "merge patch")]
323
355
  : ["application/vnd.terrabase.ops+json", stringifyExactJson({ ops: normalized.ops }, "patch ops")];
324
- return decodeWrite(await this.#transport.call("patchDocument", params, {
325
- body, contentType, headers,
356
+ return decodeWrite(await this.#transport.call("patchDocument", this.#params, {
357
+ query: this.#documentKey(id), body, contentType, headers,
326
358
  signal: options.signal,
327
359
  }));
328
360
  }
@@ -334,10 +366,9 @@ export class V1Collection {
334
366
  * @returns {Promise<V1WriteResult>}
335
367
  */
336
368
  async delete(id, options = {}) {
337
- const params = this.#document(id);
338
369
  const headers = preconditionHeaders(options, false);
339
- return decodeWrite(await this.#transport.call("deleteDocument", params, {
340
- headers, signal: options.signal,
370
+ return decodeWrite(await this.#transport.call("deleteDocument", this.#params, {
371
+ query: this.#documentKey(id), headers, signal: options.signal,
341
372
  }));
342
373
  }
343
374
 
@@ -354,6 +385,29 @@ export class V1Collection {
354
385
  return decodeQueryPage(response.value);
355
386
  }
356
387
 
388
+ /** @param {{ readonly limit?: number | undefined; readonly cursor?: string | undefined }} [request] @param {V1RequestOptions} [options] @returns {Promise<V1QueryPage>} */
389
+ async list(request = {}, options = {}) {
390
+ for (const key of Object.keys(request)) if (key !== "limit" && key !== "cursor") throw new TypeError(`Unknown document list member ${key}.`);
391
+ const query = new URLSearchParams();
392
+ if (request.limit !== undefined) {
393
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 1000) throw new TypeError("Document list limit is 1-1000.");
394
+ query.set("limit", String(request.limit));
395
+ }
396
+ if (request.cursor !== undefined) {
397
+ if (typeof request.cursor !== "string" || request.cursor === "") throw new TypeError("Document cursor is a non-empty string.");
398
+ query.set("cursor", request.cursor);
399
+ }
400
+ const response = await this.#transport.call("listDocuments", this.#params, { query, signal: options.signal });
401
+ return decodeQueryPage(response.value);
402
+ }
403
+
404
+ /** @param {V1RequestOptions} [options] */
405
+ getSchema(options = {}) { return getCollectionSchema(this.#transport, this.#params, options); }
406
+ /** @param {import("./json.js").JsonInputObject | boolean} schema @param {V1RequestOptions} [options] */
407
+ putSchema(schema, options = {}) { return putCollectionSchema(this.#transport, this.#params, schema, options); }
408
+ /** @param {V1RequestOptions} [options] */
409
+ deleteSchema(options = {}) { return deleteCollectionSchema(this.#transport, this.#params, options); }
410
+
357
411
  /**
358
412
  * Iterates over every page, following `nextCursor`. `count`
359
413
  * is sent with the first page only.
@@ -364,9 +418,13 @@ export class V1Collection {
364
418
  async *pages(query = {}, options = {}) {
365
419
  /** @type {V1Query} */
366
420
  let current = query;
421
+ const cursors = new Set(query.cursor == null ? [] : [query.cursor]);
367
422
  while (true) {
368
423
  const page = await this.query(current, options);
424
+ if (page.nextCursor !== null && cursors.has(page.nextCursor)) malformed("repeated pagination cursor");
425
+ if (page.nextCursor !== null) cursors.add(page.nextCursor);
369
426
  yield page;
427
+ options.signal?.throwIfAborted();
370
428
  if (page.nextCursor === null) return;
371
429
  const { count: _count, ...rest } = current;
372
430
  current = { ...rest, cursor: page.nextCursor };
@@ -380,17 +438,51 @@ export class V1Collection {
380
438
  * @returns {AsyncGenerator<V1Document, void, undefined>}
381
439
  */
382
440
  async *items(query = {}, options = {}) {
383
- for await (const page of this.pages(query, options)) yield* page.items;
441
+ for await (const page of this.pages(query, options)) {
442
+ for (const item of page.items) {
443
+ options.signal?.throwIfAborted();
444
+ yield item;
445
+ }
446
+ }
384
447
  }
385
448
  /**
386
449
  * Reads one C# shape snapshot page or change-log chunk. Bootstrap defaults to offset -1.
387
450
  * Echo handle, nextOffset and cursor on the following read; discard state on must_refetch.
451
+ * ifNoneMatch sends a complete entity-tag list or *; 304 throws not_modified with cache headers.
388
452
  * @param {V1ShapeRequest} [request]
389
- * @param {V1RequestOptions} [options]
453
+ * @param {V1RequestOptions & {readonly ifNoneMatch?: string}} [options]
390
454
  * @returns {Promise<V1ShapePage>}
391
455
  */
392
456
  shape(request = {}, options = {}) {
393
- return readShape(this.#transport, this.#params, request, options.signal);
457
+ return readShapePage(this.#transport, this.#params, request, options.signal, options.ifNoneMatch);
458
+ }
459
+
460
+ /**
461
+ * Subscribe to the free-read v1 protocol. Stop with close() or the request signal.
462
+ * @param {{where?: V1Filter | undefined}} [request]
463
+ * @param {import("./shape-subscription.js").V1ShapeSubscribeOptions} [options]
464
+ * @returns {V1ShapeSubscription}
465
+ */
466
+ subscribeShape(request = {}, options = {}) {
467
+ return new V1ShapeSubscription(this.#transport, this.#params, request, options);
468
+ }
469
+
470
+ /** @param {import("./shape-manifest.js").V1ShapeManifestRequest} request @param {V1RequestOptions} [options] */
471
+ shapeManifest(request, options = {}) { return readShapeManifest(this.#transport, this.#params, request, options.signal); }
472
+ /** @param {import("./shape-reader.js").V1ShapeKeyRequest} request @param {V1RequestOptions} [options] */
473
+ shapeKey(request, options = {}) { return readShapeKey(this.#transport, this.#params, request, options.signal); }
474
+
475
+
476
+ /**
477
+ * Verify a complete TBS1 object using its authenticated epoch key. Applies no state.
478
+ * Object delivery through manifests is pending the producer's FR-22 contract.
479
+ * @param {Uint8Array} bytes
480
+ * @param {import("./shape-reader.js").V1SealedShapePosition} position
481
+ * @param {V1RequestOptions} [options]
482
+ * @returns {Promise<V1ShapePage>}
483
+ */
484
+ decryptShapeObject(bytes, position, options = {}) {
485
+ return decryptSealedShapePage(this.#transport, this.#params, bytes, position, options.signal);
394
486
  }
395
487
 
396
488
  }
@@ -449,7 +541,7 @@ export class V1Transaction {
449
541
  static #precondition(precondition, allowNoneMatch) {
450
542
  if (precondition === undefined) return {};
451
543
  if ("ifMatch" in precondition && !("ifNoneMatchAny" in precondition))
452
- return { if_match: precondition.ifMatch === "*" ? "*" : requireVersion(precondition.ifMatch) };
544
+ return { if_match: precondition.ifMatch === "*" ? "*" : `"${requireVersion(precondition.ifMatch)}"` };
453
545
  if ("ifNoneMatchAny" in precondition && precondition.ifNoneMatchAny === true && !("ifMatch" in precondition)) {
454
546
  if (!allowNoneMatch) throw new TypeError("This op does not take ifNoneMatchAny.");
455
547
  return { if_none_match: "*" };
@@ -459,7 +551,7 @@ export class V1Transaction {
459
551
 
460
552
  /**
461
553
  * Adds a create (a put requiring absence). Omit `id` to have the server
462
- * generate a UUIDv7, returned in the op's result.
554
+ * generate a ULID, returned in the op's result.
463
555
  * @param {string} collection
464
556
  * @param {JsonInputObject} document
465
557
  * @param {string} [id]
@@ -589,19 +681,21 @@ export class V1Transaction {
589
681
  return Object.freeze(result.count === undefined
590
682
  ? { asserted: /** @type {const} */ (true) }
591
683
  : { asserted: /** @type {const} */ (true), count: u64(result.count, "assert_count count") });
592
- return Object.freeze({ id: requireDocumentId(result.id), version: requireVersion(result.version) });
684
+ return Object.freeze({ id: requireDocumentId(result.id), version: requireVersion(result.version),
685
+ ...(result.document === undefined ? {} : { document: documentObject(result.document) }),
686
+ });
593
687
  })),
594
688
  });
595
689
  }
596
690
  }
597
691
 
598
- /** @typedef {{ readonly name: string; readonly createdAt: string }} V1CollectionInfo */
692
+ /** @typedef {{ readonly id: import("../ids.js").EntityId; readonly name: string; readonly createdAt: string }} V1CollectionInfo */
599
693
  /** @typedef {{ readonly items: readonly V1CollectionInfo[]; readonly nextCursor: string | null }} V1CollectionPage */
600
694
  /** @typedef {{ readonly limit?: number | undefined; readonly cursor?: string | undefined }} V1CollectionListOptions */
601
695
  /** @param {JsonValue | undefined} value @returns {V1CollectionInfo} */
602
- function decodeCollection(value) {
696
+ export function decodeCollection(value) {
603
697
  const raw = object(value, "collection");
604
- return Object.freeze({ name: requireName("collection", raw.name), createdAt: u64(raw.created_at, "collection created_at") });
698
+ return Object.freeze({ id: requireId(raw.id), name: requireName("collection", raw.name), createdAt: u64(raw.created_at, "collection created_at") });
605
699
  }
606
700
  /** Collection management on the public client listener. */
607
701
  export class V1Collections {
@@ -655,6 +749,16 @@ export class V1Collections {
655
749
 
656
750
  /** A database handle by name. It performs no I/O until an operation runs. */
657
751
  export class V1Database {
752
+ /** @type {V1Imports} */
753
+ imports;
754
+ /** @type {V1Backups} */
755
+ backups;
756
+ /** @type {V1DatabaseUsageClient} */
757
+ metrics;
758
+ /** @type {V1Indexes} */
759
+ indexes;
760
+ /** @type {V1DatabaseSchemaClient} */
761
+ schema;
658
762
  /** @type {V1Collections} */
659
763
  collections;
660
764
  /** @type {V1Transport} */
@@ -670,6 +774,11 @@ export class V1Database {
670
774
  this.#transport = transport;
671
775
  this.name = requireName("database", name);
672
776
  this.collections = new V1Collections(transport, this.name);
777
+ this.schema = new V1DatabaseSchemaClient(transport, this.name);
778
+ this.indexes = new V1Indexes(transport, this.name);
779
+ this.metrics = new V1DatabaseUsageClient(transport, this.name);
780
+ this.backups = new V1Backups(transport, this.name);
781
+ this.imports = new V1Imports(transport, this.name);
673
782
  Object.freeze(this);
674
783
  }
675
784
 
@@ -686,20 +795,57 @@ export class V1Database {
686
795
  return new V1Transaction(this.#transport, this.name);
687
796
  }
688
797
 
798
+ /** @param {string} statement @param {V1RequestOptions} [options] */
799
+ sql(statement, options = {}) {
800
+ return runSql(this.#transport, this.name, statement, options);
801
+ }
802
+
803
+ /** @param {string} statement @param {V1RequestOptions} [options] */
804
+ checkSql(statement, options = {}) {
805
+ return checkSql(this.#transport, this.name, statement, options);
806
+ }
807
+
808
+ /** @param {string} statement @param {V1RequestOptions} [options] */
809
+ explainSql(statement, options = {}) {
810
+ return checkSql(this.#transport, this.name, statement, options, "explainSql");
811
+ }
689
812
 
813
+ /** @param {import("./changes.js").V1ChangeStreamRequest} [request] @param {V1RequestOptions} [options] */
814
+ changes(request = {}, options = {}) {
815
+ return streamChanges(this.#transport, this.name, request, options.signal);
816
+ }
690
817
  }
691
818
 
692
819
  /**
693
- * A TerraBase v1 data-plane client for one project and `tb_` API key. The key is held
820
+ * A TerraBase v1 data-plane client for one project and access token. The token is held
694
821
  * privately; it is never serialized, logged or forwarded to redirects.
695
822
  */
696
823
  export class TerraBaseV1Client {
824
+ /** @type {V1Health} */
825
+ #health;
826
+ /** @type {V1Platform} */
827
+ #platform;
828
+ /** @type {V1BackupDestinationClient} */
829
+ #backupDestination;
830
+ /** @type {V1ProjectUsageClient} */
831
+ #metrics;
832
+ /** @type {V1SavedQueries} */
833
+ #savedQueries;
834
+ /** @type {V1Databases} */
835
+ #databases;
697
836
  /** @type {V1Transport} */
698
837
  #transport;
699
838
 
700
839
  /** @param {TerraBaseV1ClientConfig} config */
701
840
  constructor(config) {
841
+ requireId(config.project);
702
842
  this.#transport = new V1Transport(config);
843
+ this.#databases = new V1Databases(this.#transport);
844
+ this.#savedQueries = new V1SavedQueries(this.#transport);
845
+ this.#metrics = new V1ProjectUsageClient(this.#transport);
846
+ this.#backupDestination = new V1BackupDestinationClient(this.#transport);
847
+ this.#health = new V1Health(this.#transport);
848
+ this.#platform = new V1Platform(this.#transport);
703
849
  Object.freeze(this);
704
850
  }
705
851
 
@@ -710,10 +856,23 @@ export class TerraBaseV1Client {
710
856
  database(name) {
711
857
  return new V1Database(this.#transport, name);
712
858
  }
859
+
860
+ /** @returns {V1Databases} Project-scoped database catalog. */
861
+ get databases() { return this.#databases; }
862
+ /** @returns {V1SavedQueries} */
863
+ get savedQueries() { return this.#savedQueries; }
864
+ /** @returns {V1ProjectUsageClient} */
865
+ get metrics() { return this.#metrics; }
866
+ /** @returns {V1BackupDestinationClient} */
867
+ get backupDestination() { return this.#backupDestination; }
868
+ /** @returns {V1Health} */
869
+ get health() { return this.#health; }
870
+ /** @returns {V1Platform} */
871
+ get platform() { return this.#platform; }
713
872
  }
714
873
 
715
874
  /**
716
- * Creates a TerraBase v1 client for documents, transactions, queries and shapes.
875
+ * Creates a project-scoped TerraBase client for documents, SQL, changes and catalog operations.
717
876
  * @param {TerraBaseV1ClientConfig} config
718
877
  * @returns {TerraBaseV1Client}
719
878
  */