@terrascale/sdk 0.0.5 → 0.0.7
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 +27 -0
- package/README.md +99 -12
- package/package.json +1 -1
- package/sdk-public-v1-route-manifest.json +6 -1
- package/sdk-v1-route-manifest.json +39 -36
- package/src/index.js +21 -0
- package/src/local/router.js +12 -3
- package/src/v1/change-webhooks.js +78 -0
- package/src/v1/changes.js +2 -2
- package/src/v1/decode.js +4 -3
- package/src/v1/errors.js +26 -0
- package/src/v1/export.js +3 -3
- package/src/v1/filter.js +103 -2
- package/src/v1/ids.js +7 -2
- package/src/v1/indexes.js +6 -4
- package/src/v1/management-routes.js +12 -4
- package/src/v1/shape-reader.js +19 -5
- package/src/v1/shape-replica.js +46 -0
- package/src/v1/shape-subscription.js +57 -5
- package/src/v1/shapes.js +3 -3
- package/src/v1/sql-execute.js +132 -0
- package/src/v1/sql.js +2 -2
- package/src/v1/terrabase-v1.js +141 -9
- package/src/v1/transport.js +27 -5
- package/types/index.d.ts +21 -1
- package/types/v1/change-webhooks.d.ts +59 -0
- package/types/v1/decode.d.ts +7 -2
- package/types/v1/errors.d.ts +18 -0
- package/types/v1/filter.d.ts +50 -0
- package/types/v1/ids.d.ts +3 -1
- package/types/v1/indexes.d.ts +5 -0
- package/types/v1/management-routes.d.ts +1 -1
- package/types/v1/shape-replica.d.ts +8 -0
- package/types/v1/shape-subscription.d.ts +15 -0
- package/types/v1/sql-execute.d.ts +61 -0
- package/types/v1/terrabase-v1.d.ts +95 -1
- package/types/v1/transport.d.ts +17 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { requireId } from "../ids.js";
|
|
2
|
+
import { requireName } from "./ids.js";
|
|
3
|
+
import { object, string } from "./decode.js";
|
|
4
|
+
import { list, nullable, numeric, record, text } from "./management-decode.js";
|
|
5
|
+
import { members, mutationOptions, pageQuery } from "./management-validation.js";
|
|
6
|
+
import { stringifyExactJson } from "./json.js";
|
|
7
|
+
/** @import { V1Transport } from "./transport.js" */
|
|
8
|
+
/** @import { V1RequestOptions } from "./terrabase-v1.js" */
|
|
9
|
+
const webhook = record({ id: requireId, url: text, createdAt: text, status: text });
|
|
10
|
+
const webhooks = list(webhook);
|
|
11
|
+
const deliveries = list(record({ id: requireId, createdAt: text, status: text, attempts: numeric,
|
|
12
|
+
lastAttemptAt: nullable(text), nextAttemptAt: nullable(text), httpStatus: nullable(numeric), errorCode: nullable(text) }));
|
|
13
|
+
/** @typedef {ReturnType<typeof webhook>} V1ChangeWebhook */
|
|
14
|
+
/** @typedef {V1ChangeWebhook & { readonly secret: string }} V1CreatedChangeWebhook */
|
|
15
|
+
/** @typedef {ReturnType<typeof deliveries>[number]} V1ChangeWebhookDelivery */
|
|
16
|
+
/** @typedef {{ readonly after?: string; readonly limit?: number }} V1ChangeWebhookListRequest */
|
|
17
|
+
|
|
18
|
+
/** @param {V1ChangeWebhookListRequest} request */
|
|
19
|
+
function query(request) {
|
|
20
|
+
if (request.after !== undefined) requireId(request.after, "after");
|
|
21
|
+
return pageQuery(request);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Collection change webhook registration and sanitized delivery history. Requests are never replayed. */
|
|
25
|
+
export class V1ChangeWebhooks {
|
|
26
|
+
/** @type {V1Transport} */
|
|
27
|
+
#transport;
|
|
28
|
+
/** @type {{database: string; collection: string}} */
|
|
29
|
+
#params;
|
|
30
|
+
/** @param {V1Transport} transport @param {string} database @param {string} collection */
|
|
31
|
+
constructor(transport, database, collection) {
|
|
32
|
+
this.#transport = transport;
|
|
33
|
+
this.#params = { database: requireName("database", database), collection: requireName("collection", collection) };
|
|
34
|
+
Object.freeze(this);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Registers from the current feed head. Requires documents:read and documents:write.
|
|
38
|
+
* Store secret immediately; it is non-enumerable to keep it out of JSON and ordinary diagnostics.
|
|
39
|
+
* @param {{ readonly url: string }} request @param {V1RequestOptions} [options] @returns {Promise<V1CreatedChangeWebhook>}
|
|
40
|
+
*/
|
|
41
|
+
async create(request, options = {}) {
|
|
42
|
+
members(request, ["url"]);
|
|
43
|
+
if (typeof request.url !== "string" || request.url.length > 2048) throw new TypeError("Webhook URL must be an HTTPS URL of at most 2048 characters.");
|
|
44
|
+
let url;
|
|
45
|
+
try { url = new URL(request.url); } catch { throw new TypeError("Webhook URL must be absolute HTTPS."); }
|
|
46
|
+
if (url.protocol !== "https:" || url.port !== "" || url.username !== "" || url.password !== "" || url.hash !== "")
|
|
47
|
+
throw new TypeError("Webhook URL must be HTTPS on port 443, without credentials or a fragment.");
|
|
48
|
+
mutationOptions(options);
|
|
49
|
+
const raw = object((await this.#transport.call("createChangeWebhook", this.#params, {
|
|
50
|
+
body: stringifyExactJson(request, "change webhook"), signal: options.signal,
|
|
51
|
+
})).value, "created webhook");
|
|
52
|
+
const secret = string(raw.secret, "webhook secret");
|
|
53
|
+
return Object.freeze(Object.defineProperty({ ...webhook(raw), secret }, "secret", { enumerable: false }));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Requires documents:read. Ascending IDs; continue with the final ID until an empty array. No secrets.
|
|
57
|
+
* @param {V1ChangeWebhookListRequest} [request] @param {V1RequestOptions} [options] @returns {Promise<readonly V1ChangeWebhook[]>}
|
|
58
|
+
*/
|
|
59
|
+
async list(request = {}, options = {}) {
|
|
60
|
+
return webhooks((await this.#transport.call("listChangeWebhooks", this.#params, { query: query(request), signal: options.signal })).value);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Requires documents:read and documents:write. An in-flight delivery may still complete. Unknown ID returns 404.
|
|
64
|
+
* @param {string} id @param {V1RequestOptions} [options] @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
async delete(id, options = {}) {
|
|
67
|
+
mutationOptions(options);
|
|
68
|
+
await this.#transport.call("deleteChangeWebhook", { ...this.#params, webhook: requireId(id) }, { signal: options.signal });
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Requires documents:read. Latest 100 retained records; paginate with the final ID until an empty array.
|
|
72
|
+
* @param {string} id @param {V1ChangeWebhookListRequest} [request] @param {V1RequestOptions} [options] @returns {Promise<readonly V1ChangeWebhookDelivery[]>}
|
|
73
|
+
*/
|
|
74
|
+
async deliveries(id, request = {}, options = {}) {
|
|
75
|
+
return deliveries((await this.#transport.call("listChangeWebhookDeliveries", { ...this.#params, webhook: requireId(id) },
|
|
76
|
+
{ query: query(request), signal: options.signal })).value);
|
|
77
|
+
}
|
|
78
|
+
}
|
package/src/v1/changes.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { documentObject, malformed, object, u64 } from "./decode.js";
|
|
2
2
|
import { parseV1Error } from "./errors.js";
|
|
3
|
-
import {
|
|
3
|
+
import { requireStoredId, requireName } from "./ids.js";
|
|
4
4
|
import { parseExactJson } from "./json.js";
|
|
5
5
|
import { BodyTransportError } from "./transport.js";
|
|
6
6
|
/** @import { JsonObject } from "./json.js" */
|
|
@@ -156,7 +156,7 @@ function decodeEvent(event, headers) {
|
|
|
156
156
|
if (raw.op !== "upsert" && raw.op !== "delete") malformed("changes operation");
|
|
157
157
|
if (typeof raw.origin !== "string" || !/^[0-9a-f]{16}$/u.test(raw.origin)) malformed("changes origin");
|
|
158
158
|
return Object.freeze({ type: "change", cursor, collection: requireName("collection", raw.collection),
|
|
159
|
-
id:
|
|
159
|
+
id: requireStoredId(raw.id), op: raw.op, version: u64(raw.version, "changes version"), origin: raw.origin,
|
|
160
160
|
document: raw.document === null ? null : documentObject(raw.document),
|
|
161
161
|
});
|
|
162
162
|
}
|
package/src/v1/decode.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Typed decoding of TerraBase v1 response bodies.
|
|
2
|
-
import { parseU64, requireDocumentId, requireVersion } from "./ids.js";
|
|
2
|
+
import { parseU64, requireDocumentId, requireStoredId, requireVersion } from "./ids.js";
|
|
3
3
|
import { isJsonObject } from "./json.js";
|
|
4
4
|
/** @import { JsonObject, JsonValue } from "./json.js" */
|
|
5
5
|
|
|
@@ -13,7 +13,8 @@ import { isJsonObject } from "./json.js";
|
|
|
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
|
+
* @property {number} [distance] Distance to the query vector, or meters to a geo `near` point; present only on `near` query results.
|
|
17
|
+
* @property {number} [score] Full-text relevance score on results sorted by `$score`.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -100,7 +101,7 @@ export function durability(value) {
|
|
|
100
101
|
*/
|
|
101
102
|
export function decodeDocument(value) {
|
|
102
103
|
const raw = object(value, "document");
|
|
103
|
-
const id =
|
|
104
|
+
const id = requireStoredId(raw.id);
|
|
104
105
|
const version = requireVersion(raw.version);
|
|
105
106
|
const state = string(raw.state, "document state");
|
|
106
107
|
const document = documentObject(raw.document);
|
package/src/v1/errors.js
CHANGED
|
@@ -76,6 +76,7 @@ export const V1ErrorCode = Object.freeze({
|
|
|
76
76
|
mustRefetch: "must_refetch", preconditionFailed: "precondition_failed", payloadTooLarge: "payload_too_large",
|
|
77
77
|
unsupportedMediaType: "unsupported_media_type", uriTooLong: "uri_too_long", shapeBusy: "shape_busy",
|
|
78
78
|
internalError: "internal_error", capacity: "capacity", notModified: "not_modified",
|
|
79
|
+
sqlError: "sql_error", sqlConflict: "sql_conflict", sqlTooLarge: "sql_too_large",
|
|
79
80
|
});
|
|
80
81
|
|
|
81
82
|
/** `412 precondition_failed`: an If-Match, If-None-Match, assert or assert_count failed. */
|
|
@@ -114,11 +115,36 @@ export class V1NotFoundError extends TerraBaseV1Error {
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A refused relational SQL statement: `sql_error` (400), `forbidden_scope` (403, writes and DDL need
|
|
120
|
+
* documents:write), `sql_conflict` (409) or `sql_too_large` (413). The whole request rolled back.
|
|
121
|
+
*/
|
|
122
|
+
export class SqlError extends TerraBaseV1Error {
|
|
123
|
+
/** @type {number} Zero-based index of the failing statement. */
|
|
124
|
+
statement;
|
|
125
|
+
/** @type {string | undefined} The SQLite error code, such as `SQLITE_CONSTRAINT_PRIMARYKEY`. */
|
|
126
|
+
sqliteCode;
|
|
127
|
+
|
|
128
|
+
/** @param {TerraBaseV1ErrorInit & { statement: number; sqliteCode?: string | undefined }} init */
|
|
129
|
+
constructor(init) {
|
|
130
|
+
super(init);
|
|
131
|
+
this.name = "SqlError";
|
|
132
|
+
this.statement = init.statement;
|
|
133
|
+
this.sqliteCode = init.sqliteCode;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
117
137
|
/**
|
|
118
138
|
* @param {TerraBaseV1ErrorInit} init
|
|
119
139
|
* @returns {TerraBaseV1Error}
|
|
120
140
|
*/
|
|
121
141
|
function typedError(init) {
|
|
142
|
+
// Only the SQL execute route reports a statement index.
|
|
143
|
+
const statement = init.details?.statement;
|
|
144
|
+
if (typeof statement === "number" && Number.isInteger(statement) && statement >= 0) {
|
|
145
|
+
const sqliteCode = init.details?.sqlite_code;
|
|
146
|
+
return new SqlError({ ...init, statement, sqliteCode: typeof sqliteCode === "string" ? sqliteCode : undefined });
|
|
147
|
+
}
|
|
122
148
|
switch (init.code) {
|
|
123
149
|
case "precondition_failed": return new V1PreconditionFailedError(init);
|
|
124
150
|
case "invalid_key": return new V1InvalidKeyError(init);
|
package/src/v1/export.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { documentObject, malformed, object } from "./decode.js";
|
|
2
|
-
import {
|
|
2
|
+
import { requireStoredId, requireName } from "./ids.js";
|
|
3
3
|
import { parseExactJson } from "./json.js";
|
|
4
4
|
import { BodyTransportError } from "./transport.js";
|
|
5
5
|
/** @import { JsonObject } from "./json.js" */
|
|
@@ -22,7 +22,7 @@ function decodeLine(line) {
|
|
|
22
22
|
try { value = parseExactJson(line); }
|
|
23
23
|
catch { malformed("export line JSON"); }
|
|
24
24
|
const raw = object(value, "export line");
|
|
25
|
-
return Object.freeze({ collection: requireName("collection", raw.collection), id:
|
|
25
|
+
return Object.freeze({ collection: requireName("collection", raw.collection), id: requireStoredId(raw.id), document: documentObject(raw.document) });
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -39,7 +39,7 @@ export async function* exportDocuments(transport, database, options = {}) {
|
|
|
39
39
|
if (after !== undefined) {
|
|
40
40
|
if (typeof after !== "object" || after === null) throw new TypeError("export after is { collection, id }.");
|
|
41
41
|
query.set("after_collection", requireName("collection", after.collection));
|
|
42
|
-
query.set("after_id",
|
|
42
|
+
query.set("after_id", requireStoredId(after.id));
|
|
43
43
|
}
|
|
44
44
|
const response = await transport.openStream("exportDatabase", { database }, { query, signal });
|
|
45
45
|
const body = /** @type {ReadableStream<Uint8Array>} */ (response.body);
|
package/src/v1/filter.js
CHANGED
|
@@ -24,7 +24,17 @@ import { ExactDecimal } from "./json.js";
|
|
|
24
24
|
* | { readonly nin: readonly [V1Path, readonly V1Scalar[]] }
|
|
25
25
|
* | { readonly exists: V1Path }
|
|
26
26
|
* | { readonly prefix: readonly [V1Path, string] }
|
|
27
|
-
* | { readonly contains: readonly [V1Path, V1Scalar] }
|
|
27
|
+
* | { readonly contains: readonly [V1Path, V1Scalar] }
|
|
28
|
+
* | { readonly within: readonly [string, V1GeoCircle | V1GeoBox] }
|
|
29
|
+
* | { readonly search: readonly [V1Path, string] }} V1Filter
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Points within `meters` (more than 0, at most 20000000) of a WGS 84 point, by haversine distance.
|
|
33
|
+
* @typedef {{ readonly lat: number; readonly lon: number; readonly meters: number }} V1GeoCircle
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Points in a latitude/longitude box; `west > east` crosses the antimeridian.
|
|
37
|
+
* @typedef {{ readonly south: number; readonly west: number; readonly north: number; readonly east: number }} V1GeoBox
|
|
28
38
|
*/
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -54,6 +64,28 @@ export function requirePath(path) {
|
|
|
54
64
|
throw new TypeError('A TerraBase path is an RFC 6901 JSON Pointer of 1-16 segments, "$id".');
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** @param {V1Path} path @returns {V1Path} */
|
|
68
|
+
function requireSearchPath(path) {
|
|
69
|
+
if (isJsonPointer(path)) return path;
|
|
70
|
+
throw new TypeError("search requires a JSON Pointer of 1-16 segments, excluding $id.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {unknown} terms @returns {string} */
|
|
74
|
+
function requireSearchTerms(terms) {
|
|
75
|
+
if (typeof terms !== "string" || terms.length === 0 || terms.includes("\0") || /[\uD800-\uDFFF]/u.test(terms) || new TextEncoder().encode(terms).byteLength > 4096)
|
|
76
|
+
throw new TypeError("search takes nonempty string terms of at most 4096 UTF-8 bytes without NUL.");
|
|
77
|
+
return terms;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {V1Filter | null | undefined} filter @returns {number} */
|
|
81
|
+
export function searchCount(filter) {
|
|
82
|
+
if (!filter) return 0;
|
|
83
|
+
if ("search" in filter) return 1;
|
|
84
|
+
if ("and" in filter) return filter.and.reduce((count, child) => count + searchCount(child), 0);
|
|
85
|
+
if ("or" in filter) return filter.or.reduce((count, child) => count + searchCount(child), 0);
|
|
86
|
+
return "not" in filter ? searchCount(filter.not) : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/**
|
|
58
90
|
* @param {V1Scalar} value
|
|
59
91
|
* @returns {V1Scalar}
|
|
@@ -92,6 +124,46 @@ function scalars(op, values) {
|
|
|
92
124
|
return Object.freeze(values.map(requireScalar));
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
/**
|
|
128
|
+
* @param {unknown} lat
|
|
129
|
+
* @param {unknown} lon
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
export function requireGeoPoint(lat, lon) {
|
|
133
|
+
if (typeof lat !== "number" || typeof lon !== "number" || !(lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180))
|
|
134
|
+
throw new TypeError("lat is -90 to 90 and lon is -180 to 180.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {unknown} area
|
|
139
|
+
* @returns {V1GeoCircle | V1GeoBox}
|
|
140
|
+
*/
|
|
141
|
+
function geoArea(area) {
|
|
142
|
+
if (typeof area !== "object" || area === null || Array.isArray(area)) throw new TypeError("within takes a circle or box object.");
|
|
143
|
+
const keys = Object.keys(area).sort().join();
|
|
144
|
+
const a = /** @type {Record<string, unknown>} */ (area);
|
|
145
|
+
if (keys === "east,north,south,west") {
|
|
146
|
+
requireGeoPoint(a.south, a.west);
|
|
147
|
+
requireGeoPoint(a.north, a.east);
|
|
148
|
+
if (/** @type {number} */ (a.south) > /** @type {number} */ (a.north)) throw new TypeError("A within box has south <= north.");
|
|
149
|
+
return Object.freeze({ south: /** @type {number} */ (a.south), west: /** @type {number} */ (a.west), north: /** @type {number} */ (a.north), east: /** @type {number} */ (a.east) });
|
|
150
|
+
}
|
|
151
|
+
if (keys !== "lat,lon,meters" || typeof a.meters !== "number" || !(a.meters > 0 && a.meters <= 20_000_000))
|
|
152
|
+
throw new TypeError("within takes {lat, lon, meters (0 to 20000000)} or {south, west, north, east}.");
|
|
153
|
+
requireGeoPoint(a.lat, a.lon);
|
|
154
|
+
return Object.freeze({ lat: /** @type {number} */ (a.lat), lon: /** @type {number} */ (a.lon), meters: a.meters });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {V1Path} path
|
|
159
|
+
* @param {unknown} area
|
|
160
|
+
* @returns {V1Filter}
|
|
161
|
+
*/
|
|
162
|
+
function within(path, area) {
|
|
163
|
+
if (requirePath(path) === "$id") throw new TypeError("within takes a JSON Pointer.");
|
|
164
|
+
return Object.freeze({ within: Object.freeze(/** @type {const} */ ([path, geoArea(area)])) });
|
|
165
|
+
}
|
|
166
|
+
|
|
95
167
|
const binaryOperators = new Set(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]);
|
|
96
168
|
|
|
97
169
|
/**
|
|
@@ -119,7 +191,15 @@ export function requireFilter(filter) {
|
|
|
119
191
|
for (const child of args) visit(child, depth + 1);
|
|
120
192
|
} else if (op === "not") visit(args, depth + 1);
|
|
121
193
|
else if (op === "exists") requirePath(/** @type {V1Path} */ (args));
|
|
122
|
-
else if (
|
|
194
|
+
else if (op === "within") {
|
|
195
|
+
if (!Array.isArray(args) || args.length !== 2) throw new TypeError("within takes [path, area].");
|
|
196
|
+
within(args[0], args[1]);
|
|
197
|
+
}
|
|
198
|
+
else if (op === "search") {
|
|
199
|
+
if (!Array.isArray(args) || args.length !== 2) throw new TypeError("search takes [path, terms].");
|
|
200
|
+
requireSearchPath(args[0]);
|
|
201
|
+
requireSearchTerms(args[1]);
|
|
202
|
+
} else if (binaryOperators.has(op) || op === "prefix" || op === "in" || op === "nin") {
|
|
123
203
|
if (!Array.isArray(args) || args.length !== 2) throw new TypeError(`${op} takes [path, value].`);
|
|
124
204
|
requirePath(args[0]);
|
|
125
205
|
if (op === "in" || op === "nin") scalars(op, args[1]);
|
|
@@ -220,6 +300,13 @@ export const v1Filter = Object.freeze({
|
|
|
220
300
|
if (typeof prefix !== "string") throw new TypeError("prefix takes a string.");
|
|
221
301
|
return Object.freeze({ prefix: Object.freeze(/** @type {const} */ ([requirePath(path), prefix])) });
|
|
222
302
|
},
|
|
303
|
+
/**
|
|
304
|
+
* Full-text search on a document member.
|
|
305
|
+
* @param {V1Path} path
|
|
306
|
+
* @param {string} terms
|
|
307
|
+
* @returns {V1Filter}
|
|
308
|
+
*/
|
|
309
|
+
search: (path, terms) => Object.freeze({ search: Object.freeze(/** @type {const} */ ([requireSearchPath(path), requireSearchTerms(terms)])) }),
|
|
223
310
|
/**
|
|
224
311
|
* The value is an array containing the scalar.
|
|
225
312
|
* @param {V1Path} path
|
|
@@ -227,4 +314,18 @@ export const v1Filter = Object.freeze({
|
|
|
227
314
|
* @returns {V1Filter}
|
|
228
315
|
*/
|
|
229
316
|
contains: (path, value) => Object.freeze({ contains: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
|
|
317
|
+
/**
|
|
318
|
+
* The `{lat, lon}` object at `path` lies within `meters` of the center (haversine, WGS 84 mean radius).
|
|
319
|
+
* @param {string} path JSON Pointer of a `{lat, lon}` point.
|
|
320
|
+
* @param {V1GeoCircle} circle `meters` is more than 0, at most 20000000.
|
|
321
|
+
* @returns {V1Filter}
|
|
322
|
+
*/
|
|
323
|
+
withinRadius: (path, circle) => within(path, { lat: circle?.lat, lon: circle?.lon, meters: circle?.meters }),
|
|
324
|
+
/**
|
|
325
|
+
* The `{lat, lon}` object at `path` lies in the box; `west > east` crosses the antimeridian.
|
|
326
|
+
* @param {string} path JSON Pointer of a `{lat, lon}` point.
|
|
327
|
+
* @param {V1GeoBox} box
|
|
328
|
+
* @returns {V1Filter}
|
|
329
|
+
*/
|
|
330
|
+
withinBox: (path, box) => within(path, { south: box?.south, west: box?.west, north: box?.north, east: box?.east }),
|
|
230
331
|
});
|
package/src/v1/ids.js
CHANGED
|
@@ -27,11 +27,16 @@ export function requireProject(project) {
|
|
|
27
27
|
return requireId(project);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/** Every document id is a canonical ULID. @param {unknown} id @returns {string} */
|
|
31
31
|
export function requireDocumentId(id) {
|
|
32
|
+
return requireId(id, "TerraBase document ID");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SQL-backed collections can return non-ULID primary keys. @param {unknown} id @returns {string} */
|
|
36
|
+
export function requireStoredId(id) {
|
|
32
37
|
if (typeof id !== "string" || id.length < 1 || id.length > 256 ||
|
|
33
38
|
/[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(id))
|
|
34
|
-
throw new TypeError("TerraBase
|
|
39
|
+
throw new TypeError("TerraBase stored IDs must be 1-256 characters of valid UTF-8 without control characters.");
|
|
35
40
|
return id;
|
|
36
41
|
}
|
|
37
42
|
|
package/src/v1/indexes.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { requireName } from "./ids.js";
|
|
2
|
-
import { flag, list, numeric, record, text } from "./management-decode.js";
|
|
2
|
+
import { flag, list, numeric, optional, record, text } from "./management-decode.js";
|
|
3
3
|
import { members, mutationOptions } from "./management-validation.js";
|
|
4
4
|
import { stringifyExactJson } from "./json.js";
|
|
5
5
|
/** @import { V1Transport } from "./transport.js" */
|
|
6
6
|
/** @import { V1RequestOptions } from "./terrabase-v1.js" */
|
|
7
|
-
const index = record({ name: text, collection: text, fields: list(text), state: text });
|
|
7
|
+
const index = record({ name: text, collection: text, fields: list(text), state: text, kind: optional(text) });
|
|
8
8
|
const indexes = record({ items: list(index) });
|
|
9
9
|
const policy = record({ collection: text, enabled: flag });
|
|
10
10
|
const recommendations = record({ items: list(record({ id: text, collection: text, fields: list(text), reason: text, queries: numeric })) });
|
|
@@ -14,7 +14,8 @@ const applied = record({ status: text, index });
|
|
|
14
14
|
/** @typedef {ReturnType<typeof policy>} V1AutoIndexPolicy */
|
|
15
15
|
/** @typedef {ReturnType<typeof recommendations>} V1IndexRecommendations */
|
|
16
16
|
/** @typedef {ReturnType<typeof applied>} V1AppliedIndexRecommendation */
|
|
17
|
-
/** @typedef {
|
|
17
|
+
/** @typedef {"btree" | "text"} V1IndexKind */
|
|
18
|
+
/** @typedef {{ readonly collection: string; readonly fields: readonly string[]; readonly name?: string | null; readonly kind?: V1IndexKind }} V1CreateIndexRequest */
|
|
18
19
|
/** @param {unknown} value @returns {string} */
|
|
19
20
|
function indexName(value) {
|
|
20
21
|
if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("Invalid index name.");
|
|
@@ -31,7 +32,8 @@ export class V1Indexes {
|
|
|
31
32
|
constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
|
|
32
33
|
/** @param {V1CreateIndexRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1Index>} */
|
|
33
34
|
async create(request, options = {}) {
|
|
34
|
-
members(request, ["collection", "fields", "name"]);
|
|
35
|
+
members(request, ["collection", "fields", "name", "kind"]);
|
|
36
|
+
if (request.kind !== undefined && request.kind !== "btree" && request.kind !== "text") throw new TypeError("kind must be btree or text.");
|
|
35
37
|
requireName("collection", request.collection);
|
|
36
38
|
if (!Array.isArray(request.fields) || request.fields.length < 1 || request.fields.length > 4 ||
|
|
37
39
|
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)))
|
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
/** @typedef {"getShapeManifest" | "exportDatabase" | "putCollectionTtl" | "getCollectionTtl" | "deleteCollectionTtl" | "healthz" | "readyz" | "backupz" | "replicationHealth" | "listPlatformDatabases" | "listPlatformCollections" | "listPlatformDocuments" | "getPlatformDocument" | "createImport" | "getImport" | "uploadImportSource" | "updateImport" | "startImport" | "cancelImport" | "resumeImport" | "createBackup" | "listBackups" | "restoreBackup" | "getBackupPolicy" | "setBackupPolicy" | "getRestoreWindow" | "planPointInTime" | "restorePointInTime" | "getBackupDestination" | "setBackupDestination" | "deleteBackupDestination" | "getProjectUsage" | "getProjectAnalytics" | "getProjectReplication" | "getDatabaseUsage" | "getDatabaseAnalytics" | "getDatabaseReplication" | "createIndex" | "listIndexes" | "dropIndex" | "getAutoIndexPolicy" | "setAutoIndexPolicy" | "listIndexRecommendations" | "applyIndexRecommendation" | "getDatabaseSchema" | "inferCollectionSchema" | "declareCollectionSchema" | "listQueryShapes" | "createSavedQuery" | "listSavedQueries" | "getSavedQuery" | "updateSavedQuery" | "deleteSavedQuery"} ManagementOperation */
|
|
1
|
+
/** @typedef {"getShapeManifest" | "exportDatabase" | "executeSql" | "putCollectionTtl" | "getCollectionTtl" | "deleteCollectionTtl" | "healthz" | "readyz" | "backupz" | "replicationHealth" | "listPlatformDatabases" | "listPlatformCollections" | "listPlatformDocuments" | "getPlatformDocument" | "createImport" | "getImport" | "uploadImportSource" | "updateImport" | "startImport" | "cancelImport" | "resumeImport" | "createBackup" | "listBackups" | "restoreBackup" | "getBackupPolicy" | "setBackupPolicy" | "getRestoreWindow" | "planPointInTime" | "restorePointInTime" | "getBackupDestination" | "setBackupDestination" | "deleteBackupDestination" | "getProjectUsage" | "getProjectAnalytics" | "getProjectReplication" | "getDatabaseUsage" | "getDatabaseAnalytics" | "getDatabaseReplication" | "createIndex" | "listIndexes" | "dropIndex" | "getAutoIndexPolicy" | "setAutoIndexPolicy" | "listIndexRecommendations" | "applyIndexRecommendation" | "getDatabaseSchema" | "inferCollectionSchema" | "declareCollectionSchema" | "listQueryShapes" | "createSavedQuery" | "listSavedQueries" | "getSavedQuery" | "updateSavedQuery" | "deleteSavedQuery" | "createChangeWebhook" | "listChangeWebhooks" | "deleteChangeWebhook" | "listChangeWebhookDeliveries"} ManagementOperation */
|
|
2
|
+
const webhooks = "/v1/projects/{project}/databases/{database}/collections/{collection}/webhooks";
|
|
2
3
|
const saved = "/v1/projects/{project}/saved-queries";
|
|
3
4
|
const ttl = "/v1/projects/{project}/databases/{database}/collections/{collection}/ttl";
|
|
4
5
|
/** @type {readonly import("./routes.js").V1Route[]} */
|
|
5
6
|
export const managementV1Routes = Object.freeze([
|
|
7
|
+
route("createChangeWebhook", "POST", webhooks, 201),
|
|
8
|
+
route("listChangeWebhooks", "GET", webhooks),
|
|
9
|
+
route("deleteChangeWebhook", "DELETE", `${webhooks}/{webhook}`, 204),
|
|
10
|
+
route("listChangeWebhookDeliveries", "GET", `${webhooks}/{webhook}/deliveries`),
|
|
6
11
|
route("getShapeManifest", "GET", "/v1/projects/{project}/databases/{database}/collections/{collection}/shape/manifest"),
|
|
7
12
|
route("exportDatabase", "GET", "/v1/projects/{project}/databases/{database}/export"),
|
|
13
|
+
// Reads need documents:read; writes and DDL also need documents:write, checked per statement.
|
|
14
|
+
route("executeSql", "POST", "/v1/projects/{project}/databases/{database}/sql/execute"),
|
|
8
15
|
route("putCollectionTtl", "PUT", ttl),
|
|
9
16
|
route("getCollectionTtl", "GET", ttl),
|
|
10
17
|
route("deleteCollectionTtl", "DELETE", ttl),
|
|
@@ -61,12 +68,13 @@ export const managementV1Routes = Object.freeze([
|
|
|
61
68
|
/** @param {ManagementOperation} operation @param {import("./routes.js").V1Route["method"]} method @param {string} pathTemplate @param {number} [status] @returns {import("./routes.js").V1Route} */
|
|
62
69
|
function route(operation, method, pathTemplate, status = 200) {
|
|
63
70
|
const write = method !== "GET" && operation !== "planPointInTime";
|
|
71
|
+
const webhook = pathTemplate.startsWith(webhooks);
|
|
64
72
|
const noBody = ["createBackup", "applyIndexRecommendation", "cancelImport", "resumeImport"].includes(operation);
|
|
65
73
|
return Object.freeze({ operation, method, pathTemplate,
|
|
66
|
-
permission: operation === "getShapeManifest" || operation === "exportDatabase" || operation === "getCollectionTtl" ? "documents:read" : pathTemplate === ttl ? "documents:write" : pathTemplate.startsWith("/v1/platform/") ? "platform-admin" : !pathTemplate.startsWith("/v1/") ? "anonymous" : write ? "databases:write" : "databases:read",
|
|
67
|
-
additionalPermissions: Object.freeze(["createImport", "uploadImportSource", "updateImport", "startImport", "resumeImport"].includes(operation) ? ["documents:write"] : []),
|
|
74
|
+
permission: webhook ? write ? "documents:write" : "documents:read" : operation === "getShapeManifest" || operation === "exportDatabase" || operation === "executeSql" || operation === "getCollectionTtl" ? "documents:read" : pathTemplate === ttl ? "documents:write" : pathTemplate.startsWith("/v1/platform/") ? "platform-admin" : !pathTemplate.startsWith("/v1/") ? "anonymous" : write ? "databases:write" : "databases:read",
|
|
75
|
+
additionalPermissions: Object.freeze(webhook && write ? ["documents:read"] : ["createImport", "uploadImportSource", "updateImport", "startImport", "resumeImport"].includes(operation) ? ["documents:write"] : []),
|
|
68
76
|
requestMediaTypes: Object.freeze(noBody ? [] : operation === "uploadImportSource" ? ["application/octet-stream"] : method === "POST" || method === "PUT" || method === "PATCH" ? ["application/json"] : []),
|
|
69
77
|
responseMediaType: status === 204 ? "" : operation === "exportDatabase" ? "application/x-ndjson" : "application/json", successStatuses: Object.freeze(operation === "readyz" || operation === "backupz" ? [200, 503] : operation === "applyIndexRecommendation" || operation === "createImport" ? [200, 201] : [status]),
|
|
70
|
-
idempotencyKey: "none", replaySafe: !write, contract: "docs/terrabase/api.md", contractText: Object.freeze([]),
|
|
78
|
+
idempotencyKey: "none", replaySafe: !write, contract: webhook ? "docs/terrabase/change-webhooks.md" : "docs/terrabase/api.md", contractText: Object.freeze([]),
|
|
71
79
|
});
|
|
72
80
|
}
|
package/src/v1/shape-reader.js
CHANGED
|
@@ -5,6 +5,7 @@ import { decodeShapePage, encodeShapeRequest, requireShapeHandle, requireShapeOf
|
|
|
5
5
|
import { decryptShapeObject } from "./shape-crypto.js";
|
|
6
6
|
import { parseExactJson } from "./json.js";
|
|
7
7
|
import { TerraBaseV1Error } from "./errors.js";
|
|
8
|
+
import { OpaqueRedirectError } from "./transport.js";
|
|
8
9
|
/** @import { V1Transport } from "./transport.js" */
|
|
9
10
|
/** @import { V1ShapeRequest, V1ShapePage } from "./shapes.js" */
|
|
10
11
|
/** @typedef {{handle: string; offset: string; epoch: string; keyId: string}} V1SealedShapePosition */
|
|
@@ -125,11 +126,9 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
125
126
|
query.delete("cursor");
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const location = response.headers.get("location");
|
|
132
|
-
if (location === null) malformed("shape redirect Location");
|
|
129
|
+
const bootstrap = query.get("offset") === "-1";
|
|
130
|
+
/** @param {string} location */
|
|
131
|
+
const redirectTarget = location => {
|
|
133
132
|
const target = new URL(location, source);
|
|
134
133
|
if (target.origin !== source.origin || target.pathname !== source.pathname || target.username !== "" || target.password !== "" || target.hash !== "") malformed("shape redirect target");
|
|
135
134
|
for (const key of target.searchParams.keys()) {
|
|
@@ -144,6 +143,21 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
144
143
|
delete headers["TB-Shape-Resume"];
|
|
145
144
|
query.set("handle", handle);
|
|
146
145
|
query.set("offset", offset);
|
|
146
|
+
};
|
|
147
|
+
let response;
|
|
148
|
+
try { response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal }); }
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!(error instanceof OpaqueRedirectError) || !bootstrap) throw error;
|
|
151
|
+
// Browsers hide a manual 307; follow it natively, then hold the final URL to the Location rules.
|
|
152
|
+
response = await shapeCall(transport, params, "readFreeShape", { query: new URLSearchParams(query), headers: { ...headers }, signal, redirect: "follow" });
|
|
153
|
+
if (response.redirectedTo === undefined || response.status !== 200) malformed("shape bootstrap requires redirect");
|
|
154
|
+
redirectTarget(response.redirectedTo);
|
|
155
|
+
}
|
|
156
|
+
if (response.status === 307) {
|
|
157
|
+
if (!bootstrap) malformed("unexpected shape redirect");
|
|
158
|
+
const location = response.headers.get("location");
|
|
159
|
+
if (location === null) malformed("shape redirect Location");
|
|
160
|
+
redirectTarget(location);
|
|
147
161
|
response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal });
|
|
148
162
|
if (response.status !== 200) malformed("repeated shape redirect");
|
|
149
163
|
} else if (query.get("offset") === "-1") malformed("shape bootstrap requires redirect");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Browser storage for a shape subscription's read replica. Durable writes never queue here.
|
|
2
|
+
/** @import { V1ShapeReplicaStore } from "./shape-subscription.js" */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A V1ShapeReplicaStore kept in browser IndexedDB, one record per key.
|
|
6
|
+
* @param {string} key Names one subject's shape, e.g. `${userId}/notes/open`; clear it on sign-out.
|
|
7
|
+
* @returns {V1ShapeReplicaStore}
|
|
8
|
+
*/
|
|
9
|
+
export function indexedDbShapeReplica(key) {
|
|
10
|
+
if (typeof key !== "string" || key === "") throw new TypeError("An IndexedDB replica key is a non-empty string.");
|
|
11
|
+
/** @type {Promise<IDBDatabase> | undefined} */
|
|
12
|
+
let opened;
|
|
13
|
+
/** @returns {Promise<IDBDatabase>} */
|
|
14
|
+
const open = () => opened ??= new Promise((resolve, reject) => {
|
|
15
|
+
const request = indexedDB.open("terrascale-shape-replicas", 1);
|
|
16
|
+
request.onupgradeneeded = () => { request.result.createObjectStore("replicas"); };
|
|
17
|
+
request.onsuccess = () => resolve(request.result);
|
|
18
|
+
request.onerror = () => { opened = undefined; reject(request.error); };
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* @template T
|
|
22
|
+
* @param {IDBTransactionMode} mode
|
|
23
|
+
* @param {(store: IDBObjectStore) => IDBRequest<T>} operation
|
|
24
|
+
* @returns {Promise<T>}
|
|
25
|
+
*/
|
|
26
|
+
const run = async (mode, operation) => {
|
|
27
|
+
const database = await open();
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const transaction = database.transaction("replicas", mode);
|
|
30
|
+
const request = operation(transaction.objectStore("replicas"));
|
|
31
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
32
|
+
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
load: async () => {
|
|
37
|
+
const value = await run("readonly", store => store.get(key));
|
|
38
|
+
return typeof value === "string" ? value : undefined;
|
|
39
|
+
},
|
|
40
|
+
/** @param {string | undefined} text */
|
|
41
|
+
save: async text => {
|
|
42
|
+
if (text === undefined) await run("readwrite", store => store.delete(key));
|
|
43
|
+
else await run("readwrite", store => store.put(text, key));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|