terrascale 0.3.0 → 1.0.0

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/src/v1/json.js ADDED
@@ -0,0 +1,335 @@
1
+ // Lossless JSON for the TerraBase v1 Documents API.
2
+ //
3
+ // TerraBase numbers are exact decimals with up to 128 significant digits
4
+ // (api-v1.md). Responses are parsed with JSON.parse source text access, so a
5
+ // number is returned as a JavaScript number only when that number denotes the
6
+ // same decimal value as the wire text; every other number becomes an
7
+ // ExactDecimal holding the original text. Requests are encoded with
8
+ // JSON.rawJSON, so ExactDecimal and bigint values reach the server unchanged.
9
+
10
+ const decimalGrammar = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u;
11
+ const maximumIntegerExponent = 4096n;
12
+
13
+ /**
14
+ * @typedef {object} CanonicalDecimal
15
+ * @property {boolean} negative
16
+ * @property {string} digits Significant digits without leading or trailing zeros; empty for zero.
17
+ * @property {bigint} exponent The value is digits × 10^exponent.
18
+ */
19
+
20
+ /**
21
+ * @param {string} text
22
+ * @returns {CanonicalDecimal | undefined}
23
+ */
24
+ function canonicalDecimal(text) {
25
+ const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/u.exec(text);
26
+ if (match === null) return undefined;
27
+ const fraction = match[3] ?? "";
28
+ let digits = `${match[2]}${fraction}`.replace(/^0+/u, "");
29
+ let exponent = BigInt(match[4] ?? "0") - BigInt(fraction.length);
30
+ if (digits === "") return { negative: false, digits: "", exponent: 0n };
31
+ const trimmed = digits.replace(/0+$/u, "");
32
+ exponent += BigInt(digits.length - trimmed.length);
33
+ digits = trimmed;
34
+ return { negative: match[1] === "-", digits, exponent };
35
+ }
36
+
37
+ /**
38
+ * @param {string} left
39
+ * @param {string} right
40
+ * @returns {boolean}
41
+ */
42
+ function sameDecimal(left, right) {
43
+ const a = canonicalDecimal(left);
44
+ const b = canonicalDecimal(right);
45
+ return (
46
+ a !== undefined &&
47
+ b !== undefined &&
48
+ a.negative === b.negative &&
49
+ a.digits === b.digits &&
50
+ a.exponent === b.exponent
51
+ );
52
+ }
53
+
54
+ /** @returns {(text: string) => unknown} */
55
+ function rawJsonFactory() {
56
+ const factory = /** @type {{ rawJSON?: unknown }} */ (
57
+ /** @type {unknown} */ (JSON)
58
+ ).rawJSON;
59
+ if (typeof factory !== "function")
60
+ throw new TypeError(
61
+ "TerraBase v1 requires JSON.rawJSON and JSON.parse source text access (Node.js 22+, current browsers) for exact numbers.",
62
+ );
63
+ return /** @type {(text: string) => unknown} */ (factory);
64
+ }
65
+
66
+ /**
67
+ * An exact decimal number as it travels on the wire. Values that a JavaScript
68
+ * number cannot represent exactly (large integers, long fractions) are
69
+ * returned as ExactDecimal; it serializes back to the identical JSON text.
70
+ */
71
+ export class ExactDecimal {
72
+ /** @type {string} */
73
+ #text;
74
+
75
+ /** @param {string} text A JSON number literal. */
76
+ constructor(text) {
77
+ if (typeof text !== "string" || !decimalGrammar.test(text))
78
+ throw new TypeError("ExactDecimal requires a JSON number literal.");
79
+ this.#text = text;
80
+ Object.freeze(this);
81
+ }
82
+
83
+ /**
84
+ * @param {ExactDecimal | bigint | number | string} value
85
+ * @returns {ExactDecimal}
86
+ */
87
+ static from(value) {
88
+ if (value instanceof ExactDecimal) return value;
89
+ if (typeof value === "bigint") return new ExactDecimal(value.toString());
90
+ if (typeof value === "number") {
91
+ if (!Number.isFinite(value))
92
+ throw new TypeError("TerraBase numbers must be finite.");
93
+ return new ExactDecimal(String(value));
94
+ }
95
+ return new ExactDecimal(value);
96
+ }
97
+
98
+ /** @returns {string} The exact JSON number text. */
99
+ toString() {
100
+ return this.#text;
101
+ }
102
+
103
+ /** @returns {unknown} A raw JSON value that serializes as the exact text. */
104
+ toJSON() {
105
+ return rawJsonFactory()(this.#text);
106
+ }
107
+
108
+ /** @returns {number} The nearest JavaScript number; may lose precision. */
109
+ toNumber() {
110
+ return Number(this.#text);
111
+ }
112
+
113
+ /** @returns {boolean} */
114
+ isInteger() {
115
+ return /** @type {CanonicalDecimal} */ (canonicalDecimal(this.#text)).exponent >= 0n;
116
+ }
117
+
118
+ /**
119
+ * @returns {bigint} The exact integer value.
120
+ * @throws {RangeError} When the value is not an integer.
121
+ */
122
+ toBigInt() {
123
+ const value = /** @type {CanonicalDecimal} */ (canonicalDecimal(this.#text));
124
+ if (value.exponent < 0n)
125
+ throw new RangeError(`${this.#text} is not an integer.`);
126
+ if (value.exponent > maximumIntegerExponent)
127
+ throw new RangeError(`${this.#text} is too large to expand.`);
128
+ if (value.digits === "") return 0n;
129
+ const magnitude = BigInt(
130
+ `${value.digits}${"0".repeat(Number(value.exponent))}`,
131
+ );
132
+ return value.negative ? -magnitude : magnitude;
133
+ }
134
+
135
+ /**
136
+ * Exact decimal equality: 1, 1.0 and 1e0 are equal.
137
+ * @param {ExactDecimal | bigint | number | string} other
138
+ * @returns {boolean}
139
+ */
140
+ equals(other) {
141
+ return sameDecimal(this.#text, ExactDecimal.from(other).toString());
142
+ }
143
+
144
+ /** @returns {string} */
145
+ [Symbol.for("nodejs.util.inspect.custom")]() {
146
+ return `ExactDecimal(${this.#text})`;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * A decoded JSON value. Numbers are `number` when exact, else ExactDecimal.
152
+ * @typedef {null | boolean | number | string | ExactDecimal | JsonArray | JsonObject} JsonValue
153
+ */
154
+ /** @typedef {JsonValue[]} JsonArray */
155
+ /** @typedef {{ [key: string]: JsonValue }} JsonObject */
156
+
157
+ /**
158
+ * A JSON value accepted in requests: bigint and ExactDecimal encode exactly.
159
+ * @typedef {null | boolean | number | bigint | string | ExactDecimal | JsonInputArray | JsonInputObject} JsonInput
160
+ */
161
+ /** @typedef {readonly JsonInput[]} JsonInputArray */
162
+ /** @typedef {{ readonly [key: string]: JsonInput }} JsonInputObject */
163
+
164
+ /**
165
+ * A JSON.parse reviver that maps every number through `map(source text)`.
166
+ * @param {(source: unknown) => unknown} map
167
+ * @returns {(key: string, value: unknown) => unknown}
168
+ */
169
+ function sourceReviver(map) {
170
+ /**
171
+ * @param {string} _key
172
+ * @param {unknown} value
173
+ * @param {{ source?: unknown }} [context]
174
+ * @returns {unknown}
175
+ */
176
+ function revive(_key, value, context) {
177
+ return typeof value === "number" ? map(context?.source) ?? value : value;
178
+ }
179
+ return revive;
180
+ }
181
+
182
+ let sourceAccess = /** @type {boolean | undefined} */ (undefined);
183
+
184
+ /** @returns {void} */
185
+ function requireSourceAccess() {
186
+ if (sourceAccess === undefined) {
187
+ let seen;
188
+ JSON.parse("1", sourceReviver((source) => {
189
+ seen = source;
190
+ }));
191
+ sourceAccess = seen === "1" && typeof (/** @type {{ rawJSON?: unknown }} */ (/** @type {unknown} */ (JSON)).rawJSON) === "function";
192
+ }
193
+ if (!sourceAccess)
194
+ throw new TypeError(
195
+ "TerraBase v1 requires JSON.rawJSON and JSON.parse source text access (Node.js 22+, current browsers) for exact numbers.",
196
+ );
197
+ }
198
+
199
+ /**
200
+ * @param {string} source
201
+ * @returns {number | ExactDecimal}
202
+ */
203
+ function exactNumber(source) {
204
+ const value = Number(source);
205
+ return Number.isFinite(value) && sameDecimal(source, String(value))
206
+ ? value
207
+ : new ExactDecimal(source);
208
+ }
209
+
210
+ /**
211
+ * Parses JSON text without losing number precision.
212
+ * @param {string} text
213
+ * @returns {JsonValue}
214
+ */
215
+ export function parseExactJson(text) {
216
+ requireSourceAccess();
217
+ return JSON.parse(text, sourceReviver((source) => {
218
+ if (typeof source !== "string")
219
+ throw new TypeError("JSON number source text is unavailable.");
220
+ return exactNumber(source);
221
+ }));
222
+ }
223
+
224
+ /**
225
+ * C# ExactNumber bounds: leading zeros do not count, trailing zeros do.
226
+ * @param {string} text
227
+ * @param {string} where
228
+ * @returns {void}
229
+ */
230
+ function requireProducerNumber(text, where) {
231
+ if (!decimalGrammar.test(text)) throw new TypeError(`${where} contains a non-finite number.`);
232
+ const [mantissa = "", rawExponent = "0"] = text.split(/[eE]/u);
233
+ const exponent = BigInt(rawExponent);
234
+ const fraction = mantissa.split(".")[1] ?? "";
235
+ const digits = mantissa.replace(/[-.]/gu, "").replace(/^0+/u, "");
236
+ const magnitude = exponent - BigInt(fraction.length) + BigInt(digits.length) - 1n;
237
+ if (exponent < -1_000_000n || exponent > 1_000_000n || digits.length > 128 ||
238
+ (digits !== "" && (magnitude < -307n || magnitude > 307n)))
239
+ throw new TypeError(`${where} numbers have at most 128 significant digits and a magnitude of zero or 1e-307 to below 1e308.`);
240
+ }
241
+
242
+ /**
243
+ * Validates a document, merge patch or patch operand against C# RequestJson limits.
244
+ * @param {unknown} value
245
+ * @param {string} where
246
+ * @returns {void}
247
+ */
248
+ export function assertJsonValue(value, where) {
249
+ assertJsonInput(value, 0, where, { nodes: 0 });
250
+ }
251
+
252
+ /**
253
+ * @param {unknown} value
254
+ * @param {number} depth
255
+ * @param {string} where
256
+ * @param {{ nodes: number }} [budget] Document limits; request envelopes are validated separately.
257
+ * @returns {void}
258
+ */
259
+ function assertJsonInput(value, depth, where, budget) {
260
+ if (budget !== undefined && ++budget.nodes > 4096)
261
+ throw new TypeError(`${where} holds at most 4096 values.`);
262
+ if (depth > 64) throw new TypeError(`${where} is nested too deeply.`);
263
+ if (value === null || typeof value === "boolean") return;
264
+ if (typeof value === "string") {
265
+ if (/[\uD800-\uDFFF]/u.test(value)) throw new TypeError(`${where} contains a lone surrogate.`);
266
+ return;
267
+ }
268
+ if (typeof value === "bigint" || typeof value === "number" || value instanceof ExactDecimal) {
269
+ requireProducerNumber(String(value), where);
270
+ return;
271
+ }
272
+ if (budget !== undefined && typeof value === "object" && depth >= 16)
273
+ throw new TypeError(`${where} nests at most 16 levels.`);
274
+ if (Array.isArray(value)) {
275
+ for (const item of value) assertJsonInput(item, depth + 1, where, budget);
276
+ return;
277
+ }
278
+ if (typeof value === "object") {
279
+ const prototype = Object.getPrototypeOf(value);
280
+ if (prototype !== Object.prototype && prototype !== null)
281
+ throw new TypeError(`${where} contains a non-plain object.`);
282
+ for (const [key, item] of Object.entries(value)) {
283
+ if (/[\uD800-\uDFFF]/u.test(key)) throw new TypeError(`${where} contains a lone surrogate member name.`);
284
+ if (item === undefined)
285
+ throw new TypeError(`${where} contains an undefined member.`);
286
+ assertJsonInput(item, depth + 1, where, budget);
287
+ }
288
+ return;
289
+ }
290
+ throw new TypeError(`${where} contains a value that is not JSON.`);
291
+ }
292
+
293
+ /**
294
+ * Encodes a request value exactly. bigint and ExactDecimal keep every digit.
295
+ * @param {unknown} value
296
+ * @param {string} where
297
+ * @returns {string}
298
+ */
299
+ export function stringifyExactJson(value, where) {
300
+ requireSourceAccess();
301
+ assertJsonInput(value, 0, where);
302
+ const raw = rawJsonFactory();
303
+ return JSON.stringify(value, (_key, item) =>
304
+ typeof item === "bigint" ? raw(item.toString()) : item,
305
+ );
306
+ }
307
+
308
+ /**
309
+ * @param {unknown} value
310
+ * @param {string} where
311
+ * @returns {void}
312
+ */
313
+ export function assertJsonObject(value, where) {
314
+ if (
315
+ typeof value !== "object" ||
316
+ value === null ||
317
+ Array.isArray(value) ||
318
+ value instanceof ExactDecimal
319
+ )
320
+ throw new TypeError(`${where} must be a JSON object.`);
321
+ assertJsonValue(value, where);
322
+ }
323
+
324
+ /**
325
+ * @param {unknown} value
326
+ * @returns {value is JsonObject}
327
+ */
328
+ export function isJsonObject(value) {
329
+ return (
330
+ typeof value === "object" &&
331
+ value !== null &&
332
+ !Array.isArray(value) &&
333
+ !(value instanceof ExactDecimal)
334
+ );
335
+ }
@@ -0,0 +1,57 @@
1
+ // Routes owned by the C# Documents API at 0248ad3 (decision 0004 and shape-reads.md).
2
+ /** @typedef {"getDocument" | "putDocument" | "createDocument" | "patchDocument" | "deleteDocument" | "commitTransaction" | "queryDocuments" | "createCollection" | "getCollection" | "listCollections" | "dropCollection" | "readShape"} V1Operation */
3
+ /**
4
+ * @typedef {object} V1Route
5
+ * @property {V1Operation} operation
6
+ * @property {"GET" | "PUT" | "POST" | "PATCH" | "DELETE"} method
7
+ * @property {string} pathTemplate
8
+ * @property {"documents:read" | "documents:write" | "shapes:read"} permission
9
+ * @property {readonly string[]} requestMediaTypes
10
+ * @property {string} responseMediaType
11
+ * @property {readonly number[]} successStatuses
12
+ * @property {"none"} idempotencyKey The producer rejects Idempotency-Key.
13
+ * @property {boolean} replaySafe Informational only; requests are never automatically replayed.
14
+ * @property {string} contract
15
+ * @property {readonly string[]} contractText
16
+ */
17
+ const database = "/v1/projects/{project}/databases/{database}";
18
+ const collections = `${database}/collections`;
19
+ const collection = `${collections}/{collection}`;
20
+ const documents = `${collection}/documents`;
21
+ /** @type {readonly V1Route[]} */
22
+ export const v1Routes = Object.freeze(/** @type {V1Route[]} */ ([
23
+ { operation: "getDocument", method: "GET", pathTemplate: `${documents}/{id}`, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
24
+ { operation: "putDocument", method: "PUT", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200, 201] },
25
+ { operation: "createDocument", method: "POST", pathTemplate: documents, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
26
+ { operation: "patchDocument", method: "PATCH", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: ["application/merge-patch+json", "application/vnd.terrabase.ops+json"], successStatuses: [200] },
27
+ { operation: "deleteDocument", method: "DELETE", pathTemplate: `${documents}/{id}`, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
28
+ { operation: "commitTransaction", method: "POST", pathTemplate: `${database}/transactions`, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [200] },
29
+ { operation: "queryDocuments", method: "POST", pathTemplate: `${collection}/query`, permission: "documents:read", requestMediaTypes: ["application/json"], successStatuses: [200] },
30
+ { operation: "createCollection", method: "POST", pathTemplate: collections, permission: "documents:write", requestMediaTypes: ["application/json"], successStatuses: [201] },
31
+ { operation: "getCollection", method: "GET", pathTemplate: collection, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
32
+ { operation: "listCollections", method: "GET", pathTemplate: collections, permission: "documents:read", requestMediaTypes: [], successStatuses: [200] },
33
+ { operation: "dropCollection", method: "DELETE", pathTemplate: collection, permission: "documents:write", requestMediaTypes: [], successStatuses: [200] },
34
+ { operation: "readShape", method: "GET", pathTemplate: `${collection}/shape`, permission: "shapes:read", requestMediaTypes: [], successStatuses: [200, 307] },
35
+ ].map(route => ({
36
+ ...route, responseMediaType: "application/json", idempotencyKey: "none",
37
+ replaySafe: route.permission !== "documents:write",
38
+ contract: route.operation === "readShape" ? "docs/shape-reads.md" : "docs/decisions/0004-document-api-v1.md",
39
+ contractText: [],
40
+ }))).map(route => Object.freeze({
41
+ ...route, requestMediaTypes: Object.freeze(route.requestMediaTypes),
42
+ successStatuses: Object.freeze(route.successStatuses), contractText: Object.freeze(route.contractText),
43
+ })));
44
+ /** @param {V1Operation} operation @returns {V1Route} */
45
+ export function v1Route(operation) {
46
+ const route = v1Routes.find(candidate => candidate.operation === operation);
47
+ if (route === undefined) throw new TypeError(`Unknown v1 operation ${operation}.`);
48
+ return route;
49
+ }
50
+ /** @param {V1Route} route @param {Readonly<Record<string, string>>} params @returns {string} */
51
+ export function v1RoutePath(route, params) {
52
+ return route.pathTemplate.replace(/\{([a-z_]+)\}/gu, (_match, name) => {
53
+ const value = params[name];
54
+ if (value === undefined) throw new TypeError(`Missing path parameter ${name}.`);
55
+ return encodeURIComponent(value);
56
+ });
57
+ }
@@ -0,0 +1,145 @@
1
+ // Current C# GET collection/shape contract (docs/shape-reads.md).
2
+ import { array, boolean, malformed, object, string, u64 } from "./decode.js";
3
+ import { requireFilter } from "./filter.js";
4
+ import { parseU64, requireVersion } from "./ids.js";
5
+ import { stringifyExactJson } from "./json.js";
6
+ /**
7
+ * @import { JsonObject, JsonValue } from "./json.js"
8
+ * @import { V1Filter } from "./filter.js"
9
+ * @import { V1Transport } from "./transport.js"
10
+ */
11
+ /**
12
+ * @typedef {object} V1ShapeRequest
13
+ * @property {V1Filter | undefined} [where] Filter using the current query grammar; projection is unsupported.
14
+ * @property {string | undefined} [offset] -1 (default, server bootstrap only), snap.<head>.<base64url-id>, or a decimal log position.
15
+ * @property {string | undefined} [handle] Required after bootstrap.
16
+ * @property {boolean | undefined} [live] True only for log positions; waits up to 20 seconds.
17
+ * @property {string | undefined} [cursor] Previous live response's TB-Shape-Cursor.
18
+ */
19
+ /** @typedef {{ readonly op: "upsert"; readonly id: string; readonly version: string; readonly document: JsonObject } | { readonly op: "delete"; readonly id: string }} V1ShapeOp */
20
+ /** @typedef {{ readonly offset: string; readonly ops: readonly V1ShapeOp[] }} V1ShapeChange */
21
+ /**
22
+ * One snapshot page or log chunk. Only a fresh open tail/live response can report upToDate.
23
+ * @typedef {object} V1ShapePage
24
+ * @property {string} shapeId
25
+ * @property {string} handle
26
+ * @property {string} offset
27
+ * @property {string} nextOffset
28
+ * @property {boolean} upToDate
29
+ * @property {readonly V1ShapeChange[]} changes Apply commits and operations in their received order.
30
+ * @property {string | undefined} cursor Echo this as cursor on the next live request.
31
+ * @property {string | undefined} etag
32
+ */
33
+ const maximumOffset = (1n << 63n) - 1n;
34
+ const handlePattern = /^h1-[0-9a-f]{16}$/u;
35
+ /** @param {unknown} value @returns {boolean} */
36
+ function isLogOffset(value) {
37
+ const parsed = parseU64(value);
38
+ return parsed !== undefined && parsed <= maximumOffset;
39
+ }
40
+ /**
41
+ * Validates canonical C# offsets. Snapshot positions use a UTF-8/base64url keyset, not a page number.
42
+ * @param {unknown} value
43
+ * @returns {string}
44
+ */
45
+ export function requireShapeOffset(value) {
46
+ if (value === "-1" || isLogOffset(value)) return /** @type {string} */ (value);
47
+ if (typeof value !== "string") throw new TypeError("A shape offset is a string.");
48
+ const match = /^snap\.([0-9]+)\.(0|[A-Za-z0-9_-]{2,})$/u.exec(value);
49
+ if (match !== null && isLogOffset(match[1])) {
50
+ const encoded = match[2] ?? "";
51
+ if (encoded === "0") return value;
52
+ try {
53
+ const bytes = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
54
+ const id = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
55
+ const canonical = btoa(String.fromCharCode(...new TextEncoder().encode(id))).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "");
56
+ if (canonical === encoded) return value;
57
+ } catch { /* Invalid base64url or UTF-8. */ }
58
+ }
59
+ throw new TypeError("A shape offset is -1, a signed-64-bit decimal log position, or snap.<head>.<canonical-base64url-id>.");
60
+ }
61
+ /** @param {unknown} value @returns {string} */
62
+ function requireHandle(value) {
63
+ if (typeof value !== "string" || !handlePattern.test(value)) throw new TypeError("A shape handle is h1- followed by 16 lowercase hex characters.");
64
+ return value;
65
+ }
66
+ /** @param {V1ShapeRequest} request @returns {URLSearchParams} */
67
+ export function encodeShapeRequest(request) {
68
+ if (typeof request !== "object" || request === null || Array.isArray(request)) throw new TypeError("A shape request is an object.");
69
+ for (const key of Object.keys(request))
70
+ if (!["where", "offset", "handle", "live", "cursor"].includes(key)) throw new TypeError(`Unknown shape member ${key}.`);
71
+ const query = new URLSearchParams();
72
+ if (request.where !== undefined) query.set("where", stringifyExactJson(requireFilter(request.where), "shape filter"));
73
+ const offset = requireShapeOffset(request.offset ?? "-1");
74
+ query.set("offset", offset);
75
+ if (request.handle !== undefined) query.set("handle", requireHandle(request.handle));
76
+ else if (offset !== "-1") throw new TypeError("A shape handle is required after bootstrap.");
77
+ if (request.live !== undefined && typeof request.live !== "boolean") throw new TypeError("live is a boolean.");
78
+ if (request.live === true) {
79
+ if (!isLogOffset(offset)) throw new TypeError("live requires a log offset.");
80
+ query.set("live", "true");
81
+ }
82
+ if (request.cursor !== undefined) {
83
+ if (request.live !== true || !isLogOffset(request.cursor) || BigInt(request.cursor) === maximumOffset)
84
+ throw new TypeError("cursor requires live=true and a decimal below Int64.MaxValue.");
85
+ query.set("cursor", request.cursor);
86
+ }
87
+ return query;
88
+ }
89
+ /** @param {JsonValue | undefined} value @param {Headers} headers @returns {V1ShapePage} */
90
+ export function decodeShapePage(value, headers) {
91
+ const raw = object(value, "shape page");
92
+ const shapeId = string(raw.shape_id, "shape_id");
93
+ if (!/^[0-9a-f]{32}$/u.test(shapeId)) malformed("shape_id");
94
+ const cursor = headers.get("tb-shape-cursor") ?? undefined;
95
+ if (cursor !== undefined && !isLogOffset(cursor)) malformed("shape cursor");
96
+ return Object.freeze({
97
+ shapeId, handle: requireHandle(raw.handle),
98
+ offset: requireShapeOffset(raw.offset), nextOffset: requireShapeOffset(raw.next_offset),
99
+ upToDate: boolean(raw.up_to_date, "shape up_to_date"), cursor,
100
+ etag: headers.get("etag") ?? undefined,
101
+ changes: Object.freeze(array(raw.changes, "shape changes").map(value => {
102
+ const change = object(value, "shape change");
103
+ return Object.freeze({ offset: u64(change.offset, "shape change offset"), ops: Object.freeze(array(change.ops, "shape operations").map(value => {
104
+ const op = object(value, "shape operation");
105
+ // Shape snapshots can include stored legacy ids beyond the HTTP point-id limit.
106
+ const id = string(op.id, "shape document id");
107
+ if (op.op === "delete") return Object.freeze({ op: /** @type {const} */ ("delete"), id });
108
+ if (op.op !== "upsert") malformed("shape operation kind");
109
+ return Object.freeze({ op: /** @type {const} */ ("upsert"), id, version: requireVersion(op.version), document: object(op.document, "shape document") });
110
+ })) });
111
+ })),
112
+ });
113
+ }
114
+ /**
115
+ * Bootstrap extracts continuation metadata from 307 but never forwards credentials to Location.
116
+ * The next request is rebuilt against the same named route and configured endpoint.
117
+ * @param {V1Transport} transport
118
+ * @param {Readonly<Record<string, string>>} params
119
+ * @param {V1ShapeRequest} request
120
+ * @param {AbortSignal | undefined} signal
121
+ * @returns {Promise<V1ShapePage>}
122
+ */
123
+ export async function readShape(transport, params, request, signal) {
124
+ const query = encodeShapeRequest(request);
125
+ let response = await transport.call("readShape", params, { query, signal });
126
+ if (response.status === 307) {
127
+ if (query.get("offset") !== "-1") malformed("unexpected shape redirect");
128
+ const source = new URL(await transport.url("readShape", params, signal));
129
+ const location = response.headers.get("location");
130
+ if (location === null) malformed("shape redirect Location");
131
+ const target = new URL(location, source);
132
+ if (target.origin !== source.origin || target.pathname !== source.pathname || target.username !== "" || target.password !== "" || target.hash !== "")
133
+ malformed("shape redirect target");
134
+ const handle = requireHandle(target.searchParams.get("handle"));
135
+ const offset = requireShapeOffset(target.searchParams.get("offset"));
136
+ if (!offset.startsWith("snap.")) malformed("shape bootstrap offset");
137
+ query.set("handle", handle);
138
+ query.set("offset", offset);
139
+ response = await transport.call("readShape", params, { query, signal });
140
+ if (response.status !== 200) malformed("repeated shape redirect");
141
+ }
142
+ const page = decodeShapePage(response.value, response.headers);
143
+ if (page.offset !== query.get("offset") || (query.has("handle") && page.handle !== query.get("handle"))) malformed("shape response position");
144
+ return page;
145
+ }