@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.
- package/CHANGELOG.md +43 -0
- package/README.md +193 -56
- package/package.json +14 -5
- package/sdk-public-v1-route-manifest.json +81 -0
- package/sdk-v1-route-manifest.json +8996 -1697
- package/src/database-codec.js +21 -26
- package/src/database-types.js +11 -11
- package/src/database-view.js +6 -6
- package/src/http.js +13 -48
- package/src/ids.js +22 -0
- package/src/index.js +77 -2
- package/src/local/better-auth.js +17 -6
- package/src/local/environment.js +0 -1
- package/src/local/index.js +2 -2
- package/src/local/legacy-client.js +5 -5
- package/src/local/router.js +12 -12
- package/src/local/store.js +29 -28
- package/src/local/test-environment.js +3 -2
- package/src/postgres.js +5 -4
- package/src/react/core.js +2 -2
- package/src/ts-auth-ids.js +122 -0
- package/src/ts-auth.js +75 -77
- 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 +20 -8
- package/src/v1/errors.js +23 -3
- package/src/v1/ids.js +9 -59
- 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 +36 -11
- 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 +15 -10
- package/src/v1/sql.js +98 -0
- package/src/v1/system.js +80 -0
- package/src/v1/terrabase-v1.js +192 -33
- package/src/v1/transport.js +80 -34
- package/src/v1/usage.js +101 -0
- package/types/database-codec.d.ts +1 -6
- package/types/database-types.d.ts +11 -11
- package/types/http.d.ts +2 -8
- package/types/ids.d.ts +10 -0
- package/types/index.d.ts +70 -1
- package/types/local/environment.d.ts +1 -1
- package/types/local/store.d.ts +10 -0
- package/types/local/test-environment.d.ts +2 -2
- package/types/postgres.d.ts +4 -4
- package/types/ts-auth-ids.d.ts +14 -0
- package/types/ts-auth.d.ts +99 -101
- 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 +17 -4
- package/types/v1/errors.d.ts +16 -1
- package/types/v1/ids.d.ts +4 -16
- 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 +14 -0
- package/types/v1/sql.d.ts +47 -0
- package/types/v1/system.d.ts +85 -0
- package/types/v1/terrabase-v1.d.ts +116 -7
- package/types/v1/transport.d.ts +27 -10
- 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,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]
|
|
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 =
|
|
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 =
|
|
105
|
+
const document = documentObject(raw.document);
|
|
100
106
|
if (raw.siblings === undefined) return Object.freeze({ id, version, state, document });
|
|
101
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
}
|