@terrascale/sdk 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +193 -56
  3. package/package.json +14 -5
  4. package/sdk-public-v1-route-manifest.json +81 -0
  5. package/sdk-v1-route-manifest.json +8996 -1697
  6. package/src/database-codec.js +21 -26
  7. package/src/database-types.js +11 -11
  8. package/src/database-view.js +6 -6
  9. package/src/http.js +13 -48
  10. package/src/ids.js +22 -0
  11. package/src/index.js +77 -2
  12. package/src/local/better-auth.js +17 -6
  13. package/src/local/environment.js +0 -1
  14. package/src/local/index.js +2 -2
  15. package/src/local/legacy-client.js +5 -5
  16. package/src/local/router.js +12 -12
  17. package/src/local/store.js +29 -28
  18. package/src/local/test-environment.js +3 -2
  19. package/src/postgres.js +5 -4
  20. package/src/react/core.js +2 -2
  21. package/src/ts-auth-ids.js +122 -0
  22. package/src/ts-auth.js +75 -77
  23. package/src/v1/backups.js +111 -0
  24. package/src/v1/catalog.js +210 -0
  25. package/src/v1/changes.js +225 -0
  26. package/src/v1/database-schema.js +45 -0
  27. package/src/v1/decode.js +20 -8
  28. package/src/v1/errors.js +23 -3
  29. package/src/v1/ids.js +9 -59
  30. package/src/v1/imports.js +72 -0
  31. package/src/v1/indexes.js +76 -0
  32. package/src/v1/json.js +4 -3
  33. package/src/v1/management-decode.js +24 -0
  34. package/src/v1/management-routes.js +67 -0
  35. package/src/v1/management-validation.js +25 -0
  36. package/src/v1/routes.js +36 -11
  37. package/src/v1/saved-queries.js +58 -0
  38. package/src/v1/shape-crypto.js +125 -0
  39. package/src/v1/shape-manifest.js +31 -0
  40. package/src/v1/shape-reader.js +212 -0
  41. package/src/v1/shape-subscription.js +208 -0
  42. package/src/v1/shapes.js +15 -10
  43. package/src/v1/sql.js +98 -0
  44. package/src/v1/system.js +80 -0
  45. package/src/v1/terrabase-v1.js +192 -33
  46. package/src/v1/transport.js +80 -34
  47. package/src/v1/usage.js +101 -0
  48. package/types/database-codec.d.ts +1 -6
  49. package/types/database-types.d.ts +11 -11
  50. package/types/http.d.ts +2 -8
  51. package/types/ids.d.ts +10 -0
  52. package/types/index.d.ts +70 -1
  53. package/types/local/environment.d.ts +1 -1
  54. package/types/local/store.d.ts +10 -0
  55. package/types/local/test-environment.d.ts +2 -2
  56. package/types/postgres.d.ts +4 -4
  57. package/types/ts-auth-ids.d.ts +14 -0
  58. package/types/ts-auth.d.ts +99 -101
  59. package/types/v1/backups.d.ts +128 -0
  60. package/types/v1/catalog.d.ts +92 -0
  61. package/types/v1/changes.d.ts +53 -0
  62. package/types/v1/database-schema.d.ts +82 -0
  63. package/types/v1/decode.d.ts +17 -4
  64. package/types/v1/errors.d.ts +16 -1
  65. package/types/v1/ids.d.ts +4 -16
  66. package/types/v1/imports.d.ts +132 -0
  67. package/types/v1/indexes.d.ts +75 -0
  68. package/types/v1/management-decode.d.ts +15 -0
  69. package/types/v1/management-routes.d.ts +3 -0
  70. package/types/v1/management-validation.d.ts +8 -0
  71. package/types/v1/routes.d.ts +5 -2
  72. package/types/v1/saved-queries.d.ts +33 -0
  73. package/types/v1/shape-crypto.d.ts +39 -0
  74. package/types/v1/shape-manifest.d.ts +28 -0
  75. package/types/v1/shape-reader.d.ts +45 -0
  76. package/types/v1/shape-subscription.d.ts +35 -0
  77. package/types/v1/shapes.d.ts +14 -0
  78. package/types/v1/sql.d.ts +47 -0
  79. package/types/v1/system.d.ts +85 -0
  80. package/types/v1/terrabase-v1.d.ts +116 -7
  81. package/types/v1/transport.d.ts +27 -10
  82. package/types/v1/usage.d.ts +171 -0
  83. package/src/ts-auth-discovery.js +0 -207
  84. package/types/ts-auth-discovery.d.ts +0 -19
@@ -0,0 +1,210 @@
1
+ import { requireId } from "../ids.js";
2
+ import { array, malformed, object, string } from "./decode.js";
3
+ import { requireName } from "./ids.js";
4
+ import { isJsonObject, stringifyExactJson } from "./json.js";
5
+ /**
6
+ * @import { JsonInputObject, JsonObject, JsonValue } from "./json.js"
7
+ * @import { V1Transport } from "./transport.js"
8
+ * @import { V1RequestOptions } from "./terrabase-v1.js"
9
+ */
10
+
11
+ /**
12
+ * @typedef {object} V1ManagedDatabase
13
+ * @property {string} id Canonical database ULID.
14
+ * @property {string} projectId Canonical owning-project ULID.
15
+ * @property {string} name Database name used in data routes.
16
+ * @property {string | null} branchedFrom Source database ULID, or null.
17
+ * @property {string} createdAt ISO date-time returned by the producer.
18
+ * @property {string} region Serving region.
19
+ * @property {string} homeRegion Management region.
20
+ * @property {readonly string[]} replicaRegions Other serving regions; empty when allRegions is true.
21
+ * @property {boolean} allRegions True when a full copy is served in every serving region, including later ones.
22
+ */
23
+ /** @typedef {{ readonly name: string; readonly homeRegion?: string | null | undefined; readonly replicaRegions?: readonly string[] | null | undefined; readonly allRegions?: boolean | undefined }} V1DatabaseCreateRequest */
24
+ /** @typedef {{ readonly replicaRegions?: readonly string[] | undefined; readonly allRegions?: true | undefined; readonly homeRegion?: string | null | undefined }} V1DatabaseUpdateRequest */
25
+ /** @typedef {{ readonly name: string }} V1DatabaseBranchRequest */
26
+ /** @typedef {{ readonly after?: string | undefined; readonly limit?: number | undefined }} V1DatabaseListRequest */
27
+ /** @typedef {{ readonly items: readonly V1ManagedDatabase[]; readonly next: string | null }} V1DatabasePage */
28
+ /** @typedef {{ readonly schema: JsonObject | boolean | null; readonly dialect?: string | null | undefined }} V1CollectionSchema */
29
+
30
+ /** @param {unknown} value @param {readonly string[]} allowed @returns {void} */
31
+ function members(value, allowed) {
32
+ if (typeof value !== "object" || value === null || Array.isArray(value) ||
33
+ ![Object.prototype, null].includes(Object.getPrototypeOf(value)))
34
+ throw new TypeError("A catalog request must be a plain object.");
35
+ for (const key of Object.keys(value))
36
+ if (!allowed.includes(key)) throw new TypeError(`Unknown catalog request member ${key}.`);
37
+ }
38
+
39
+ /** @param {object} options @returns {void} */
40
+ function mutationOptions(options) {
41
+ if ("idempotencyKey" in options)
42
+ throw new TypeError("The current producer does not support Idempotency-Key; mutations are never automatically retried.");
43
+ }
44
+
45
+ /** @param {unknown} value @returns {string} */
46
+ function region(value) {
47
+ if (typeof value !== "string" || !/^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(value))
48
+ throw new TypeError("A region is 1-63 lowercase letters, digits or hyphens, starts with a letter and cannot end with a hyphen.");
49
+ return value;
50
+ }
51
+
52
+ /** @param {unknown} value @param {string | null | undefined} home @returns {readonly string[]} */
53
+ function replicas(value, home) {
54
+ if (!Array.isArray(value) || value.length > 8) throw new TypeError("replicaRegions is an array of at most 8 regions.");
55
+ const result = value.map(region);
56
+ if (new Set(result).size !== result.length) throw new TypeError("replicaRegions must not repeat a region.");
57
+ if (home !== undefined && home !== null && result.includes(home))
58
+ throw new TypeError("replicaRegions must not include homeRegion.");
59
+ return result;
60
+ }
61
+
62
+ /** @param {JsonValue | undefined} value @returns {V1ManagedDatabase} */
63
+ function database(value) {
64
+ const raw = object(value, "managed database");
65
+ const createdAt = string(raw.createdAt, "database createdAt");
66
+ if (!/^\d{4}-\d{2}-\d{2}T/u.test(createdAt) || !Number.isFinite(Date.parse(createdAt))) malformed("database createdAt");
67
+ if (typeof raw.allRegions !== "boolean") malformed("database allRegions");
68
+ return Object.freeze({
69
+ id: requireId(raw.id, "Database ID"), projectId: requireId(raw.projectId, "Project ID"),
70
+ name: requireName("database", raw.name),
71
+ branchedFrom: raw.branchedFrom === null ? null : requireId(raw.branchedFrom, "Source database ID"),
72
+ createdAt, region: region(raw.region), homeRegion: region(raw.homeRegion),
73
+ replicaRegions: Object.freeze(array(raw.replicaRegions, "database replicaRegions").map(region)),
74
+ allRegions: raw.allRegions,
75
+ });
76
+ }
77
+
78
+ /** Public database catalog operations, bound to the transport's authorized project. */
79
+ export class V1Databases {
80
+ /** @type {V1Transport} */
81
+ #transport;
82
+ /** @param {V1Transport} transport */
83
+ constructor(transport) { this.#transport = transport; Object.freeze(this); }
84
+
85
+ /** @param {V1DatabaseCreateRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1ManagedDatabase>} */
86
+ async create(request, options = {}) {
87
+ members(request, ["name", "homeRegion", "replicaRegions", "allRegions"]);
88
+ mutationOptions(options);
89
+ const name = requireName("database", request.name);
90
+ if (request.homeRegion !== undefined && request.homeRegion !== null) region(request.homeRegion);
91
+ if (request.replicaRegions !== undefined && request.replicaRegions !== null) replicas(request.replicaRegions, request.homeRegion);
92
+ if (request.allRegions !== undefined && typeof request.allRegions !== "boolean") throw new TypeError("allRegions must be a boolean.");
93
+ if (request.allRegions === true && request.replicaRegions !== undefined && request.replicaRegions !== null)
94
+ throw new TypeError("allRegions cannot be combined with replicaRegions.");
95
+ const response = await this.#transport.call("createDatabase", { database: name }, {
96
+ body: stringifyExactJson({ name,
97
+ ...(request.homeRegion === undefined ? {} : { homeRegion: request.homeRegion }),
98
+ ...(request.replicaRegions === undefined ? {} : { replicaRegions: request.replicaRegions }),
99
+ ...(request.allRegions === undefined ? {} : { allRegions: request.allRegions }),
100
+ }, "database create"), signal: options.signal,
101
+ });
102
+ return database(response.value);
103
+ }
104
+
105
+ /** @param {V1DatabaseListRequest} [request] @param {V1RequestOptions} [options] @returns {Promise<V1DatabasePage>} */
106
+ async list(request = {}, options = {}) {
107
+ members(request, ["after", "limit"]);
108
+ const query = new URLSearchParams();
109
+ if (request.after !== undefined) {
110
+ if (typeof request.after !== "string") throw new TypeError("Database after must be a string.");
111
+ query.set("after", request.after);
112
+ }
113
+ if (request.limit !== undefined) {
114
+ if (!Number.isInteger(request.limit) || request.limit < 1 || request.limit > 100) throw new TypeError("Database list limit is 1-100.");
115
+ query.set("limit", String(request.limit));
116
+ }
117
+ const response = await this.#transport.call("listDatabases", {}, { query, signal: options.signal });
118
+ const raw = object(response.value, "database page");
119
+ if (raw.next !== null && (typeof raw.next !== "string" || raw.next === "")) malformed("database next");
120
+ return Object.freeze({ items: Object.freeze(array(raw.items, "databases").map(database)), next: raw.next });
121
+ }
122
+
123
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<V1ManagedDatabase>} */
124
+ async get(name, options = {}) {
125
+ const response = await this.#transport.call("getDatabase", { database: requireName("database", name) }, { signal: options.signal });
126
+ return database(response.value);
127
+ }
128
+
129
+ /** @param {string} name @param {V1DatabaseUpdateRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1ManagedDatabase>} */
130
+ async update(name, request, options = {}) {
131
+ members(request, ["homeRegion", "replicaRegions", "allRegions"]);
132
+ mutationOptions(options);
133
+ if (request.homeRegion !== undefined && request.homeRegion !== null) region(request.homeRegion);
134
+ if (request.allRegions === true) {
135
+ if (request.replicaRegions !== undefined) throw new TypeError("allRegions cannot be combined with replicaRegions.");
136
+ } else {
137
+ if (request.allRegions !== undefined) throw new TypeError("allRegions must be true; send replicaRegions to turn it off.");
138
+ replicas(request.replicaRegions, request.homeRegion);
139
+ }
140
+ const response = await this.#transport.call("updateDatabase", { database: requireName("database", name) }, {
141
+ body: stringifyExactJson({
142
+ ...(request.allRegions === true ? { allRegions: true } : { replicaRegions: request.replicaRegions }),
143
+ ...(request.homeRegion === undefined ? {} : { homeRegion: request.homeRegion }),
144
+ }, "database update"), signal: options.signal,
145
+ });
146
+ return database(response.value);
147
+ }
148
+
149
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<void>} */
150
+ async delete(name, options = {}) {
151
+ mutationOptions(options);
152
+ await this.#transport.call("deleteDatabase", { database: requireName("database", name) }, { signal: options.signal });
153
+ }
154
+
155
+ /** @param {string} name @param {V1DatabaseBranchRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1ManagedDatabase>} */
156
+ async branch(name, request, options = {}) {
157
+ members(request, ["name"]);
158
+ mutationOptions(options);
159
+ const response = await this.#transport.call("branchDatabase", { database: requireName("database", name) }, {
160
+ body: stringifyExactJson({ name: requireName("database", request.name) }, "database branch"), signal: options.signal,
161
+ });
162
+ return database(response.value);
163
+ }
164
+ }
165
+
166
+ /** @param {JsonValue | undefined} value @returns {V1CollectionSchema} */
167
+ function collectionSchema(value) {
168
+ const raw = object(value, "collection schema");
169
+ const schema = raw.schema;
170
+ if (schema !== null && typeof schema !== "boolean" && !isJsonObject(schema)) malformed("declared schema");
171
+ const dialect = raw.dialect;
172
+ if (dialect !== undefined && dialect !== null && typeof dialect !== "string") malformed("schema dialect");
173
+ return Object.freeze({ schema, ...(dialect === undefined ? {} : { dialect }) });
174
+ }
175
+
176
+ /** @param {boolean | JsonInputObject} schema @returns {string} */
177
+ function schemaText(schema) {
178
+ if (typeof schema !== "boolean" && !isJsonObject(schema)) throw new TypeError("A declared schema must be a JSON object or boolean.");
179
+ const body = stringifyExactJson(schema, "declared schema");
180
+ if (new TextEncoder().encode(body).byteLength > 64 * 1024) throw new TypeError("A declared schema is at most 64 KiB.");
181
+ let nodes = 0;
182
+ /** @param {unknown} value @param {number} depth */
183
+ const visit = (value, depth) => {
184
+ if (++nodes > 4093) throw new TypeError("A declared schema holds at most 4093 values.");
185
+ if (!Array.isArray(value) && !isJsonObject(value)) return;
186
+ if (depth >= 15) throw new TypeError("A declared schema nests at most 15 levels.");
187
+ for (const child of Object.values(value)) visit(child, depth + 1);
188
+ };
189
+ visit(schema, 0);
190
+ return body;
191
+ }
192
+
193
+ /** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1RequestOptions} [options] @returns {Promise<V1CollectionSchema>} */
194
+ export async function getCollectionSchema(transport, params, options = {}) {
195
+ return collectionSchema((await transport.call("getCollectionSchema", params, { signal: options.signal })).value);
196
+ }
197
+
198
+ /** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {boolean | JsonInputObject} schema @param {V1RequestOptions} [options] @returns {Promise<V1CollectionSchema>} */
199
+ export async function putCollectionSchema(transport, params, schema, options = {}) {
200
+ mutationOptions(options);
201
+ return collectionSchema((await transport.call("putCollectionSchema", params, {
202
+ body: schemaText(schema), signal: options.signal,
203
+ })).value);
204
+ }
205
+
206
+ /** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1RequestOptions} [options] @returns {Promise<V1CollectionSchema>} */
207
+ export async function deleteCollectionSchema(transport, params, options = {}) {
208
+ mutationOptions(options);
209
+ return collectionSchema((await transport.call("deleteCollectionSchema", params, { signal: options.signal })).value);
210
+ }
@@ -0,0 +1,225 @@
1
+ import { documentObject, malformed, object, u64 } from "./decode.js";
2
+ import { parseV1Error } from "./errors.js";
3
+ import { requireDocumentId, requireName } from "./ids.js";
4
+ import { parseExactJson } from "./json.js";
5
+ import { BodyTransportError } from "./transport.js";
6
+ /** @import { JsonObject } from "./json.js" */
7
+ /** @import { V1Transport } from "./transport.js" */
8
+
9
+ /**
10
+ * Cursors are opaque and must be resumed with the identical collection filter.
11
+ * @typedef {object} V1ChangeStreamRequest
12
+ * @property {string | undefined} [cursor] An issued cursor, or `now`; omitted starts at retention.
13
+ * @property {readonly string[] | undefined} [collections] One to 64 collection names.
14
+ * @property {string | undefined} [lastEventId] Overrides cursor, as the producer's Last-Event-ID header.
15
+ */
16
+ /**
17
+ * At-least-once delivery: callers deduplicate by origin, version, collection and id.
18
+ * The document is its current serving value, including null, rather than a historical value.
19
+ * Only the last change of an origin/version carries a resumable cursor.
20
+ * @typedef {{ readonly type: "change"; readonly cursor: string | undefined; readonly collection: string; readonly id: string;
21
+ * readonly op: "upsert" | "delete"; readonly version: string; readonly origin: string;
22
+ * readonly document: JsonObject | null }} V1DatabaseChange
23
+ */
24
+ /** @typedef {{ readonly type: "cursor"; readonly cursor: string }} V1DatabaseChangeCursor */
25
+ /** @typedef {{ type: string | undefined; id: string | undefined; data: string }} WireEvent */
26
+
27
+ // A 1 MiB document may require six bytes per character when JSON escapes it.
28
+ const maximumEventBytes = 8 * 1024 * 1024;
29
+
30
+ /** @param {unknown} value @returns {string} */
31
+ function requireCursor(value) {
32
+ if (typeof value !== "string" || value === "" || /[\u0000-\u0020\u007f-\u009f\ud800-\udfff]/u.test(value))
33
+ throw new TypeError("A changes cursor is a non-empty opaque string without whitespace or control characters.");
34
+ return value;
35
+ }
36
+
37
+ /** @param {V1ChangeStreamRequest} request */
38
+ function encodeRequest(request) {
39
+ if (typeof request !== "object" || request === null || Array.isArray(request)) throw new TypeError("A changes request is an object.");
40
+ for (const key of Object.keys(request))
41
+ if (!["cursor", "collections", "lastEventId"].includes(key)) throw new TypeError(`Unknown changes member ${key}.`);
42
+ const query = new URLSearchParams();
43
+ if (request.cursor !== undefined) query.set("cursor", requireCursor(request.cursor));
44
+ if (request.collections !== undefined) {
45
+ if (!Array.isArray(request.collections) || request.collections.length < 1 || request.collections.length > 64)
46
+ throw new TypeError("A changes filter has one to 64 collections.");
47
+ query.set("collections", request.collections.map(name => requireName("collection", name)).join(","));
48
+ }
49
+ return { query, lastEventId: request.lastEventId === undefined ? undefined : requireCursor(request.lastEventId) };
50
+ }
51
+
52
+ /**
53
+ * Parses lines from bytes so split UTF-8 and CRLF never change a checkpoint.
54
+ * @param {Response} response
55
+ * @param {AbortSignal | undefined} signal
56
+ * @returns {AsyncGenerator<WireEvent, void, undefined>}
57
+ */
58
+ async function* events(response, signal) {
59
+ const reader = response.body?.getReader();
60
+ if (reader === undefined) malformed("empty changes stream");
61
+ const abort = () => { void reader.cancel(signal?.reason).catch(() => undefined); };
62
+ signal?.addEventListener("abort", abort, { once: true });
63
+ const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
64
+ /** @type {Uint8Array[]} */
65
+ let lineParts = [];
66
+ let lineBytes = 0;
67
+ let eventBytes = 0;
68
+ let skipLf = false;
69
+ let firstLine = true;
70
+ /** @type {string | undefined} */
71
+ let type;
72
+ /** @type {string | undefined} */
73
+ let id;
74
+ /** @type {string[]} */
75
+ let data = [];
76
+ let hasFields = false;
77
+ /** @param {Uint8Array} bytes */
78
+ const append = bytes => {
79
+ eventBytes += bytes.byteLength;
80
+ if (eventBytes > maximumEventBytes) malformed("changes event exceeding 8 MiB");
81
+ if (bytes.byteLength > 0) { lineParts.push(bytes); lineBytes += bytes.byteLength; }
82
+ };
83
+ try {
84
+ while (true) {
85
+ signal?.throwIfAborted();
86
+ /** @type {ReadableStreamReadResult<Uint8Array>} */
87
+ let part;
88
+ try { part = await reader.read(); }
89
+ catch (error) { signal?.throwIfAborted(); throw new BodyTransportError(error); }
90
+ signal?.throwIfAborted();
91
+ if (part.done) break;
92
+ let start = 0;
93
+ for (let index = 0; index < part.value.byteLength; index++) {
94
+ const byte = part.value[index];
95
+ if (skipLf) {
96
+ skipLf = false;
97
+ if (byte === 10) { start = index + 1; continue; }
98
+ }
99
+ if (byte !== 10 && byte !== 13) continue;
100
+ append(part.value.subarray(start, index));
101
+ if (++eventBytes > maximumEventBytes) malformed("changes event exceeding 8 MiB");
102
+ const bytes = new Uint8Array(lineBytes);
103
+ let at = 0;
104
+ for (const fragment of lineParts) { bytes.set(fragment, at); at += fragment.byteLength; }
105
+ let line = decoder.decode(bytes);
106
+ if (firstLine) { firstLine = false; if (line.startsWith("\ufeff")) line = line.slice(1); }
107
+ lineParts = [];
108
+ lineBytes = 0;
109
+ start = index + 1;
110
+ skipLf = byte === 13;
111
+ if (line === "") {
112
+ const event = hasFields ? { type, id, data: data.join("\n") } : undefined;
113
+ type = undefined; id = undefined; data = []; hasFields = false; eventBytes = 0;
114
+ if (event !== undefined) { signal?.throwIfAborted(); yield event; signal?.throwIfAborted(); }
115
+ continue;
116
+ }
117
+ if (line.startsWith(":")) continue;
118
+ const colon = line.indexOf(":");
119
+ const field = colon === -1 ? line : line.slice(0, colon);
120
+ let value = colon === -1 ? "" : line.slice(colon + 1);
121
+ if (value.startsWith(" ")) value = value.slice(1);
122
+ if (field === "event") { type = value; hasFields = true; }
123
+ else if (field === "id") { id = value; hasFields = true; }
124
+ else if (field === "data") { data.push(value); hasFields = true; }
125
+ }
126
+ append(part.value.subarray(start));
127
+ }
128
+ if (lineBytes !== 0 || hasFields) malformed("incomplete changes event");
129
+ } finally {
130
+ signal?.removeEventListener("abort", abort);
131
+ await reader.cancel().catch(() => undefined);
132
+ reader.releaseLock();
133
+ }
134
+ }
135
+
136
+ /** @param {WireEvent} event @param {Headers} headers @returns {V1DatabaseChange | V1DatabaseChangeCursor} */
137
+ function decodeEvent(event, headers) {
138
+ /** @type {import("./json.js").JsonValue} */
139
+ let value;
140
+ try { value = parseExactJson(event.data); }
141
+ catch { malformed("changes event JSON"); }
142
+ const raw = object(value, "changes event");
143
+ if (event.type === "error") {
144
+ const detail = object(raw.error, "changes error");
145
+ if (typeof detail.code !== "string" || detail.code === "") malformed("changes error code");
146
+ // Headers have already committed: retain the actual HTTP 200 and the producer's terminal code.
147
+ throw parseV1Error(200, headers, event.data);
148
+ }
149
+ if (event.type === "cursor") {
150
+ const cursor = requireCursor(event.id);
151
+ if (raw.cursor !== cursor) malformed("changes cursor position");
152
+ return Object.freeze({ type: "cursor", cursor });
153
+ }
154
+ if (event.type !== "change") malformed("changes event kind");
155
+ const cursor = event.id === undefined ? undefined : requireCursor(event.id);
156
+ if (raw.op !== "upsert" && raw.op !== "delete") malformed("changes operation");
157
+ if (typeof raw.origin !== "string" || !/^[0-9a-f]{16}$/u.test(raw.origin)) malformed("changes origin");
158
+ return Object.freeze({ type: "change", cursor, collection: requireName("collection", raw.collection),
159
+ id: requireDocumentId(raw.id), op: raw.op, version: u64(raw.version, "changes version"), origin: raw.origin,
160
+ document: raw.document === null ? null : documentObject(raw.document),
161
+ });
162
+ }
163
+
164
+ /** @param {AbortSignal | undefined} signal @returns {Promise<void>} */
165
+ function reconnectDelay(signal) {
166
+ signal?.throwIfAborted();
167
+ return new Promise((resolve, reject) => {
168
+ const abort = () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); reject(signal?.reason); };
169
+ const timer = setTimeout(() => { signal?.removeEventListener("abort", abort); resolve(); }, 250);
170
+ signal?.addEventListener("abort", abort, { once: true });
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Reconnects only a normally completed, valid stream, with the latest issued cursor.
176
+ * Denials, terminal errors, malformed data and lost connections are returned to the caller.
177
+ * @param {V1Transport} transport
178
+ * @param {string} database
179
+ * @param {V1ChangeStreamRequest} [request]
180
+ * @param {AbortSignal | undefined} [signal]
181
+ * @returns {AsyncGenerator<V1DatabaseChange | V1DatabaseChangeCursor, void, undefined>}
182
+ */
183
+ export function streamChanges(transport, database, request = {}, signal) {
184
+ const controller = new AbortController();
185
+ const combined = signal === undefined ? controller.signal : AbortSignal.any([signal, controller.signal]);
186
+ const iterator = readChanges(transport, database, request, combined);
187
+ const next = iterator.next.bind(iterator);
188
+ const finish = iterator.return.bind(iterator);
189
+ const closed = new DOMException("The changes stream was closed.", "AbortError");
190
+ iterator.next = async () => {
191
+ try { return await next(); }
192
+ catch (error) { if (error === closed) return { done: true, value: undefined }; throw error; }
193
+ };
194
+ // Native generator return queues behind a pending read; interrupt its private signal first.
195
+ iterator.return = value => { controller.abort(closed); return finish(value); };
196
+ return iterator;
197
+ }
198
+
199
+ /**
200
+ * @param {V1Transport} transport
201
+ * @param {string} database
202
+ * @param {V1ChangeStreamRequest} request
203
+ * @param {AbortSignal} signal
204
+ * @returns {AsyncGenerator<V1DatabaseChange | V1DatabaseChangeCursor, void, undefined>}
205
+ */
206
+ async function* readChanges(transport, database, request, signal) {
207
+ const encoded = encodeRequest(request);
208
+ let lastEventId = encoded.lastEventId;
209
+ while (true) {
210
+ signal?.throwIfAborted();
211
+ const response = await transport.openStream("streamDatabaseChanges", { database }, {
212
+ query: encoded.query, ...(lastEventId === undefined ? {} : { headers: { "last-event-id": lastEventId } }), signal,
213
+ });
214
+ let count = 0;
215
+ for await (const wire of events(response, signal)) {
216
+ const event = decodeEvent(wire, response.headers);
217
+ if (event.cursor !== undefined) lastEventId = event.cursor;
218
+ count++;
219
+ yield event;
220
+ signal?.throwIfAborted();
221
+ }
222
+ if (count === 0 || lastEventId === undefined) malformed("empty changes stream");
223
+ await reconnectDelay(signal);
224
+ }
225
+ }
@@ -0,0 +1,45 @@
1
+ import { requireName } from "./ids.js";
2
+ import { list, numeric, optional, nullable, record, text, dictionary } from "./management-decode.js";
3
+ import { members, mutationOptions } from "./management-validation.js";
4
+ import { stringifyExactJson } from "./json.js";
5
+ /** @import { V1Transport } from "./transport.js" */
6
+ /** @import { V1RequestOptions } from "./terrabase-v1.js" */
7
+ const constraint = record({ name: text, fields: list(text), normalization: text });
8
+ const schema = record({ collection: text, documents: numeric, sampledDocuments: numeric,
9
+ fields: list(record({ path: text, present: numeric, types: dictionary(numeric) })), uniqueConstraints: optional(nullable(list(constraint))) });
10
+ const collections = record({ items: list(record({ name: text, documents: numeric, bytes: numeric })) });
11
+ const unique = record({ constraints: list(constraint) });
12
+ const shapes = record({ items: list(record({ id: text, collection: text, shape: text, queries: numeric,
13
+ totalLatencyMs: numeric, maxLatencyMs: numeric, rowsScanned: numeric, lastSeenAt: text })) });
14
+ /** @typedef {ReturnType<typeof schema>} V1InferredSchema */
15
+ /** @typedef {ReturnType<typeof collections>} V1DatabaseSchema */
16
+ /** @typedef {ReturnType<typeof unique>} V1UniqueSchema */
17
+ /** @typedef {ReturnType<typeof shapes>} V1QueryShapes */
18
+ /** @typedef {"none" | "lowercase" | "trim_lowercase"} V1Normalization */
19
+ /** @typedef {{ readonly fields?: readonly { readonly name: string; readonly unique?: boolean; readonly normalization?: V1Normalization | null }[] | null; readonly indexes?: readonly { readonly name: string; readonly fields: readonly string[]; readonly unique?: boolean; readonly normalization?: V1Normalization | null }[] | null }} V1UniqueSchemaRequest */
20
+
21
+ /** Inferred collection fields, declared uniqueness, and observed query statistics. */
22
+ export class V1DatabaseSchemaClient {
23
+ /** @type {V1Transport} */
24
+ #transport;
25
+ /** @type {{database: string}} */
26
+ #params;
27
+ /** @param {V1Transport} transport @param {string} database */
28
+ constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
29
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1DatabaseSchema>} */
30
+ async list(options = {}) { return collections((await this.#transport.call("getDatabaseSchema", this.#params, { signal: options.signal })).value); }
31
+ /** @param {string} collection @param {V1RequestOptions} [options] @returns {Promise<V1InferredSchema>} */
32
+ async infer(collection, options = {}) {
33
+ return schema((await this.#transport.call("inferCollectionSchema", { ...this.#params, collection: requireName("collection", collection) }, { signal: options.signal })).value);
34
+ }
35
+ /** @param {string} collection @param {V1UniqueSchemaRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1UniqueSchema>} */
36
+ async declare(collection, request, options = {}) {
37
+ members(request, ["fields", "indexes"]);
38
+ mutationOptions(options);
39
+ return unique((await this.#transport.call("declareCollectionSchema", { ...this.#params, collection: requireName("collection", collection) }, {
40
+ body: stringifyExactJson(request, "unique schema"), signal: options.signal,
41
+ })).value);
42
+ }
43
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1QueryShapes>} */
44
+ async queryShapes(options = {}) { return shapes((await this.#transport.call("listQueryShapes", this.#params, { signal: options.signal })).value); }
45
+ }
package/src/v1/decode.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Typed decoding of TerraBase v1 response bodies.
2
- import { parseU64, requireVersion } from "./ids.js";
2
+ import { parseU64, requireDocumentId, requireVersion } from "./ids.js";
3
3
  import { isJsonObject } from "./json.js";
4
4
  /** @import { JsonObject, JsonValue } from "./json.js" */
5
5
 
@@ -8,22 +8,23 @@ import { isJsonObject } from "./json.js";
8
8
  * `document` is the value, or the LWW winner when a causal read answers `"conflict"` with `siblings`.
9
9
  * Unknown future states pass through.
10
10
  * @typedef {object} V1Document
11
- * @property {string} id
11
+ * @property {string} id Caller-supplied key or server-generated ULID.
12
12
  * @property {string} version
13
13
  * @property {"value" | "conflict" | (string & {})} state
14
14
  * @property {JsonObject} document
15
- * @property {readonly JsonValue[]} [siblings] Every live version, on a causal read with several.
15
+ * @property {readonly JsonValue[]} [siblings] Live versions and tombstones on a causal conflict.
16
16
  */
17
17
 
18
18
  /**
19
19
  * The result of a document write.
20
20
  * @typedef {object} V1WriteResult
21
- * @property {string} id
21
+ * @property {string} id Caller-supplied key or server-generated ULID.
22
22
  * @property {string} version For a delete, the tombstone's version.
23
23
  * @property {string} offset The database commit offset (decimal u64), usable with shape `waitFor`.
24
24
  * @property {V1Durability} durability
25
25
  * @property {boolean} created True when a PUT or POST answered 201.
26
26
  * @property {string | undefined} location The Location header of a POST create.
27
+ * @property {JsonObject} [document] Stored document returned by PATCH.
27
28
  */
28
29
 
29
30
  /**
@@ -43,6 +44,11 @@ export function object(value, what) {
43
44
  return isJsonObject(value) ? value : malformed(what);
44
45
  }
45
46
 
47
+ /** @param {JsonValue | undefined} value @returns {JsonObject} */
48
+ export function documentObject(value) {
49
+ return object(value, "document body");
50
+ }
51
+
46
52
  /**
47
53
  * @param {JsonValue | undefined} value
48
54
  * @param {string} what
@@ -93,12 +99,17 @@ export function durability(value) {
93
99
  */
94
100
  export function decodeDocument(value) {
95
101
  const raw = object(value, "document");
96
- const id = string(raw.id, "document id");
102
+ const id = requireDocumentId(raw.id);
97
103
  const version = requireVersion(raw.version);
98
104
  const state = string(raw.state, "document state");
99
- const document = object(raw.document, "document body");
105
+ const document = documentObject(raw.document);
100
106
  if (raw.siblings === undefined) return Object.freeze({ id, version, state, document });
101
- return Object.freeze({ id, version, state, document, siblings: Object.freeze(array(raw.siblings, "document siblings")) });
107
+ const siblings = array(raw.siblings, "document siblings");
108
+ for (const sibling of siblings) {
109
+ const entry = object(sibling, "document sibling");
110
+ if (entry.deleted !== true || entry.document !== undefined) documentObject(entry.document);
111
+ }
112
+ return Object.freeze({ id, version, state, document, siblings: Object.freeze(siblings) });
102
113
  }
103
114
 
104
115
  /**
@@ -108,11 +119,12 @@ export function decodeDocument(value) {
108
119
  export function decodeWrite(response) {
109
120
  const raw = object(response.value, "write result");
110
121
  return Object.freeze({
111
- id: string(raw.id, "write id"),
122
+ id: requireDocumentId(raw.id),
112
123
  version: requireVersion(raw.version),
113
124
  offset: u64(raw.offset, "write offset"),
114
125
  durability: durability(raw.durability),
115
126
  created: response.status === 201,
116
127
  location: response.headers.get("location") ?? undefined,
128
+ ...(raw.document === undefined ? {} : { document: documentObject(raw.document) }),
117
129
  });
118
130
  }
package/src/v1/errors.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { isJsonObject, parseExactJson } from "./json.js";
2
- /** @import { JsonObject } from "./json.js" */
2
+ /** @import { JsonObject, JsonValue } from "./json.js" */
3
3
 
4
4
  /**
5
5
  * @typedef {object} TerraBaseV1ErrorInit
@@ -11,6 +11,9 @@ import { isJsonObject, parseExactJson } from "./json.js";
11
11
  * @property {number | undefined} [retryAfter]
12
12
  * @property {string | undefined} [etag]
13
13
  * @property {JsonObject | undefined} [details]
14
+ * @property {JsonValue | undefined} [body]
15
+ * @property {string | undefined} [responseText]
16
+ * @property {Headers | undefined} [headers]
14
17
  */
15
18
 
16
19
  /**
@@ -32,6 +35,14 @@ export class TerraBaseV1Error extends Error {
32
35
  etag;
33
36
  /** @type {JsonObject | undefined} The raw error object, for route-specific metadata. */
34
37
  details;
38
+ /** @type {JsonValue | undefined} Complete parsed response body, including unknown fields. */
39
+ body;
40
+ /** @type {string} Original response text; empty when absent or unreadable. */
41
+ responseText;
42
+ /** @type {Headers} Response headers, including authentication challenges and retry metadata. */
43
+ headers;
44
+ /** @type {string | undefined} Diagnostic X-Request-ID, when supplied by the responder. */
45
+ requestId;
35
46
 
36
47
  /** @param {TerraBaseV1ErrorInit} init */
37
48
  constructor(init) {
@@ -48,6 +59,10 @@ export class TerraBaseV1Error extends Error {
48
59
  this.retryAfter = init.retryAfter;
49
60
  this.etag = init.etag;
50
61
  this.details = init.details;
62
+ this.body = init.body;
63
+ this.responseText = init.responseText ?? "";
64
+ this.headers = new Headers(init.headers);
65
+ this.requestId = this.headers.get("x-request-id") ?? undefined;
51
66
  }
52
67
  }
53
68
 
@@ -153,11 +168,13 @@ export function parseRetryAfter(headers) {
153
168
  * @returns {TerraBaseV1Error}
154
169
  */
155
170
  export function parseV1Error(status, headers, text) {
171
+ /** @type {JsonValue | undefined} */
172
+ let body;
156
173
  /** @type {JsonObject | undefined} */
157
174
  let details;
158
175
  try {
159
- const parsed = text === "" ? undefined : parseExactJson(text);
160
- if (isJsonObject(parsed) && isJsonObject(parsed.error)) details = parsed.error;
176
+ body = text === "" ? undefined : parseExactJson(text);
177
+ if (isJsonObject(body) && isJsonObject(body.error)) details = body.error;
161
178
  } catch {
162
179
  details = undefined;
163
180
  }
@@ -181,5 +198,8 @@ export function parseV1Error(status, headers, text) {
181
198
  retryAfter: parseRetryAfter(headers),
182
199
  etag: unquoteEtag(headers.get("etag")),
183
200
  details,
201
+ body,
202
+ responseText: text,
203
+ headers,
184
204
  });
185
205
  }