@terrascale/sdk 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +177 -67
- package/package.json +10 -4
- package/sdk-public-v1-route-manifest.json +81 -0
- package/sdk-v1-route-manifest.json +896 -15
- package/src/http.js +3 -48
- package/src/index.js +75 -1
- package/src/local/environment.js +0 -1
- package/src/ts-auth.js +1 -5
- package/src/v1/backups.js +111 -0
- package/src/v1/catalog.js +210 -0
- package/src/v1/changes.js +225 -0
- package/src/v1/database-schema.js +45 -0
- package/src/v1/decode.js +5 -5
- package/src/v1/errors.js +23 -3
- package/src/v1/ids.js +5 -2
- package/src/v1/imports.js +72 -0
- package/src/v1/indexes.js +76 -0
- package/src/v1/json.js +4 -3
- package/src/v1/management-decode.js +24 -0
- package/src/v1/management-routes.js +67 -0
- package/src/v1/management-validation.js +25 -0
- package/src/v1/routes.js +32 -7
- package/src/v1/saved-queries.js +58 -0
- package/src/v1/shape-crypto.js +125 -0
- package/src/v1/shape-manifest.js +31 -0
- package/src/v1/shape-reader.js +212 -0
- package/src/v1/shape-subscription.js +208 -0
- package/src/v1/shapes.js +11 -5
- package/src/v1/sql.js +98 -0
- package/src/v1/system.js +80 -0
- package/src/v1/terrabase-v1.js +178 -27
- package/src/v1/transport.js +75 -31
- package/src/v1/usage.js +101 -0
- package/types/http.d.ts +2 -8
- package/types/index.d.ts +68 -0
- package/types/local/environment.d.ts +1 -1
- package/types/ts-auth.d.ts +0 -2
- package/types/v1/backups.d.ts +128 -0
- package/types/v1/catalog.d.ts +92 -0
- package/types/v1/changes.d.ts +53 -0
- package/types/v1/database-schema.d.ts +82 -0
- package/types/v1/decode.d.ts +15 -4
- package/types/v1/errors.d.ts +16 -1
- package/types/v1/ids.d.ts +2 -2
- package/types/v1/imports.d.ts +132 -0
- package/types/v1/indexes.d.ts +75 -0
- package/types/v1/management-decode.d.ts +15 -0
- package/types/v1/management-routes.d.ts +3 -0
- package/types/v1/management-validation.d.ts +8 -0
- package/types/v1/routes.d.ts +5 -2
- package/types/v1/saved-queries.d.ts +33 -0
- package/types/v1/shape-crypto.d.ts +39 -0
- package/types/v1/shape-manifest.d.ts +28 -0
- package/types/v1/shape-reader.d.ts +45 -0
- package/types/v1/shape-subscription.d.ts +35 -0
- package/types/v1/shapes.d.ts +16 -2
- package/types/v1/sql.d.ts +47 -0
- package/types/v1/system.d.ts +85 -0
- package/types/v1/terrabase-v1.d.ts +113 -4
- package/types/v1/transport.d.ts +25 -8
- package/types/v1/usage.d.ts +171 -0
- package/src/ts-auth-discovery.js +0 -207
- package/types/ts-auth-discovery.d.ts +0 -19
|
@@ -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,7 +8,7 @@ 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 {
|
|
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
|
|
@@ -18,12 +18,13 @@ import { isJsonObject } from "./json.js";
|
|
|
18
18
|
/**
|
|
19
19
|
* The result of a document write.
|
|
20
20
|
* @typedef {object} V1WriteResult
|
|
21
|
-
* @property {
|
|
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
|
/**
|
|
@@ -45,9 +46,7 @@ export function object(value, what) {
|
|
|
45
46
|
|
|
46
47
|
/** @param {JsonValue | undefined} value @returns {JsonObject} */
|
|
47
48
|
export function documentObject(value) {
|
|
48
|
-
|
|
49
|
-
if (Object.hasOwn(document, "id")) malformed("document body: the entity ID belongs in the document key");
|
|
50
|
-
return document;
|
|
49
|
+
return object(value, "document body");
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
/**
|
|
@@ -126,5 +125,6 @@ export function decodeWrite(response) {
|
|
|
126
125
|
durability: durability(raw.durability),
|
|
127
126
|
created: response.status === 201,
|
|
128
127
|
location: response.headers.get("location") ?? undefined,
|
|
128
|
+
...(raw.document === undefined ? {} : { document: documentObject(raw.document) }),
|
|
129
129
|
});
|
|
130
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
|
-
|
|
160
|
-
if (isJsonObject(
|
|
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
|
}
|
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 {
|
|
30
|
+
/** A caller-supplied key is not a generated entity ID. @param {unknown} id @returns {string} */
|
|
31
31
|
export function requireDocumentId(id) {
|
|
32
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
digits
|
|
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
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { array, malformed, object, string, boolean } from "./decode.js";
|
|
2
|
+
import { ExactDecimal } from "./json.js";
|
|
3
|
+
/** @import { JsonValue } from "./json.js" */
|
|
4
|
+
/** @template T @typedef {(value: JsonValue | undefined) => T} Decoder */
|
|
5
|
+
export const text = /** @type {Decoder<string>} */ (value => string(value, "string"));
|
|
6
|
+
export const flag = /** @type {Decoder<boolean>} */ (value => boolean(value, "boolean"));
|
|
7
|
+
/** @type {Decoder<number | ExactDecimal>} */
|
|
8
|
+
export const numeric = value => typeof value === "number" && Number.isFinite(value) || value instanceof ExactDecimal ? value : malformed("number");
|
|
9
|
+
/** @template T @param {Decoder<T>} decode @returns {Decoder<T | null>} */
|
|
10
|
+
export const nullable = decode => value => value === null ? null : decode(value);
|
|
11
|
+
/** @template T @param {Decoder<T>} decode @returns {Decoder<T | undefined>} */
|
|
12
|
+
export const optional = decode => value => value === undefined ? undefined : decode(value);
|
|
13
|
+
/** @template T @param {Decoder<T>} decode @returns {Decoder<readonly T[]>} */
|
|
14
|
+
export const list = decode => value => Object.freeze(array(value, "array").map(decode));
|
|
15
|
+
/** @template T @param {{ [K in keyof T]: Decoder<T[K]> }} fields @returns {Decoder<Readonly<T>>} */
|
|
16
|
+
export function record(fields) {
|
|
17
|
+
return value => {
|
|
18
|
+
const raw = object(value, "management response");
|
|
19
|
+
return Object.freeze(/** @type {T} */ (Object.fromEntries(Object.entries(fields).map(([key, decode]) =>
|
|
20
|
+
[key, /** @type {Decoder<unknown>} */ (decode)(raw[key])]))));
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** @template T @param {Decoder<T>} decode @returns {Decoder<Readonly<Record<string, T>>>} */
|
|
24
|
+
export const dictionary = decode => value => Object.freeze(Object.fromEntries(Object.entries(object(value, "dictionary")).map(([key, item]) => [key, decode(item)])));
|