@terrascale/sdk 0.0.3 → 0.0.5

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 (66) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +206 -67
  3. package/package.json +10 -4
  4. package/sdk-public-v1-route-manifest.json +85 -0
  5. package/sdk-v1-route-manifest.json +896 -15
  6. package/src/http.js +3 -48
  7. package/src/index.js +80 -1
  8. package/src/local/environment.js +0 -1
  9. package/src/ts-auth.js +1 -5
  10. package/src/v1/backups.js +111 -0
  11. package/src/v1/catalog.js +240 -0
  12. package/src/v1/changes.js +225 -0
  13. package/src/v1/database-schema.js +45 -0
  14. package/src/v1/decode.js +6 -5
  15. package/src/v1/errors.js +23 -3
  16. package/src/v1/export.js +68 -0
  17. package/src/v1/ids.js +5 -2
  18. package/src/v1/imports.js +72 -0
  19. package/src/v1/indexes.js +76 -0
  20. package/src/v1/json.js +4 -3
  21. package/src/v1/management-decode.js +24 -0
  22. package/src/v1/management-routes.js +72 -0
  23. package/src/v1/management-validation.js +25 -0
  24. package/src/v1/routes.js +32 -7
  25. package/src/v1/saved-queries.js +58 -0
  26. package/src/v1/shape-crypto.js +125 -0
  27. package/src/v1/shape-manifest.js +31 -0
  28. package/src/v1/shape-reader.js +214 -0
  29. package/src/v1/shape-subscription.js +208 -0
  30. package/src/v1/shapes.js +11 -5
  31. package/src/v1/sql.js +98 -0
  32. package/src/v1/system.js +80 -0
  33. package/src/v1/terrabase-v1.js +231 -30
  34. package/src/v1/transport.js +117 -33
  35. package/src/v1/usage.js +101 -0
  36. package/types/http.d.ts +2 -8
  37. package/types/index.d.ts +73 -0
  38. package/types/local/environment.d.ts +1 -1
  39. package/types/ts-auth.d.ts +0 -2
  40. package/types/v1/backups.d.ts +128 -0
  41. package/types/v1/catalog.d.ts +104 -0
  42. package/types/v1/changes.d.ts +53 -0
  43. package/types/v1/database-schema.d.ts +82 -0
  44. package/types/v1/decode.d.ts +20 -4
  45. package/types/v1/errors.d.ts +16 -1
  46. package/types/v1/export.d.ts +26 -0
  47. package/types/v1/ids.d.ts +2 -2
  48. package/types/v1/imports.d.ts +132 -0
  49. package/types/v1/indexes.d.ts +75 -0
  50. package/types/v1/management-decode.d.ts +15 -0
  51. package/types/v1/management-routes.d.ts +3 -0
  52. package/types/v1/management-validation.d.ts +8 -0
  53. package/types/v1/routes.d.ts +5 -2
  54. package/types/v1/saved-queries.d.ts +33 -0
  55. package/types/v1/shape-crypto.d.ts +39 -0
  56. package/types/v1/shape-manifest.d.ts +28 -0
  57. package/types/v1/shape-reader.d.ts +45 -0
  58. package/types/v1/shape-subscription.d.ts +35 -0
  59. package/types/v1/shapes.d.ts +16 -2
  60. package/types/v1/sql.d.ts +47 -0
  61. package/types/v1/system.d.ts +85 -0
  62. package/types/v1/terrabase-v1.d.ts +140 -6
  63. package/types/v1/transport.d.ts +33 -10
  64. package/types/v1/usage.d.ts +171 -0
  65. package/src/ts-auth-discovery.js +0 -207
  66. package/types/ts-auth-discovery.d.ts +0 -19
@@ -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,27 @@
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 { exportDocuments } from "./export.js";
16
+ import { V1Databases, getCollectionSchema, putCollectionSchema, deleteCollectionSchema, getCollectionTtl, putCollectionTtl, deleteCollectionTtl } from "./catalog.js";
10
17
  import { V1Transport } from "./transport.js";
18
+ import { V1Health, V1Platform } from "./system.js";
19
+ import { V1Imports } from "./imports.js";
20
+ import { V1Backups, V1BackupDestinationClient } from "./backups.js";
21
+ import { V1ProjectUsageClient, V1DatabaseUsageClient } from "./usage.js";
22
+ import { V1Indexes } from "./indexes.js";
23
+ import { V1DatabaseSchemaClient } from "./database-schema.js";
24
+ import { V1SavedQueries } from "./saved-queries.js";
11
25
  /**
12
26
  * @import { V1Document, V1Durability, V1WriteResult } from "./decode.js"
13
27
  * @import { V1Filter, V1Path } from "./filter.js"
@@ -37,6 +51,7 @@ import { V1Transport } from "./transport.js";
37
51
  * @typedef {{ readonly op: "set"; readonly path: string; readonly value: JsonInput }
38
52
  * | { readonly op: "unset"; readonly path: string }
39
53
  * | { readonly op: "inc"; readonly path: string; readonly by: number | bigint | ExactDecimal }
54
+ * | { readonly op: "add" | "remove"; readonly path: string; readonly value: import("./filter.js").V1Scalar }
40
55
  * | { readonly op: "append"; readonly path: string; readonly value: JsonInput }} V1PatchOp
41
56
  */
42
57
  /**
@@ -44,12 +59,17 @@ import { V1Transport } from "./transport.js";
44
59
  * @typedef {{ readonly merge: JsonInputObject } | { readonly ops: readonly V1PatchOp[] }} V1Patch
45
60
  */
46
61
 
47
- /** @typedef {{ readonly path: V1Path; readonly order?: "asc" | "desc" | undefined }} V1SortKey */
62
+ /** @typedef {{ readonly path: V1Path; readonly order?: "asc" | "desc" | undefined }} V1FieldSortKey */
63
+ /**
64
+ * Vector similarity sort, nearest first. It must be the only sort key; limit is at most 100 and there is no cursor.
65
+ * @typedef {{ readonly near: { readonly path: V1Path; readonly vector: readonly number[]; readonly metric?: "cosine" | "l2" | undefined } }} V1NearSortKey
66
+ */
67
+ /** @typedef {V1FieldSortKey | V1NearSortKey} V1SortKey */
48
68
  /**
49
69
  * A query-v1 request. Every member is optional.
50
70
  * @typedef {object} V1Query
51
71
  * @property {V1Filter | null | undefined} [filter]
52
- * @property {readonly V1SortKey[] | undefined} [sort] At most 4 keys; results are finally ordered by `$id`.
72
+ * @property {readonly V1SortKey[] | undefined} [sort] At most 4 keys, or one `near` key; results are finally ordered by `$id`.
53
73
  * @property {readonly string[] | undefined} [project] At most 64 paths.
54
74
  * @property {number | undefined} [limit] 1-1000 (server default 50).
55
75
  * @property {string | null | undefined} [cursor] A previous page's `nextCursor`.
@@ -67,7 +87,7 @@ import { V1Transport } from "./transport.js";
67
87
  /**
68
88
  * One op's result: `id` and `version` for writes, `asserted` for asserts, and
69
89
  * also `count` (decimal u64) for `assertCount`.
70
- * @typedef {{ readonly id: string; readonly version: string } | { readonly asserted: true; readonly count?: string }} V1TransactionOpResult
90
+ * @typedef {{ readonly id: string; readonly version: string; readonly document?: import("./json.js").JsonObject } | { readonly asserted: true; readonly count?: string }} V1TransactionOpResult
71
91
  */
72
92
  /** @typedef {{ readonly gte?: number | undefined; readonly lte?: number | undefined }} V1CountBounds */
73
93
  /**
@@ -94,13 +114,14 @@ function pointer(path) {
94
114
  */
95
115
  function patchOp(op) {
96
116
  if (typeof op !== "object" || op === null || Array.isArray(op)) throw new TypeError("A patch op is an object.");
97
- if (op.path === "/id" || op.path?.startsWith("/id/")) throw new TypeError("The document key is the entity ID; payloads must not contain an id field.");
98
- if (op.path === "" && op.op === "set") assertNoPayloadId(op.value);
99
117
  let allowed = ["op", "path", "value"];
100
118
  switch (op.op) {
101
119
  case "unset": allowed = ["op", "path"]; break;
102
120
  case "inc": allowed = ["op", "path", "by"]; break;
103
121
  }
122
+ const path = pointer(op.path);
123
+ if (["inc", "add", "remove"].includes(op.op) && path.split("/").length !== 2)
124
+ throw new TypeError("inc, add and remove require a top-level field.");
104
125
  for (const key of Object.keys(op)) if (!allowed.includes(key)) throw new TypeError(`Unknown patch op member ${key}.`);
105
126
  switch (op.op) {
106
127
  case "set":
@@ -109,14 +130,30 @@ function patchOp(op) {
109
130
  return { op: op.op, path: pointer(op.path), value: op.value };
110
131
  case "unset":
111
132
  return { op: "unset", path: pointer(op.path) };
133
+ case "add":
134
+ case "remove": {
135
+ const value = op.value;
136
+ if (value !== null && typeof value !== "boolean" && typeof value !== "string" &&
137
+ typeof value !== "number" && typeof value !== "bigint" && !(value instanceof ExactDecimal))
138
+ throw new TypeError("add and remove require a JSON scalar of at most 1 KiB.");
139
+ assertJsonValue(value, "set element");
140
+ if (new TextEncoder().encode(stringifyExactJson(value, "set element")).byteLength > 1024)
141
+ throw new TypeError("add and remove require a JSON scalar of at most 1 KiB.");
142
+ return { op: op.op, path, value };
143
+ }
112
144
  case "inc": {
113
145
  const by = op.by;
114
- if (!(typeof by === "bigint" || by instanceof ExactDecimal || (typeof by === "number" && Number.isFinite(by))))
115
- throw new TypeError("inc takes a finite number, bigint or ExactDecimal.");
116
- return { op: "inc", path: pointer(op.path), by };
146
+ if (!(typeof by === "bigint" || by instanceof ExactDecimal || (typeof by === "number" && Number.isSafeInteger(by))))
147
+ throw new TypeError("inc requires a non-zero signed 64-bit integer.");
148
+ const text = String(by);
149
+ if (!/^-?(?:0|[1-9]\d*)$/u.test(text)) throw new TypeError("inc requires an integer JSON literal.");
150
+ const integer = BigInt(text);
151
+ if (integer === 0n || integer < -(1n << 63n) || integer >= (1n << 63n))
152
+ throw new TypeError("inc requires a non-zero signed 64-bit integer.");
153
+ return { op: "inc", path, by };
117
154
  }
118
155
  default:
119
- throw new TypeError("Patch ops are set, unset, inc or append.");
156
+ throw new TypeError("Patch ops are set, unset, inc, append, add or remove.");
120
157
  }
121
158
  }
122
159
 
@@ -135,7 +172,6 @@ function normalizePatch(patch) {
135
172
  if (hasMerge) {
136
173
  const merge = /** @type {{ readonly merge: JsonInputObject }} */ (patch).merge;
137
174
  assertJsonObject(merge, "merge patch");
138
- assertNoPayloadId(merge);
139
175
  return { merge };
140
176
  }
141
177
  const ops = /** @type {{ readonly ops: readonly V1PatchOp[] }} */ (patch).ops;
@@ -144,6 +180,26 @@ function normalizePatch(patch) {
144
180
  return { ops: ops.map(patchOp) };
145
181
  }
146
182
 
183
+ /**
184
+ * @param {V1NearSortKey} key
185
+ * @param {V1Query} query
186
+ * @returns {Record<string, unknown>}
187
+ */
188
+ function nearSort(key, query) {
189
+ if (Object.keys(key).length !== 1 || query.sort?.length !== 1) throw new TypeError("near is the only sort key and has no path or order beside it.");
190
+ if (query.cursor != null) throw new TypeError("A near query has one page and takes no cursor.");
191
+ if (query.limit !== undefined && query.limit > 100) throw new TypeError("A near query limit is at most 100.");
192
+ const near = key.near;
193
+ if (near === null || typeof near !== "object") throw new TypeError("near is an object.");
194
+ for (const member of Object.keys(near)) if (!["path", "vector", "metric"].includes(member)) throw new TypeError(`Unknown near member ${member}.`);
195
+ if (near.metric !== undefined && near.metric !== "cosine" && near.metric !== "l2") throw new TypeError('near metric is "cosine" or "l2".');
196
+ const vector = near.vector;
197
+ if (!Array.isArray(vector) || vector.length < 1 || vector.length > 4096) throw new TypeError("A near vector has 1-4096 numbers.");
198
+ if (!vector.every(value => typeof value === "number" && Math.abs(value) <= 3.4028234663852886e38)) throw new TypeError("near vector values are finite float32 numbers.");
199
+ if (near.metric !== "l2" && vector.every(value => value === 0)) throw new TypeError("A cosine near vector is not all zeros.");
200
+ return { near: { path: requirePath(near.path), vector: [...vector], ...(near.metric === undefined ? {} : { metric: near.metric }) } };
201
+ }
202
+
147
203
  /**
148
204
  * @param {V1Query} query
149
205
  * @returns {string}
@@ -159,6 +215,7 @@ function encodeQuery(query) {
159
215
  if (!Array.isArray(query.sort) || query.sort.length > 4) throw new TypeError("sort has at most 4 keys.");
160
216
  body.sort = query.sort.map((key) => {
161
217
  if (key === null || typeof key !== "object") throw new TypeError("Sort keys are objects.");
218
+ if ("near" in key) return nearSort(key, query);
162
219
  for (const member of Object.keys(key)) if (member !== "path" && member !== "order") throw new TypeError(`Unknown sort member ${member}.`);
163
220
  if (key.order !== undefined && key.order !== "asc" && key.order !== "desc") throw new TypeError('Sort order is "asc" or "desc".');
164
221
  return { path: requirePath(key.path), ...(key.order === undefined ? {} : { order: key.order }) };
@@ -187,7 +244,7 @@ function encodeQuery(query) {
187
244
  * @param {JsonValue | undefined} value
188
245
  * @returns {V1QueryPage}
189
246
  */
190
- function decodeQueryPage(value) {
247
+ export function decodeQueryPage(value) {
191
248
  const raw = object(value, "query response");
192
249
  const next = raw.next_cursor;
193
250
  if (next !== null && (typeof next !== "string" || next === "")) malformed("next_cursor");
@@ -198,7 +255,13 @@ function decodeQueryPage(value) {
198
255
  count = Object.freeze({ value: u64(rawCount.value, "count value"), exact: boolean(rawCount.exact, "count exact flag") });
199
256
  }
200
257
  return Object.freeze({
201
- items: Object.freeze(array(raw.items, "query items").map(decodeDocument)),
258
+ items: Object.freeze(array(raw.items, "query items").map(item => {
259
+ const distance = object(item, "document").distance;
260
+ if (distance === undefined) return decodeDocument(item);
261
+ const value = distance instanceof ExactDecimal ? distance.toNumber() : distance;
262
+ if (typeof value !== "number" || !Number.isFinite(value)) malformed("near distance");
263
+ return Object.freeze({ ...decodeDocument(item), distance: value });
264
+ })),
202
265
  nextCursor: typeof next === "string" && next !== "" ? next : null,
203
266
  count,
204
267
  });
@@ -231,18 +294,11 @@ function rejectIdempotency(options) {
231
294
  /** @param {JsonInputObject} document @returns {string} */
232
295
  function encodeDocument(document) {
233
296
  assertJsonObject(document, "document");
234
- assertNoPayloadId(document);
235
297
  const body = stringifyExactJson(document, "document");
236
298
  if (new TextEncoder().encode(body).byteLength > 1024 * 1024) throw new TypeError("A document is at most 1 MiB.");
237
299
  return body;
238
300
  }
239
301
 
240
- /** @param {unknown} document @returns {void} */
241
- function assertNoPayloadId(document) {
242
- if (typeof document === "object" && document !== null && Object.hasOwn(document, "id"))
243
- throw new TypeError("The document key is the entity ID; payloads must not contain an id field.");
244
- }
245
-
246
302
  /** A collection handle by name. It performs no I/O until an operation runs. */
247
303
  export class V1Collection {
248
304
  /** @type {V1Transport} */
@@ -362,6 +418,38 @@ export class V1Collection {
362
418
  return decodeQueryPage(response.value);
363
419
  }
364
420
 
421
+ /** @param {{ readonly limit?: number | undefined; readonly cursor?: string | undefined }} [request] @param {V1RequestOptions} [options] @returns {Promise<V1QueryPage>} */
422
+ async list(request = {}, options = {}) {
423
+ for (const key of Object.keys(request)) if (key !== "limit" && key !== "cursor") throw new TypeError(`Unknown document list member ${key}.`);
424
+ const query = new URLSearchParams();
425
+ if (request.limit !== undefined) {
426
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 1000) throw new TypeError("Document list limit is 1-1000.");
427
+ query.set("limit", String(request.limit));
428
+ }
429
+ if (request.cursor !== undefined) {
430
+ if (typeof request.cursor !== "string" || request.cursor === "") throw new TypeError("Document cursor is a non-empty string.");
431
+ query.set("cursor", request.cursor);
432
+ }
433
+ const response = await this.#transport.call("listDocuments", this.#params, { query, signal: options.signal });
434
+ return decodeQueryPage(response.value);
435
+ }
436
+
437
+ /** @param {V1RequestOptions} [options] */
438
+ getSchema(options = {}) { return getCollectionSchema(this.#transport, this.#params, options); }
439
+ /** @param {import("./json.js").JsonInputObject | boolean} schema @param {V1RequestOptions} [options] */
440
+ putSchema(schema, options = {}) { return putCollectionSchema(this.#transport, this.#params, schema, options); }
441
+ /** @param {V1RequestOptions} [options] */
442
+ deleteSchema(options = {}) { return deleteCollectionSchema(this.#transport, this.#params, options); }
443
+ /** @param {V1RequestOptions} [options] */
444
+ getTtl(options = {}) { return getCollectionTtl(this.#transport, this.#params, options); }
445
+ /**
446
+ * Documents whose member at `path` holds a past RFC 3339 UTC time are deleted by an hourly sweep (up to about an hour late).
447
+ * @param {string} path JSON Pointer such as `/expiresAt`. @param {V1RequestOptions} [options]
448
+ */
449
+ putTtl(path, options = {}) { return putCollectionTtl(this.#transport, this.#params, path, options); }
450
+ /** @param {V1RequestOptions} [options] */
451
+ deleteTtl(options = {}) { return deleteCollectionTtl(this.#transport, this.#params, options); }
452
+
365
453
  /**
366
454
  * Iterates over every page, following `nextCursor`. `count`
367
455
  * is sent with the first page only.
@@ -372,9 +460,13 @@ export class V1Collection {
372
460
  async *pages(query = {}, options = {}) {
373
461
  /** @type {V1Query} */
374
462
  let current = query;
463
+ const cursors = new Set(query.cursor == null ? [] : [query.cursor]);
375
464
  while (true) {
376
465
  const page = await this.query(current, options);
466
+ if (page.nextCursor !== null && cursors.has(page.nextCursor)) malformed("repeated pagination cursor");
467
+ if (page.nextCursor !== null) cursors.add(page.nextCursor);
377
468
  yield page;
469
+ options.signal?.throwIfAborted();
378
470
  if (page.nextCursor === null) return;
379
471
  const { count: _count, ...rest } = current;
380
472
  current = { ...rest, cursor: page.nextCursor };
@@ -388,17 +480,51 @@ export class V1Collection {
388
480
  * @returns {AsyncGenerator<V1Document, void, undefined>}
389
481
  */
390
482
  async *items(query = {}, options = {}) {
391
- for await (const page of this.pages(query, options)) yield* page.items;
483
+ for await (const page of this.pages(query, options)) {
484
+ for (const item of page.items) {
485
+ options.signal?.throwIfAborted();
486
+ yield item;
487
+ }
488
+ }
392
489
  }
393
490
  /**
394
491
  * Reads one C# shape snapshot page or change-log chunk. Bootstrap defaults to offset -1.
395
492
  * Echo handle, nextOffset and cursor on the following read; discard state on must_refetch.
493
+ * ifNoneMatch sends a complete entity-tag list or *; 304 throws not_modified with cache headers.
396
494
  * @param {V1ShapeRequest} [request]
397
- * @param {V1RequestOptions} [options]
495
+ * @param {V1RequestOptions & {readonly ifNoneMatch?: string}} [options]
398
496
  * @returns {Promise<V1ShapePage>}
399
497
  */
400
498
  shape(request = {}, options = {}) {
401
- return readShape(this.#transport, this.#params, request, options.signal);
499
+ return readShapePage(this.#transport, this.#params, request, options.signal, options.ifNoneMatch);
500
+ }
501
+
502
+ /**
503
+ * Subscribe to the free-read v1 protocol. Stop with close() or the request signal.
504
+ * @param {{where?: V1Filter | undefined}} [request]
505
+ * @param {import("./shape-subscription.js").V1ShapeSubscribeOptions} [options]
506
+ * @returns {V1ShapeSubscription}
507
+ */
508
+ subscribeShape(request = {}, options = {}) {
509
+ return new V1ShapeSubscription(this.#transport, this.#params, request, options);
510
+ }
511
+
512
+ /** @param {import("./shape-manifest.js").V1ShapeManifestRequest} request @param {V1RequestOptions} [options] */
513
+ shapeManifest(request, options = {}) { return readShapeManifest(this.#transport, this.#params, request, options.signal); }
514
+ /** @param {import("./shape-reader.js").V1ShapeKeyRequest} request @param {V1RequestOptions} [options] */
515
+ shapeKey(request, options = {}) { return readShapeKey(this.#transport, this.#params, request, options.signal); }
516
+
517
+
518
+ /**
519
+ * Verify a complete TBS1 object using its authenticated epoch key. Applies no state.
520
+ * Object delivery through manifests is pending the producer's FR-22 contract.
521
+ * @param {Uint8Array} bytes
522
+ * @param {import("./shape-reader.js").V1SealedShapePosition} position
523
+ * @param {V1RequestOptions} [options]
524
+ * @returns {Promise<V1ShapePage>}
525
+ */
526
+ decryptShapeObject(bytes, position, options = {}) {
527
+ return decryptSealedShapePage(this.#transport, this.#params, bytes, position, options.signal);
402
528
  }
403
529
 
404
530
  }
@@ -457,7 +583,7 @@ export class V1Transaction {
457
583
  static #precondition(precondition, allowNoneMatch) {
458
584
  if (precondition === undefined) return {};
459
585
  if ("ifMatch" in precondition && !("ifNoneMatchAny" in precondition))
460
- return { if_match: precondition.ifMatch === "*" ? "*" : requireVersion(precondition.ifMatch) };
586
+ return { if_match: precondition.ifMatch === "*" ? "*" : `"${requireVersion(precondition.ifMatch)}"` };
461
587
  if ("ifNoneMatchAny" in precondition && precondition.ifNoneMatchAny === true && !("ifMatch" in precondition)) {
462
588
  if (!allowNoneMatch) throw new TypeError("This op does not take ifNoneMatchAny.");
463
589
  return { if_none_match: "*" };
@@ -597,19 +723,21 @@ export class V1Transaction {
597
723
  return Object.freeze(result.count === undefined
598
724
  ? { asserted: /** @type {const} */ (true) }
599
725
  : { asserted: /** @type {const} */ (true), count: u64(result.count, "assert_count count") });
600
- return Object.freeze({ id: requireDocumentId(result.id), version: requireVersion(result.version) });
726
+ return Object.freeze({ id: requireDocumentId(result.id), version: requireVersion(result.version),
727
+ ...(result.document === undefined ? {} : { document: documentObject(result.document) }),
728
+ });
601
729
  })),
602
730
  });
603
731
  }
604
732
  }
605
733
 
606
- /** @typedef {{ readonly name: string; readonly createdAt: string }} V1CollectionInfo */
734
+ /** @typedef {{ readonly id: import("../ids.js").EntityId; readonly name: string; readonly createdAt: string }} V1CollectionInfo */
607
735
  /** @typedef {{ readonly items: readonly V1CollectionInfo[]; readonly nextCursor: string | null }} V1CollectionPage */
608
736
  /** @typedef {{ readonly limit?: number | undefined; readonly cursor?: string | undefined }} V1CollectionListOptions */
609
737
  /** @param {JsonValue | undefined} value @returns {V1CollectionInfo} */
610
- function decodeCollection(value) {
738
+ export function decodeCollection(value) {
611
739
  const raw = object(value, "collection");
612
- return Object.freeze({ name: requireName("collection", raw.name), createdAt: u64(raw.created_at, "collection created_at") });
740
+ return Object.freeze({ id: requireId(raw.id), name: requireName("collection", raw.name), createdAt: u64(raw.created_at, "collection created_at") });
613
741
  }
614
742
  /** Collection management on the public client listener. */
615
743
  export class V1Collections {
@@ -663,6 +791,16 @@ export class V1Collections {
663
791
 
664
792
  /** A database handle by name. It performs no I/O until an operation runs. */
665
793
  export class V1Database {
794
+ /** @type {V1Imports} */
795
+ imports;
796
+ /** @type {V1Backups} */
797
+ backups;
798
+ /** @type {V1DatabaseUsageClient} */
799
+ metrics;
800
+ /** @type {V1Indexes} */
801
+ indexes;
802
+ /** @type {V1DatabaseSchemaClient} */
803
+ schema;
666
804
  /** @type {V1Collections} */
667
805
  collections;
668
806
  /** @type {V1Transport} */
@@ -678,6 +816,11 @@ export class V1Database {
678
816
  this.#transport = transport;
679
817
  this.name = requireName("database", name);
680
818
  this.collections = new V1Collections(transport, this.name);
819
+ this.schema = new V1DatabaseSchemaClient(transport, this.name);
820
+ this.indexes = new V1Indexes(transport, this.name);
821
+ this.metrics = new V1DatabaseUsageClient(transport, this.name);
822
+ this.backups = new V1Backups(transport, this.name);
823
+ this.imports = new V1Imports(transport, this.name);
681
824
  Object.freeze(this);
682
825
  }
683
826
 
@@ -694,7 +837,33 @@ export class V1Database {
694
837
  return new V1Transaction(this.#transport, this.name);
695
838
  }
696
839
 
840
+ /** @param {string} statement @param {V1RequestOptions} [options] */
841
+ sql(statement, options = {}) {
842
+ return runSql(this.#transport, this.name, statement, options);
843
+ }
844
+
845
+ /** @param {string} statement @param {V1RequestOptions} [options] */
846
+ checkSql(statement, options = {}) {
847
+ return checkSql(this.#transport, this.name, statement, options);
848
+ }
849
+
850
+ /** @param {string} statement @param {V1RequestOptions} [options] */
851
+ explainSql(statement, options = {}) {
852
+ return checkSql(this.#transport, this.name, statement, options, "explainSql");
853
+ }
854
+
855
+ /** @param {import("./changes.js").V1ChangeStreamRequest} [request] @param {V1RequestOptions} [options] */
856
+ changes(request = {}, options = {}) {
857
+ return streamChanges(this.#transport, this.name, request, options.signal);
858
+ }
697
859
 
860
+ /**
861
+ * Streams every live document as `{ collection, id, document }`, in collection then id order, from one snapshot.
862
+ * @param {import("./export.js").V1ExportOptions} [options]
863
+ */
864
+ export(options = {}) {
865
+ return exportDocuments(this.#transport, this.name, options);
866
+ }
698
867
  }
699
868
 
700
869
  /**
@@ -702,12 +871,31 @@ export class V1Database {
702
871
  * privately; it is never serialized, logged or forwarded to redirects.
703
872
  */
704
873
  export class TerraBaseV1Client {
874
+ /** @type {V1Health} */
875
+ #health;
876
+ /** @type {V1Platform} */
877
+ #platform;
878
+ /** @type {V1BackupDestinationClient} */
879
+ #backupDestination;
880
+ /** @type {V1ProjectUsageClient} */
881
+ #metrics;
882
+ /** @type {V1SavedQueries} */
883
+ #savedQueries;
884
+ /** @type {V1Databases} */
885
+ #databases;
705
886
  /** @type {V1Transport} */
706
887
  #transport;
707
888
 
708
889
  /** @param {TerraBaseV1ClientConfig} config */
709
890
  constructor(config) {
891
+ requireId(config.project);
710
892
  this.#transport = new V1Transport(config);
893
+ this.#databases = new V1Databases(this.#transport);
894
+ this.#savedQueries = new V1SavedQueries(this.#transport);
895
+ this.#metrics = new V1ProjectUsageClient(this.#transport);
896
+ this.#backupDestination = new V1BackupDestinationClient(this.#transport);
897
+ this.#health = new V1Health(this.#transport);
898
+ this.#platform = new V1Platform(this.#transport);
711
899
  Object.freeze(this);
712
900
  }
713
901
 
@@ -718,10 +906,23 @@ export class TerraBaseV1Client {
718
906
  database(name) {
719
907
  return new V1Database(this.#transport, name);
720
908
  }
909
+
910
+ /** @returns {V1Databases} Project-scoped database catalog. */
911
+ get databases() { return this.#databases; }
912
+ /** @returns {V1SavedQueries} */
913
+ get savedQueries() { return this.#savedQueries; }
914
+ /** @returns {V1ProjectUsageClient} */
915
+ get metrics() { return this.#metrics; }
916
+ /** @returns {V1BackupDestinationClient} */
917
+ get backupDestination() { return this.#backupDestination; }
918
+ /** @returns {V1Health} */
919
+ get health() { return this.#health; }
920
+ /** @returns {V1Platform} */
921
+ get platform() { return this.#platform; }
721
922
  }
722
923
 
723
924
  /**
724
- * Creates a TerraBase v1 client for documents, transactions, queries and shapes.
925
+ * Creates a project-scoped TerraBase client for documents, SQL, changes and catalog operations.
725
926
  * @param {TerraBaseV1ClientConfig} config
726
927
  * @returns {TerraBaseV1Client}
727
928
  */