@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.
@@ -3,6 +3,7 @@ import { TerraBaseV1Error } from "./errors.js";
3
3
  import { requireFilter } from "./filter.js";
4
4
  import { parseExactJson, stringifyExactJson } from "./json.js";
5
5
  import { readShapePage } from "./shape-reader.js";
6
+ import { BodyTransportError, RequestTransportError } from "./transport.js";
6
7
  /**
7
8
  * @import { JsonObject } from "./json.js"
8
9
  * @import { V1Filter } from "./filter.js"
@@ -12,6 +13,14 @@ import { readShapePage } from "./shape-reader.js";
12
13
  * @typedef {object} V1ShapeSubscribeOptions
13
14
  * @property {AbortSignal | undefined} [signal] Closes the subscription when aborted; cancellation settles `done`.
14
15
  * @property {((subscription: V1ShapeSubscription) => void) | undefined} [onUpdate] Runs after validated responses and freshness changes; throwing rejects `done` unless already closed or aborted.
16
+ * @property {V1ShapeReplicaStore | undefined} [replica] Keeps the last resumable state for reads across restarts and while offline; writes still go to the server.
17
+ */
18
+ /**
19
+ * Holds one subscription's state as exact JSON text, e.g. in IndexedDB or React Native storage. Scope one store
20
+ * to one signed-in subject: the replica holds that subject's authorized documents. A failing store rejects `done`.
21
+ * @typedef {object} V1ShapeReplicaStore
22
+ * @property {() => string | undefined | Promise<string | undefined>} load
23
+ * @property {(text: string | undefined) => void | Promise<void>} save Receives undefined after must_refetch discards state.
15
24
  */
16
25
  /** @typedef {{ readonly version: string; readonly document: JsonObject }} V1ShapeDocument */
17
26
  const retryBaseMilliseconds = 250;
@@ -81,6 +90,9 @@ export class V1ShapeSubscription {
81
90
  /** @type {V1Filter | undefined} */
82
91
  #where;
83
92
  #onUpdate;
93
+ #replica;
94
+ /** @type {string} */
95
+ #scope;
84
96
  #external;
85
97
  #onExternalAbort = () => this.close();
86
98
  #done;
@@ -92,9 +104,11 @@ export class V1ShapeSubscription {
92
104
  */
93
105
  constructor(transport, params, shape = {}, options = {}) {
94
106
  requireMembers(shape, ["where"], "shape");
95
- requireMembers(options, ["signal", "onUpdate"], "shape subscribe option");
107
+ requireMembers(options, ["signal", "onUpdate", "replica"], "shape subscribe option");
96
108
  const { where } = shape;
97
- const { signal, onUpdate } = options;
109
+ const { signal, onUpdate, replica } = options;
110
+ if (replica !== undefined && (typeof replica?.load !== "function" || typeof replica.save !== "function"))
111
+ throw new TypeError("replica has load and save functions.");
98
112
  if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal is an AbortSignal.");
99
113
  if (onUpdate !== undefined && typeof onUpdate !== "function") throw new TypeError("onUpdate is a function.");
100
114
  this.#transport = transport;
@@ -102,6 +116,9 @@ export class V1ShapeSubscription {
102
116
  // Exact copy: a structuredClone would lose the source text of large or fractional JSON numbers.
103
117
  this.#where = where === undefined ? undefined : requireFilter(parseExactJson(stringifyExactJson(requireFilter(where), "shape filter")));
104
118
  this.#onUpdate = onUpdate;
119
+ this.#replica = replica;
120
+ // A replica restores only the identical shape it was saved from.
121
+ this.#scope = stringifyExactJson([this.#params.database, this.#params.collection, this.#where ?? null], "shape scope");
105
122
  this.#external = signal;
106
123
  if (signal?.aborted === true) this.close();
107
124
  else signal?.addEventListener("abort", this.#onExternalAbort);
@@ -120,6 +137,7 @@ export class V1ShapeSubscription {
120
137
  const signal = this.#controller.signal;
121
138
  let retries = 0;
122
139
  try {
140
+ if (this.#replica !== undefined) await this.#restore(signal);
123
141
  for (;;) {
124
142
  signal.throwIfAborted();
125
143
  const request = this.#request();
@@ -127,18 +145,22 @@ export class V1ShapeSubscription {
127
145
  try {
128
146
  page = await readShapePage(this.#transport, this.#params, request, signal);
129
147
  } catch (error) {
130
- if (!(error instanceof TerraBaseV1Error)) throw error;
131
- if (isRetryable(error)) {
148
+ // Shape reads are replay-safe GETs: a lost connection keeps state and resumes at the same position.
149
+ const lost = error instanceof RequestTransportError || error instanceof BodyTransportError;
150
+ if (lost || (error instanceof TerraBaseV1Error && isRetryable(error))) {
132
151
  retries = Math.min(retries + 1, maximumBackoffAttempt);
133
- await sleep(retryDelay(retries, error.retryAfter), signal);
152
+ await sleep(retryDelay(retries, lost ? undefined : error.retryAfter), signal);
134
153
  continue;
135
154
  }
155
+ if (!(error instanceof TerraBaseV1Error)) throw error;
136
156
  if (error.status !== 409 || error.code !== "must_refetch") throw error;
137
157
  this.#recover(request, error);
158
+ if (this.#handle === undefined) await this.#replica?.save(undefined);
138
159
  continue;
139
160
  }
140
161
  retries = 0;
141
162
  this.#commit(page);
163
+ await this.#persist(signal);
142
164
  this.#onUpdate?.(this);
143
165
  }
144
166
  } catch (error) {
@@ -188,6 +210,36 @@ export class V1ShapeSubscription {
188
210
  this.#resumeAttempt = false;
189
211
  this.#onUpdate?.(this);
190
212
  }
213
+ /** @param {AbortSignal} signal */
214
+ async #restore(signal) {
215
+ const text = await this.#replica?.load();
216
+ signal.throwIfAborted();
217
+ if (text === undefined) return;
218
+ /** @type {any} */
219
+ let saved;
220
+ try { saved = parseExactJson(text); } catch { saved = undefined; }
221
+ // Unknown or other-shape replicas are discarded, never merged.
222
+ if (saved?.format !== 1 || saved.scope !== this.#scope || typeof saved.handle !== "string" || typeof saved.offset !== "string" ||
223
+ !/^(?:0|[1-9][0-9]*)$/u.test(saved.offset) || !Array.isArray(saved.documents) ||
224
+ !saved.documents.every((/** @type {any} */ item) => Array.isArray(item) && typeof item[0] === "string" &&
225
+ typeof item[1]?.version === "string" && typeof item[1].document === "object" && item[1].document !== null)) return;
226
+ this.#documents = new Map(saved.documents.map((/** @type {[string, V1ShapeDocument]} */ [id, entry]) => [id, deepFreeze({ version: entry.version, document: entry.document })]));
227
+ this.#handle = saved.handle;
228
+ this.#offset = saved.offset;
229
+ this.#cursor = typeof saved.cursor === "string" ? saved.cursor : undefined;
230
+ this.#resume = typeof saved.resume === "string" ? saved.resume : undefined;
231
+ // Restored state is stale until the server confirms a fresh head.
232
+ this.#upToDate = false;
233
+ this.#onUpdate?.(this);
234
+ }
235
+ /** @param {AbortSignal} signal */
236
+ async #persist(signal) {
237
+ // Snapshot pages are not resumable positions; keep the previous replica until the snapshot completes.
238
+ if (this.#replica === undefined || this.#handle === undefined || this.#offset.startsWith("snap.")) return;
239
+ await this.#replica.save(stringifyExactJson({ format: 1, scope: this.#scope, handle: this.#handle, offset: this.#offset,
240
+ cursor: this.#cursor ?? null, resume: this.#resume ?? null, documents: [...this.#documents] }, "shape replica"));
241
+ signal.throwIfAborted();
242
+ }
191
243
  /** @param {import("./shapes.js").V1ShapePage} page */
192
244
  #commit(page) {
193
245
  // Repeats are applied, never skipped: a repeated event can carry a newer materialized value.
package/src/v1/shapes.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Current C# GET collection/shape contract (docs/shape-reads.md).
2
2
  import { array, boolean, documentObject, malformed, object, string, u64 } from "./decode.js";
3
3
  import { requireFilter } from "./filter.js";
4
- import { parseU64, requireDocumentId, requireVersion } from "./ids.js";
4
+ import { parseU64, requireStoredId, requireVersion } from "./ids.js";
5
5
  import { stringifyExactJson } from "./json.js";
6
6
  /**
7
7
  * @import { JsonObject, JsonValue } from "./json.js"
@@ -56,7 +56,7 @@ export function requireShapeOffset(value) {
56
56
  const bytes = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
57
57
  const id = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
58
58
  const canonical = btoa(String.fromCharCode(...new TextEncoder().encode(id))).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "");
59
- if (canonical === encoded) { requireDocumentId(id); return value; }
59
+ if (canonical === encoded) { requireStoredId(id); return value; }
60
60
  } catch { /* Invalid base64url or UTF-8. */ }
61
61
  }
62
62
  throw new TypeError("A shape offset is -1, a signed-64-bit decimal log position, or snap.<head>.<canonical-base64url-id>.");
@@ -108,7 +108,7 @@ export function decodeShapePage(value, headers) {
108
108
  const change = object(value, "shape change");
109
109
  return Object.freeze({ offset: u64(change.offset, "shape change offset"), ops: Object.freeze(array(change.ops, "shape operations").map(value => {
110
110
  const op = object(value, "shape operation");
111
- const id = requireDocumentId(op.id);
111
+ const id = requireStoredId(op.id);
112
112
  if (op.op === "delete") return Object.freeze({ op: /** @type {const} */ ("delete"), id });
113
113
  if (op.op !== "upsert") malformed("shape operation kind");
114
114
  return Object.freeze({ op: /** @type {const} */ ("upsert"), id, version: requireVersion(op.version), document: documentObject(op.document) });
package/src/v1/sql.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { array, boolean, malformed, object, string } from "./decode.js";
2
- import { requireDocumentId, requireName } from "./ids.js";
2
+ import { requireStoredId, requireName } from "./ids.js";
3
3
  /**
4
4
  * @import { JsonObject, JsonValue } from "./json.js"
5
5
  * @import { V1RequestOptions } from "./terrabase-v1.js"
@@ -41,7 +41,7 @@ function decodeResult(value) {
41
41
  const raw = object(value, "SQL result");
42
42
  const rows = array(raw.rows, "SQL rows").map(entry => {
43
43
  const row = object(entry, "SQL row");
44
- return Object.freeze({ id: requireDocumentId(row.id), value: object(row.value, "SQL row value") });
44
+ return Object.freeze({ id: requireStoredId(row.id), value: object(row.value, "SQL row value") });
45
45
  });
46
46
  const matched = raw.matched;
47
47
  if (typeof matched !== "number" || !Number.isInteger(matched) || matched < 0 || matched > 1000 || matched !== rows.length)
@@ -1,10 +1,11 @@
1
+ import { V1FileStorage } from "./file-storage.js";
1
2
  // The TerraBase v1 client: documents, transactions, queries and shapes over
2
3
  // the project-scoped C# Documents API (decision 0004 and shape-reads.md).
3
4
  import {
4
5
  array, boolean, decodeDocument, decodeWrite, documentObject, durability, malformed, object, u64,
5
6
  } from "./decode.js";
6
7
  import { requireId } from "../ids.js";
7
- import { isJsonPointer, requireFilter, requirePath } from "./filter.js";
8
+ import { searchCount, isJsonPointer, requireFilter, requireGeoPoint, requirePath } from "./filter.js";
8
9
  import { requireDocumentId, requireName, requireVersion } from "./ids.js";
9
10
  import { assertJsonObject, assertJsonValue, ExactDecimal, stringifyExactJson } from "./json.js";
10
11
  import { readShapeManifest } from "./shape-manifest.js";
@@ -66,7 +67,32 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
66
67
  * Vector similarity sort, nearest first. It must be the only sort key; limit is at most 100 and there is no cursor.
67
68
  * @typedef {{ readonly near: { readonly path: V1Path; readonly vector: readonly number[]; readonly metric?: "cosine" | "l2" | undefined } }} V1NearSortKey
68
69
  */
69
- /** @typedef {V1FieldSortKey | V1NearSortKey} V1SortKey */
70
+ /**
71
+ * Geo distance sort: meters from a WGS 84 point to each document's `{lat, lon}` object at `path`, nearest first,
72
+ * returned as `distance`. Like the vector `near`, it is the only sort key, limit is at most 100 and there is no cursor.
73
+ * @typedef {{ readonly near: { readonly path: string; readonly lat: number; readonly lon: number } }} V1GeoNearSortKey
74
+ */
75
+ /** Relevance order, only sort key with exactly one search predicate. @typedef {{ readonly path: "$score"; readonly order: "desc" }} V1ScoreSortKey */
76
+ /** @typedef {V1FieldSortKey | V1NearSortKey | V1GeoNearSortKey | V1ScoreSortKey} V1SortKey */
77
+ /**
78
+ * Groups matches instead of returning documents. Summaries consider numbers only; at most 16 summary paths in total.
79
+ * @typedef {object} V1Aggregate
80
+ * @property {readonly V1Path[] | undefined} [groupBy] 0-4 paths; without it every match is one group.
81
+ * @property {readonly string[] | undefined} [sum] Distinct JSON Pointers.
82
+ * @property {readonly string[] | undefined} [avg] Distinct JSON Pointers.
83
+ * @property {readonly string[] | undefined} [min] Distinct JSON Pointers.
84
+ * @property {readonly string[] | undefined} [max] Distinct JSON Pointers.
85
+ */
86
+ /**
87
+ * One aggregate group. Summaries are keyed by the requested JSON Pointer; `sum` is 0 and the others null without numbers.
88
+ * @typedef {object} V1Group
89
+ * @property {readonly JsonValue[]} key One value per `groupBy` path (missing members group as null).
90
+ * @property {string} count Matching documents in the group (decimal u64).
91
+ * @property {Readonly<Record<string, number | null>> | undefined} [sum]
92
+ * @property {Readonly<Record<string, number | null>> | undefined} [avg]
93
+ * @property {Readonly<Record<string, number | null>> | undefined} [min]
94
+ * @property {Readonly<Record<string, number | null>> | undefined} [max]
95
+ */
70
96
  /**
71
97
  * A query-v1 request. Every member is optional.
72
98
  * @typedef {object} V1Query
@@ -76,6 +102,7 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
76
102
  * @property {number | undefined} [limit] 1-1000 (server default 50).
77
103
  * @property {string | null | undefined} [cursor] A previous page's `nextCursor`.
78
104
  * @property {boolean | undefined} [count] Count every match (first page only).
105
+ * @property {V1Aggregate | undefined} [aggregate] Answer `groups` instead of items; `limit` bounds the groups. No sort, project, cursor or count.
79
106
  */
80
107
  /** @typedef {{ readonly value: string; readonly exact: boolean }} V1Count */
81
108
  /**
@@ -83,6 +110,7 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
83
110
  * @property {readonly V1Document[]} items Documents in query order.
84
111
  * @property {string | null} nextCursor
85
112
  * @property {V1Count | undefined} count
113
+ * @property {readonly V1Group[] | undefined} groups An aggregate query's groups in key order (`items` is then empty).
86
114
  */
87
115
 
88
116
  /** @typedef {{ readonly ifMatch: string } | { readonly ifNoneMatchAny: true }} V1Precondition */
@@ -99,7 +127,8 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
99
127
  * @property {readonly V1TransactionOpResult[]} results One per op, in order.
100
128
  */
101
129
 
102
- const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count"]);
130
+ const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count", "aggregate"]);
131
+ const summaries = /** @type {const} */ (["sum", "avg", "min", "max"]);
103
132
 
104
133
  /**
105
134
  * @param {string} path
@@ -183,16 +212,53 @@ function normalizePatch(patch) {
183
212
  }
184
213
 
185
214
  /**
186
- * @param {V1NearSortKey} key
215
+ * @param {V1Aggregate} aggregate
216
+ * @param {V1Query} query
217
+ * @returns {Record<string, unknown>}
218
+ */
219
+ function encodeAggregate(aggregate, query) {
220
+ if (typeof aggregate !== "object" || aggregate === null || Array.isArray(aggregate)) throw new TypeError("aggregate is an object.");
221
+ if (query.sort !== undefined || query.project !== undefined || query.cursor != null || query.count !== undefined)
222
+ throw new TypeError("aggregate takes no sort, project, cursor or count.");
223
+ for (const member of Object.keys(aggregate))
224
+ if (member !== "groupBy" && !summaries.includes(/** @type {any} */ (member))) throw new TypeError(`Unknown aggregate member ${member}.`);
225
+ /** @type {Record<string, unknown>} */
226
+ const body = {};
227
+ if (aggregate.groupBy !== undefined) {
228
+ if (!Array.isArray(aggregate.groupBy) || aggregate.groupBy.length > 4) throw new TypeError("groupBy has at most 4 paths.");
229
+ body.group_by = aggregate.groupBy.map(requirePath);
230
+ }
231
+ let total = 0;
232
+ for (const op of summaries) {
233
+ const paths = aggregate[op];
234
+ if (paths === undefined) continue;
235
+ if (!Array.isArray(paths) || new Set(paths).size !== paths.length) throw new TypeError(`${op} takes distinct JSON Pointers.`);
236
+ total += paths.length;
237
+ body[op] = paths.map(pointer);
238
+ }
239
+ if (total > 16) throw new TypeError("An aggregate has at most 16 sum, avg, min and max paths.");
240
+ return body;
241
+ }
242
+
243
+ /**
244
+ * @param {V1NearSortKey | V1GeoNearSortKey} key
187
245
  * @param {V1Query} query
188
246
  * @returns {Record<string, unknown>}
189
247
  */
190
248
  function nearSort(key, query) {
191
249
  if (Object.keys(key).length !== 1 || query.sort?.length !== 1) throw new TypeError("near is the only sort key and has no path or order beside it.");
250
+ if (searchCount(query.filter) !== 0) throw new TypeError("near cannot combine with search.");
192
251
  if (query.cursor != null) throw new TypeError("A near query has one page and takes no cursor.");
193
252
  if (query.limit !== undefined && query.limit > 100) throw new TypeError("A near query limit is at most 100.");
194
253
  const near = key.near;
195
254
  if (near === null || typeof near !== "object") throw new TypeError("near is an object.");
255
+ if ("lat" in near || "lon" in near) {
256
+ const geo = /** @type {V1GeoNearSortKey["near"]} */ (near);
257
+ for (const member of Object.keys(geo)) if (!["path", "lat", "lon"].includes(member)) throw new TypeError(`A geo near takes path, lat and lon, not ${member}.`);
258
+ requireGeoPoint(geo.lat, geo.lon);
259
+ if (requirePath(geo.path) === "$id") throw new TypeError("A geo near path is a JSON Pointer.");
260
+ return { near: { path: geo.path, lat: geo.lat, lon: geo.lon } };
261
+ }
196
262
  for (const member of Object.keys(near)) if (!["path", "vector", "metric"].includes(member)) throw new TypeError(`Unknown near member ${member}.`);
197
263
  if (near.metric !== undefined && near.metric !== "cosine" && near.metric !== "l2") throw new TypeError('near metric is "cosine" or "l2".');
198
264
  const vector = near.vector;
@@ -220,6 +286,10 @@ function encodeQuery(query) {
220
286
  if ("near" in key) return nearSort(key, query);
221
287
  for (const member of Object.keys(key)) if (member !== "path" && member !== "order") throw new TypeError(`Unknown sort member ${member}.`);
222
288
  if (key.order !== undefined && key.order !== "asc" && key.order !== "desc") throw new TypeError('Sort order is "asc" or "desc".');
289
+ if (key.path === "$score") {
290
+ if (key.order !== "desc" || query.sort?.length !== 1 || searchCount(query.filter) !== 1) throw new TypeError("$score requires descending order as the only sort key and exactly one search predicate.");
291
+ return { path: "$score", order: "desc" };
292
+ }
223
293
  return { path: requirePath(key.path), ...(key.order === undefined ? {} : { order: key.order }) };
224
294
  });
225
295
  }
@@ -239,9 +309,29 @@ function encodeQuery(query) {
239
309
  if (typeof query.count !== "boolean") throw new TypeError("count is a boolean.");
240
310
  body.count = query.count;
241
311
  }
312
+ if (query.aggregate !== undefined) body.aggregate = encodeAggregate(query.aggregate, query);
242
313
  return stringifyExactJson(body, "query");
243
314
  }
244
315
 
316
+ /**
317
+ * @param {JsonValue} value
318
+ * @returns {V1Group}
319
+ */
320
+ function decodeGroup(value) {
321
+ const raw = object(value, "aggregate group");
322
+ /** @type {Record<string, unknown>} */
323
+ const group = { key: Object.freeze([...array(raw.key, "group key")]), count: u64(raw.count, "group count") };
324
+ for (const op of summaries) {
325
+ if (raw[op] === undefined) continue;
326
+ group[op] = Object.freeze(Object.fromEntries(Object.entries(object(raw[op], `group ${op}`)).map(([path, summary]) => {
327
+ const number = summary instanceof ExactDecimal ? summary.toNumber() : summary;
328
+ if (number !== null && (typeof number !== "number" || !Number.isFinite(number))) malformed(`group ${op}`);
329
+ return [path, number];
330
+ })));
331
+ }
332
+ return /** @type {V1Group} */ (Object.freeze(group));
333
+ }
334
+
245
335
  /**
246
336
  * @param {JsonValue | undefined} value
247
337
  * @returns {V1QueryPage}
@@ -258,14 +348,19 @@ export function decodeQueryPage(value) {
258
348
  }
259
349
  return Object.freeze({
260
350
  items: Object.freeze(array(raw.items, "query items").map(item => {
261
- const distance = object(item, "document").distance;
262
- if (distance === undefined) return decodeDocument(item);
263
- const value = distance instanceof ExactDecimal ? distance.toNumber() : distance;
264
- if (typeof value !== "number" || !Number.isFinite(value)) malformed("near distance");
265
- return Object.freeze({ ...decodeDocument(item), distance: value });
351
+ const raw = object(item, "document");
352
+ const document = { ...decodeDocument(item) };
353
+ for (const field of /** @type {const} */ (["distance", "score"])) {
354
+ if (raw[field] === undefined) continue;
355
+ const value = raw[field] instanceof ExactDecimal ? raw[field].toNumber() : raw[field];
356
+ if (typeof value !== "number" || !Number.isFinite(value)) malformed(field);
357
+ document[field] = value;
358
+ }
359
+ return Object.freeze(document);
266
360
  })),
267
361
  nextCursor: typeof next === "string" && next !== "" ? next : null,
268
362
  count,
363
+ groups: raw.groups === undefined ? undefined : Object.freeze(array(raw.groups, "aggregate groups").map(decodeGroup)),
269
364
  });
270
365
  }
271
366
 
@@ -909,6 +1004,8 @@ export class V1Database {
909
1004
  * privately; it is never serialized, logged or forwarded to redirects.
910
1005
  */
911
1006
  export class TerraBaseV1Client {
1007
+ /** @type {V1FileStorage} */
1008
+ #storage;
912
1009
  /** @type {V1Health} */
913
1010
  #health;
914
1011
  /** @type {V1Platform} */
@@ -928,6 +1025,7 @@ export class TerraBaseV1Client {
928
1025
  constructor(config) {
929
1026
  requireId(config.project);
930
1027
  this.#transport = new V1Transport(config);
1028
+ this.#storage = new V1FileStorage(this.#transport, config.fetch ?? globalThis.fetch.bind(globalThis));
931
1029
  this.#databases = new V1Databases(this.#transport);
932
1030
  this.#savedQueries = new V1SavedQueries(this.#transport);
933
1031
  this.#metrics = new V1ProjectUsageClient(this.#transport);
@@ -947,6 +1045,8 @@ export class TerraBaseV1Client {
947
1045
 
948
1046
  /** @returns {V1Databases} Project-scoped database catalog. */
949
1047
  get databases() { return this.#databases; }
1048
+ /** @returns {V1FileStorage} Project file buckets. */
1049
+ get storage() { return this.#storage; }
950
1050
  /** @returns {V1SavedQueries} */
951
1051
  get savedQueries() { return this.#savedQueries; }
952
1052
  /** @returns {V1ProjectUsageClient} */
@@ -44,6 +44,7 @@ const tokenPattern = /^(?!tbk1\.)(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-
44
44
  * @property {URLSearchParams | undefined} [query]
45
45
  * @property {AbortSignal | undefined} [signal]
46
46
  * @property {"no-store" | undefined} [cache] Authenticated shape epoch keys must bypass caches.
47
+ * @property {"follow" | undefined} [redirect] Only the shape bootstrap, after a browser hides its 307 behind an opaque redirect.
47
48
  */
48
49
 
49
50
  /**
@@ -51,6 +52,7 @@ const tokenPattern = /^(?!tbk1\.)(?:[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-
51
52
  * @property {number} status
52
53
  * @property {Headers} headers
53
54
  * @property {JsonValue | undefined} value
55
+ * @property {string | undefined} [redirectedTo] The final URL after a followed redirect.
54
56
  */
55
57
 
56
58
  /**
@@ -134,6 +136,23 @@ export class BodyTransportError extends Error {
134
136
  }
135
137
  }
136
138
 
139
+ /** A request that failed in transit before any response; the native failure is the cause. */
140
+ export class RequestTransportError extends TypeError {
141
+ /** @param {unknown} cause */
142
+ constructor(cause) {
143
+ super(cause instanceof Error ? cause.message : "TerraBase request failed in transit.", { cause });
144
+ this.name = "RequestTransportError";
145
+ }
146
+ }
147
+
148
+ /** A browser hid a 307 behind an opaque redirect; only the shape bootstrap may follow it. */
149
+ export class OpaqueRedirectError extends TypeError {
150
+ constructor() {
151
+ super("Fresh shape bootstrap requires server fetch; obtain a handle and snapshot offset with collection.shape() on your server.");
152
+ this.name = "OpaqueRedirectError";
153
+ }
154
+ }
155
+
137
156
  /** Internal transport bound to one project credential. */
138
157
  export class V1Transport {
139
158
  /** @type {string | undefined} */
@@ -353,7 +372,8 @@ export class V1Transport {
353
372
  await response.body?.cancel();
354
373
  return { status: response.status, headers: response.headers, value: undefined };
355
374
  }
356
- return { status: response.status, headers: response.headers, value: await this.readJson(response, options.signal) };
375
+ return { status: response.status, headers: response.headers, value: await this.readJson(response, options.signal),
376
+ ...(response.redirected ? { redirectedTo: response.url } : {}) };
357
377
  }
358
378
 
359
379
  /**
@@ -387,7 +407,7 @@ export class V1Transport {
387
407
  if (route.responseMediaType !== "") headers.set("accept", route.responseMediaType);
388
408
  if (options.body !== undefined) headers.set("content-type", options.contentType ?? "application/json");
389
409
  /** @type {RequestInit & { duplex?: "half" }} */
390
- const init = { method: route.method, headers, redirect: "manual" };
410
+ const init = { method: route.method, headers, redirect: options.redirect ?? "manual" };
391
411
  if (options.cache !== undefined) init.cache = options.cache;
392
412
  if (route.permission.endsWith(":write") && typeof process !== "undefined" && process.release?.name === "node") {
393
413
  // Node fetch replays buffered or absent bodies on 421; use a one-shot stream, including DELETE.
@@ -397,13 +417,15 @@ export class V1Transport {
397
417
  } else if (options.body !== undefined) init.body = typeof options.body === "string" ? options.body : options.body.slice().buffer;
398
418
  if (options.signal !== undefined) init.signal = options.signal;
399
419
  options.signal?.throwIfAborted();
400
- const response = await this.#fetch(url, init);
420
+ /** @type {Response} */
421
+ let response;
422
+ try { response = await this.#fetch(url, init); }
423
+ catch (error) { options.signal?.throwIfAborted(); throw new RequestTransportError(error); }
401
424
  if (options.signal?.aborted) {
402
425
  void response.body?.cancel().catch(() => undefined);
403
426
  options.signal.throwIfAborted();
404
427
  }
405
- if (response.type === "opaqueredirect")
406
- throw new TypeError("Fresh shape bootstrap requires server fetch; obtain a handle and snapshot offset with collection.shape() on your server.");
428
+ if (response.type === "opaqueredirect") throw new OpaqueRedirectError();
407
429
  if (!route.successStatuses.includes(response.status)) throw await this.error(response, options.signal);
408
430
  return response;
409
431
  }
package/src/v1/usage.js CHANGED
@@ -8,7 +8,7 @@ const period = record({ from: text, to: text, granularity: text, reads: numeric,
8
8
  const series = list(record({ start: text, reads: numeric, writes: numeric }));
9
9
  const databaseUsage = record({ databaseId: requireId, storageBytes: numeric, logicalBytes: numeric, collections: numeric,
10
10
  documents: numeric, reads: numeric, writes: numeric, period, series });
11
- const projectUsage = record({ projectId: requireId, databases: numeric, storageBytes: numeric, logicalBytes: numeric,
11
+ const projectUsage = record({ projectId: requireId, databases: numeric, storageBytes: numeric, logicalBytes: numeric, fileBytes: numeric,
12
12
  documents: numeric, reads: numeric, writes: numeric, period, series, items: list(databaseUsage) });
13
13
  const bandwidth = record({ bytesSent: numeric, bytesReceived: numeric });
14
14
  const directedBandwidth = list(record({ sourceRegion: text, destinationRegion: text, kind: text, bytesSent: numeric, bytesReceived: numeric }));
package/types/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { v1Filter } from "./v1/filter.js";
2
2
  export { V1ShapeSubscription } from "./v1/shape-subscription.js";
3
+ export { indexedDbShapeReplica } from "./v1/shape-replica.js";
3
4
  export { ExactDecimal } from "./v1/json.js";
4
5
  export { staticBaseUrlResolver } from "./v1/transport.js";
5
6
  export { V1Databases } from "./v1/catalog.js";
@@ -8,6 +9,7 @@ export { V1DatabaseSchemaClient } from "./v1/database-schema.js";
8
9
  export { V1Indexes } from "./v1/indexes.js";
9
10
  export { V1ChangeWebhooks } from "./v1/change-webhooks.js";
10
11
  export { V1Imports } from "./v1/imports.js";
12
+ export type V1ShapeReplicaStore = import("./v1/shape-subscription.js").V1ShapeReplicaStore;
11
13
  export type V1ShapeSubscribeOptions = import("./v1/shape-subscription.js").V1ShapeSubscribeOptions;
12
14
  export type V1ShapeDocument = import("./v1/shape-subscription.js").V1ShapeDocument;
13
15
  export type V1SealedShapePosition = import("./v1/shape-reader.js").V1SealedShapePosition;
@@ -23,6 +25,10 @@ export type V1PatchOp = import("./v1/terrabase-v1.js").V1PatchOp;
23
25
  export type V1Patch = import("./v1/terrabase-v1.js").V1Patch;
24
26
  export type V1SortKey = import("./v1/terrabase-v1.js").V1SortKey;
25
27
  export type V1NearSortKey = import("./v1/terrabase-v1.js").V1NearSortKey;
28
+ export type V1GeoNearSortKey = import("./v1/terrabase-v1.js").V1GeoNearSortKey;
29
+ export type V1Aggregate = import("./v1/terrabase-v1.js").V1Aggregate;
30
+ export type V1Group = import("./v1/terrabase-v1.js").V1Group;
31
+ export type V1ScoreSortKey = import("./v1/terrabase-v1.js").V1ScoreSortKey;
26
32
  export type V1Query = import("./v1/terrabase-v1.js").V1Query;
27
33
  export type V1Count = import("./v1/terrabase-v1.js").V1Count;
28
34
  export type V1QueryPage = import("./v1/terrabase-v1.js").V1QueryPage;
@@ -36,6 +42,8 @@ export type V1Durability = import("./v1/decode.js").V1Durability;
36
42
  export type V1Filter = import("./v1/filter.js").V1Filter;
37
43
  export type V1Path = import("./v1/filter.js").V1Path;
38
44
  export type V1Scalar = import("./v1/filter.js").V1Scalar;
45
+ export type V1GeoCircle = import("./v1/filter.js").V1GeoCircle;
46
+ export type V1GeoBox = import("./v1/filter.js").V1GeoBox;
39
47
  export type V1JsonValue = import("./v1/json.js").JsonValue;
40
48
  export type V1JsonObject = import("./v1/json.js").JsonObject;
41
49
  export type V1JsonInput = import("./v1/json.js").JsonInput;
@@ -82,6 +90,7 @@ export type V1UniqueSchema = import("./v1/database-schema.js").V1UniqueSchema;
82
90
  export type V1UniqueSchemaRequest = import("./v1/database-schema.js").V1UniqueSchemaRequest;
83
91
  export type V1QueryShapes = import("./v1/database-schema.js").V1QueryShapes;
84
92
  export type V1Index = import("./v1/indexes.js").V1Index;
93
+ export type V1IndexKind = import("./v1/indexes.js").V1IndexKind;
85
94
  export type V1IndexList = import("./v1/indexes.js").V1IndexList;
86
95
  export type V1AutoIndexPolicy = import("./v1/indexes.js").V1AutoIndexPolicy;
87
96
  export type V1IndexRecommendations = import("./v1/indexes.js").V1IndexRecommendations;
@@ -119,6 +128,14 @@ export type V1ShapeManifest = import("./v1/shape-manifest.js").V1ShapeManifest;
119
128
  export type V1ShapeManifestRequest = import("./v1/shape-manifest.js").V1ShapeManifestRequest;
120
129
  export type V1ShapeKeyRequest = import("./v1/shape-reader.js").V1ShapeKeyRequest;
121
130
  export type V1ShapeKey = import("./v1/shape-reader.js").V1ShapeKey;
131
+ export type FileAccessPolicy = import("./v1/file-storage.js").FileAccessPolicy;
132
+ export type FileAccessRules = import("./v1/file-storage.js").FileAccessRules;
133
+ export type FileBucketInfo = import("./v1/file-storage.js").FileBucketInfo;
134
+ export type StoredFile = import("./v1/file-storage.js").StoredFile;
135
+ export type SignedFileUrl = import("./v1/file-storage.js").SignedFileUrl;
136
+ export type FileUploadRequest = import("./v1/file-storage.js").FileUploadRequest;
137
+ export type FileUploadOptions = import("./v1/file-storage.js").FileUploadOptions;
138
+ export type FileListOptions = import("./v1/file-storage.js").FileListOptions;
122
139
  export { createTerraBaseV1Client as createTerraBaseClient, TerraBaseV1Client as TerraBaseClient, createTerraBaseV1Client, TerraBaseV1Client, V1Database, V1Collection, V1Collections, V1Transaction } from "./v1/terrabase-v1.js";
123
140
  export { terraScaleReleaseBlockers, TerraScaleCapabilityUnavailableError } from "./management-contracts.js";
124
141
  export { TerraBaseV1Error, V1PreconditionFailedError, V1InvalidKeyError, V1ForbiddenScopeError, V1NotFoundError, SqlError, V1ErrorCode, isTerraBaseV1Error } from "./v1/errors.js";
@@ -126,3 +143,4 @@ export { generateId, isValidId, requireId } from "./ids.js";
126
143
  export { V1ProjectUsageClient, V1DatabaseUsageClient } from "./v1/usage.js";
127
144
  export { V1Backups, V1BackupDestinationClient } from "./v1/backups.js";
128
145
  export { createTerraBaseSystemClient, V1Health, V1Platform } from "./v1/system.js";
146
+ export { V1FileStorage, V1FileBucket } from "./v1/file-storage.js";
@@ -9,7 +9,8 @@
9
9
  * @property {"value" | "conflict" | (string & {})} state
10
10
  * @property {JsonObject} document
11
11
  * @property {readonly JsonValue[]} [siblings] Live versions and tombstones on a causal conflict.
12
- * @property {number} [distance] Distance to the query vector; present only on `near` query results.
12
+ * @property {number} [distance] Distance to the query vector, or meters to a geo `near` point; present only on `near` query results.
13
+ * @property {number} [score] Full-text relevance score on results sorted by `$score`.
13
14
  */
14
15
  /**
15
16
  * The result of a document write.
@@ -99,9 +100,13 @@ export type V1Document = {
99
100
  */
100
101
  siblings?: readonly JsonValue[];
101
102
  /**
102
- * Distance to the query vector; present only on `near` query results.
103
+ * Distance to the query vector, or meters to a geo `near` point; present only on `near` query results.
103
104
  */
104
105
  distance?: number;
106
+ /**
107
+ * Full-text relevance score on results sorted by `$score`.
108
+ */
109
+ score?: number;
105
110
  };
106
111
  /**
107
112
  * The result of a document write.
@@ -0,0 +1,3 @@
1
+ /** @type {readonly import("./routes.js").V1Route[]} */
2
+ export const fileStorageRoutes: readonly import("./routes.js").V1Route[];
3
+ export type FileStorageOperation = "createFileBucket" | "listFileBuckets" | "setFileBucketRules" | "deleteFileBucket" | "createFileUpload" | "completeFileUpload" | "listBucketFiles" | "createFileDownload" | "deleteBucketFile";