@terrascale/sdk 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +206 -67
  3. package/package.json +10 -4
  4. package/sdk-public-v1-route-manifest.json +85 -0
  5. package/sdk-v1-route-manifest.json +896 -15
  6. package/src/http.js +3 -48
  7. package/src/index.js +80 -1
  8. package/src/local/environment.js +0 -1
  9. package/src/ts-auth.js +1 -5
  10. package/src/v1/backups.js +111 -0
  11. package/src/v1/catalog.js +240 -0
  12. package/src/v1/changes.js +225 -0
  13. package/src/v1/database-schema.js +45 -0
  14. package/src/v1/decode.js +6 -5
  15. package/src/v1/errors.js +23 -3
  16. package/src/v1/export.js +68 -0
  17. package/src/v1/ids.js +5 -2
  18. package/src/v1/imports.js +72 -0
  19. package/src/v1/indexes.js +76 -0
  20. package/src/v1/json.js +4 -3
  21. package/src/v1/management-decode.js +24 -0
  22. package/src/v1/management-routes.js +72 -0
  23. package/src/v1/management-validation.js +25 -0
  24. package/src/v1/routes.js +32 -7
  25. package/src/v1/saved-queries.js +58 -0
  26. package/src/v1/shape-crypto.js +125 -0
  27. package/src/v1/shape-manifest.js +31 -0
  28. package/src/v1/shape-reader.js +214 -0
  29. package/src/v1/shape-subscription.js +208 -0
  30. package/src/v1/shapes.js +11 -5
  31. package/src/v1/sql.js +98 -0
  32. package/src/v1/system.js +80 -0
  33. package/src/v1/terrabase-v1.js +231 -30
  34. package/src/v1/transport.js +117 -33
  35. package/src/v1/usage.js +101 -0
  36. package/types/http.d.ts +2 -8
  37. package/types/index.d.ts +73 -0
  38. package/types/local/environment.d.ts +1 -1
  39. package/types/ts-auth.d.ts +0 -2
  40. package/types/v1/backups.d.ts +128 -0
  41. package/types/v1/catalog.d.ts +104 -0
  42. package/types/v1/changes.d.ts +53 -0
  43. package/types/v1/database-schema.d.ts +82 -0
  44. package/types/v1/decode.d.ts +20 -4
  45. package/types/v1/errors.d.ts +16 -1
  46. package/types/v1/export.d.ts +26 -0
  47. package/types/v1/ids.d.ts +2 -2
  48. package/types/v1/imports.d.ts +132 -0
  49. package/types/v1/indexes.d.ts +75 -0
  50. package/types/v1/management-decode.d.ts +15 -0
  51. package/types/v1/management-routes.d.ts +3 -0
  52. package/types/v1/management-validation.d.ts +8 -0
  53. package/types/v1/routes.d.ts +5 -2
  54. package/types/v1/saved-queries.d.ts +33 -0
  55. package/types/v1/shape-crypto.d.ts +39 -0
  56. package/types/v1/shape-manifest.d.ts +28 -0
  57. package/types/v1/shape-reader.d.ts +45 -0
  58. package/types/v1/shape-subscription.d.ts +35 -0
  59. package/types/v1/shapes.d.ts +16 -2
  60. package/types/v1/sql.d.ts +47 -0
  61. package/types/v1/system.d.ts +85 -0
  62. package/types/v1/terrabase-v1.d.ts +140 -6
  63. package/types/v1/transport.d.ts +33 -10
  64. package/types/v1/usage.d.ts +171 -0
  65. package/src/ts-auth-discovery.js +0 -207
  66. package/types/ts-auth-discovery.d.ts +0 -19
@@ -0,0 +1,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
@@ -8,22 +8,24 @@ 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 {import("../ids.js").EntityId} 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
15
  * @property {readonly JsonValue[]} [siblings] Live versions and tombstones on a causal conflict.
16
+ * @property {number} [distance] Distance to the query vector; present only on `near` query results.
16
17
  */
17
18
 
18
19
  /**
19
20
  * The result of a document write.
20
21
  * @typedef {object} V1WriteResult
21
- * @property {import("../ids.js").EntityId} id
22
+ * @property {string} id Caller-supplied key or server-generated ULID.
22
23
  * @property {string} version For a delete, the tombstone's version.
23
24
  * @property {string} offset The database commit offset (decimal u64), usable with shape `waitFor`.
24
25
  * @property {V1Durability} durability
25
26
  * @property {boolean} created True when a PUT or POST answered 201.
26
27
  * @property {string | undefined} location The Location header of a POST create.
28
+ * @property {JsonObject} [document] Stored document returned by PATCH.
27
29
  */
28
30
 
29
31
  /**
@@ -45,9 +47,7 @@ export function object(value, what) {
45
47
 
46
48
  /** @param {JsonValue | undefined} value @returns {JsonObject} */
47
49
  export function documentObject(value) {
48
- const document = object(value, "document body");
49
- if (Object.hasOwn(document, "id")) malformed("document body: the entity ID belongs in the document key");
50
- return document;
50
+ return object(value, "document body");
51
51
  }
52
52
 
53
53
  /**
@@ -126,5 +126,6 @@ export function decodeWrite(response) {
126
126
  durability: durability(raw.durability),
127
127
  created: response.status === 201,
128
128
  location: response.headers.get("location") ?? undefined,
129
+ ...(raw.document === undefined ? {} : { document: documentObject(raw.document) }),
129
130
  });
130
131
  }
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
  }
@@ -0,0 +1,68 @@
1
+ import { documentObject, malformed, object } from "./decode.js";
2
+ import { requireDocumentId, requireName } from "./ids.js";
3
+ import { parseExactJson } from "./json.js";
4
+ import { BodyTransportError } from "./transport.js";
5
+ /** @import { JsonObject } from "./json.js" */
6
+ /** @import { V1Transport } from "./transport.js" */
7
+
8
+ /** @typedef {{ readonly collection: string; readonly id: string; readonly document: JsonObject }} V1ExportedDocument */
9
+ /**
10
+ * @typedef {object} V1ExportOptions
11
+ * @property {{ readonly collection: string; readonly id: string } | undefined} [after] Resume after this document; the resumed part reads a new snapshot.
12
+ * @property {AbortSignal | undefined} [signal]
13
+ */
14
+
15
+ // A 1 MiB document may require six characters per character when JSON escapes it.
16
+ const maximumLineLength = 8 * 1024 * 1024;
17
+
18
+ /** @param {string} line @returns {V1ExportedDocument} */
19
+ function decodeLine(line) {
20
+ /** @type {import("./json.js").JsonValue} */
21
+ let value;
22
+ try { value = parseExactJson(line); }
23
+ catch { malformed("export line JSON"); }
24
+ const raw = object(value, "export line");
25
+ return Object.freeze({ collection: requireName("collection", raw.collection), id: requireDocumentId(raw.id), document: documentObject(raw.document) });
26
+ }
27
+
28
+ /**
29
+ * Streams every live document of a database as NDJSON, in collection then id order, from one read snapshot.
30
+ * A stream that ends inside a line is incomplete and throws; resume with `after` set to the last document received.
31
+ * @param {V1Transport} transport
32
+ * @param {string} database
33
+ * @param {V1ExportOptions} [options]
34
+ * @returns {AsyncGenerator<V1ExportedDocument, void, undefined>}
35
+ */
36
+ export async function* exportDocuments(transport, database, options = {}) {
37
+ const { after, signal } = options;
38
+ const query = new URLSearchParams();
39
+ if (after !== undefined) {
40
+ if (typeof after !== "object" || after === null) throw new TypeError("export after is { collection, id }.");
41
+ query.set("after_collection", requireName("collection", after.collection));
42
+ query.set("after_id", requireDocumentId(after.id));
43
+ }
44
+ const response = await transport.openStream("exportDatabase", { database }, { query, signal });
45
+ const body = /** @type {ReadableStream<Uint8Array>} */ (response.body);
46
+ const decoder = /** @type {ReadableWritablePair<string, Uint8Array>} */ (new TextDecoderStream("utf-8", { fatal: true, ignoreBOM: true }));
47
+ const reader = body.pipeThrough(decoder).getReader();
48
+ let pending = "";
49
+ try {
50
+ while (true) {
51
+ /** @type {ReadableStreamReadResult<string>} */
52
+ let part;
53
+ try { part = await reader.read(); }
54
+ catch (error) { signal?.throwIfAborted(); throw new BodyTransportError(error); }
55
+ if (part.done) break;
56
+ // Split only the new text so a long line arriving in many chunks is scanned once.
57
+ const lines = part.value.split("\n");
58
+ lines[0] = pending + lines[0];
59
+ pending = /** @type {string} */ (lines.pop());
60
+ if (pending.length > maximumLineLength) malformed("export line exceeding 8 MiB");
61
+ for (const line of lines) yield decodeLine(line);
62
+ }
63
+ if (pending !== "") malformed("incomplete export line");
64
+ } finally {
65
+ await reader.cancel().catch(() => undefined);
66
+ reader.releaseLock();
67
+ }
68
+ }
package/src/v1/ids.js CHANGED
@@ -27,9 +27,12 @@ export function requireProject(project) {
27
27
  return requireId(project);
28
28
  }
29
29
 
30
- /** @param {unknown} id @returns {import("../ids.js").EntityId} */
30
+ /** A caller-supplied key is not a generated entity ID. @param {unknown} id @returns {string} */
31
31
  export function requireDocumentId(id) {
32
- return requireId(id);
32
+ if (typeof id !== "string" || id.length < 1 || id.length > 256 ||
33
+ /[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(id))
34
+ throw new TypeError("TerraBase document keys must be 1-256 characters of valid UTF-8 without control characters.");
35
+ return id;
33
36
  }
34
37
 
35
38
  /**
@@ -0,0 +1,72 @@
1
+ import { requireId } from "../ids.js";
2
+ import { requireName } from "./ids.js";
3
+ import { flag, list, nullable, numeric, record, text } from "./management-decode.js";
4
+ import { members, mutationOptions } from "./management-validation.js";
5
+ import { stringifyExactJson } from "./json.js";
6
+ /** @import { V1Transport } from "./transport.js" */
7
+ /** @import { V1RequestOptions } from "./terrabase-v1.js" */
8
+ const importOptions = record({ missingPrimaryKey: nullable(text), exclude: nullable(list(text)) });
9
+ const column = record({ name: text, sourceType: text, mapping: text, nullable: flag, excluded: flag });
10
+ const constraint = record({ kind: text, name: nullable(text), columns: list(text), references: nullable(text), enforced: flag });
11
+ const issue = record({ severity: text, code: text, column: nullable(text), row: nullable(numeric), message: text });
12
+ const table = record({ source: text, collection: text, excluded: flag, rows: numeric, idPolicy: text, primaryKey: list(text),
13
+ columns: list(column), constraints: list(constraint), issues: list(issue) });
14
+ const preview = record({ id: requireId, tables: list(table), skipped: list(record({ kind: text, name: text })), skippedTruncated: flag, blocking: numeric, warnings: numeric });
15
+ const progress = record({ tablesDone: numeric, tablesTotal: numeric, rowsWritten: numeric, rowsTotal: numeric, currentTable: nullable(text) });
16
+ const validation = record({ passed: flag, tables: list(record({ source: text, collection: text, sourceRows: numeric, documents: numeric,
17
+ sourceChecksum: text, documentChecksum: text, match: flag })) });
18
+ const error = record({ code: text, message: text, retryable: flag, table: nullable(text), row: nullable(numeric), column: nullable(text) });
19
+ const job = record({ id: requireId, project: requireId, database: text, region: text, state: text, cancelRequested: flag, options: importOptions,
20
+ source: record({ format: text, bytes: numeric, sha256: text, receivedBytes: numeric, staged: flag, retainedUntil: nullable(text) }),
21
+ preview: nullable(preview), confirmedPreviewId: nullable(requireId), progress: nullable(progress), validation: nullable(validation), error: nullable(error),
22
+ createdAt: text, updatedAt: text, startedAt: nullable(text), finishedAt: nullable(text) });
23
+ /** @typedef {ReturnType<typeof job>} V1ImportJob */
24
+ /** @typedef {{ readonly missingPrimaryKey?: "reject" | "generate" | null; readonly exclude?: readonly string[] | null }} V1ImportOptions */
25
+ /** @typedef {{ readonly format: "postgres_plain" | "sqlite"; readonly sourceBytes: number; readonly sourceSha256: string; readonly options?: V1ImportOptions | null }} V1CreateImportRequest */
26
+
27
+ /** One import job belongs to its destination database; preview confirmation is explicit. */
28
+ export class V1Imports {
29
+ /** @type {V1Transport} */
30
+ #transport;
31
+ /** @type {{database: string}} */
32
+ #params;
33
+ /** @param {V1Transport} transport @param {string} database */
34
+ constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
35
+ /** @param {V1CreateImportRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
36
+ async create(request, options = {}) {
37
+ members(request, ["format", "sourceBytes", "sourceSha256", "options"]);
38
+ if (!["postgres_plain", "sqlite"].includes(request.format) || !Number.isSafeInteger(request.sourceBytes) || request.sourceBytes < 1 || request.sourceBytes > 1073741824 ||
39
+ typeof request.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(request.sourceSha256)) throw new TypeError("Invalid import format, size or SHA-256.");
40
+ mutationOptions(options);
41
+ return job((await this.#transport.call("createImport", this.#params, { body: stringifyExactJson(request, "import create"), signal: options.signal })).value);
42
+ }
43
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
44
+ async get(options = {}) { return job((await this.#transport.call("getImport", this.#params, { signal: options.signal })).value); }
45
+ /** @param {number | bigint} offset @param {Uint8Array} bytes @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
46
+ async uploadSource(offset, bytes, options = {}) {
47
+ if (!(typeof offset === "bigint" || typeof offset === "number" && Number.isSafeInteger(offset)) || offset < 0 || BigInt(offset) > 9223372036854775807n)
48
+ throw new TypeError("offset must be a non-negative signed 64-bit integer.");
49
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > 4194304) throw new TypeError("A source part must contain 1-4194304 bytes.");
50
+ mutationOptions(options);
51
+ return job((await this.#transport.call("uploadImportSource", this.#params, {
52
+ body: bytes.slice(), contentType: "application/octet-stream", query: new URLSearchParams({ offset: String(offset) }), signal: options.signal,
53
+ })).value);
54
+ }
55
+ /** @param {{readonly options?: V1ImportOptions | null}} request @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
56
+ async update(request, options = {}) {
57
+ members(request, ["options"]);
58
+ mutationOptions(options);
59
+ return job((await this.#transport.call("updateImport", this.#params, { body: stringifyExactJson(request, "import update"), signal: options.signal })).value);
60
+ }
61
+ /** @param {{readonly previewId: string}} request @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
62
+ async start(request, options = {}) {
63
+ members(request, ["previewId"]);
64
+ requireId(request.previewId, "Preview ID");
65
+ mutationOptions(options);
66
+ return job((await this.#transport.call("startImport", this.#params, { body: stringifyExactJson(request, "import start"), signal: options.signal })).value);
67
+ }
68
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
69
+ async cancel(options = {}) { mutationOptions(options); return job((await this.#transport.call("cancelImport", this.#params, { signal: options.signal })).value); }
70
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1ImportJob>} */
71
+ async resume(options = {}) { mutationOptions(options); return job((await this.#transport.call("resumeImport", this.#params, { signal: options.signal })).value); }
72
+ }
@@ -0,0 +1,76 @@
1
+ import { requireName } from "./ids.js";
2
+ import { flag, list, numeric, record, text } 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 index = record({ name: text, collection: text, fields: list(text), state: text });
8
+ const indexes = record({ items: list(index) });
9
+ const policy = record({ collection: text, enabled: flag });
10
+ const recommendations = record({ items: list(record({ id: text, collection: text, fields: list(text), reason: text, queries: numeric })) });
11
+ const applied = record({ status: text, index });
12
+ /** @typedef {ReturnType<typeof index>} V1Index */
13
+ /** @typedef {ReturnType<typeof indexes>} V1IndexList */
14
+ /** @typedef {ReturnType<typeof policy>} V1AutoIndexPolicy */
15
+ /** @typedef {ReturnType<typeof recommendations>} V1IndexRecommendations */
16
+ /** @typedef {ReturnType<typeof applied>} V1AppliedIndexRecommendation */
17
+ /** @typedef {{ readonly collection: string; readonly fields: readonly string[]; readonly name?: string | null }} V1CreateIndexRequest */
18
+ /** @param {unknown} value @returns {string} */
19
+ function indexName(value) {
20
+ if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("Invalid index name.");
21
+ return value;
22
+ }
23
+
24
+ /** Secondary indexes, automatic policies and workload recommendations for one database. */
25
+ export class V1Indexes {
26
+ /** @type {V1Transport} */
27
+ #transport;
28
+ /** @type {{database: string}} */
29
+ #params;
30
+ /** @param {V1Transport} transport @param {string} database */
31
+ constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
32
+ /** @param {V1CreateIndexRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1Index>} */
33
+ async create(request, options = {}) {
34
+ members(request, ["collection", "fields", "name"]);
35
+ requireName("collection", request.collection);
36
+ if (!Array.isArray(request.fields) || request.fields.length < 1 || request.fields.length > 4 ||
37
+ new Set(request.fields).size !== request.fields.length || request.fields.some(f => typeof f !== "string" || f === "_id" || f === "_rev" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}(?:\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,7}$/u.test(f)))
38
+ throw new TypeError("fields must be 1-4 distinct dotted paths, excluding _id and _rev.");
39
+ if (request.name !== undefined && request.name !== null) indexName(request.name);
40
+ mutationOptions(options);
41
+ return index((await this.#transport.call("createIndex", this.#params, { body: stringifyExactJson(request, "index"), signal: options.signal })).value);
42
+ }
43
+ /** @param {{ readonly collection?: string }} [request] @param {V1RequestOptions} [options] @returns {Promise<V1IndexList>} */
44
+ async list(request = {}, options = {}) {
45
+ members(request, ["collection"]);
46
+ const query = new URLSearchParams();
47
+ if (request.collection !== undefined) query.set("collection", requireName("collection", request.collection));
48
+ return indexes((await this.#transport.call("listIndexes", this.#params, { query, signal: options.signal })).value);
49
+ }
50
+ /** @param {string} name @param {V1RequestOptions} [options] @returns {Promise<void>} */
51
+ async drop(name, options = {}) {
52
+ mutationOptions(options);
53
+ await this.#transport.call("dropIndex", { ...this.#params, index: indexName(name) }, { signal: options.signal });
54
+ }
55
+ /** @param {string} collection @param {V1RequestOptions} [options] @returns {Promise<V1AutoIndexPolicy>} */
56
+ async getAutoPolicy(collection, options = {}) {
57
+ return policy((await this.#transport.call("getAutoIndexPolicy", { ...this.#params, collection: requireName("collection", collection) }, { signal: options.signal })).value);
58
+ }
59
+ /** @param {string} collection @param {{ readonly enabled: boolean }} request @param {V1RequestOptions} [options] @returns {Promise<V1AutoIndexPolicy>} */
60
+ async setAutoPolicy(collection, request, options = {}) {
61
+ members(request, ["enabled"]);
62
+ if (typeof request.enabled !== "boolean") throw new TypeError("enabled must be boolean.");
63
+ mutationOptions(options);
64
+ return policy((await this.#transport.call("setAutoIndexPolicy", { ...this.#params, collection: requireName("collection", collection) }, {
65
+ body: stringifyExactJson(request, "auto-index policy"), signal: options.signal,
66
+ })).value);
67
+ }
68
+ /** @param {V1RequestOptions} [options] @returns {Promise<V1IndexRecommendations>} */
69
+ async recommendations(options = {}) { return recommendations((await this.#transport.call("listIndexRecommendations", this.#params, { signal: options.signal })).value); }
70
+ /** @param {string} id Opaque producer recommendation identifier. @param {V1RequestOptions} [options] @returns {Promise<V1AppliedIndexRecommendation>} */
71
+ async applyRecommendation(id, options = {}) {
72
+ if (typeof id !== "string" || id === "") throw new TypeError("A recommendation identifier is required.");
73
+ mutationOptions(options);
74
+ return applied((await this.#transport.call("applyIndexRecommendation", { ...this.#params, recommendation: id }, { signal: options.signal })).value);
75
+ }
76
+ }
package/src/v1/json.js CHANGED
@@ -28,9 +28,10 @@ function canonicalDecimal(text) {
28
28
  let digits = `${match[2]}${fraction}`.replace(/^0+/u, "");
29
29
  let exponent = BigInt(match[4] ?? "0") - BigInt(fraction.length);
30
30
  if (digits === "") return { negative: false, digits: "", exponent: 0n };
31
- const trimmed = digits.replace(/0+$/u, "");
32
- exponent += BigInt(digits.length - trimmed.length);
33
- digits = trimmed;
31
+ let end = digits.length;
32
+ while (digits[end - 1] === "0") end--;
33
+ exponent += BigInt(digits.length - end);
34
+ digits = digits.slice(0, end);
34
35
  return { negative: match[1] === "-", digits, exponent };
35
36
  }
36
37