@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.
- package/CHANGELOG.md +17 -0
- package/README.md +177 -67
- package/package.json +10 -4
- package/sdk-public-v1-route-manifest.json +81 -0
- package/sdk-v1-route-manifest.json +896 -15
- package/src/http.js +3 -48
- package/src/index.js +75 -1
- package/src/local/environment.js +0 -1
- package/src/ts-auth.js +1 -5
- package/src/v1/backups.js +111 -0
- package/src/v1/catalog.js +210 -0
- package/src/v1/changes.js +225 -0
- package/src/v1/database-schema.js +45 -0
- package/src/v1/decode.js +5 -5
- package/src/v1/errors.js +23 -3
- package/src/v1/ids.js +5 -2
- package/src/v1/imports.js +72 -0
- package/src/v1/indexes.js +76 -0
- package/src/v1/json.js +4 -3
- package/src/v1/management-decode.js +24 -0
- package/src/v1/management-routes.js +67 -0
- package/src/v1/management-validation.js +25 -0
- package/src/v1/routes.js +32 -7
- package/src/v1/saved-queries.js +58 -0
- package/src/v1/shape-crypto.js +125 -0
- package/src/v1/shape-manifest.js +31 -0
- package/src/v1/shape-reader.js +212 -0
- package/src/v1/shape-subscription.js +208 -0
- package/src/v1/shapes.js +11 -5
- package/src/v1/sql.js +98 -0
- package/src/v1/system.js +80 -0
- package/src/v1/terrabase-v1.js +178 -27
- package/src/v1/transport.js +75 -31
- package/src/v1/usage.js +101 -0
- package/types/http.d.ts +2 -8
- package/types/index.d.ts +68 -0
- package/types/local/environment.d.ts +1 -1
- package/types/ts-auth.d.ts +0 -2
- package/types/v1/backups.d.ts +128 -0
- package/types/v1/catalog.d.ts +92 -0
- package/types/v1/changes.d.ts +53 -0
- package/types/v1/database-schema.d.ts +82 -0
- package/types/v1/decode.d.ts +15 -4
- package/types/v1/errors.d.ts +16 -1
- package/types/v1/ids.d.ts +2 -2
- package/types/v1/imports.d.ts +132 -0
- package/types/v1/indexes.d.ts +75 -0
- package/types/v1/management-decode.d.ts +15 -0
- package/types/v1/management-routes.d.ts +3 -0
- package/types/v1/management-validation.d.ts +8 -0
- package/types/v1/routes.d.ts +5 -2
- package/types/v1/saved-queries.d.ts +33 -0
- package/types/v1/shape-crypto.d.ts +39 -0
- package/types/v1/shape-manifest.d.ts +28 -0
- package/types/v1/shape-reader.d.ts +45 -0
- package/types/v1/shape-subscription.d.ts +35 -0
- package/types/v1/shapes.d.ts +16 -2
- package/types/v1/sql.d.ts +47 -0
- package/types/v1/system.d.ts +85 -0
- package/types/v1/terrabase-v1.d.ts +113 -4
- package/types/v1/transport.d.ts +25 -8
- package/types/v1/usage.d.ts +171 -0
- package/src/ts-auth-discovery.js +0 -207
- package/types/ts-auth-discovery.d.ts +0 -19
package/src/v1/terrabase-v1.js
CHANGED
|
@@ -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 {
|
|
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.
|
|
115
|
-
throw new TypeError("inc
|
|
116
|
-
|
|
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
|
|
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))
|
|
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
|
|
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
|
|
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
|
*/
|
package/src/v1/transport.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|