@terrascale/sdk 0.0.3 → 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 (64) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +177 -67
  3. package/package.json +10 -4
  4. package/sdk-public-v1-route-manifest.json +81 -0
  5. package/sdk-v1-route-manifest.json +896 -15
  6. package/src/http.js +3 -48
  7. package/src/index.js +75 -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 +210 -0
  12. package/src/v1/changes.js +225 -0
  13. package/src/v1/database-schema.js +45 -0
  14. package/src/v1/decode.js +5 -5
  15. package/src/v1/errors.js +23 -3
  16. package/src/v1/ids.js +5 -2
  17. package/src/v1/imports.js +72 -0
  18. package/src/v1/indexes.js +76 -0
  19. package/src/v1/json.js +4 -3
  20. package/src/v1/management-decode.js +24 -0
  21. package/src/v1/management-routes.js +67 -0
  22. package/src/v1/management-validation.js +25 -0
  23. package/src/v1/routes.js +32 -7
  24. package/src/v1/saved-queries.js +58 -0
  25. package/src/v1/shape-crypto.js +125 -0
  26. package/src/v1/shape-manifest.js +31 -0
  27. package/src/v1/shape-reader.js +212 -0
  28. package/src/v1/shape-subscription.js +208 -0
  29. package/src/v1/shapes.js +11 -5
  30. package/src/v1/sql.js +98 -0
  31. package/src/v1/system.js +80 -0
  32. package/src/v1/terrabase-v1.js +178 -27
  33. package/src/v1/transport.js +75 -31
  34. package/src/v1/usage.js +101 -0
  35. package/types/http.d.ts +2 -8
  36. package/types/index.d.ts +68 -0
  37. package/types/local/environment.d.ts +1 -1
  38. package/types/ts-auth.d.ts +0 -2
  39. package/types/v1/backups.d.ts +128 -0
  40. package/types/v1/catalog.d.ts +92 -0
  41. package/types/v1/changes.d.ts +53 -0
  42. package/types/v1/database-schema.d.ts +82 -0
  43. package/types/v1/decode.d.ts +15 -4
  44. package/types/v1/errors.d.ts +16 -1
  45. package/types/v1/ids.d.ts +2 -2
  46. package/types/v1/imports.d.ts +132 -0
  47. package/types/v1/indexes.d.ts +75 -0
  48. package/types/v1/management-decode.d.ts +15 -0
  49. package/types/v1/management-routes.d.ts +3 -0
  50. package/types/v1/management-validation.d.ts +8 -0
  51. package/types/v1/routes.d.ts +5 -2
  52. package/types/v1/saved-queries.d.ts +33 -0
  53. package/types/v1/shape-crypto.d.ts +39 -0
  54. package/types/v1/shape-manifest.d.ts +28 -0
  55. package/types/v1/shape-reader.d.ts +45 -0
  56. package/types/v1/shape-subscription.d.ts +35 -0
  57. package/types/v1/shapes.d.ts +16 -2
  58. package/types/v1/sql.d.ts +47 -0
  59. package/types/v1/system.d.ts +85 -0
  60. package/types/v1/terrabase-v1.d.ts +113 -4
  61. package/types/v1/transport.d.ts +25 -8
  62. package/types/v1/usage.d.ts +171 -0
  63. package/src/ts-auth-discovery.js +0 -207
  64. package/types/ts-auth-discovery.d.ts +0 -19
@@ -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
  /**
@@ -94,13 +108,14 @@ function pointer(path) {
94
108
  */
95
109
  function patchOp(op) {
96
110
  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
111
  let allowed = ["op", "path", "value"];
100
112
  switch (op.op) {
101
113
  case "unset": allowed = ["op", "path"]; break;
102
114
  case "inc": allowed = ["op", "path", "by"]; break;
103
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.");
104
119
  for (const key of Object.keys(op)) if (!allowed.includes(key)) throw new TypeError(`Unknown patch op member ${key}.`);
105
120
  switch (op.op) {
106
121
  case "set":
@@ -109,14 +124,30 @@ function patchOp(op) {
109
124
  return { op: op.op, path: pointer(op.path), value: op.value };
110
125
  case "unset":
111
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
+ }
112
138
  case "inc": {
113
139
  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 };
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 };
117
148
  }
118
149
  default:
119
- 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.");
120
151
  }
121
152
  }
122
153
 
@@ -135,7 +166,6 @@ function normalizePatch(patch) {
135
166
  if (hasMerge) {
136
167
  const merge = /** @type {{ readonly merge: JsonInputObject }} */ (patch).merge;
137
168
  assertJsonObject(merge, "merge patch");
138
- assertNoPayloadId(merge);
139
169
  return { merge };
140
170
  }
141
171
  const ops = /** @type {{ readonly ops: readonly V1PatchOp[] }} */ (patch).ops;
@@ -187,7 +217,7 @@ function encodeQuery(query) {
187
217
  * @param {JsonValue | undefined} value
188
218
  * @returns {V1QueryPage}
189
219
  */
190
- function decodeQueryPage(value) {
220
+ export function decodeQueryPage(value) {
191
221
  const raw = object(value, "query response");
192
222
  const next = raw.next_cursor;
193
223
  if (next !== null && (typeof next !== "string" || next === "")) malformed("next_cursor");
@@ -231,18 +261,11 @@ function rejectIdempotency(options) {
231
261
  /** @param {JsonInputObject} document @returns {string} */
232
262
  function encodeDocument(document) {
233
263
  assertJsonObject(document, "document");
234
- assertNoPayloadId(document);
235
264
  const body = stringifyExactJson(document, "document");
236
265
  if (new TextEncoder().encode(body).byteLength > 1024 * 1024) throw new TypeError("A document is at most 1 MiB.");
237
266
  return body;
238
267
  }
239
268
 
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
269
  /** A collection handle by name. It performs no I/O until an operation runs. */
247
270
  export class V1Collection {
248
271
  /** @type {V1Transport} */
@@ -362,6 +385,29 @@ export class V1Collection {
362
385
  return decodeQueryPage(response.value);
363
386
  }
364
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
+
365
411
  /**
366
412
  * Iterates over every page, following `nextCursor`. `count`
367
413
  * is sent with the first page only.
@@ -372,9 +418,13 @@ export class V1Collection {
372
418
  async *pages(query = {}, options = {}) {
373
419
  /** @type {V1Query} */
374
420
  let current = query;
421
+ const cursors = new Set(query.cursor == null ? [] : [query.cursor]);
375
422
  while (true) {
376
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);
377
426
  yield page;
427
+ options.signal?.throwIfAborted();
378
428
  if (page.nextCursor === null) return;
379
429
  const { count: _count, ...rest } = current;
380
430
  current = { ...rest, cursor: page.nextCursor };
@@ -388,17 +438,51 @@ export class V1Collection {
388
438
  * @returns {AsyncGenerator<V1Document, void, undefined>}
389
439
  */
390
440
  async *items(query = {}, options = {}) {
391
- 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
+ }
392
447
  }
393
448
  /**
394
449
  * Reads one C# shape snapshot page or change-log chunk. Bootstrap defaults to offset -1.
395
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.
396
452
  * @param {V1ShapeRequest} [request]
397
- * @param {V1RequestOptions} [options]
453
+ * @param {V1RequestOptions & {readonly ifNoneMatch?: string}} [options]
398
454
  * @returns {Promise<V1ShapePage>}
399
455
  */
400
456
  shape(request = {}, options = {}) {
401
- 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);
402
486
  }
403
487
 
404
488
  }
@@ -457,7 +541,7 @@ export class V1Transaction {
457
541
  static #precondition(precondition, allowNoneMatch) {
458
542
  if (precondition === undefined) return {};
459
543
  if ("ifMatch" in precondition && !("ifNoneMatchAny" in precondition))
460
- return { if_match: precondition.ifMatch === "*" ? "*" : requireVersion(precondition.ifMatch) };
544
+ return { if_match: precondition.ifMatch === "*" ? "*" : `"${requireVersion(precondition.ifMatch)}"` };
461
545
  if ("ifNoneMatchAny" in precondition && precondition.ifNoneMatchAny === true && !("ifMatch" in precondition)) {
462
546
  if (!allowNoneMatch) throw new TypeError("This op does not take ifNoneMatchAny.");
463
547
  return { if_none_match: "*" };
@@ -597,19 +681,21 @@ export class V1Transaction {
597
681
  return Object.freeze(result.count === undefined
598
682
  ? { asserted: /** @type {const} */ (true) }
599
683
  : { asserted: /** @type {const} */ (true), count: u64(result.count, "assert_count count") });
600
- 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
+ });
601
687
  })),
602
688
  });
603
689
  }
604
690
  }
605
691
 
606
- /** @typedef {{ readonly name: string; readonly createdAt: string }} V1CollectionInfo */
692
+ /** @typedef {{ readonly id: import("../ids.js").EntityId; readonly name: string; readonly createdAt: string }} V1CollectionInfo */
607
693
  /** @typedef {{ readonly items: readonly V1CollectionInfo[]; readonly nextCursor: string | null }} V1CollectionPage */
608
694
  /** @typedef {{ readonly limit?: number | undefined; readonly cursor?: string | undefined }} V1CollectionListOptions */
609
695
  /** @param {JsonValue | undefined} value @returns {V1CollectionInfo} */
610
- function decodeCollection(value) {
696
+ export function decodeCollection(value) {
611
697
  const raw = object(value, "collection");
612
- 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") });
613
699
  }
614
700
  /** Collection management on the public client listener. */
615
701
  export class V1Collections {
@@ -663,6 +749,16 @@ export class V1Collections {
663
749
 
664
750
  /** A database handle by name. It performs no I/O until an operation runs. */
665
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;
666
762
  /** @type {V1Collections} */
667
763
  collections;
668
764
  /** @type {V1Transport} */
@@ -678,6 +774,11 @@ export class V1Database {
678
774
  this.#transport = transport;
679
775
  this.name = requireName("database", name);
680
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);
681
782
  Object.freeze(this);
682
783
  }
683
784
 
@@ -694,7 +795,25 @@ export class V1Database {
694
795
  return new V1Transaction(this.#transport, this.name);
695
796
  }
696
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
+ }
697
807
 
808
+ /** @param {string} statement @param {V1RequestOptions} [options] */
809
+ explainSql(statement, options = {}) {
810
+ return checkSql(this.#transport, this.name, statement, options, "explainSql");
811
+ }
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
+ }
698
817
  }
699
818
 
700
819
  /**
@@ -702,12 +821,31 @@ export class V1Database {
702
821
  * privately; it is never serialized, logged or forwarded to redirects.
703
822
  */
704
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;
705
836
  /** @type {V1Transport} */
706
837
  #transport;
707
838
 
708
839
  /** @param {TerraBaseV1ClientConfig} config */
709
840
  constructor(config) {
841
+ requireId(config.project);
710
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);
711
849
  Object.freeze(this);
712
850
  }
713
851
 
@@ -718,10 +856,23 @@ export class TerraBaseV1Client {
718
856
  database(name) {
719
857
  return new V1Database(this.#transport, name);
720
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; }
721
872
  }
722
873
 
723
874
  /**
724
- * 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.
725
876
  * @param {TerraBaseV1ClientConfig} config
726
877
  * @returns {TerraBaseV1Client}
727
878
  */
@@ -16,11 +16,11 @@ export const maximumRequestBytes = 4 * 1024 * 1024;
16
16
  // Query budgets count stored JSON before wrappers and serializer escaping.
17
17
  const defaultMaximumResponseBytes = 64 * 1024 * 1024;
18
18
  // Validate header-safe credential syntax only; the producer validates JWT claims/signatures.
19
- // Retain C# keys while the published TS-589 JWT contract awaits a producer release.
19
+ // Retain C# keys; current project routes also authenticate project access JWTs.
20
20
  const tokenPattern = /^(?!tbk1\.)(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|tb_[A-Za-z0-9_-]{43})$/u;
21
21
  /**
22
22
  * Resolver input contains no credential. A successful URL is cached for this client/database.
23
- * @typedef {{ readonly project: string; readonly database: string; readonly signal?: AbortSignal | undefined }} V1EndpointContext
23
+ * @typedef {{ readonly project: string | undefined; readonly database?: string | undefined; readonly signal?: AbortSignal | undefined }} V1EndpointContext
24
24
  */
25
25
  /** @typedef {(context: V1EndpointContext) => string | Promise<string>} V1EndpointResolver */
26
26
  /**
@@ -37,11 +37,12 @@ const tokenPattern = /^(?!tbk1\.)(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-
37
37
 
38
38
  /**
39
39
  * @typedef {object} V1CallOptions
40
- * @property {string} [body]
40
+ * @property {string | Uint8Array} [body]
41
41
  * @property {string} [contentType]
42
42
  * @property {Readonly<Record<string, string>>} [headers] Contract headers only (If-Match, If-None-Match).
43
43
  * @property {URLSearchParams | undefined} [query]
44
44
  * @property {AbortSignal | undefined} [signal]
45
+ * @property {"no-store" | undefined} [cache] Authenticated shape epoch keys must bypass caches.
45
46
  */
46
47
 
47
48
  /**
@@ -127,29 +128,29 @@ export class BodyTransportError extends Error {
127
128
 
128
129
  /** Internal transport bound to one project credential. */
129
130
  export class V1Transport {
130
- /** @type {string} */
131
+ /** @type {string | undefined} */
131
132
  #project;
132
133
  /** @type {V1EndpointResolver} */
133
134
  #resolver;
134
135
  /** @type {Map<string, { promise: Promise<string>; timer: ReturnType<typeof setTimeout>; waiters: number }>} */
135
136
  #bases = new Map();
136
- /** @type {string} */
137
+ /** @type {string | undefined} */
137
138
  #token;
138
139
  /** @type {FetchLike} */
139
140
  #fetch;
140
141
  /** @type {number} */
141
142
  #maxResponseBytes;
142
143
 
143
- /** @param {TerraBaseV1ClientConfig} config */
144
+ /** @param {TerraBaseV1ClientConfig | (import("./system.js").TerraBaseSystemClientConfig & { project?: undefined; endpointResolver?: undefined })} config */
144
145
  constructor(config) {
145
146
  if (typeof config !== "object" || config === null) throw new TypeError("TerraBase v1 client config is required.");
146
- this.#project = requireProject(config.project);
147
+ this.#project = config.project === undefined ? undefined : requireProject(config.project);
147
148
  if ((config.baseUrl === undefined) === (config.endpointResolver === undefined))
148
149
  throw new TypeError("Supply exactly one of baseUrl or endpointResolver.");
149
150
  if (config.endpointResolver !== undefined && typeof config.endpointResolver !== "function")
150
151
  throw new TypeError("endpointResolver must be a function.");
151
152
  this.#resolver = config.endpointResolver ?? staticBaseUrlResolver(/** @type {string} */ (config.baseUrl));
152
- if (typeof config.token !== "string" || !tokenPattern.test(config.token))
153
+ if ((config.project !== undefined || config.token !== undefined) && (typeof config.token !== "string" || !tokenPattern.test(config.token)))
153
154
  throw new TypeError("token must be a compact access JWT or a legacy C# tb_ API key.");
154
155
  if ("autoIdempotencyKey" in config || "maxRetries" in config || "resolver" in config)
155
156
  throw new TypeError("Unsupported client configuration member.");
@@ -171,8 +172,9 @@ export class V1Transport {
171
172
  */
172
173
  async url(operation, params, signal) {
173
174
  signal?.throwIfAborted();
174
- const database = requireName("database", params.database);
175
- let base = this.#bases.get(database);
175
+ const database = v1Route(operation).pathTemplate.includes("{database}") ? requireName("database", params.database) : undefined;
176
+ const session = database ?? "";
177
+ let base = this.#bases.get(session);
176
178
  if (base === undefined) {
177
179
  const controller = new AbortController();
178
180
  const timer = setTimeout(() => controller.abort(new DOMException(
@@ -182,13 +184,13 @@ export class V1Transport {
182
184
  const promise = new Promise((resolve, reject) => {
183
185
  controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true });
184
186
  void Promise.resolve().then(() => this.#resolver(Object.freeze({
185
- project: this.#project, database, signal: controller.signal,
187
+ project: this.#project, ...(database === undefined ? {} : { database }), signal: controller.signal,
186
188
  }))).then(canonicalBaseUrl).then(resolve, reject).finally(() => clearTimeout(timer));
187
189
  });
188
190
  base = { promise, timer, waiters: 0 };
189
- this.#bases.set(database, base);
191
+ this.#bases.set(session, base);
190
192
  // A failed resolution is not a session; a later operation may resolve again.
191
- void promise.catch(() => { if (this.#bases.get(database) === base) this.#bases.delete(database); });
193
+ void promise.catch(() => { if (this.#bases.get(session) === base) this.#bases.delete(session); });
192
194
  }
193
195
  base.waiters++;
194
196
  if (typeof base.timer === "object") base.timer.ref();
@@ -206,7 +208,8 @@ export class V1Transport {
206
208
  });
207
209
  });
208
210
  signal?.throwIfAborted();
209
- return `${resolved}${v1RoutePath(v1Route(operation), { ...params, project: this.#project })}`;
211
+ if (v1Route(operation).pathTemplate.includes("{project}") && this.#project === undefined) throw new TypeError("This operation requires a project client.");
212
+ return `${resolved}${v1RoutePath(v1Route(operation), { ...params, ...(this.#project === undefined ? {} : { project: this.#project }) })}`;
210
213
  } finally {
211
214
  // Keep Node alive only while callers are waiting for this resolution.
212
215
  if (--base.waiters === 0 && typeof base.timer === "object") base.timer.unref();
@@ -217,9 +220,11 @@ export class V1Transport {
217
220
  * Reads a response body as UTF-8 text within the response byte bound. A
218
221
  * connection failure while reading is a BodyTransportError.
219
222
  * @param {Response} response
223
+ * @param {AbortSignal | undefined} [signal]
220
224
  * @returns {Promise<string>}
221
225
  */
222
- async readText(response) {
226
+ async readText(response, signal) {
227
+ signal?.throwIfAborted();
223
228
  const reader = response.body?.getReader();
224
229
  if (reader === undefined) return "";
225
230
  /** @type {Uint8Array[]} */
@@ -227,13 +232,16 @@ export class V1Transport {
227
232
  let size = 0;
228
233
  try {
229
234
  while (true) {
235
+ signal?.throwIfAborted();
230
236
  /** @type {ReadableStreamReadResult<Uint8Array>} */
231
237
  let part;
232
238
  try {
233
239
  part = await reader.read();
234
240
  } catch (error) {
241
+ signal?.throwIfAborted();
235
242
  throw new BodyTransportError(error);
236
243
  }
244
+ signal?.throwIfAborted();
237
245
  if (part.done) break;
238
246
  size += part.value.byteLength;
239
247
  if (size > this.#maxResponseBytes)
@@ -252,16 +260,17 @@ export class V1Transport {
252
260
  bytes.set(part, offset);
253
261
  offset += part.byteLength;
254
262
  }
255
- return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
263
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
256
264
  }
257
265
 
258
266
  /**
259
267
  * Parses a JSON response body exactly.
260
268
  * @param {Response} response
269
+ * @param {AbortSignal | undefined} [signal]
261
270
  * @returns {Promise<JsonValue | undefined>}
262
271
  */
263
- async readJson(response) {
264
- return V1Transport.#decodeJson(response, await this.readText(response));
272
+ async readJson(response, signal) {
273
+ return V1Transport.#decodeJson(response, await this.readText(response, signal));
265
274
  }
266
275
 
267
276
  /**
@@ -280,13 +289,15 @@ export class V1Transport {
280
289
  /**
281
290
  * Converts a non-success response to a TerraBaseV1Error.
282
291
  * @param {Response} response
292
+ * @param {AbortSignal | undefined} [signal]
283
293
  * @returns {Promise<TerraBaseV1Error>}
284
294
  */
285
- async error(response) {
295
+ async error(response, signal) {
286
296
  let text = "";
287
297
  try {
288
- text = await this.readText(response);
298
+ text = await this.readText(response, signal);
289
299
  } catch {
300
+ signal?.throwIfAborted();
290
301
  text = "";
291
302
  }
292
303
  return parseV1Error(response.status, response.headers, text);
@@ -300,27 +311,60 @@ export class V1Transport {
300
311
  * @returns {Promise<V1CallResult>}
301
312
  */
302
313
  async call(operation, params, options = {}) {
314
+ const response = await this.#send(operation, params, options);
315
+ if (response.status === 307) {
316
+ await response.body?.cancel();
317
+ return { status: response.status, headers: response.headers, value: undefined };
318
+ }
319
+ return { status: response.status, headers: response.headers, value: await this.readJson(response, options.signal) };
320
+ }
321
+
322
+ /**
323
+ * @param {"streamDatabaseChanges"} operation
324
+ * @param {Readonly<Record<string, string>>} params
325
+ * @param {V1CallOptions} [options]
326
+ * @returns {Promise<Response>}
327
+ */
328
+ async openStream(operation, params, options = {}) {
329
+ const response = await this.#send(operation, params, options);
330
+ const mediaType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
331
+ if (mediaType !== "text/event-stream" || response.body === null) {
332
+ void response.body?.cancel().catch(() => undefined);
333
+ throw new TypeError("TerraBase changes require a text/event-stream response body.");
334
+ }
335
+ return response;
336
+ }
337
+
338
+ /** @param {V1Operation} operation @param {Readonly<Record<string, string>>} params @param {V1CallOptions} options @returns {Promise<Response>} */
339
+ async #send(operation, params, options) {
303
340
  const route = v1Route(operation);
304
- if (options.body !== undefined && new TextEncoder().encode(options.body).byteLength > maximumRequestBytes)
341
+ const bytes = typeof options.body === "string" ? new TextEncoder().encode(options.body) : options.body;
342
+ if (bytes !== undefined && bytes.byteLength > maximumRequestBytes)
305
343
  throw new TypeError(`TerraBase request bodies are limited to ${maximumRequestBytes} bytes.`);
306
344
  let url = await this.url(operation, params, options.signal);
307
345
  if (options.query !== undefined && options.query.size > 0) url += `?${options.query}`;
308
346
  const headers = new Headers(options.headers);
309
- headers.set("authorization", `Bearer ${this.#token}`);
310
- headers.set("accept", route.responseMediaType);
347
+ if (route.permission !== "anonymous" && this.#token !== undefined) headers.set("authorization", `Bearer ${this.#token}`);
348
+ if (route.responseMediaType !== "") headers.set("accept", route.responseMediaType);
311
349
  if (options.body !== undefined) headers.set("content-type", options.contentType ?? "application/json");
312
- /** @type {RequestInit} */
350
+ /** @type {RequestInit & { duplex?: "half" }} */
313
351
  const init = { method: route.method, headers, redirect: "manual" };
314
- if (options.body !== undefined) init.body = options.body;
352
+ if (options.cache !== undefined) init.cache = options.cache;
353
+ if (route.permission.endsWith(":write") && typeof process !== "undefined" && process.release?.name === "node") {
354
+ // Node fetch replays buffered or absent bodies on 421; use a one-shot stream, including DELETE.
355
+ if (operation === "uploadImportSource" && bytes !== undefined) headers.set("content-length", String(bytes.byteLength));
356
+ init.body = new ReadableStream({ start(controller) { if (bytes !== undefined && bytes.byteLength > 0) controller.enqueue(bytes); controller.close(); } });
357
+ init.duplex = "half";
358
+ } else if (options.body !== undefined) init.body = typeof options.body === "string" ? options.body : options.body.slice().buffer;
315
359
  if (options.signal !== undefined) init.signal = options.signal;
316
360
  const response = await this.#fetch(url, init);
361
+ if (options.signal?.aborted) {
362
+ void response.body?.cancel().catch(() => undefined);
363
+ options.signal.throwIfAborted();
364
+ }
317
365
  if (response.type === "opaqueredirect")
318
366
  throw new TypeError("Fresh shape bootstrap requires server fetch; obtain a handle and snapshot offset with collection.shape() on your server.");
319
- if (!route.successStatuses.includes(response.status)) throw await this.error(response);
320
- if (response.status === 307) {
321
- await response.body?.cancel();
322
- return { status: response.status, headers: response.headers, value: undefined };
323
- }
324
- return { status: response.status, headers: response.headers, value: await this.readJson(response) };
367
+ if (!route.successStatuses.includes(response.status)) throw await this.error(response, options.signal);
368
+ return response;
325
369
  }
326
370
  }