@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.
@@ -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 { requireDocumentId, requireName } from "./ids.js";
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: requireDocumentId(raw.id), op: raw.op, version: u64(raw.version, "changes version"), origin: raw.origin,
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 = requireDocumentId(raw.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 { requireDocumentId, requireName } from "./ids.js";
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: requireDocumentId(raw.id), document: documentObject(raw.document) });
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", requireDocumentId(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] }} V1Filter
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 (binaryOperators.has(op) || op === "prefix" || op === "in" || op === "nin") {
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
- /** A caller-supplied key is not a generated entity ID. @param {unknown} id @returns {string} */
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 document keys must be 1-256 characters of valid UTF-8 without control characters.");
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 {{ readonly collection: string; readonly fields: readonly string[]; readonly name?: string | null }} V1CreateIndexRequest */
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
  }
@@ -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
- let response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal });
129
- if (response.status === 307) {
130
- if (query.get("offset") !== "-1") malformed("unexpected shape redirect");
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
+ }