@terrascale/sdk 0.0.6 → 0.0.8

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.
@@ -1,4 +1,4 @@
1
- import { generateId } from "../ids.js";
1
+ import { generateId, isValidId } from "../ids.js";
2
2
  import {
3
3
  createHash,
4
4
  createHmac,
@@ -171,7 +171,7 @@ export class TerraScaleLocalRouter {
171
171
  );
172
172
  if (match === null) return problemResponse(404, "unknown_route");
173
173
  const collection = requireDatabaseId(match[1]);
174
- const document = requireDatabaseId(match[2]);
174
+ const document = documentId(match[2]);
175
175
  return await this.#store.exclusive(() => {
176
176
  this.#binding(collection);
177
177
  return jsonResponse(this.#point(collection, document));
@@ -752,6 +752,15 @@ function transactionKey(raw) {
752
752
  const id = object(raw.transaction_id);
753
753
  return `${text(id.expires_at_unix_ms)}:${text(id.nonce)}`;
754
754
  }
755
+ /**
756
+ * @param {unknown} value
757
+ * @returns {string}
758
+ */
759
+ function documentId(value) {
760
+ if (!isValidId(value)) throw new NativeLocalError(400, "invalid_document_id");
761
+ return value;
762
+ }
763
+
755
764
  /**
756
765
  * @param {unknown} value
757
766
  * @returns {readonly DatabaseField[]}
@@ -815,7 +824,7 @@ function decodeCommit(raw) {
815
824
  collection: requireDatabaseId(write.collection),
816
825
  schema_generation: decimal(write.schema_generation),
817
826
  schema_digest: text(write.schema_digest),
818
- document: requireDatabaseId(write.document),
827
+ document: documentId(write.document),
819
828
  condition: /** @type {DatabaseCondition} */ (parsedCondition),
820
829
  observed: write.observed.map((rawObserved) => {
821
830
  const observed = object(rawObserved);
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/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);
@@ -0,0 +1,17 @@
1
+ /** @typedef {"createFileBucket" | "listFileBuckets" | "setFileBucketRules" | "deleteFileBucket" | "createFileUpload" | "completeFileUpload" | "listBucketFiles" | "createFileDownload" | "deleteBucketFile"} FileStorageOperation */
2
+ const buckets = "/v1/projects/{project}/buckets";
3
+ const bucket = `${buckets}/{bucket}`;
4
+ const files = `${bucket}/files`;
5
+ const file = `${files}/{file}`;
6
+ /** @type {readonly import("./routes.js").V1Route[]} */
7
+ export const fileStorageRoutes = Object.freeze(/** @type {import("./routes.js").V1Route[]} */ ([
8
+ { operation: "createFileBucket", method: "POST", pathTemplate: buckets, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
9
+ { operation: "listFileBuckets", method: "GET", pathTemplate: buckets, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
10
+ { operation: "setFileBucketRules", method: "PUT", pathTemplate: `${bucket}/rules`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
11
+ { operation: "deleteFileBucket", method: "DELETE", pathTemplate: bucket, permission: "documents:write", requestMediaTypes: [], successStatuses: [204] },
12
+ { operation: "createFileUpload", method: "POST", pathTemplate: `${files}/upload`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
13
+ { operation: "completeFileUpload", method: "POST", pathTemplate: `${file}/complete`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
14
+ { operation: "listBucketFiles", method: "GET", pathTemplate: files, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
15
+ { operation: "createFileDownload", method: "POST", pathTemplate: `${file}/download`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
16
+ { operation: "deleteBucketFile", method: "DELETE", pathTemplate: file, permission: "documents:write", requestMediaTypes: [], successStatuses: [204] },
17
+ ].map(route => Object.freeze({ ...route, responseMediaType: route.successStatuses.includes(204) ? "" : "application/json", idempotencyKey: "none", replaySafe: route.method === "GET", contract: "docs/file-buckets.md", contractText: [] }))));
@@ -0,0 +1,163 @@
1
+ import { array, boolean, malformed, object, string } from "./decode.js";
2
+ import { requireId } from "../ids.js";
3
+ import { assertJsonObject, stringifyExactJson } from "./json.js";
4
+ import { requireServiceUrl } from "./transport.js";
5
+ /** @import { V1Transport } from "./transport.js" */
6
+ /** @import { FetchLike } from "../config.js" */
7
+ /** @import { JsonObject, JsonValue } from "./json.js" */
8
+ /** @typedef {{ owner: boolean, public: boolean, roles: readonly string[] }} FileAccessPolicy */
9
+ /** @typedef {{ owner: string | null, read: FileAccessPolicy, write: FileAccessPolicy }} FileAccessRules */
10
+ /** @typedef {{ id: string, name: string, region: string, rules: FileAccessRules }} FileBucketInfo */
11
+ /** @typedef {{ id: string, name: string, contentType: string, size: number, sha256: string, metadata: JsonObject, status: string, uploadExpiresAt: string }} StoredFile */
12
+ /** @typedef {{ id: string, url: string, expiresAt: string, headers: Readonly<Record<string, string>> }} SignedFileUrl */
13
+ /** @typedef {{ name: string, contentType: string, size: number, sha256: string, metadata: JsonObject }} FileUploadRequest */
14
+ /** @typedef {{ signal?: AbortSignal | undefined }} FileRequestOptions */
15
+ /** @typedef {{ after?: string | undefined, signal?: AbortSignal | undefined }} FileListOptions */
16
+ /** @typedef {{ metadata: JsonObject, contentType?: string | undefined, signal?: AbortSignal | undefined }} FileUploadOptions */
17
+ const maxFileBytes = 100 * 1024 * 1024;
18
+
19
+ /** @param {JsonValue | undefined} value @returns {FileAccessPolicy} */
20
+ function policy(value) {
21
+ const v = object(value, "file access policy");
22
+ return { owner: boolean(v.owner, "owner grant"), public: boolean(v.public, "public grant"), roles: array(v.roles, "roles").map(v => string(v, "role")) };
23
+ }
24
+ /** @param {JsonValue | undefined} value @returns {FileAccessRules} */
25
+ function rules(value) {
26
+ const v = object(value, "file access rules");
27
+ return { owner: v.owner === null ? null : string(v.owner, "owner pointer"), read: policy(v.read), write: policy(v.write) };
28
+ }
29
+ /** @param {JsonValue | undefined} value @returns {FileBucketInfo} */
30
+ function bucketInfo(value) {
31
+ const v = object(value, "file bucket");
32
+ return { id: requireId(v.id), name: string(v.name, "bucket name"), region: string(v.region, "bucket region"), rules: rules(v.rules) };
33
+ }
34
+ /** @param {JsonValue | undefined} value @returns {StoredFile} */
35
+ function storedFile(value) {
36
+ const v = object(value, "file");
37
+ if (typeof v.size !== "number" || !Number.isSafeInteger(v.size) || v.size < 1 || v.size > maxFileBytes) malformed("file size");
38
+ return { id: requireId(v.id), name: string(v.name, "file name"), contentType: string(v.contentType, "content type"), size: v.size,
39
+ sha256: string(v.sha256, "checksum"), metadata: object(v.metadata, "file metadata"), status: string(v.status, "file status"), uploadExpiresAt: string(v.uploadExpiresAt, "upload expiry") };
40
+ }
41
+ /** @param {JsonValue | undefined} value @returns {SignedFileUrl} */
42
+ function signed(value) {
43
+ const v = object(value, "signed file URL");
44
+ const url = string(v.url, "signed URL");
45
+ requireServiceUrl(url, "Signed file URL");
46
+ /** @type {Record<string, string>} */
47
+ const headers = {};
48
+ for (const [key, val] of Object.entries(object(v.headers, "signed headers"))) {
49
+ // Object-store capabilities must never carry the project's authorization or cookies.
50
+ if (!["content-type", "content-length", "x-amz-checksum-sha256"].includes(key.toLowerCase())) malformed("signed upload header");
51
+ headers[key] = string(val, "signed header value");
52
+ }
53
+ return { id: requireId(v.id), url, expiresAt: string(v.expiresAt, "signed URL expiry"), headers };
54
+ }
55
+ /** @param {FileListOptions} options @returns {URLSearchParams} */
56
+ function pageQuery(options) {
57
+ const query = new URLSearchParams();
58
+ if (options.after !== undefined) query.set("after", requireId(options.after));
59
+ return query;
60
+ }
61
+
62
+ /** Project file buckets, authenticated by the same token as documents. */
63
+ export class V1FileStorage {
64
+ /** @type {V1Transport} */ #transport;
65
+ /** @type {FetchLike} */ #fetch;
66
+ /** @param {V1Transport} transport @param {FetchLike} fetcher */
67
+ constructor(transport, fetcher) { this.#transport = transport; this.#fetch = fetcher; Object.freeze(this); }
68
+ /** @param {string} name @param {FileAccessRules} accessRules @param {FileRequestOptions} [options] @returns {Promise<FileBucketInfo>} */
69
+ async createBucket(name, accessRules, options = {}) {
70
+ return bucketInfo((await this.#transport.call("createFileBucket", {}, { body: stringifyExactJson({ name, rules: accessRules }, "file bucket"), contentType: "application/json", signal: options.signal })).value);
71
+ }
72
+ /** @param {FileListOptions} [options] @returns {Promise<{items: FileBucketInfo[], next: string | null}>} */
73
+ async listBuckets(options = {}) {
74
+ const v = object((await this.#transport.call("listFileBuckets", {}, { query: pageQuery(options), signal: options.signal })).value, "bucket page");
75
+ return { items: array(v.items, "bucket items").map(bucketInfo), next: v.next === null ? null : requireId(v.next) };
76
+ }
77
+ /** @param {string} id @returns {V1FileBucket} */
78
+ bucket(id) { return new V1FileBucket(this.#transport, this.#fetch, requireId(id)); }
79
+ }
80
+
81
+ /** Immutable files in one project bucket. */
82
+ export class V1FileBucket {
83
+ /** @type {V1Transport} */ #transport;
84
+ /** @type {FetchLike} */ #fetch;
85
+ /** @type {string} */ #bucket;
86
+ /** @param {V1Transport} transport @param {FetchLike} fetcher @param {string} bucket */
87
+ constructor(transport, fetcher, bucket) { this.#transport = transport; this.#fetch = fetcher; this.#bucket = requireId(bucket); Object.freeze(this); }
88
+ /** @param {FileAccessRules} accessRules @param {FileRequestOptions} [options] @returns {Promise<FileBucketInfo>} */
89
+ async setRules(accessRules, options = {}) {
90
+ return bucketInfo((await this.#transport.call("setFileBucketRules", { bucket: this.#bucket }, { body: stringifyExactJson({ rules: accessRules }, "file bucket rules"), contentType: "application/json", signal: options.signal })).value);
91
+ }
92
+ /** @param {FileRequestOptions} [options] @returns {Promise<void>} */
93
+ async delete(options = {}) { await this.#transport.call("deleteFileBucket", { bucket: this.#bucket }, { signal: options.signal }); }
94
+ /** @param {FileUploadRequest} request @param {FileRequestOptions} [options] @returns {Promise<SignedFileUrl>} */
95
+ async createUpload(request, options = {}) {
96
+ assertJsonObject(request.metadata, "file metadata");
97
+ if (!Number.isSafeInteger(request.size) || request.size < 1 || request.size > maxFileBytes) throw new TypeError("Files must contain 1–104857600 bytes.");
98
+ return signed((await this.#transport.call("createFileUpload", { bucket: this.#bucket }, { body: stringifyExactJson(request, "file upload"), contentType: "application/json", signal: options.signal })).value);
99
+ }
100
+ /** @param {string} id @param {FileRequestOptions} [options] @returns {Promise<StoredFile>} */
101
+ async completeUpload(id, options = {}) {
102
+ return storedFile((await this.#transport.call("completeFileUpload", { bucket: this.#bucket, file: requireId(id) }, { signal: options.signal })).value);
103
+ }
104
+ /** @param {FileListOptions} [options] @returns {Promise<{items: StoredFile[], next: string | null}>} */
105
+ async list(options = {}) {
106
+ const v = object((await this.#transport.call("listBucketFiles", { bucket: this.#bucket }, { query: pageQuery(options), signal: options.signal })).value, "file page");
107
+ return { items: array(v.items, "file items").map(storedFile), next: v.next === null ? null : requireId(v.next) };
108
+ }
109
+ /** @param {string} id @param {FileRequestOptions} [options] @returns {Promise<SignedFileUrl>} */
110
+ async downloadUrl(id, options = {}) {
111
+ return signed((await this.#transport.call("createFileDownload", { bucket: this.#bucket, file: requireId(id) }, { signal: options.signal })).value);
112
+ }
113
+ /** @param {string} id @param {FileRequestOptions} [options] @returns {Promise<void>} */
114
+ async deleteFile(id, options = {}) { await this.#transport.call("deleteBucketFile", { bucket: this.#bucket, file: requireId(id) }, { signal: options.signal }); }
115
+ /** Hashes a private byte snapshot, uploads it without project credentials, and verifies completion. No retries.
116
+ * @param {string} name @param {Uint8Array | Blob} input @param {FileUploadOptions} options @returns {Promise<StoredFile>} */
117
+ async upload(name, input, options) {
118
+ const size = input instanceof Blob ? input.size : input.byteLength;
119
+ if (size < 1 || size > maxFileBytes) throw new TypeError("Files must contain 1–104857600 bytes.");
120
+ const bytes = input instanceof Blob ? new Uint8Array(await input.arrayBuffer()) : new Uint8Array(input);
121
+ options.signal?.throwIfAborted();
122
+ const hash = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-256", bytes));
123
+ const sha256 = btoa(String.fromCharCode(...hash));
124
+ const capability = await this.createUpload({ name, contentType: options.contentType ?? "application/octet-stream", size, sha256, metadata: options.metadata }, options);
125
+ const headers = new Headers(capability.headers);
126
+ // Fetch computes Content-Length from the body; browsers prohibit setting it explicitly.
127
+ headers.delete("Content-Length");
128
+ /** @type {RequestInit & { duplex?: "half" }} */
129
+ const init = { method: "PUT", headers, body: bytes, credentials: "omit", redirect: "error", signal: options.signal ?? null };
130
+ if (typeof process !== "undefined" && process.release?.name === "node") {
131
+ // A one-shot stream prevents Node fetch from replaying the signed PUT on HTTP 421.
132
+ headers.set("Content-Length", String(size));
133
+ init.body = new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); } });
134
+ init.duplex = "half";
135
+ }
136
+ const response = await this.#fetch(capability.url, init);
137
+ void response.body?.cancel().catch(() => {});
138
+ if (!response.ok) throw new Error(`Object upload failed (${response.status}).`);
139
+ return this.completeUpload(capability.id, options);
140
+ }
141
+ /** Fetches file bytes without sending the project token to the object store.
142
+ * @param {string} id @param {FileRequestOptions} [options] @returns {Promise<Blob>} */
143
+ async download(id, options = {}) {
144
+ const capability = await this.downloadUrl(id, options);
145
+ const response = await this.#fetch(capability.url, { method: "GET", credentials: "omit", redirect: "error", signal: options.signal ?? null });
146
+ if (!response.ok) throw new Error(`Object download failed (${response.status}).`);
147
+ if (response.body === null) throw new Error("Object download returned no body.");
148
+ const reader = response.body.getReader();
149
+ /** @type {Uint8Array<ArrayBuffer>[]} */ const chunks = [];
150
+ let length = 0;
151
+ try {
152
+ for (;;) {
153
+ options.signal?.throwIfAborted();
154
+ const part = await reader.read();
155
+ if (part.done) break;
156
+ length += part.value.byteLength;
157
+ if (length > maxFileBytes) throw new Error("Object download exceeds the file size limit.");
158
+ chunks.push(new Uint8Array(part.value));
159
+ }
160
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
161
+ return new Blob(chunks, { type: "application/octet-stream" });
162
+ }
163
+ }
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)))
package/src/v1/routes.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { fileStorageRoutes } from "./file-storage-routes.js";
1
2
  import { managementV1Routes } from "./management-routes.js";
2
3
  // Current TerraBase routes at e30ffdc; readShape is retained from release 6cb9745 only.
3
- /** @typedef {import("./management-routes.js").ManagementOperation | "readFreeShape" | "getShapeKey" | "getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape" | "runSql" | "checkSql" | "explainSql" | "streamDatabaseChanges" | "listDocuments" | "getCollectionSchema" | "putCollectionSchema" | "deleteCollectionSchema" | "createDatabase" | "listDatabases" | "getDatabase" | "updateDatabase" | "deleteDatabase" | "branchDatabase"} V1Operation */
4
+ /** @typedef {import("./file-storage-routes.js").FileStorageOperation | import("./management-routes.js").ManagementOperation | "readFreeShape" | "getShapeKey" | "getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape" | "runSql" | "checkSql" | "explainSql" | "streamDatabaseChanges" | "listDocuments" | "getCollectionSchema" | "putCollectionSchema" | "deleteCollectionSchema" | "createDatabase" | "listDatabases" | "getDatabase" | "updateDatabase" | "deleteDatabase" | "branchDatabase"} V1Operation */
4
5
  /**
5
6
  * @typedef {object} V1Route
6
7
  * @property {V1Operation} operation
@@ -61,7 +62,7 @@ export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
61
62
  })));
62
63
  /** @param {V1Operation} operation @returns {V1Route} */
63
64
  export function v1Route(operation) {
64
- const route = [...v1Routes, ...shapeV1Routes, ...managementV1Routes].find(candidate => candidate.operation === operation);
65
+ const route = [...v1Routes, ...shapeV1Routes, ...managementV1Routes, ...fileStorageRoutes].find(candidate => candidate.operation === operation);
65
66
  if (route === undefined) throw new TypeError(`Unknown v1 operation ${operation}.`);
66
67
  return route;
67
68
  }
@@ -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
+ }