terrascale 0.3.0 → 1.0.0

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.
@@ -0,0 +1,722 @@
1
+ // The TerraBase v1 client: documents, transactions, queries and shapes over
2
+ // the project-scoped C# Documents API (decision 0004 and shape-reads.md).
3
+ import {
4
+ array, boolean, decodeDocument, decodeWrite, durability, malformed, object, u64,
5
+ } from "./decode.js";
6
+ import { isJsonPointer, requireFilter, requirePath } from "./filter.js";
7
+ import { requireDocumentId, requireName, requireVersion } from "./ids.js";
8
+ import { assertJsonObject, assertJsonValue, ExactDecimal, stringifyExactJson } from "./json.js";
9
+ import { readShape } from "./shapes.js";
10
+ import { V1Transport } from "./transport.js";
11
+ /**
12
+ * @import { V1Document, V1Durability, V1WriteResult } from "./decode.js"
13
+ * @import { V1Filter, V1Path } from "./filter.js"
14
+ * @import { JsonInput, JsonInputObject, JsonValue } from "./json.js"
15
+ * @import { V1ShapePage, V1ShapeRequest } from "./shapes.js"
16
+ * @import { TerraBaseV1ClientConfig } from "./transport.js"
17
+ */
18
+
19
+ /** @typedef {{ readonly signal?: AbortSignal | undefined }} V1RequestOptions */
20
+ /**
21
+ * @typedef {object} V1ReadOptions
22
+ * @property {string | undefined} [ifNoneMatch] A version; an unchanged document fails with `not_modified` (304).
23
+ * @property {AbortSignal | undefined} [signal]
24
+ */
25
+ /**
26
+ * @typedef {object} V1WriteOptions
27
+ * @property {string | undefined} [ifMatch] Require the current version, or "*" for a live document.
28
+ * @property {boolean | undefined} [ifNoneMatchAny] Require the document to be absent or deleted (`If-None-Match: *`).
29
+ * @property {AbortSignal | undefined} [signal]
30
+ */
31
+ /** @typedef {V1WriteOptions} V1CreateOptions */
32
+ /** @typedef {{ readonly ifMatch?: string | undefined; readonly signal?: AbortSignal | undefined }} V1ConditionalWriteOptions */
33
+
34
+ /**
35
+ * One operation of an `application/vnd.terrabase.ops+json` patch. Paths are
36
+ * RFC 6901 JSON Pointers without array indices.
37
+ * @typedef {{ readonly op: "set"; readonly path: string; readonly value: JsonInput }
38
+ * | { readonly op: "unset"; readonly path: string }
39
+ * | { readonly op: "inc"; readonly path: string; readonly by: number | bigint | ExactDecimal }
40
+ * | { readonly op: "append"; readonly path: string; readonly value: JsonInput }} V1PatchOp
41
+ */
42
+ /**
43
+ * A patch: an RFC 7396 merge patch (`null` removes members) or 1-256 ops.
44
+ * @typedef {{ readonly merge: JsonInputObject } | { readonly ops: readonly V1PatchOp[] }} V1Patch
45
+ */
46
+
47
+ /** @typedef {{ readonly path: V1Path; readonly order?: "asc" | "desc" | undefined }} V1SortKey */
48
+ /**
49
+ * A query-v1 request. Every member is optional.
50
+ * @typedef {object} V1Query
51
+ * @property {V1Filter | null | undefined} [filter]
52
+ * @property {readonly V1SortKey[] | undefined} [sort] At most 4 keys; results are finally ordered by `$id`.
53
+ * @property {readonly string[] | undefined} [project] At most 64 paths.
54
+ * @property {number | undefined} [limit] 1-1000 (server default 50).
55
+ * @property {string | null | undefined} [cursor] A previous page's `nextCursor`.
56
+ * @property {boolean | undefined} [count] Count every match (first page only).
57
+ */
58
+ /** @typedef {{ readonly value: string; readonly exact: boolean }} V1Count */
59
+ /**
60
+ * @typedef {object} V1QueryPage
61
+ * @property {readonly V1Document[]} items Documents in query order.
62
+ * @property {string | null} nextCursor
63
+ * @property {V1Count | undefined} count
64
+ */
65
+
66
+ /** @typedef {{ readonly ifMatch: string } | { readonly ifNoneMatchAny: true }} V1Precondition */
67
+ /**
68
+ * One op's result: `id` and `version` for writes, `asserted` for asserts, and
69
+ * also `count` (decimal u64) for `assertCount`.
70
+ * @typedef {{ readonly id: string; readonly version: string } | { readonly asserted: true; readonly count?: string }} V1TransactionOpResult
71
+ */
72
+ /** @typedef {{ readonly gte?: number | undefined; readonly lte?: number | undefined }} V1CountBounds */
73
+ /**
74
+ * @typedef {object} V1TransactionResult
75
+ * @property {string} offset
76
+ * @property {V1Durability} durability
77
+ * @property {readonly V1TransactionOpResult[]} results One per op, in order.
78
+ */
79
+
80
+ const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count"]);
81
+
82
+ /**
83
+ * @param {string} path
84
+ * @returns {string}
85
+ */
86
+ function pointer(path) {
87
+ if (!isJsonPointer(path)) throw new TypeError("Paths are RFC 6901 JSON Pointers (only ~0 and ~1 escapes).");
88
+ return path;
89
+ }
90
+
91
+ /**
92
+ * @param {V1PatchOp} op
93
+ * @returns {Record<string, JsonInput>}
94
+ */
95
+ function patchOp(op) {
96
+ if (typeof op !== "object" || op === null || Array.isArray(op)) throw new TypeError("A patch op is an object.");
97
+ let allowed = ["op", "path", "value"];
98
+ switch (op.op) {
99
+ case "unset": allowed = ["op", "path"]; break;
100
+ case "inc": allowed = ["op", "path", "by"]; break;
101
+ }
102
+ for (const key of Object.keys(op)) if (!allowed.includes(key)) throw new TypeError(`Unknown patch op member ${key}.`);
103
+ switch (op.op) {
104
+ case "set":
105
+ case "append":
106
+ assertJsonValue(op.value, "patch value");
107
+ return { op: op.op, path: pointer(op.path), value: op.value };
108
+ case "unset":
109
+ return { op: "unset", path: pointer(op.path) };
110
+ case "inc": {
111
+ const by = op.by;
112
+ if (!(typeof by === "bigint" || by instanceof ExactDecimal || (typeof by === "number" && Number.isFinite(by))))
113
+ throw new TypeError("inc takes a finite number, bigint or ExactDecimal.");
114
+ return { op: "inc", path: pointer(op.path), by };
115
+ }
116
+ default:
117
+ throw new TypeError("Patch ops are set, unset, inc or append.");
118
+ }
119
+ }
120
+
121
+ /**
122
+ * @param {V1Patch} patch
123
+ * @returns {{ merge: JsonInputObject } | { ops: Record<string, JsonInput>[] }}
124
+ */
125
+ function normalizePatch(patch) {
126
+ if (typeof patch !== "object" || patch === null || Array.isArray(patch)) throw new TypeError("A patch is { merge } or { ops }.");
127
+ for (const key of Object.keys(patch)) if (key !== "merge" && key !== "ops") throw new TypeError(`Unknown patch member ${key}.`);
128
+ const hasMerge = Object.hasOwn(patch, "merge");
129
+ const hasOps = Object.hasOwn(patch, "ops");
130
+ if (hasMerge === hasOps) throw new TypeError("A patch has exactly one of merge or ops.");
131
+ // Branch on the same own-property test that was validated, never on an
132
+ // inherited member.
133
+ if (hasMerge) {
134
+ const merge = /** @type {{ readonly merge: JsonInputObject }} */ (patch).merge;
135
+ assertJsonObject(merge, "merge patch");
136
+ return { merge };
137
+ }
138
+ const ops = /** @type {{ readonly ops: readonly V1PatchOp[] }} */ (patch).ops;
139
+ if (!Array.isArray(ops) || ops.length < 1 || ops.length > 256)
140
+ throw new TypeError("An ops patch has 1-256 ops.");
141
+ return { ops: ops.map(patchOp) };
142
+ }
143
+
144
+ /**
145
+ * @param {V1Query} query
146
+ * @returns {string}
147
+ */
148
+ function encodeQuery(query) {
149
+ if (typeof query !== "object" || query === null || Array.isArray(query)) throw new TypeError("A query is an object.");
150
+ for (const key of Object.keys(query))
151
+ if (!queryMembers.has(key)) throw new TypeError(`Unknown query member ${key}.`);
152
+ /** @type {Record<string, unknown>} */
153
+ const body = {};
154
+ if (query.filter !== undefined) body.filter = query.filter === null ? null : requireFilter(query.filter);
155
+ if (query.sort !== undefined) {
156
+ if (!Array.isArray(query.sort) || query.sort.length > 4) throw new TypeError("sort has at most 4 keys.");
157
+ body.sort = query.sort.map((key) => {
158
+ if (key === null || typeof key !== "object") throw new TypeError("Sort keys are objects.");
159
+ for (const member of Object.keys(key)) if (member !== "path" && member !== "order") throw new TypeError(`Unknown sort member ${member}.`);
160
+ if (key.order !== undefined && key.order !== "asc" && key.order !== "desc") throw new TypeError('Sort order is "asc" or "desc".');
161
+ return { path: requirePath(key.path), ...(key.order === undefined ? {} : { order: key.order }) };
162
+ });
163
+ }
164
+ if (query.project !== undefined) {
165
+ if (!Array.isArray(query.project) || query.project.length > 64) throw new TypeError("project lists at most 64 paths.");
166
+ body.project = query.project.map(requirePath);
167
+ }
168
+ if (query.limit !== undefined) {
169
+ if (!Number.isInteger(query.limit) || query.limit < 1 || query.limit > 1000) throw new TypeError("limit is 1-1000.");
170
+ body.limit = query.limit;
171
+ }
172
+ if (query.cursor !== undefined) {
173
+ if (query.cursor !== null && (typeof query.cursor !== "string" || query.cursor === "")) throw new TypeError("cursor is a string or null.");
174
+ body.cursor = query.cursor;
175
+ }
176
+ if (query.count !== undefined) {
177
+ if (typeof query.count !== "boolean") throw new TypeError("count is a boolean.");
178
+ body.count = query.count;
179
+ }
180
+ return stringifyExactJson(body, "query");
181
+ }
182
+
183
+ /**
184
+ * @param {JsonValue | undefined} value
185
+ * @returns {V1QueryPage}
186
+ */
187
+ function decodeQueryPage(value) {
188
+ const raw = object(value, "query response");
189
+ const next = raw.next_cursor;
190
+ if (next !== null && (typeof next !== "string" || next === "")) malformed("next_cursor");
191
+ /** @type {V1Count | undefined} */
192
+ let count;
193
+ if (raw.count !== undefined) {
194
+ const rawCount = object(raw.count, "count");
195
+ count = Object.freeze({ value: u64(rawCount.value, "count value"), exact: boolean(rawCount.exact, "count exact flag") });
196
+ }
197
+ return Object.freeze({
198
+ items: Object.freeze(array(raw.items, "query items").map(decodeDocument)),
199
+ nextCursor: typeof next === "string" && next !== "" ? next : null,
200
+ count,
201
+ });
202
+ }
203
+
204
+ /**
205
+ * @param {{ readonly ifMatch?: string | undefined; readonly ifNoneMatchAny?: boolean | undefined }} options
206
+ * @param {boolean} allowNoneMatch
207
+ * @returns {Record<string, string>}
208
+ */
209
+ function preconditionHeaders(options, allowNoneMatch) {
210
+ /** @type {Record<string, string>} */
211
+ const headers = {};
212
+ rejectIdempotency(options);
213
+ if (options.ifNoneMatchAny !== undefined && typeof options.ifNoneMatchAny !== "boolean") throw new TypeError("ifNoneMatchAny is a boolean.");
214
+ if (options.ifMatch !== undefined && options.ifNoneMatchAny === true)
215
+ throw new TypeError("ifMatch and ifNoneMatchAny are mutually exclusive.");
216
+ if (options.ifMatch !== undefined) headers["if-match"] = options.ifMatch === "*" ? "*" : `"${requireVersion(options.ifMatch)}"`;
217
+ if (options.ifNoneMatchAny === true) {
218
+ if (!allowNoneMatch) throw new TypeError("This operation does not take ifNoneMatchAny.");
219
+ headers["if-none-match"] = "*";
220
+ }
221
+ return headers;
222
+ }
223
+
224
+ /** @param {object} options @returns {void} */
225
+ function rejectIdempotency(options) {
226
+ if ("idempotencyKey" in options) throw new TypeError("The current producer does not support Idempotency-Key; mutations are never automatically retried.");
227
+ }
228
+ /** @param {JsonInputObject} document @returns {string} */
229
+ function encodeDocument(document) {
230
+ assertJsonObject(document, "document");
231
+ const body = stringifyExactJson(document, "document");
232
+ if (new TextEncoder().encode(body).byteLength > 1024 * 1024) throw new TypeError("A document is at most 1 MiB.");
233
+ return body;
234
+ }
235
+
236
+ /** A collection handle by name. It performs no I/O until an operation runs. */
237
+ export class V1Collection {
238
+ /** @type {V1Transport} */
239
+ #transport;
240
+ /** @type {Readonly<Record<string, string>>} */
241
+ #params;
242
+ /** @type {string} */
243
+ name;
244
+ /** @type {string} */
245
+ database;
246
+
247
+ /**
248
+ * @param {V1Transport} transport
249
+ * @param {string} database
250
+ * @param {string} name
251
+ */
252
+ constructor(transport, database, name) {
253
+ this.#transport = transport;
254
+ this.database = requireName("database", database);
255
+ this.name = requireName("collection", name);
256
+ this.#params = Object.freeze({ database: this.database, collection: this.name });
257
+ Object.freeze(this);
258
+ }
259
+
260
+ /**
261
+ * @param {string} id
262
+ * @returns {Readonly<Record<string, string>>}
263
+ */
264
+ #document(id) {
265
+ return { ...this.#params, id: requireDocumentId(id) };
266
+ }
267
+
268
+ /**
269
+ * Reads a document. Absent and deleted documents fail with
270
+ * `not_found`; a deleted document's error carries its tombstone `etag`.
271
+ * @param {string} id
272
+ * @param {V1ReadOptions} [options]
273
+ * @returns {Promise<V1Document>}
274
+ */
275
+ async get(id, options = {}) {
276
+ /** @type {Record<string, string>} */
277
+ const headers = {};
278
+ if (options.ifNoneMatch !== undefined) headers["if-none-match"] = `"${requireVersion(options.ifNoneMatch)}"`;
279
+ const response = await this.#transport.call("getDocument", this.#document(id), { headers, signal: options.signal });
280
+ return decodeDocument(response.value);
281
+ }
282
+
283
+ /**
284
+ * Writes the complete document. `created` reports a 201.
285
+ * @param {string} id
286
+ * @param {JsonInputObject} document
287
+ * @param {V1WriteOptions} [options]
288
+ * @returns {Promise<V1WriteResult>}
289
+ */
290
+ async put(id, document, options = {}) {
291
+ const params = this.#document(id);
292
+ const headers = preconditionHeaders(options, true);
293
+ return decodeWrite(await this.#transport.call("putDocument", params, {
294
+ body: encodeDocument(document), headers, signal: options.signal,
295
+ }));
296
+ }
297
+
298
+ /**
299
+ * Creates a document under a server-generated UUIDv7.
300
+ * @param {JsonInputObject} document
301
+ * @param {V1CreateOptions} [options]
302
+ * @returns {Promise<V1WriteResult>}
303
+ */
304
+ async create(document, options = {}) {
305
+ return decodeWrite(await this.#transport.call("createDocument", this.#params, {
306
+ body: encodeDocument(document), headers: preconditionHeaders(options, true), signal: options.signal,
307
+ }));
308
+ }
309
+
310
+ /**
311
+ * Applies a merge patch or ops patch to an existing document.
312
+ * @param {string} id
313
+ * @param {V1Patch} patch
314
+ * @param {V1ConditionalWriteOptions} [options]
315
+ * @returns {Promise<V1WriteResult>}
316
+ */
317
+ async patch(id, patch, options = {}) {
318
+ const params = this.#document(id);
319
+ const headers = preconditionHeaders(options, false);
320
+ const normalized = normalizePatch(patch);
321
+ const [contentType, body] = "merge" in normalized
322
+ ? ["application/merge-patch+json", stringifyExactJson(normalized.merge, "merge patch")]
323
+ : ["application/vnd.terrabase.ops+json", stringifyExactJson({ ops: normalized.ops }, "patch ops")];
324
+ return decodeWrite(await this.#transport.call("patchDocument", params, {
325
+ body, contentType, headers,
326
+ signal: options.signal,
327
+ }));
328
+ }
329
+
330
+ /**
331
+ * Tombstones a document; `version` is the tombstone's.
332
+ * @param {string} id
333
+ * @param {V1ConditionalWriteOptions} [options]
334
+ * @returns {Promise<V1WriteResult>}
335
+ */
336
+ async delete(id, options = {}) {
337
+ const params = this.#document(id);
338
+ const headers = preconditionHeaders(options, false);
339
+ return decodeWrite(await this.#transport.call("deleteDocument", params, {
340
+ headers, signal: options.signal,
341
+ }));
342
+ }
343
+
344
+ /**
345
+ * Runs one page of a query.
346
+ * @param {V1Query} [query]
347
+ * @param {V1RequestOptions} [options]
348
+ * @returns {Promise<V1QueryPage>}
349
+ */
350
+ async query(query = {}, options = {}) {
351
+ const response = await this.#transport.call("queryDocuments", this.#params, {
352
+ body: encodeQuery(query), signal: options.signal,
353
+ });
354
+ return decodeQueryPage(response.value);
355
+ }
356
+
357
+ /**
358
+ * Iterates over every page, following `nextCursor`. `count`
359
+ * is sent with the first page only.
360
+ * @param {V1Query} [query]
361
+ * @param {V1RequestOptions} [options]
362
+ * @returns {AsyncGenerator<V1QueryPage, void, undefined>}
363
+ */
364
+ async *pages(query = {}, options = {}) {
365
+ /** @type {V1Query} */
366
+ let current = query;
367
+ while (true) {
368
+ const page = await this.query(current, options);
369
+ yield page;
370
+ if (page.nextCursor === null) return;
371
+ const { count: _count, ...rest } = current;
372
+ current = { ...rest, cursor: page.nextCursor };
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Iterates over every matching document across pages.
378
+ * @param {V1Query} [query]
379
+ * @param {V1RequestOptions} [options]
380
+ * @returns {AsyncGenerator<V1Document, void, undefined>}
381
+ */
382
+ async *items(query = {}, options = {}) {
383
+ for await (const page of this.pages(query, options)) yield* page.items;
384
+ }
385
+ /**
386
+ * Reads one C# shape snapshot page or change-log chunk. Bootstrap defaults to offset -1.
387
+ * Echo handle, nextOffset and cursor on the following read; discard state on must_refetch.
388
+ * @param {V1ShapeRequest} [request]
389
+ * @param {V1RequestOptions} [options]
390
+ * @returns {Promise<V1ShapePage>}
391
+ */
392
+ shape(request = {}, options = {}) {
393
+ return readShape(this.#transport, this.#params, request, options.signal);
394
+ }
395
+
396
+ }
397
+
398
+ /**
399
+ * An atomic transaction of up to 1024 ops across the collections of one
400
+ * database. No two ops may target the same document. Documents are encoded
401
+ * when an op is added, so later mutation of the input does not change it.
402
+ */
403
+ export class V1Transaction {
404
+ /** @type {V1Transport} */
405
+ #transport;
406
+ /** @type {string} */
407
+ #database;
408
+ /** @type {string[]} */
409
+ #ops = [];
410
+ /** @type {Set<string>} */
411
+ #targets = new Set();
412
+ #countAsserts = 0;
413
+
414
+ /**
415
+ * @param {V1Transport} transport
416
+ * @param {string} database
417
+ */
418
+ constructor(transport, database) {
419
+ this.#transport = transport;
420
+ this.#database = requireName("database", database);
421
+ }
422
+
423
+ /** @returns {number} The number of ops added. */
424
+ get size() {
425
+ return this.#ops.length;
426
+ }
427
+
428
+ /**
429
+ * @param {Record<string, unknown>} op
430
+ * @returns {this}
431
+ */
432
+ #add(op) {
433
+ if (this.#ops.length >= 1024) throw new TypeError("A transaction has at most 1024 ops.");
434
+ const encoded = stringifyExactJson(op, `transaction op ${this.#ops.length}`);
435
+ const target = op.id === undefined ? undefined : JSON.stringify([op.collection, op.id]);
436
+ if (target !== undefined) {
437
+ if (this.#targets.has(target)) throw new TypeError("Two transaction operations cannot target the same document.");
438
+ this.#targets.add(target);
439
+ }
440
+ this.#ops.push(encoded);
441
+ return this;
442
+ }
443
+
444
+ /**
445
+ * @param {V1Precondition | undefined} precondition
446
+ * @param {boolean} allowNoneMatch
447
+ * @returns {Record<string, string>}
448
+ */
449
+ static #precondition(precondition, allowNoneMatch) {
450
+ if (precondition === undefined) return {};
451
+ if ("ifMatch" in precondition && !("ifNoneMatchAny" in precondition))
452
+ return { if_match: precondition.ifMatch === "*" ? "*" : requireVersion(precondition.ifMatch) };
453
+ if ("ifNoneMatchAny" in precondition && precondition.ifNoneMatchAny === true && !("ifMatch" in precondition)) {
454
+ if (!allowNoneMatch) throw new TypeError("This op does not take ifNoneMatchAny.");
455
+ return { if_none_match: "*" };
456
+ }
457
+ throw new TypeError("A precondition is { ifMatch } or { ifNoneMatchAny: true }.");
458
+ }
459
+
460
+ /**
461
+ * Adds a create (a put requiring absence). Omit `id` to have the server
462
+ * generate a UUIDv7, returned in the op's result.
463
+ * @param {string} collection
464
+ * @param {JsonInputObject} document
465
+ * @param {string} [id]
466
+ * @returns {this}
467
+ */
468
+ create(collection, document, id) {
469
+ encodeDocument(document);
470
+ return this.#add({
471
+ op: "create", collection: requireName("collection", collection),
472
+ ...(id === undefined ? {} : { id: requireDocumentId(id) }), document,
473
+ });
474
+ }
475
+
476
+ /**
477
+ * @param {string} collection
478
+ * @param {string} id
479
+ * @param {JsonInputObject} document
480
+ * @param {V1Precondition} [precondition]
481
+ * @returns {this}
482
+ */
483
+ put(collection, id, document, precondition) {
484
+ encodeDocument(document);
485
+ return this.#add({
486
+ op: "put", collection: requireName("collection", collection), id: requireDocumentId(id), document,
487
+ ...V1Transaction.#precondition(precondition, true),
488
+ });
489
+ }
490
+
491
+ /**
492
+ * @param {string} collection
493
+ * @param {string} id
494
+ * @param {V1Patch} patch
495
+ * @param {{ readonly ifMatch: string }} [precondition]
496
+ * @returns {this}
497
+ */
498
+ patch(collection, id, patch, precondition) {
499
+ return this.#add({
500
+ op: "patch", collection: requireName("collection", collection), id: requireDocumentId(id),
501
+ ...normalizePatch(patch), ...V1Transaction.#precondition(precondition, false),
502
+ });
503
+ }
504
+
505
+ /**
506
+ * @param {string} collection
507
+ * @param {string} id
508
+ * @param {{ readonly ifMatch: string }} [precondition]
509
+ * @returns {this}
510
+ */
511
+ delete(collection, id, precondition) {
512
+ return this.#add({
513
+ op: "delete", collection: requireName("collection", collection), id: requireDocumentId(id),
514
+ ...V1Transaction.#precondition(precondition, false),
515
+ });
516
+ }
517
+
518
+ /**
519
+ * Requires the document to exist and match the filter.
520
+ * @param {string} collection
521
+ * @param {string} id
522
+ * @param {V1Filter} filter
523
+ * @returns {this}
524
+ */
525
+ assert(collection, id, filter) {
526
+ return this.#add({ op: "assert", collection: requireName("collection", collection), id: requireDocumentId(id), filter: requireFilter(filter) });
527
+ }
528
+
529
+ /**
530
+ * Requires the document to be absent or deleted.
531
+ * @param {string} collection
532
+ * @param {string} id
533
+ * @returns {this}
534
+ */
535
+ assertAbsent(collection, id) {
536
+ return this.#add({ op: "assert", collection: requireName("collection", collection), id: requireDocumentId(id), absent: true });
537
+ }
538
+
539
+ /**
540
+ * Requires the number of the collection's documents matching `filter` to
541
+ * satisfy `gte` and/or `lte`. Omit the filter with null to count all documents. It is evaluated inside the commit, before
542
+ * this transaction's writes, so it is serializable with every other write.
543
+ * At most 16 per transaction; the result carries the `count`.
544
+ * @param {string} collection
545
+ * @param {V1Filter | null} filter
546
+ * @param {V1CountBounds} bounds
547
+ * @returns {this}
548
+ */
549
+ assertCount(collection, filter, bounds) {
550
+ if (filter !== null) requireFilter(filter);
551
+ /** @type {Record<string, number>} */
552
+ const limits = {};
553
+ for (const name of /** @type {const} */ (["gte", "lte"])) {
554
+ const value = bounds?.[name];
555
+ if (value === undefined) continue;
556
+ if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`assertCount ${name} must be a non-negative integer.`);
557
+ limits[name] = value;
558
+ }
559
+ if (Object.keys(limits).length === 0) throw new TypeError("assertCount needs gte, lte or both.");
560
+ if (limits.gte !== undefined && limits.lte !== undefined && limits.gte > limits.lte) throw new TypeError("assertCount gte must not exceed lte.");
561
+ if (this.#countAsserts >= 16) throw new TypeError("A transaction has at most 16 assertCount ops.");
562
+ this.#add({ op: "assert_count", collection: requireName("collection", collection), ...(filter === null ? {} : { filter }), ...limits });
563
+ this.#countAsserts++;
564
+ return this;
565
+ }
566
+
567
+ /**
568
+ * Applies every op atomically. Failures are TerraBaseV1Error values whose
569
+ * `op` names the failing op index.
570
+ * @param {V1RequestOptions} [options]
571
+ * @returns {Promise<V1TransactionResult>}
572
+ */
573
+ async commit(options = {}) {
574
+ if (this.#ops.length === 0) throw new TypeError("A transaction needs at least one op.");
575
+ rejectIdempotency(options);
576
+ const sentCount = this.#ops.length;
577
+ const response = await this.#transport.call("commitTransaction", { database: this.#database }, {
578
+ body: `{"ops":[${this.#ops.join(",")}]}`,
579
+ signal: options.signal,
580
+ });
581
+ const raw = object(response.value, "transaction result");
582
+ if (array(raw.results, "transaction results").length !== sentCount) malformed("transaction result count");
583
+ return Object.freeze({
584
+ offset: u64(raw.offset, "transaction offset"),
585
+ durability: durability(raw.durability),
586
+ results: Object.freeze(array(raw.results, "transaction results").map((value) => {
587
+ const result = object(value, "transaction op result");
588
+ if (result.asserted === true)
589
+ return Object.freeze(result.count === undefined
590
+ ? { asserted: /** @type {const} */ (true) }
591
+ : { asserted: /** @type {const} */ (true), count: u64(result.count, "assert_count count") });
592
+ return Object.freeze({ id: requireDocumentId(result.id), version: requireVersion(result.version) });
593
+ })),
594
+ });
595
+ }
596
+ }
597
+
598
+ /** @typedef {{ readonly name: string; readonly createdAt: string }} V1CollectionInfo */
599
+ /** @typedef {{ readonly items: readonly V1CollectionInfo[]; readonly nextCursor: string | null }} V1CollectionPage */
600
+ /** @typedef {{ readonly limit?: number | undefined; readonly cursor?: string | undefined }} V1CollectionListOptions */
601
+ /** @param {JsonValue | undefined} value @returns {V1CollectionInfo} */
602
+ function decodeCollection(value) {
603
+ const raw = object(value, "collection");
604
+ return Object.freeze({ name: requireName("collection", raw.name), createdAt: u64(raw.created_at, "collection created_at") });
605
+ }
606
+ /** Collection management on the public client listener. */
607
+ export class V1Collections {
608
+ /** @type {V1Transport} */
609
+ #transport;
610
+ /** @type {string} */
611
+ #database;
612
+ /** @param {V1Transport} transport @param {string} database */
613
+ constructor(transport, database) {
614
+ this.#transport = transport;
615
+ this.#database = requireName("database", database);
616
+ Object.freeze(this);
617
+ }
618
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<V1CollectionInfo>} */
619
+ async create(name, options = {}) {
620
+ rejectIdempotency(options);
621
+ const response = await this.#transport.call("createCollection", { database: this.#database }, {
622
+ body: stringifyExactJson({ name: requireName("collection", name) }, "collection"), signal: options.signal,
623
+ });
624
+ return decodeCollection(response.value);
625
+ }
626
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<V1CollectionInfo>} */
627
+ async get(name, options = {}) {
628
+ const response = await this.#transport.call("getCollection", { database: this.#database, collection: requireName("collection", name) }, { signal: options.signal });
629
+ return decodeCollection(response.value);
630
+ }
631
+ /** @param {V1CollectionListOptions} [request] @param {V1RequestOptions} [options] @returns {Promise<V1CollectionPage>} */
632
+ async list(request = {}, options = {}) {
633
+ const query = new URLSearchParams();
634
+ for (const key of Object.keys(request)) if (key !== "limit" && key !== "cursor") throw new TypeError(`Unknown collection list member ${key}.`);
635
+ if (request.limit !== undefined) {
636
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 200) throw new TypeError("Collection list limit is 1-200.");
637
+ query.set("limit", String(request.limit));
638
+ }
639
+ if (request.cursor !== undefined) {
640
+ if (typeof request.cursor !== "string" || request.cursor === "") throw new TypeError("Collection cursor is a non-empty string.");
641
+ query.set("cursor", request.cursor);
642
+ }
643
+ const response = await this.#transport.call("listCollections", { database: this.#database }, { query, signal: options.signal });
644
+ const raw = object(response.value, "collection page");
645
+ if (raw.next_cursor !== null && (typeof raw.next_cursor !== "string" || raw.next_cursor === "")) malformed("collection next_cursor");
646
+ return Object.freeze({ items: Object.freeze(array(raw.items, "collections").map(decodeCollection)), nextCursor: raw.next_cursor });
647
+ }
648
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<V1CollectionInfo>} */
649
+ async drop(name, options = {}) {
650
+ rejectIdempotency(options);
651
+ const response = await this.#transport.call("dropCollection", { database: this.#database, collection: requireName("collection", name) }, { signal: options.signal });
652
+ return decodeCollection(response.value);
653
+ }
654
+ }
655
+
656
+ /** A database handle by name. It performs no I/O until an operation runs. */
657
+ export class V1Database {
658
+ /** @type {V1Collections} */
659
+ collections;
660
+ /** @type {V1Transport} */
661
+ #transport;
662
+ /** @type {string} */
663
+ name;
664
+
665
+ /**
666
+ * @param {V1Transport} transport
667
+ * @param {string} name
668
+ */
669
+ constructor(transport, name) {
670
+ this.#transport = transport;
671
+ this.name = requireName("database", name);
672
+ this.collections = new V1Collections(transport, this.name);
673
+ Object.freeze(this);
674
+ }
675
+
676
+ /**
677
+ * @param {string} name
678
+ * @returns {V1Collection}
679
+ */
680
+ collection(name) {
681
+ return new V1Collection(this.#transport, this.name, name);
682
+ }
683
+
684
+ /** @returns {V1Transaction} */
685
+ transaction() {
686
+ return new V1Transaction(this.#transport, this.name);
687
+ }
688
+
689
+
690
+ }
691
+
692
+ /**
693
+ * A TerraBase v1 data-plane client for one project and `tb_` API key. The key is held
694
+ * privately; it is never serialized, logged or forwarded to redirects.
695
+ */
696
+ export class TerraBaseV1Client {
697
+ /** @type {V1Transport} */
698
+ #transport;
699
+
700
+ /** @param {TerraBaseV1ClientConfig} config */
701
+ constructor(config) {
702
+ this.#transport = new V1Transport(config);
703
+ Object.freeze(this);
704
+ }
705
+
706
+ /**
707
+ * @param {string} name
708
+ * @returns {V1Database}
709
+ */
710
+ database(name) {
711
+ return new V1Database(this.#transport, name);
712
+ }
713
+ }
714
+
715
+ /**
716
+ * Creates a TerraBase v1 client for documents, transactions, queries and shapes.
717
+ * @param {TerraBaseV1ClientConfig} config
718
+ * @returns {TerraBaseV1Client}
719
+ */
720
+ export function createTerraBaseV1Client(config) {
721
+ return new TerraBaseV1Client(config);
722
+ }