@terrascale/sdk 0.0.6 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +66 -11
- package/package.json +1 -1
- package/sdk-v1-route-manifest.json +39 -36
- package/src/index.js +9 -0
- package/src/local/router.js +12 -3
- package/src/v1/changes.js +2 -2
- package/src/v1/decode.js +4 -3
- package/src/v1/export.js +3 -3
- package/src/v1/filter.js +103 -2
- package/src/v1/ids.js +7 -2
- package/src/v1/indexes.js +6 -4
- package/src/v1/shape-reader.js +19 -5
- package/src/v1/shape-replica.js +46 -0
- package/src/v1/shape-subscription.js +57 -5
- package/src/v1/shapes.js +3 -3
- package/src/v1/sql.js +2 -2
- package/src/v1/terrabase-v1.js +103 -9
- package/src/v1/transport.js +27 -5
- package/types/index.d.ts +9 -0
- package/types/v1/decode.d.ts +7 -2
- package/types/v1/filter.d.ts +50 -0
- package/types/v1/ids.d.ts +3 -1
- package/types/v1/indexes.d.ts +5 -0
- package/types/v1/shape-replica.d.ts +8 -0
- package/types/v1/shape-subscription.d.ts +15 -0
- package/types/v1/terrabase-v1.d.ts +69 -1
- package/types/v1/transport.d.ts +17 -0
package/src/v1/filter.js
CHANGED
|
@@ -24,7 +24,17 @@ import { ExactDecimal } from "./json.js";
|
|
|
24
24
|
* | { readonly nin: readonly [V1Path, readonly V1Scalar[]] }
|
|
25
25
|
* | { readonly exists: V1Path }
|
|
26
26
|
* | { readonly prefix: readonly [V1Path, string] }
|
|
27
|
-
* | { readonly contains: readonly [V1Path, V1Scalar] }
|
|
27
|
+
* | { readonly contains: readonly [V1Path, V1Scalar] }
|
|
28
|
+
* | { readonly within: readonly [string, V1GeoCircle | V1GeoBox] }
|
|
29
|
+
* | { readonly search: readonly [V1Path, string] }} V1Filter
|
|
30
|
+
*/
|
|
31
|
+
/**
|
|
32
|
+
* Points within `meters` (more than 0, at most 20000000) of a WGS 84 point, by haversine distance.
|
|
33
|
+
* @typedef {{ readonly lat: number; readonly lon: number; readonly meters: number }} V1GeoCircle
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* Points in a latitude/longitude box; `west > east` crosses the antimeridian.
|
|
37
|
+
* @typedef {{ readonly south: number; readonly west: number; readonly north: number; readonly east: number }} V1GeoBox
|
|
28
38
|
*/
|
|
29
39
|
|
|
30
40
|
/**
|
|
@@ -54,6 +64,28 @@ export function requirePath(path) {
|
|
|
54
64
|
throw new TypeError('A TerraBase path is an RFC 6901 JSON Pointer of 1-16 segments, "$id".');
|
|
55
65
|
}
|
|
56
66
|
|
|
67
|
+
/** @param {V1Path} path @returns {V1Path} */
|
|
68
|
+
function requireSearchPath(path) {
|
|
69
|
+
if (isJsonPointer(path)) return path;
|
|
70
|
+
throw new TypeError("search requires a JSON Pointer of 1-16 segments, excluding $id.");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {unknown} terms @returns {string} */
|
|
74
|
+
function requireSearchTerms(terms) {
|
|
75
|
+
if (typeof terms !== "string" || terms.length === 0 || terms.includes("\0") || /[\uD800-\uDFFF]/u.test(terms) || new TextEncoder().encode(terms).byteLength > 4096)
|
|
76
|
+
throw new TypeError("search takes nonempty string terms of at most 4096 UTF-8 bytes without NUL.");
|
|
77
|
+
return terms;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {V1Filter | null | undefined} filter @returns {number} */
|
|
81
|
+
export function searchCount(filter) {
|
|
82
|
+
if (!filter) return 0;
|
|
83
|
+
if ("search" in filter) return 1;
|
|
84
|
+
if ("and" in filter) return filter.and.reduce((count, child) => count + searchCount(child), 0);
|
|
85
|
+
if ("or" in filter) return filter.or.reduce((count, child) => count + searchCount(child), 0);
|
|
86
|
+
return "not" in filter ? searchCount(filter.not) : 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
57
89
|
/**
|
|
58
90
|
* @param {V1Scalar} value
|
|
59
91
|
* @returns {V1Scalar}
|
|
@@ -92,6 +124,46 @@ function scalars(op, values) {
|
|
|
92
124
|
return Object.freeze(values.map(requireScalar));
|
|
93
125
|
}
|
|
94
126
|
|
|
127
|
+
/**
|
|
128
|
+
* @param {unknown} lat
|
|
129
|
+
* @param {unknown} lon
|
|
130
|
+
* @returns {void}
|
|
131
|
+
*/
|
|
132
|
+
export function requireGeoPoint(lat, lon) {
|
|
133
|
+
if (typeof lat !== "number" || typeof lon !== "number" || !(lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180))
|
|
134
|
+
throw new TypeError("lat is -90 to 90 and lon is -180 to 180.");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {unknown} area
|
|
139
|
+
* @returns {V1GeoCircle | V1GeoBox}
|
|
140
|
+
*/
|
|
141
|
+
function geoArea(area) {
|
|
142
|
+
if (typeof area !== "object" || area === null || Array.isArray(area)) throw new TypeError("within takes a circle or box object.");
|
|
143
|
+
const keys = Object.keys(area).sort().join();
|
|
144
|
+
const a = /** @type {Record<string, unknown>} */ (area);
|
|
145
|
+
if (keys === "east,north,south,west") {
|
|
146
|
+
requireGeoPoint(a.south, a.west);
|
|
147
|
+
requireGeoPoint(a.north, a.east);
|
|
148
|
+
if (/** @type {number} */ (a.south) > /** @type {number} */ (a.north)) throw new TypeError("A within box has south <= north.");
|
|
149
|
+
return Object.freeze({ south: /** @type {number} */ (a.south), west: /** @type {number} */ (a.west), north: /** @type {number} */ (a.north), east: /** @type {number} */ (a.east) });
|
|
150
|
+
}
|
|
151
|
+
if (keys !== "lat,lon,meters" || typeof a.meters !== "number" || !(a.meters > 0 && a.meters <= 20_000_000))
|
|
152
|
+
throw new TypeError("within takes {lat, lon, meters (0 to 20000000)} or {south, west, north, east}.");
|
|
153
|
+
requireGeoPoint(a.lat, a.lon);
|
|
154
|
+
return Object.freeze({ lat: /** @type {number} */ (a.lat), lon: /** @type {number} */ (a.lon), meters: a.meters });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {V1Path} path
|
|
159
|
+
* @param {unknown} area
|
|
160
|
+
* @returns {V1Filter}
|
|
161
|
+
*/
|
|
162
|
+
function within(path, area) {
|
|
163
|
+
if (requirePath(path) === "$id") throw new TypeError("within takes a JSON Pointer.");
|
|
164
|
+
return Object.freeze({ within: Object.freeze(/** @type {const} */ ([path, geoArea(area)])) });
|
|
165
|
+
}
|
|
166
|
+
|
|
95
167
|
const binaryOperators = new Set(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]);
|
|
96
168
|
|
|
97
169
|
/**
|
|
@@ -119,7 +191,15 @@ export function requireFilter(filter) {
|
|
|
119
191
|
for (const child of args) visit(child, depth + 1);
|
|
120
192
|
} else if (op === "not") visit(args, depth + 1);
|
|
121
193
|
else if (op === "exists") requirePath(/** @type {V1Path} */ (args));
|
|
122
|
-
else if (
|
|
194
|
+
else if (op === "within") {
|
|
195
|
+
if (!Array.isArray(args) || args.length !== 2) throw new TypeError("within takes [path, area].");
|
|
196
|
+
within(args[0], args[1]);
|
|
197
|
+
}
|
|
198
|
+
else if (op === "search") {
|
|
199
|
+
if (!Array.isArray(args) || args.length !== 2) throw new TypeError("search takes [path, terms].");
|
|
200
|
+
requireSearchPath(args[0]);
|
|
201
|
+
requireSearchTerms(args[1]);
|
|
202
|
+
} else if (binaryOperators.has(op) || op === "prefix" || op === "in" || op === "nin") {
|
|
123
203
|
if (!Array.isArray(args) || args.length !== 2) throw new TypeError(`${op} takes [path, value].`);
|
|
124
204
|
requirePath(args[0]);
|
|
125
205
|
if (op === "in" || op === "nin") scalars(op, args[1]);
|
|
@@ -220,6 +300,13 @@ export const v1Filter = Object.freeze({
|
|
|
220
300
|
if (typeof prefix !== "string") throw new TypeError("prefix takes a string.");
|
|
221
301
|
return Object.freeze({ prefix: Object.freeze(/** @type {const} */ ([requirePath(path), prefix])) });
|
|
222
302
|
},
|
|
303
|
+
/**
|
|
304
|
+
* Full-text search on a document member.
|
|
305
|
+
* @param {V1Path} path
|
|
306
|
+
* @param {string} terms
|
|
307
|
+
* @returns {V1Filter}
|
|
308
|
+
*/
|
|
309
|
+
search: (path, terms) => Object.freeze({ search: Object.freeze(/** @type {const} */ ([requireSearchPath(path), requireSearchTerms(terms)])) }),
|
|
223
310
|
/**
|
|
224
311
|
* The value is an array containing the scalar.
|
|
225
312
|
* @param {V1Path} path
|
|
@@ -227,4 +314,18 @@ export const v1Filter = Object.freeze({
|
|
|
227
314
|
* @returns {V1Filter}
|
|
228
315
|
*/
|
|
229
316
|
contains: (path, value) => Object.freeze({ contains: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
|
|
317
|
+
/**
|
|
318
|
+
* The `{lat, lon}` object at `path` lies within `meters` of the center (haversine, WGS 84 mean radius).
|
|
319
|
+
* @param {string} path JSON Pointer of a `{lat, lon}` point.
|
|
320
|
+
* @param {V1GeoCircle} circle `meters` is more than 0, at most 20000000.
|
|
321
|
+
* @returns {V1Filter}
|
|
322
|
+
*/
|
|
323
|
+
withinRadius: (path, circle) => within(path, { lat: circle?.lat, lon: circle?.lon, meters: circle?.meters }),
|
|
324
|
+
/**
|
|
325
|
+
* The `{lat, lon}` object at `path` lies in the box; `west > east` crosses the antimeridian.
|
|
326
|
+
* @param {string} path JSON Pointer of a `{lat, lon}` point.
|
|
327
|
+
* @param {V1GeoBox} box
|
|
328
|
+
* @returns {V1Filter}
|
|
329
|
+
*/
|
|
330
|
+
withinBox: (path, box) => within(path, { south: box?.south, west: box?.west, north: box?.north, east: box?.east }),
|
|
230
331
|
});
|
package/src/v1/ids.js
CHANGED
|
@@ -27,11 +27,16 @@ export function requireProject(project) {
|
|
|
27
27
|
return requireId(project);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
30
|
+
/** Every document id is a canonical ULID. @param {unknown} id @returns {string} */
|
|
31
31
|
export function requireDocumentId(id) {
|
|
32
|
+
return requireId(id, "TerraBase document ID");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** SQL-backed collections can return non-ULID primary keys. @param {unknown} id @returns {string} */
|
|
36
|
+
export function requireStoredId(id) {
|
|
32
37
|
if (typeof id !== "string" || id.length < 1 || id.length > 256 ||
|
|
33
38
|
/[\u0000-\u001f\u007f-\u009f\ud800-\udfff]/u.test(id))
|
|
34
|
-
throw new TypeError("TerraBase
|
|
39
|
+
throw new TypeError("TerraBase stored IDs must be 1-256 characters of valid UTF-8 without control characters.");
|
|
35
40
|
return id;
|
|
36
41
|
}
|
|
37
42
|
|
package/src/v1/indexes.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { requireName } from "./ids.js";
|
|
2
|
-
import { flag, list, numeric, record, text } from "./management-decode.js";
|
|
2
|
+
import { flag, list, numeric, optional, record, text } from "./management-decode.js";
|
|
3
3
|
import { members, mutationOptions } from "./management-validation.js";
|
|
4
4
|
import { stringifyExactJson } from "./json.js";
|
|
5
5
|
/** @import { V1Transport } from "./transport.js" */
|
|
6
6
|
/** @import { V1RequestOptions } from "./terrabase-v1.js" */
|
|
7
|
-
const index = record({ name: text, collection: text, fields: list(text), state: text });
|
|
7
|
+
const index = record({ name: text, collection: text, fields: list(text), state: text, kind: optional(text) });
|
|
8
8
|
const indexes = record({ items: list(index) });
|
|
9
9
|
const policy = record({ collection: text, enabled: flag });
|
|
10
10
|
const recommendations = record({ items: list(record({ id: text, collection: text, fields: list(text), reason: text, queries: numeric })) });
|
|
@@ -14,7 +14,8 @@ const applied = record({ status: text, index });
|
|
|
14
14
|
/** @typedef {ReturnType<typeof policy>} V1AutoIndexPolicy */
|
|
15
15
|
/** @typedef {ReturnType<typeof recommendations>} V1IndexRecommendations */
|
|
16
16
|
/** @typedef {ReturnType<typeof applied>} V1AppliedIndexRecommendation */
|
|
17
|
-
/** @typedef {
|
|
17
|
+
/** @typedef {"btree" | "text"} V1IndexKind */
|
|
18
|
+
/** @typedef {{ readonly collection: string; readonly fields: readonly string[]; readonly name?: string | null; readonly kind?: V1IndexKind }} V1CreateIndexRequest */
|
|
18
19
|
/** @param {unknown} value @returns {string} */
|
|
19
20
|
function indexName(value) {
|
|
20
21
|
if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,62}$/u.test(value)) throw new TypeError("Invalid index name.");
|
|
@@ -31,7 +32,8 @@ export class V1Indexes {
|
|
|
31
32
|
constructor(transport, database) { this.#transport = transport; this.#params = { database: requireName("database", database) }; Object.freeze(this); }
|
|
32
33
|
/** @param {V1CreateIndexRequest} request @param {V1RequestOptions} [options] @returns {Promise<V1Index>} */
|
|
33
34
|
async create(request, options = {}) {
|
|
34
|
-
members(request, ["collection", "fields", "name"]);
|
|
35
|
+
members(request, ["collection", "fields", "name", "kind"]);
|
|
36
|
+
if (request.kind !== undefined && request.kind !== "btree" && request.kind !== "text") throw new TypeError("kind must be btree or text.");
|
|
35
37
|
requireName("collection", request.collection);
|
|
36
38
|
if (!Array.isArray(request.fields) || request.fields.length < 1 || request.fields.length > 4 ||
|
|
37
39
|
new Set(request.fields).size !== request.fields.length || request.fields.some(f => typeof f !== "string" || f === "_id" || f === "_rev" || !/^[A-Za-z_][A-Za-z0-9_]{0,63}(?:\.[A-Za-z_][A-Za-z0-9_]{0,63}){0,7}$/u.test(f)))
|
package/src/v1/shape-reader.js
CHANGED
|
@@ -5,6 +5,7 @@ import { decodeShapePage, encodeShapeRequest, requireShapeHandle, requireShapeOf
|
|
|
5
5
|
import { decryptShapeObject } from "./shape-crypto.js";
|
|
6
6
|
import { parseExactJson } from "./json.js";
|
|
7
7
|
import { TerraBaseV1Error } from "./errors.js";
|
|
8
|
+
import { OpaqueRedirectError } from "./transport.js";
|
|
8
9
|
/** @import { V1Transport } from "./transport.js" */
|
|
9
10
|
/** @import { V1ShapeRequest, V1ShapePage } from "./shapes.js" */
|
|
10
11
|
/** @typedef {{handle: string; offset: string; epoch: string; keyId: string}} V1SealedShapePosition */
|
|
@@ -125,11 +126,9 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
125
126
|
query.delete("cursor");
|
|
126
127
|
}
|
|
127
128
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const location = response.headers.get("location");
|
|
132
|
-
if (location === null) malformed("shape redirect Location");
|
|
129
|
+
const bootstrap = query.get("offset") === "-1";
|
|
130
|
+
/** @param {string} location */
|
|
131
|
+
const redirectTarget = location => {
|
|
133
132
|
const target = new URL(location, source);
|
|
134
133
|
if (target.origin !== source.origin || target.pathname !== source.pathname || target.username !== "" || target.password !== "" || target.hash !== "") malformed("shape redirect target");
|
|
135
134
|
for (const key of target.searchParams.keys()) {
|
|
@@ -144,6 +143,21 @@ export async function readShapePage(transport, params, request, signal, ifNoneMa
|
|
|
144
143
|
delete headers["TB-Shape-Resume"];
|
|
145
144
|
query.set("handle", handle);
|
|
146
145
|
query.set("offset", offset);
|
|
146
|
+
};
|
|
147
|
+
let response;
|
|
148
|
+
try { response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal }); }
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (!(error instanceof OpaqueRedirectError) || !bootstrap) throw error;
|
|
151
|
+
// Browsers hide a manual 307; follow it natively, then hold the final URL to the Location rules.
|
|
152
|
+
response = await shapeCall(transport, params, "readFreeShape", { query: new URLSearchParams(query), headers: { ...headers }, signal, redirect: "follow" });
|
|
153
|
+
if (response.redirectedTo === undefined || response.status !== 200) malformed("shape bootstrap requires redirect");
|
|
154
|
+
redirectTarget(response.redirectedTo);
|
|
155
|
+
}
|
|
156
|
+
if (response.status === 307) {
|
|
157
|
+
if (!bootstrap) malformed("unexpected shape redirect");
|
|
158
|
+
const location = response.headers.get("location");
|
|
159
|
+
if (location === null) malformed("shape redirect Location");
|
|
160
|
+
redirectTarget(location);
|
|
147
161
|
response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal });
|
|
148
162
|
if (response.status !== 200) malformed("repeated shape redirect");
|
|
149
163
|
} else if (query.get("offset") === "-1") malformed("shape bootstrap requires redirect");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Browser storage for a shape subscription's read replica. Durable writes never queue here.
|
|
2
|
+
/** @import { V1ShapeReplicaStore } from "./shape-subscription.js" */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A V1ShapeReplicaStore kept in browser IndexedDB, one record per key.
|
|
6
|
+
* @param {string} key Names one subject's shape, e.g. `${userId}/notes/open`; clear it on sign-out.
|
|
7
|
+
* @returns {V1ShapeReplicaStore}
|
|
8
|
+
*/
|
|
9
|
+
export function indexedDbShapeReplica(key) {
|
|
10
|
+
if (typeof key !== "string" || key === "") throw new TypeError("An IndexedDB replica key is a non-empty string.");
|
|
11
|
+
/** @type {Promise<IDBDatabase> | undefined} */
|
|
12
|
+
let opened;
|
|
13
|
+
/** @returns {Promise<IDBDatabase>} */
|
|
14
|
+
const open = () => opened ??= new Promise((resolve, reject) => {
|
|
15
|
+
const request = indexedDB.open("terrascale-shape-replicas", 1);
|
|
16
|
+
request.onupgradeneeded = () => { request.result.createObjectStore("replicas"); };
|
|
17
|
+
request.onsuccess = () => resolve(request.result);
|
|
18
|
+
request.onerror = () => { opened = undefined; reject(request.error); };
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* @template T
|
|
22
|
+
* @param {IDBTransactionMode} mode
|
|
23
|
+
* @param {(store: IDBObjectStore) => IDBRequest<T>} operation
|
|
24
|
+
* @returns {Promise<T>}
|
|
25
|
+
*/
|
|
26
|
+
const run = async (mode, operation) => {
|
|
27
|
+
const database = await open();
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const transaction = database.transaction("replicas", mode);
|
|
30
|
+
const request = operation(transaction.objectStore("replicas"));
|
|
31
|
+
transaction.oncomplete = () => resolve(request.result);
|
|
32
|
+
transaction.onerror = transaction.onabort = () => reject(transaction.error ?? request.error);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
load: async () => {
|
|
37
|
+
const value = await run("readonly", store => store.get(key));
|
|
38
|
+
return typeof value === "string" ? value : undefined;
|
|
39
|
+
},
|
|
40
|
+
/** @param {string | undefined} text */
|
|
41
|
+
save: async text => {
|
|
42
|
+
if (text === undefined) await run("readwrite", store => store.delete(key));
|
|
43
|
+
else await run("readwrite", store => store.put(text, key));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -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
|
-
|
|
131
|
-
|
|
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,
|
|
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) {
|
|
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 =
|
|
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 {
|
|
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:
|
|
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)
|
package/src/v1/terrabase-v1.js
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
array, boolean, decodeDocument, decodeWrite, documentObject, durability, malformed, object, u64,
|
|
5
5
|
} from "./decode.js";
|
|
6
6
|
import { requireId } from "../ids.js";
|
|
7
|
-
import { isJsonPointer, requireFilter, requirePath } from "./filter.js";
|
|
7
|
+
import { searchCount, isJsonPointer, requireFilter, requireGeoPoint, requirePath } from "./filter.js";
|
|
8
8
|
import { requireDocumentId, requireName, requireVersion } from "./ids.js";
|
|
9
9
|
import { assertJsonObject, assertJsonValue, ExactDecimal, stringifyExactJson } from "./json.js";
|
|
10
10
|
import { readShapeManifest } from "./shape-manifest.js";
|
|
@@ -66,7 +66,32 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
|
|
|
66
66
|
* Vector similarity sort, nearest first. It must be the only sort key; limit is at most 100 and there is no cursor.
|
|
67
67
|
* @typedef {{ readonly near: { readonly path: V1Path; readonly vector: readonly number[]; readonly metric?: "cosine" | "l2" | undefined } }} V1NearSortKey
|
|
68
68
|
*/
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* Geo distance sort: meters from a WGS 84 point to each document's `{lat, lon}` object at `path`, nearest first,
|
|
71
|
+
* returned as `distance`. Like the vector `near`, it is the only sort key, limit is at most 100 and there is no cursor.
|
|
72
|
+
* @typedef {{ readonly near: { readonly path: string; readonly lat: number; readonly lon: number } }} V1GeoNearSortKey
|
|
73
|
+
*/
|
|
74
|
+
/** Relevance order, only sort key with exactly one search predicate. @typedef {{ readonly path: "$score"; readonly order: "desc" }} V1ScoreSortKey */
|
|
75
|
+
/** @typedef {V1FieldSortKey | V1NearSortKey | V1GeoNearSortKey | V1ScoreSortKey} V1SortKey */
|
|
76
|
+
/**
|
|
77
|
+
* Groups matches instead of returning documents. Summaries consider numbers only; at most 16 summary paths in total.
|
|
78
|
+
* @typedef {object} V1Aggregate
|
|
79
|
+
* @property {readonly V1Path[] | undefined} [groupBy] 0-4 paths; without it every match is one group.
|
|
80
|
+
* @property {readonly string[] | undefined} [sum] Distinct JSON Pointers.
|
|
81
|
+
* @property {readonly string[] | undefined} [avg] Distinct JSON Pointers.
|
|
82
|
+
* @property {readonly string[] | undefined} [min] Distinct JSON Pointers.
|
|
83
|
+
* @property {readonly string[] | undefined} [max] Distinct JSON Pointers.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* One aggregate group. Summaries are keyed by the requested JSON Pointer; `sum` is 0 and the others null without numbers.
|
|
87
|
+
* @typedef {object} V1Group
|
|
88
|
+
* @property {readonly JsonValue[]} key One value per `groupBy` path (missing members group as null).
|
|
89
|
+
* @property {string} count Matching documents in the group (decimal u64).
|
|
90
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [sum]
|
|
91
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [avg]
|
|
92
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [min]
|
|
93
|
+
* @property {Readonly<Record<string, number | null>> | undefined} [max]
|
|
94
|
+
*/
|
|
70
95
|
/**
|
|
71
96
|
* A query-v1 request. Every member is optional.
|
|
72
97
|
* @typedef {object} V1Query
|
|
@@ -76,6 +101,7 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
|
|
|
76
101
|
* @property {number | undefined} [limit] 1-1000 (server default 50).
|
|
77
102
|
* @property {string | null | undefined} [cursor] A previous page's `nextCursor`.
|
|
78
103
|
* @property {boolean | undefined} [count] Count every match (first page only).
|
|
104
|
+
* @property {V1Aggregate | undefined} [aggregate] Answer `groups` instead of items; `limit` bounds the groups. No sort, project, cursor or count.
|
|
79
105
|
*/
|
|
80
106
|
/** @typedef {{ readonly value: string; readonly exact: boolean }} V1Count */
|
|
81
107
|
/**
|
|
@@ -83,6 +109,7 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
|
|
|
83
109
|
* @property {readonly V1Document[]} items Documents in query order.
|
|
84
110
|
* @property {string | null} nextCursor
|
|
85
111
|
* @property {V1Count | undefined} count
|
|
112
|
+
* @property {readonly V1Group[] | undefined} groups An aggregate query's groups in key order (`items` is then empty).
|
|
86
113
|
*/
|
|
87
114
|
|
|
88
115
|
/** @typedef {{ readonly ifMatch: string } | { readonly ifNoneMatchAny: true }} V1Precondition */
|
|
@@ -99,7 +126,8 @@ import { V1ChangeWebhooks } from "./change-webhooks.js";
|
|
|
99
126
|
* @property {readonly V1TransactionOpResult[]} results One per op, in order.
|
|
100
127
|
*/
|
|
101
128
|
|
|
102
|
-
const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count"]);
|
|
129
|
+
const queryMembers = new Set(["filter", "sort", "project", "limit", "cursor", "count", "aggregate"]);
|
|
130
|
+
const summaries = /** @type {const} */ (["sum", "avg", "min", "max"]);
|
|
103
131
|
|
|
104
132
|
/**
|
|
105
133
|
* @param {string} path
|
|
@@ -183,16 +211,53 @@ function normalizePatch(patch) {
|
|
|
183
211
|
}
|
|
184
212
|
|
|
185
213
|
/**
|
|
186
|
-
* @param {
|
|
214
|
+
* @param {V1Aggregate} aggregate
|
|
215
|
+
* @param {V1Query} query
|
|
216
|
+
* @returns {Record<string, unknown>}
|
|
217
|
+
*/
|
|
218
|
+
function encodeAggregate(aggregate, query) {
|
|
219
|
+
if (typeof aggregate !== "object" || aggregate === null || Array.isArray(aggregate)) throw new TypeError("aggregate is an object.");
|
|
220
|
+
if (query.sort !== undefined || query.project !== undefined || query.cursor != null || query.count !== undefined)
|
|
221
|
+
throw new TypeError("aggregate takes no sort, project, cursor or count.");
|
|
222
|
+
for (const member of Object.keys(aggregate))
|
|
223
|
+
if (member !== "groupBy" && !summaries.includes(/** @type {any} */ (member))) throw new TypeError(`Unknown aggregate member ${member}.`);
|
|
224
|
+
/** @type {Record<string, unknown>} */
|
|
225
|
+
const body = {};
|
|
226
|
+
if (aggregate.groupBy !== undefined) {
|
|
227
|
+
if (!Array.isArray(aggregate.groupBy) || aggregate.groupBy.length > 4) throw new TypeError("groupBy has at most 4 paths.");
|
|
228
|
+
body.group_by = aggregate.groupBy.map(requirePath);
|
|
229
|
+
}
|
|
230
|
+
let total = 0;
|
|
231
|
+
for (const op of summaries) {
|
|
232
|
+
const paths = aggregate[op];
|
|
233
|
+
if (paths === undefined) continue;
|
|
234
|
+
if (!Array.isArray(paths) || new Set(paths).size !== paths.length) throw new TypeError(`${op} takes distinct JSON Pointers.`);
|
|
235
|
+
total += paths.length;
|
|
236
|
+
body[op] = paths.map(pointer);
|
|
237
|
+
}
|
|
238
|
+
if (total > 16) throw new TypeError("An aggregate has at most 16 sum, avg, min and max paths.");
|
|
239
|
+
return body;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @param {V1NearSortKey | V1GeoNearSortKey} key
|
|
187
244
|
* @param {V1Query} query
|
|
188
245
|
* @returns {Record<string, unknown>}
|
|
189
246
|
*/
|
|
190
247
|
function nearSort(key, query) {
|
|
191
248
|
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.");
|
|
249
|
+
if (searchCount(query.filter) !== 0) throw new TypeError("near cannot combine with search.");
|
|
192
250
|
if (query.cursor != null) throw new TypeError("A near query has one page and takes no cursor.");
|
|
193
251
|
if (query.limit !== undefined && query.limit > 100) throw new TypeError("A near query limit is at most 100.");
|
|
194
252
|
const near = key.near;
|
|
195
253
|
if (near === null || typeof near !== "object") throw new TypeError("near is an object.");
|
|
254
|
+
if ("lat" in near || "lon" in near) {
|
|
255
|
+
const geo = /** @type {V1GeoNearSortKey["near"]} */ (near);
|
|
256
|
+
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}.`);
|
|
257
|
+
requireGeoPoint(geo.lat, geo.lon);
|
|
258
|
+
if (requirePath(geo.path) === "$id") throw new TypeError("A geo near path is a JSON Pointer.");
|
|
259
|
+
return { near: { path: geo.path, lat: geo.lat, lon: geo.lon } };
|
|
260
|
+
}
|
|
196
261
|
for (const member of Object.keys(near)) if (!["path", "vector", "metric"].includes(member)) throw new TypeError(`Unknown near member ${member}.`);
|
|
197
262
|
if (near.metric !== undefined && near.metric !== "cosine" && near.metric !== "l2") throw new TypeError('near metric is "cosine" or "l2".');
|
|
198
263
|
const vector = near.vector;
|
|
@@ -220,6 +285,10 @@ function encodeQuery(query) {
|
|
|
220
285
|
if ("near" in key) return nearSort(key, query);
|
|
221
286
|
for (const member of Object.keys(key)) if (member !== "path" && member !== "order") throw new TypeError(`Unknown sort member ${member}.`);
|
|
222
287
|
if (key.order !== undefined && key.order !== "asc" && key.order !== "desc") throw new TypeError('Sort order is "asc" or "desc".');
|
|
288
|
+
if (key.path === "$score") {
|
|
289
|
+
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.");
|
|
290
|
+
return { path: "$score", order: "desc" };
|
|
291
|
+
}
|
|
223
292
|
return { path: requirePath(key.path), ...(key.order === undefined ? {} : { order: key.order }) };
|
|
224
293
|
});
|
|
225
294
|
}
|
|
@@ -239,9 +308,29 @@ function encodeQuery(query) {
|
|
|
239
308
|
if (typeof query.count !== "boolean") throw new TypeError("count is a boolean.");
|
|
240
309
|
body.count = query.count;
|
|
241
310
|
}
|
|
311
|
+
if (query.aggregate !== undefined) body.aggregate = encodeAggregate(query.aggregate, query);
|
|
242
312
|
return stringifyExactJson(body, "query");
|
|
243
313
|
}
|
|
244
314
|
|
|
315
|
+
/**
|
|
316
|
+
* @param {JsonValue} value
|
|
317
|
+
* @returns {V1Group}
|
|
318
|
+
*/
|
|
319
|
+
function decodeGroup(value) {
|
|
320
|
+
const raw = object(value, "aggregate group");
|
|
321
|
+
/** @type {Record<string, unknown>} */
|
|
322
|
+
const group = { key: Object.freeze([...array(raw.key, "group key")]), count: u64(raw.count, "group count") };
|
|
323
|
+
for (const op of summaries) {
|
|
324
|
+
if (raw[op] === undefined) continue;
|
|
325
|
+
group[op] = Object.freeze(Object.fromEntries(Object.entries(object(raw[op], `group ${op}`)).map(([path, summary]) => {
|
|
326
|
+
const number = summary instanceof ExactDecimal ? summary.toNumber() : summary;
|
|
327
|
+
if (number !== null && (typeof number !== "number" || !Number.isFinite(number))) malformed(`group ${op}`);
|
|
328
|
+
return [path, number];
|
|
329
|
+
})));
|
|
330
|
+
}
|
|
331
|
+
return /** @type {V1Group} */ (Object.freeze(group));
|
|
332
|
+
}
|
|
333
|
+
|
|
245
334
|
/**
|
|
246
335
|
* @param {JsonValue | undefined} value
|
|
247
336
|
* @returns {V1QueryPage}
|
|
@@ -258,14 +347,19 @@ export function decodeQueryPage(value) {
|
|
|
258
347
|
}
|
|
259
348
|
return Object.freeze({
|
|
260
349
|
items: Object.freeze(array(raw.items, "query items").map(item => {
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
350
|
+
const raw = object(item, "document");
|
|
351
|
+
const document = { ...decodeDocument(item) };
|
|
352
|
+
for (const field of /** @type {const} */ (["distance", "score"])) {
|
|
353
|
+
if (raw[field] === undefined) continue;
|
|
354
|
+
const value = raw[field] instanceof ExactDecimal ? raw[field].toNumber() : raw[field];
|
|
355
|
+
if (typeof value !== "number" || !Number.isFinite(value)) malformed(field);
|
|
356
|
+
document[field] = value;
|
|
357
|
+
}
|
|
358
|
+
return Object.freeze(document);
|
|
266
359
|
})),
|
|
267
360
|
nextCursor: typeof next === "string" && next !== "" ? next : null,
|
|
268
361
|
count,
|
|
362
|
+
groups: raw.groups === undefined ? undefined : Object.freeze(array(raw.groups, "aggregate groups").map(decodeGroup)),
|
|
269
363
|
});
|
|
270
364
|
}
|
|
271
365
|
|