@terrascale/sdk 0.0.2 → 0.0.4
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 +43 -0
- package/README.md +193 -56
- package/package.json +14 -5
- package/sdk-public-v1-route-manifest.json +81 -0
- package/sdk-v1-route-manifest.json +8996 -1697
- package/src/database-codec.js +21 -26
- package/src/database-types.js +11 -11
- package/src/database-view.js +6 -6
- package/src/http.js +13 -48
- package/src/ids.js +22 -0
- package/src/index.js +77 -2
- package/src/local/better-auth.js +17 -6
- package/src/local/environment.js +0 -1
- package/src/local/index.js +2 -2
- package/src/local/legacy-client.js +5 -5
- package/src/local/router.js +12 -12
- package/src/local/store.js +29 -28
- package/src/local/test-environment.js +3 -2
- package/src/postgres.js +5 -4
- package/src/react/core.js +2 -2
- package/src/ts-auth-ids.js +122 -0
- package/src/ts-auth.js +75 -77
- package/src/v1/backups.js +111 -0
- package/src/v1/catalog.js +210 -0
- package/src/v1/changes.js +225 -0
- package/src/v1/database-schema.js +45 -0
- package/src/v1/decode.js +20 -8
- package/src/v1/errors.js +23 -3
- package/src/v1/ids.js +9 -59
- package/src/v1/imports.js +72 -0
- package/src/v1/indexes.js +76 -0
- package/src/v1/json.js +4 -3
- package/src/v1/management-decode.js +24 -0
- package/src/v1/management-routes.js +67 -0
- package/src/v1/management-validation.js +25 -0
- package/src/v1/routes.js +36 -11
- package/src/v1/saved-queries.js +58 -0
- package/src/v1/shape-crypto.js +125 -0
- package/src/v1/shape-manifest.js +31 -0
- package/src/v1/shape-reader.js +212 -0
- package/src/v1/shape-subscription.js +208 -0
- package/src/v1/shapes.js +15 -10
- package/src/v1/sql.js +98 -0
- package/src/v1/system.js +80 -0
- package/src/v1/terrabase-v1.js +192 -33
- package/src/v1/transport.js +80 -34
- package/src/v1/usage.js +101 -0
- package/types/database-codec.d.ts +1 -6
- package/types/database-types.d.ts +11 -11
- package/types/http.d.ts +2 -8
- package/types/ids.d.ts +10 -0
- package/types/index.d.ts +70 -1
- package/types/local/environment.d.ts +1 -1
- package/types/local/store.d.ts +10 -0
- package/types/local/test-environment.d.ts +2 -2
- package/types/postgres.d.ts +4 -4
- package/types/ts-auth-ids.d.ts +14 -0
- package/types/ts-auth.d.ts +99 -101
- package/types/v1/backups.d.ts +128 -0
- package/types/v1/catalog.d.ts +92 -0
- package/types/v1/changes.d.ts +53 -0
- package/types/v1/database-schema.d.ts +82 -0
- package/types/v1/decode.d.ts +17 -4
- package/types/v1/errors.d.ts +16 -1
- package/types/v1/ids.d.ts +4 -16
- package/types/v1/imports.d.ts +132 -0
- package/types/v1/indexes.d.ts +75 -0
- package/types/v1/management-decode.d.ts +15 -0
- package/types/v1/management-routes.d.ts +3 -0
- package/types/v1/management-validation.d.ts +8 -0
- package/types/v1/routes.d.ts +5 -2
- package/types/v1/saved-queries.d.ts +33 -0
- package/types/v1/shape-crypto.d.ts +39 -0
- package/types/v1/shape-manifest.d.ts +28 -0
- package/types/v1/shape-reader.d.ts +45 -0
- package/types/v1/shape-subscription.d.ts +35 -0
- package/types/v1/shapes.d.ts +14 -0
- package/types/v1/sql.d.ts +47 -0
- package/types/v1/system.d.ts +85 -0
- package/types/v1/terrabase-v1.d.ts +116 -7
- package/types/v1/transport.d.ts +27 -10
- package/types/v1/usage.d.ts +171 -0
- package/src/ts-auth-discovery.js +0 -207
- package/types/ts-auth-discovery.d.ts +0 -19
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const MAGIC = "TBS1";
|
|
2
|
+
const HEADER_BYTES = 70;
|
|
3
|
+
const KEY_ID_BYTES = 26;
|
|
4
|
+
const FRAME_BYTES = 1048576;
|
|
5
|
+
const MAX_PLAINTEXT_BYTES = 8388608;
|
|
6
|
+
const TAG_BYTES = 16;
|
|
7
|
+
const LAST_FRAME_FLAG = 0x80000000;
|
|
8
|
+
const LENGTH_MASK = 0x7fffffff;
|
|
9
|
+
const MAX_UINT64 = 2n ** 64n - 1n;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} ShapeObjectRequest
|
|
13
|
+
* @property {string} handle The shape handle this client requested, e.g. `h1-…`.
|
|
14
|
+
* @property {string} offset Canonical offset of the object's URL, e.g. `2` or `snap.0.0`.
|
|
15
|
+
* @property {string} epoch Decimal epoch the client expects in the header.
|
|
16
|
+
* @property {string} keyId The 26-character project data key id the client expects in the header.
|
|
17
|
+
* @property {Uint8Array} key Raw 32-byte epoch key `K_e`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Verifies and decrypts one TBS1 sealed shape object. The plaintext is returned only when the
|
|
22
|
+
* header, every frame tag and the framing rules pass. Any failure throws and yields no bytes.
|
|
23
|
+
*
|
|
24
|
+
* @param {Uint8Array} input Exact TBS1 object bytes.
|
|
25
|
+
* @param {ShapeObjectRequest} request
|
|
26
|
+
* @returns {Promise<Uint8Array>}
|
|
27
|
+
*/
|
|
28
|
+
export async function decryptShapeObject(input, { handle, offset, epoch, keyId, key: keyInput }) {
|
|
29
|
+
if (input.byteLength > HEADER_BYTES + 9 * (4 + TAG_BYTES) + MAX_PLAINTEXT_BYTES)
|
|
30
|
+
throw new TypeError("TBS1 object exceeds the maximum sealed size");
|
|
31
|
+
// WebCrypto needs ArrayBuffer-backed views; copying keeps the caller's (possibly shared) buffer out of it.
|
|
32
|
+
const bytes = Uint8Array.from(input);
|
|
33
|
+
const key = Uint8Array.from(keyInput);
|
|
34
|
+
if (key.byteLength !== 32) throw new TypeError("TBS1 epoch key must be 32 bytes");
|
|
35
|
+
if (typeof keyId !== "string" || !/^[\x00-\x7f]{26}$/u.test(keyId))
|
|
36
|
+
throw new TypeError("TBS1 key id must be 26 ASCII characters");
|
|
37
|
+
if (bytes.byteLength < HEADER_BYTES) throw new TypeError("TBS1 header is short");
|
|
38
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
39
|
+
if (new TextDecoder().decode(bytes.subarray(0, 4)) !== MAGIC) throw new TypeError("TBS1 magic mismatch");
|
|
40
|
+
if (view.getBigUint64(4) !== parseEpoch(epoch)) throw new TypeError("TBS1 epoch mismatch");
|
|
41
|
+
if (new TextDecoder().decode(bytes.subarray(12, 12 + KEY_ID_BYTES)) !== keyId) throw new TypeError("TBS1 key id mismatch");
|
|
42
|
+
|
|
43
|
+
const header = bytes.subarray(0, HEADER_BYTES);
|
|
44
|
+
const { subtle } = globalThis.crypto;
|
|
45
|
+
const epochKey = await subtle.importKey("raw", key, "HKDF", false, ["deriveKey"]);
|
|
46
|
+
const objectKey = await subtle.deriveKey(
|
|
47
|
+
{
|
|
48
|
+
name: "HKDF",
|
|
49
|
+
hash: "SHA-256",
|
|
50
|
+
salt: bytes.slice(38, HEADER_BYTES),
|
|
51
|
+
info: new TextEncoder().encode(`TBS1 object\u0000${handle}\u0000${offset}`),
|
|
52
|
+
},
|
|
53
|
+
epochKey,
|
|
54
|
+
{ name: "AES-GCM", length: 256 },
|
|
55
|
+
false,
|
|
56
|
+
["decrypt"],
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const plaintextParts = [];
|
|
60
|
+
let plaintextTotal = 0;
|
|
61
|
+
let position = HEADER_BYTES;
|
|
62
|
+
for (let index = 0; ; index += 1) {
|
|
63
|
+
if (position + 4 > bytes.byteLength) throw new TypeError("TBS1 object ends before its last frame");
|
|
64
|
+
const lengthWord = view.getUint32(position);
|
|
65
|
+
const last = (lengthWord & LAST_FRAME_FLAG) !== 0;
|
|
66
|
+
const length = lengthWord & LENGTH_MASK;
|
|
67
|
+
if (last ? length >= FRAME_BYTES : length !== FRAME_BYTES) throw new TypeError("TBS1 frame length is invalid");
|
|
68
|
+
plaintextTotal += length;
|
|
69
|
+
if (plaintextTotal > MAX_PLAINTEXT_BYTES) throw new TypeError("TBS1 plaintext exceeds 8 MiB");
|
|
70
|
+
const bodyStart = position + 4;
|
|
71
|
+
const frameEnd = bodyStart + length + TAG_BYTES;
|
|
72
|
+
if (frameEnd > bytes.byteLength) throw new TypeError("TBS1 object is truncated");
|
|
73
|
+
const nonce = concat(new Uint8Array(4), uint64(index));
|
|
74
|
+
const additionalData = concat(header, uint64(index), bytes.subarray(position, bodyStart));
|
|
75
|
+
try {
|
|
76
|
+
const plaintext = await subtle.decrypt(
|
|
77
|
+
{ name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 },
|
|
78
|
+
objectKey,
|
|
79
|
+
bytes.subarray(bodyStart, frameEnd),
|
|
80
|
+
);
|
|
81
|
+
plaintextParts.push(new Uint8Array(plaintext));
|
|
82
|
+
} catch {
|
|
83
|
+
throw new TypeError("TBS1 frame failed authentication");
|
|
84
|
+
}
|
|
85
|
+
position = frameEnd;
|
|
86
|
+
if (last) break;
|
|
87
|
+
}
|
|
88
|
+
if (position !== bytes.byteLength) throw new TypeError("TBS1 object has bytes after its last frame");
|
|
89
|
+
return concat(...plaintextParts);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} value
|
|
94
|
+
* @returns {bigint}
|
|
95
|
+
*/
|
|
96
|
+
function parseEpoch(value) {
|
|
97
|
+
if (!/^(0|[1-9][0-9]*)$/u.test(value)) throw new TypeError("TBS1 epoch must be a canonical decimal");
|
|
98
|
+
const epoch = BigInt(value);
|
|
99
|
+
if (epoch > MAX_UINT64) throw new TypeError("TBS1 epoch exceeds uint64");
|
|
100
|
+
return epoch;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @param {number} value
|
|
105
|
+
* @returns {Uint8Array}
|
|
106
|
+
*/
|
|
107
|
+
function uint64(value) {
|
|
108
|
+
const out = new Uint8Array(8);
|
|
109
|
+
new DataView(out.buffer).setBigUint64(0, BigInt(value));
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {...Uint8Array} parts
|
|
115
|
+
* @returns {Uint8Array<ArrayBuffer>}
|
|
116
|
+
*/
|
|
117
|
+
function concat(...parts) {
|
|
118
|
+
const out = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
|
|
119
|
+
let at = 0;
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
out.set(part, at);
|
|
122
|
+
at += part.byteLength;
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { requireId } from "../ids.js";
|
|
2
|
+
import { malformed } from "./decode.js";
|
|
3
|
+
import { list, numeric, record, text } from "./management-decode.js";
|
|
4
|
+
import { requireFilter } from "./filter.js";
|
|
5
|
+
import { stringifyExactJson } from "./json.js";
|
|
6
|
+
import { requireShapeHandle, requireShapeOffset } from "./shapes.js";
|
|
7
|
+
/** @import { V1Transport } from "./transport.js" */
|
|
8
|
+
const manifest = record({ handle: requireShapeHandle, objects: list(record({ offset: requireShapeOffset, next_offset: requireShapeOffset,
|
|
9
|
+
binding: text, object: text, url: text, expires_at: text, epoch: text, key_id: requireId, sha256: text, size: numeric })) });
|
|
10
|
+
/** @typedef {ReturnType<typeof manifest>} V1ShapeManifest */
|
|
11
|
+
/** @typedef {{readonly handle: string; readonly offset: string; readonly where?: import("./filter.js").V1Filter}} V1ShapeManifestRequest */
|
|
12
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1ShapeManifestRequest} request @param {AbortSignal | undefined} signal @returns {Promise<V1ShapeManifest>} */
|
|
13
|
+
export async function readShapeManifest(transport, params, request, signal) {
|
|
14
|
+
const handle = requireShapeHandle(request.handle);
|
|
15
|
+
const offset = requireShapeOffset(request.offset);
|
|
16
|
+
if (offset === "-1") throw new TypeError("A manifest requires a snapshot or log offset.");
|
|
17
|
+
const query = new URLSearchParams({ handle, offset });
|
|
18
|
+
if (request.where !== undefined) query.set("where", stringifyExactJson(requireFilter(request.where), "shape filter"));
|
|
19
|
+
const response = await transport.call("getShapeManifest", params, { query, cache: "no-store", signal });
|
|
20
|
+
const result = manifest(response.value);
|
|
21
|
+
if (result.handle !== handle || result.objects.length > 8) malformed("shape manifest binding");
|
|
22
|
+
let next = offset;
|
|
23
|
+
for (const item of result.objects) {
|
|
24
|
+
if (item.offset !== next || item.next_offset === item.offset || item.offset === "-1" || item.next_offset === "-1" ||
|
|
25
|
+
!/^[a-f0-9]{64}$/u.test(item.sha256) || !/^(?:0|[1-9][0-9]*)$/u.test(item.epoch)) malformed("shape manifest object");
|
|
26
|
+
const url = new URL(item.url);
|
|
27
|
+
if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hash !== "") malformed("shape object URL");
|
|
28
|
+
next = item.next_offset;
|
|
29
|
+
}
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// The proposed free-read v1 protocol is separate from the historical shape() contract.
|
|
2
|
+
import { requireId } from "../ids.js";
|
|
3
|
+
import { malformed, object, string } from "./decode.js";
|
|
4
|
+
import { decodeShapePage, encodeShapeRequest, requireShapeHandle, requireShapeOffset } from "./shapes.js";
|
|
5
|
+
import { decryptShapeObject } from "./shape-crypto.js";
|
|
6
|
+
import { parseExactJson } from "./json.js";
|
|
7
|
+
import { TerraBaseV1Error } from "./errors.js";
|
|
8
|
+
/** @import { V1Transport } from "./transport.js" */
|
|
9
|
+
/** @import { V1ShapeRequest, V1ShapePage } from "./shapes.js" */
|
|
10
|
+
/** @typedef {{handle: string; offset: string; epoch: string; keyId: string}} V1SealedShapePosition */
|
|
11
|
+
/** @typedef {{ latestEpoch: bigint; keys: Map<string, { epoch: bigint; key: Uint8Array }> }} ShapeKeys */
|
|
12
|
+
/** @type {WeakMap<V1Transport, Map<string, ShapeKeys>>} Keys stay scoped to a credential, database and collection. */
|
|
13
|
+
const shapeKeys = new WeakMap();
|
|
14
|
+
/** @param {Readonly<Record<string, string>>} params */
|
|
15
|
+
const keyScope = params => JSON.stringify([params.database, params.collection]);
|
|
16
|
+
/**
|
|
17
|
+
* A refetch invalidates both the materialized state and any epoch keys for that collection.
|
|
18
|
+
* @param {V1Transport} transport @param {Readonly<Record<string, string>>} params
|
|
19
|
+
* @param {"readFreeShape" | "getShapeKey"} operation @param {import("./transport.js").V1CallOptions} options
|
|
20
|
+
*/
|
|
21
|
+
async function shapeCall(transport, params, operation, options) {
|
|
22
|
+
try { return await transport.call(operation, params, options); }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error instanceof TerraBaseV1Error && error.status === 409 && error.code === "must_refetch")
|
|
25
|
+
shapeKeys.get(transport)?.delete(keyScope(params));
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @returns {ShapeKeys} */
|
|
30
|
+
function currentKeys(transport, params) {
|
|
31
|
+
let scopes = shapeKeys.get(transport);
|
|
32
|
+
if (scopes === undefined) { scopes = new Map(); shapeKeys.set(transport, scopes); }
|
|
33
|
+
const scope = keyScope(params);
|
|
34
|
+
let cache = scopes.get(scope);
|
|
35
|
+
if (cache === undefined) { cache = { latestEpoch: 0n, keys: new Map() }; scopes.set(scope, cache); }
|
|
36
|
+
// Expiry is lazy; a higher served epoch also protects against a regressed client clock.
|
|
37
|
+
const epoch = BigInt(Math.floor(Date.now() / 600_000));
|
|
38
|
+
if (epoch > cache.latestEpoch) cache.latestEpoch = epoch;
|
|
39
|
+
for (const [id, entry] of cache.keys) if (entry.epoch < cache.latestEpoch) cache.keys.delete(id);
|
|
40
|
+
return cache;
|
|
41
|
+
}
|
|
42
|
+
/** @param {unknown} value @returns {string} */
|
|
43
|
+
function decimal(value) {
|
|
44
|
+
const text = requireShapeOffset(value);
|
|
45
|
+
if (text === "-1" || text.startsWith("snap.")) malformed("shape decimal");
|
|
46
|
+
return text;
|
|
47
|
+
}
|
|
48
|
+
/** @param {unknown} value @returns {string} */
|
|
49
|
+
function resumeToken(value) {
|
|
50
|
+
// Resume envelopes are opaque: validate header safety, never decode or normalize them.
|
|
51
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]+$/u.test(value)) malformed("shape resume cursor");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/** @param {string} offset @returns {string} */
|
|
55
|
+
function snapshotKey(offset) {
|
|
56
|
+
const encoded = offset.split(".")[2] ?? "";
|
|
57
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
|
58
|
+
Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0)),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
/** @param {import("./json.js").JsonValue | undefined} value @param {Headers} headers @returns {V1ShapePage} */
|
|
62
|
+
export function decodeFreeShapePage(value, headers) {
|
|
63
|
+
const page = decodeShapePage(value, headers);
|
|
64
|
+
const raw = object(value, "free shape page");
|
|
65
|
+
const covered = decimal(raw.covered_offset);
|
|
66
|
+
const values = decimal(raw.values_at);
|
|
67
|
+
resumeToken(raw.cursor_resume);
|
|
68
|
+
if (page.offset === "-1" || page.nextOffset === "-1" || BigInt(values) < BigInt(covered)) malformed("shape coverage");
|
|
69
|
+
if (page.offset.startsWith("snap.")) {
|
|
70
|
+
const head = page.offset.split(".")[1];
|
|
71
|
+
if (covered !== head || values !== head || page.upToDate || page.changes.length !== 1 || page.changes[0]?.offset !== head ||
|
|
72
|
+
page.changes[0]?.ops.some(op => op.op !== "upsert") ||
|
|
73
|
+
(page.nextOffset.startsWith("snap.") ? page.nextOffset.split(".")[1] !== head || page.nextOffset === page.offset : page.nextOffset !== head))
|
|
74
|
+
malformed("shape snapshot coverage");
|
|
75
|
+
if (page.nextOffset.startsWith("snap.") && (page.nextOffset.endsWith(".0") ||
|
|
76
|
+
(!page.offset.endsWith(".0") && snapshotKey(page.nextOffset) <= snapshotKey(page.offset)))) malformed("shape snapshot progression");
|
|
77
|
+
} else {
|
|
78
|
+
if (page.nextOffset.startsWith("snap.") || page.nextOffset !== covered || BigInt(covered) < BigInt(page.offset) ||
|
|
79
|
+
(!page.upToDate && covered === page.offset)) malformed("shape log coverage");
|
|
80
|
+
let previous = BigInt(page.offset);
|
|
81
|
+
for (const change of page.changes) {
|
|
82
|
+
const offset = BigInt(decimal(change.offset));
|
|
83
|
+
if (offset <= previous || offset > BigInt(covered)) malformed("shape commit order");
|
|
84
|
+
previous = offset;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const change of page.changes) {
|
|
88
|
+
let previousId;
|
|
89
|
+
for (const op of change.ops) {
|
|
90
|
+
if (previousId !== undefined && op.id <= previousId) malformed("shape document order");
|
|
91
|
+
previousId = op.id;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return page;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Follows exactly one authorized same-route 307; credentials never follow an arbitrary Location.
|
|
98
|
+
* @param {V1Transport} transport @param {Readonly<Record<string,string>>} params
|
|
99
|
+
* @param {V1ShapeRequest} request @param {AbortSignal | undefined} signal
|
|
100
|
+
* @param {string | undefined} [ifNoneMatch] A complete entity-tag list or *; 304 throws not_modified with response headers.
|
|
101
|
+
* @returns {Promise<V1ShapePage>}
|
|
102
|
+
*/
|
|
103
|
+
export async function readShapePage(transport, params, request, signal, ifNoneMatch) {
|
|
104
|
+
const isResume = (request.offset ?? "-1") === "-1" && request.cursor !== undefined;
|
|
105
|
+
if (isResume && (request.handle !== undefined || request.live !== undefined)) throw new TypeError("Shape resume requires offset=-1 without handle or live.");
|
|
106
|
+
const query = encodeShapeRequest(isResume ? { ...request, cursor: undefined } : request);
|
|
107
|
+
// Canonical filter size is checked by the producer; check our exact wire spelling here.
|
|
108
|
+
if (new TextEncoder().encode(query.get("where") ?? "").byteLength > 8192) throw new TypeError("Shape filters are limited to 8 KiB of UTF-8.");
|
|
109
|
+
if (query.get("offset") === "-1" && query.has("handle")) throw new TypeError("Shape bootstrap must omit handle.");
|
|
110
|
+
/** @type {Record<string,string>} */
|
|
111
|
+
const headers = {};
|
|
112
|
+
if (ifNoneMatch !== undefined) {
|
|
113
|
+
if (typeof ifNoneMatch !== "string" || !/^[ \t]*(?:\*|(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*"(?:[ \t]*,[ \t]*(?:W\/)?"[\x21\x23-\x7e\x80-\xff]*")*)[ \t]*$/u.test(ifNoneMatch))
|
|
114
|
+
throw new TypeError("ifNoneMatch must be * or a list of quoted entity tags.");
|
|
115
|
+
headers["If-None-Match"] = ifNoneMatch;
|
|
116
|
+
}
|
|
117
|
+
const source = new URL(await transport.url("readFreeShape", params, signal));
|
|
118
|
+
if (isResume) {
|
|
119
|
+
query.set("cursor", resumeToken(request.cursor));
|
|
120
|
+
// The producer reserves 64 bytes for request-line framing in its 8 KiB ceiling.
|
|
121
|
+
if (new TextEncoder().encode(`${source.pathname}?${query}`).length > 8192 - 64) {
|
|
122
|
+
headers["TB-Shape-Resume"] = resumeToken(request.cursor);
|
|
123
|
+
query.delete("cursor");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
let response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal });
|
|
127
|
+
if (response.status === 307) {
|
|
128
|
+
if (query.get("offset") !== "-1") malformed("unexpected shape redirect");
|
|
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 !== "") malformed("shape redirect target");
|
|
133
|
+
for (const key of target.searchParams.keys()) {
|
|
134
|
+
if (!["where", "handle", "offset"].includes(key) || target.searchParams.getAll(key).length !== 1) malformed("shape redirect query");
|
|
135
|
+
}
|
|
136
|
+
const handle = requireShapeHandle(target.searchParams.get("handle"));
|
|
137
|
+
const offset = requireShapeOffset(target.searchParams.get("offset"));
|
|
138
|
+
if (offset === "-1" || (!isResume && !/^snap\.[0-9]+\.0$/u.test(offset)) || (isResume && offset.startsWith("snap."))) malformed("shape redirect offset");
|
|
139
|
+
if (target.searchParams.has("where") !== query.has("where")) malformed("shape redirect filter");
|
|
140
|
+
// Keep the caller's validated filter: the server's canonical spelling must not broaden it.
|
|
141
|
+
query.delete("cursor");
|
|
142
|
+
delete headers["TB-Shape-Resume"];
|
|
143
|
+
query.set("handle", handle);
|
|
144
|
+
query.set("offset", offset);
|
|
145
|
+
response = await shapeCall(transport, params, "readFreeShape", { query, headers, signal });
|
|
146
|
+
if (response.status !== 200) malformed("repeated shape redirect");
|
|
147
|
+
} else if (query.get("offset") === "-1") malformed("shape bootstrap requires redirect");
|
|
148
|
+
const page = decodeFreeShapePage(response.value, response.headers);
|
|
149
|
+
if (page.handle !== query.get("handle") || page.offset !== query.get("offset")) malformed("shape response position");
|
|
150
|
+
const inputCursor = query.get("cursor");
|
|
151
|
+
if (request.live === true && (page.cursor === undefined || (inputCursor !== null && BigInt(page.cursor) <= BigInt(inputCursor)))) malformed("shape live cursor advance");
|
|
152
|
+
return page;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Reuse process-memory keys for this handle/epoch/KID; fetch misses without HTTP cache reuse.
|
|
156
|
+
* @param {V1Transport} transport @param {Readonly<Record<string,string>>} params
|
|
157
|
+
* @param {Uint8Array} bytes @param {V1SealedShapePosition} position @param {AbortSignal | undefined} signal
|
|
158
|
+
* @returns {Promise<V1ShapePage>}
|
|
159
|
+
*/
|
|
160
|
+
export async function decryptSealedShapePage(transport, params, bytes, position, signal) {
|
|
161
|
+
const handle = requireShapeHandle(position.handle);
|
|
162
|
+
const offset = requireShapeOffset(position.offset);
|
|
163
|
+
if (offset === "-1") throw new TypeError("Sealed shapes require a snapshot or log offset.");
|
|
164
|
+
if (typeof position.epoch !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(position.epoch) || BigInt(position.epoch) > (1n << 64n) - 1n) throw new TypeError("Shape epoch must be a canonical uint64.");
|
|
165
|
+
const epoch = position.epoch;
|
|
166
|
+
const keyId = requireId(position.keyId, "Shape key ID");
|
|
167
|
+
signal?.throwIfAborted();
|
|
168
|
+
const cache = currentKeys(transport, params);
|
|
169
|
+
const cacheId = JSON.stringify([handle, epoch, keyId]);
|
|
170
|
+
let raw = cache.keys.get(cacheId)?.key;
|
|
171
|
+
if (raw === undefined) {
|
|
172
|
+
const query = new URLSearchParams({ handle, epoch, key_id: keyId });
|
|
173
|
+
const response = await shapeCall(transport, params, "getShapeKey", { query, cache: "no-store", signal });
|
|
174
|
+
const key = object(response.value, "shape epoch key");
|
|
175
|
+
if (key.handle !== handle || key.epoch !== epoch || key.key_id !== keyId) malformed("shape epoch key binding");
|
|
176
|
+
const encoded = string(key.key, "shape epoch key");
|
|
177
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(encoded)) malformed("shape epoch key encoding");
|
|
178
|
+
raw = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
|
|
179
|
+
if (btoa(String.fromCharCode(...raw)).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "") !== encoded) malformed("shape epoch key encoding");
|
|
180
|
+
const servedEpoch = BigInt(epoch);
|
|
181
|
+
const active = currentKeys(transport, params);
|
|
182
|
+
if (active === cache && servedEpoch >= cache.latestEpoch) {
|
|
183
|
+
cache.latestEpoch = servedEpoch;
|
|
184
|
+
for (const [id, entry] of cache.keys) if (entry.epoch < servedEpoch) cache.keys.delete(id);
|
|
185
|
+
cache.keys.set(cacheId, { epoch: servedEpoch, key: raw });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const plaintext = await decryptShapeObject(bytes, { handle, offset, epoch, keyId, key: raw });
|
|
189
|
+
signal?.throwIfAborted();
|
|
190
|
+
const page = decodeFreeShapePage(parseExactJson(new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(plaintext)), new Headers());
|
|
191
|
+
if (page.handle !== handle || page.offset !== offset || page.upToDate) malformed("sealed shape position");
|
|
192
|
+
return page;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @typedef {{readonly handle: string; readonly epoch: string; readonly keyId: string}} V1ShapeKeyRequest */
|
|
196
|
+
/** @typedef {V1ShapeKeyRequest & {readonly key: Uint8Array}} V1ShapeKey */
|
|
197
|
+
/** @param {V1Transport} transport @param {Readonly<Record<string, string>>} params @param {V1ShapeKeyRequest} request @param {AbortSignal | undefined} signal @returns {Promise<V1ShapeKey>} */
|
|
198
|
+
export async function readShapeKey(transport, params, request, signal) {
|
|
199
|
+
const handle = requireShapeHandle(request.handle);
|
|
200
|
+
const epoch = decimal(request.epoch);
|
|
201
|
+
const keyId = requireId(request.keyId);
|
|
202
|
+
const response = await shapeCall(transport, params, "getShapeKey", {
|
|
203
|
+
query: new URLSearchParams({ handle, epoch, key_id: keyId }), cache: "no-store", signal,
|
|
204
|
+
});
|
|
205
|
+
const key = object(response.value, "shape epoch key");
|
|
206
|
+
if (key.handle !== handle || key.epoch !== epoch || key.key_id !== keyId) malformed("shape epoch key binding");
|
|
207
|
+
const encoded = string(key.key, "shape key");
|
|
208
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(encoded)) malformed("shape key encoding");
|
|
209
|
+
const bytes = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
|
|
210
|
+
if (btoa(String.fromCharCode(...bytes)).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "") !== encoded) malformed("shape key encoding");
|
|
211
|
+
return Object.freeze({ handle, epoch, keyId, key: bytes });
|
|
212
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Client state for one collection shape: bootstrap, snapshot pages, log chunks, then the live tail.
|
|
2
|
+
import { TerraBaseV1Error } from "./errors.js";
|
|
3
|
+
import { requireFilter } from "./filter.js";
|
|
4
|
+
import { parseExactJson, stringifyExactJson } from "./json.js";
|
|
5
|
+
import { readShapePage } from "./shape-reader.js";
|
|
6
|
+
/**
|
|
7
|
+
* @import { JsonObject } from "./json.js"
|
|
8
|
+
* @import { V1Filter } from "./filter.js"
|
|
9
|
+
* @import { V1Transport } from "./transport.js"
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} V1ShapeSubscribeOptions
|
|
13
|
+
* @property {AbortSignal | undefined} [signal] Closes the subscription when aborted; cancellation settles `done`.
|
|
14
|
+
* @property {((subscription: V1ShapeSubscription) => void) | undefined} [onUpdate] Runs after validated responses and freshness changes; throwing rejects `done` unless already closed or aborted.
|
|
15
|
+
*/
|
|
16
|
+
/** @typedef {{ readonly version: string; readonly document: JsonObject }} V1ShapeDocument */
|
|
17
|
+
const retryBaseMilliseconds = 250;
|
|
18
|
+
const retryCapMilliseconds = 30_000;
|
|
19
|
+
// The eighth attempt reaches the delay ceiling; retries continue until success or cancellation.
|
|
20
|
+
const maximumBackoffAttempt = 8;
|
|
21
|
+
/** @param {import("./errors.js").TerraBaseV1Error} error @returns {boolean} */
|
|
22
|
+
function isRetryable(error) {
|
|
23
|
+
return (error.status === 429 && error.code === "shape_busy") ||
|
|
24
|
+
(error.status === 503 && (error.code === "unavailable" || error.code === "shape_too_large"));
|
|
25
|
+
}
|
|
26
|
+
/** @param {number} attempt @param {number | undefined} retryAfter @returns {number} */
|
|
27
|
+
function retryDelay(attempt, retryAfter) {
|
|
28
|
+
return Math.max(Math.min(retryCapMilliseconds, retryBaseMilliseconds * 2 ** (attempt - 1)), (retryAfter ?? 0) * 1000);
|
|
29
|
+
}
|
|
30
|
+
/** @param {unknown} value @param {readonly string[]} allowed @param {string} what */
|
|
31
|
+
function requireMembers(value, allowed, what) {
|
|
32
|
+
if (typeof value !== "object" || value === null) throw new TypeError(`${what} is an object.`);
|
|
33
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) throw new TypeError(`Unknown ${what} member ${key}.`);
|
|
34
|
+
}
|
|
35
|
+
/** @template T @param {T} value @returns {T} */
|
|
36
|
+
function deepFreeze(value) {
|
|
37
|
+
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
|
|
38
|
+
Object.freeze(value);
|
|
39
|
+
for (const item of Object.values(value)) deepFreeze(item);
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
// setTimeout overflows above 2^31-1 ms and fires at once, so a long Retry-After is waited in chunks.
|
|
44
|
+
const maximumTimerMilliseconds = 2 ** 31 - 1;
|
|
45
|
+
/** @param {number} milliseconds @param {AbortSignal} signal @returns {Promise<void>} */
|
|
46
|
+
function sleep(milliseconds, signal) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
let remaining = milliseconds;
|
|
50
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
51
|
+
let timer;
|
|
52
|
+
const onAbort = () => { clearTimeout(timer); reject(signal.reason); };
|
|
53
|
+
const wait = () => {
|
|
54
|
+
const chunk = Math.min(remaining, maximumTimerMilliseconds);
|
|
55
|
+
timer = setTimeout(() => {
|
|
56
|
+
remaining -= chunk;
|
|
57
|
+
if (remaining > 0) wait();
|
|
58
|
+
else { signal.removeEventListener("abort", onAbort); resolve(); }
|
|
59
|
+
}, chunk);
|
|
60
|
+
};
|
|
61
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
62
|
+
wait();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export class V1ShapeSubscription {
|
|
66
|
+
/** @type {Map<string, V1ShapeDocument>} */
|
|
67
|
+
#documents = new Map();
|
|
68
|
+
/** @type {string | undefined} */
|
|
69
|
+
#handle;
|
|
70
|
+
#offset = "-1";
|
|
71
|
+
/** @type {string | undefined} */
|
|
72
|
+
#cursor;
|
|
73
|
+
/** @type {string | undefined} Usable only from a snapshot-complete or log position. */
|
|
74
|
+
#resume;
|
|
75
|
+
#upToDate = false;
|
|
76
|
+
// Set after a changed-handle 409 while the saved resume is still usable; cleared by any commit or reset.
|
|
77
|
+
#resumeAttempt = false;
|
|
78
|
+
#controller = new AbortController();
|
|
79
|
+
#transport;
|
|
80
|
+
#params;
|
|
81
|
+
/** @type {V1Filter | undefined} */
|
|
82
|
+
#where;
|
|
83
|
+
#onUpdate;
|
|
84
|
+
#external;
|
|
85
|
+
#onExternalAbort = () => this.close();
|
|
86
|
+
#done;
|
|
87
|
+
/**
|
|
88
|
+
* @param {V1Transport} transport
|
|
89
|
+
* @param {Readonly<Record<string, string>>} params
|
|
90
|
+
* @param {{ where?: V1Filter | undefined }} [shape]
|
|
91
|
+
* @param {V1ShapeSubscribeOptions} [options]
|
|
92
|
+
*/
|
|
93
|
+
constructor(transport, params, shape = {}, options = {}) {
|
|
94
|
+
requireMembers(shape, ["where"], "shape");
|
|
95
|
+
requireMembers(options, ["signal", "onUpdate"], "shape subscribe option");
|
|
96
|
+
const { where } = shape;
|
|
97
|
+
const { signal, onUpdate } = options;
|
|
98
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError("signal is an AbortSignal.");
|
|
99
|
+
if (onUpdate !== undefined && typeof onUpdate !== "function") throw new TypeError("onUpdate is a function.");
|
|
100
|
+
this.#transport = transport;
|
|
101
|
+
this.#params = Object.freeze({ ...params });
|
|
102
|
+
// Exact copy: a structuredClone would lose the source text of large or fractional JSON numbers.
|
|
103
|
+
this.#where = where === undefined ? undefined : requireFilter(parseExactJson(stringifyExactJson(requireFilter(where), "shape filter")));
|
|
104
|
+
this.#onUpdate = onUpdate;
|
|
105
|
+
this.#external = signal;
|
|
106
|
+
if (signal?.aborted === true) this.close();
|
|
107
|
+
else signal?.addEventListener("abort", this.#onExternalAbort);
|
|
108
|
+
this.#done = this.#run();
|
|
109
|
+
// Callback-only consumers must not trigger a process-level unhandled rejection.
|
|
110
|
+
void this.#done.catch(() => {});
|
|
111
|
+
}
|
|
112
|
+
/** A copy per read: callers cannot change the documents this subscription holds. */
|
|
113
|
+
get state() { return new Map(this.#documents); }
|
|
114
|
+
get upToDate() { return this.#upToDate; }
|
|
115
|
+
/** Settles when the run ends: resolves on close, rejects on a terminal failure. */
|
|
116
|
+
get done() { return this.#done; }
|
|
117
|
+
close() { this.#controller.abort(); }
|
|
118
|
+
/** @returns {Promise<void>} */
|
|
119
|
+
async #run() {
|
|
120
|
+
const signal = this.#controller.signal;
|
|
121
|
+
let retries = 0;
|
|
122
|
+
try {
|
|
123
|
+
for (;;) {
|
|
124
|
+
signal.throwIfAborted();
|
|
125
|
+
const request = this.#request();
|
|
126
|
+
let page;
|
|
127
|
+
try {
|
|
128
|
+
page = await readShapePage(this.#transport, this.#params, request, signal);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (!(error instanceof TerraBaseV1Error)) throw error;
|
|
131
|
+
if (isRetryable(error)) {
|
|
132
|
+
retries = Math.min(retries + 1, maximumBackoffAttempt);
|
|
133
|
+
await sleep(retryDelay(retries, error.retryAfter), signal);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (error.status !== 409 || error.code !== "must_refetch") throw error;
|
|
137
|
+
this.#recover(request, error);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
retries = 0;
|
|
141
|
+
this.#commit(page);
|
|
142
|
+
this.#onUpdate?.(this);
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (signal.aborted) return;
|
|
146
|
+
throw error;
|
|
147
|
+
} finally {
|
|
148
|
+
// A finished run must not keep the caller's signal (and this subscription) alive.
|
|
149
|
+
this.#external?.removeEventListener("abort", this.#onExternalAbort);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/** @returns {import("./shapes.js").V1ShapeRequest} */
|
|
153
|
+
#request() {
|
|
154
|
+
if (this.#resumeAttempt) return { where: this.#where, offset: "-1", cursor: this.#resume };
|
|
155
|
+
if (this.#handle === undefined) return { where: this.#where, offset: "-1" };
|
|
156
|
+
// Only a fresh head is a live tail; snapshot positions are never live.
|
|
157
|
+
if (this.#upToDate && !this.#offset.startsWith("snap.")) return { where: this.#where, offset: this.#offset, handle: this.#handle, live: true, cursor: this.#cursor };
|
|
158
|
+
return { where: this.#where, offset: this.#offset, handle: this.#handle };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* @param {import("./shapes.js").V1ShapeRequest} request
|
|
162
|
+
* @param {TerraBaseV1Error} error
|
|
163
|
+
*/
|
|
164
|
+
#recover(request, error) {
|
|
165
|
+
if (this.#resumeAttempt) return this.#reset();
|
|
166
|
+
if (request.handle === undefined) throw error;
|
|
167
|
+
// §7.6 reads the lineage from the response header. A body that names another handle is ambiguous: fail closed.
|
|
168
|
+
const handle = error.headers.get("tb-shape-handle");
|
|
169
|
+
const changed = handle !== null && handle !== this.#handle && (error.handle === undefined || error.handle === handle);
|
|
170
|
+
if (changed && this.#resume !== undefined) {
|
|
171
|
+
// One attempt on the new lineage; its own 409 falls through to #reset above.
|
|
172
|
+
this.#resumeAttempt = true;
|
|
173
|
+
if (this.#upToDate) {
|
|
174
|
+
this.#upToDate = false;
|
|
175
|
+
this.#onUpdate?.(this);
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.#reset();
|
|
180
|
+
}
|
|
181
|
+
#reset() {
|
|
182
|
+
this.#documents = new Map();
|
|
183
|
+
this.#handle = undefined;
|
|
184
|
+
this.#offset = "-1";
|
|
185
|
+
this.#cursor = undefined;
|
|
186
|
+
this.#resume = undefined;
|
|
187
|
+
this.#upToDate = false;
|
|
188
|
+
this.#resumeAttempt = false;
|
|
189
|
+
this.#onUpdate?.(this);
|
|
190
|
+
}
|
|
191
|
+
/** @param {import("./shapes.js").V1ShapePage} page */
|
|
192
|
+
#commit(page) {
|
|
193
|
+
// Repeats are applied, never skipped: a repeated event can carry a newer materialized value.
|
|
194
|
+
const documents = new Map(this.#documents);
|
|
195
|
+
for (const change of page.changes)
|
|
196
|
+
for (const op of change.ops) {
|
|
197
|
+
if (op.op === "delete") documents.delete(op.id);
|
|
198
|
+
else documents.set(op.id, deepFreeze({ version: op.version, document: op.document }));
|
|
199
|
+
}
|
|
200
|
+
this.#documents = documents;
|
|
201
|
+
this.#handle = page.handle;
|
|
202
|
+
this.#offset = page.nextOffset;
|
|
203
|
+
this.#cursor = page.cursor;
|
|
204
|
+
this.#resume = page.nextOffset.startsWith("snap.") ? undefined : page.cursorResume;
|
|
205
|
+
this.#upToDate = page.upToDate;
|
|
206
|
+
this.#resumeAttempt = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
package/src/v1/shapes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Current C# GET collection/shape contract (docs/shape-reads.md).
|
|
2
|
-
import { array, boolean, malformed, object, string, u64 } from "./decode.js";
|
|
2
|
+
import { array, boolean, documentObject, malformed, object, string, u64 } from "./decode.js";
|
|
3
3
|
import { requireFilter } from "./filter.js";
|
|
4
|
-
import { parseU64, requireVersion } from "./ids.js";
|
|
4
|
+
import { parseU64, requireDocumentId, requireVersion } from "./ids.js";
|
|
5
5
|
import { stringifyExactJson } from "./json.js";
|
|
6
6
|
/**
|
|
7
7
|
* @import { JsonObject, JsonValue } from "./json.js"
|
|
@@ -29,6 +29,9 @@ import { stringifyExactJson } from "./json.js";
|
|
|
29
29
|
* @property {readonly V1ShapeChange[]} changes Apply commits and operations in their received order.
|
|
30
30
|
* @property {string | undefined} cursor Echo this as cursor on the next live request.
|
|
31
31
|
* @property {string | undefined} etag
|
|
32
|
+
* @property {string | undefined} [coveredOffset] Free-read protocol coverage (feed sequence, not write offset).
|
|
33
|
+
* @property {string | undefined} [valuesAt] Free-read materialization position.
|
|
34
|
+
* @property {string | undefined} [cursorResume] Opaque free-read resume token.
|
|
32
35
|
*/
|
|
33
36
|
const maximumOffset = (1n << 63n) - 1n;
|
|
34
37
|
const handlePattern = /^h1-[0-9a-f]{16}$/u;
|
|
@@ -53,13 +56,13 @@ export function requireShapeOffset(value) {
|
|
|
53
56
|
const bytes = Uint8Array.from(atob(encoded.replace(/-/gu, "+").replace(/_/gu, "/")), c => c.charCodeAt(0));
|
|
54
57
|
const id = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
55
58
|
const canonical = btoa(String.fromCharCode(...new TextEncoder().encode(id))).replace(/\+/gu, "-").replace(/\//gu, "_").replace(/=+$/u, "");
|
|
56
|
-
if (canonical === encoded) return value;
|
|
59
|
+
if (canonical === encoded) { requireDocumentId(id); return value; }
|
|
57
60
|
} catch { /* Invalid base64url or UTF-8. */ }
|
|
58
61
|
}
|
|
59
62
|
throw new TypeError("A shape offset is -1, a signed-64-bit decimal log position, or snap.<head>.<canonical-base64url-id>.");
|
|
60
63
|
}
|
|
61
64
|
/** @param {unknown} value @returns {string} */
|
|
62
|
-
function
|
|
65
|
+
export function requireShapeHandle(value) {
|
|
63
66
|
if (typeof value !== "string" || !handlePattern.test(value)) throw new TypeError("A shape handle is h1- followed by 16 lowercase hex characters.");
|
|
64
67
|
return value;
|
|
65
68
|
}
|
|
@@ -72,7 +75,7 @@ export function encodeShapeRequest(request) {
|
|
|
72
75
|
if (request.where !== undefined) query.set("where", stringifyExactJson(requireFilter(request.where), "shape filter"));
|
|
73
76
|
const offset = requireShapeOffset(request.offset ?? "-1");
|
|
74
77
|
query.set("offset", offset);
|
|
75
|
-
if (request.handle !== undefined) query.set("handle",
|
|
78
|
+
if (request.handle !== undefined) query.set("handle", requireShapeHandle(request.handle));
|
|
76
79
|
else if (offset !== "-1") throw new TypeError("A shape handle is required after bootstrap.");
|
|
77
80
|
if (request.live !== undefined && typeof request.live !== "boolean") throw new TypeError("live is a boolean.");
|
|
78
81
|
if (request.live === true) {
|
|
@@ -94,19 +97,21 @@ export function decodeShapePage(value, headers) {
|
|
|
94
97
|
const cursor = headers.get("tb-shape-cursor") ?? undefined;
|
|
95
98
|
if (cursor !== undefined && !isLogOffset(cursor)) malformed("shape cursor");
|
|
96
99
|
return Object.freeze({
|
|
97
|
-
shapeId, handle:
|
|
100
|
+
shapeId, handle: requireShapeHandle(raw.handle),
|
|
98
101
|
offset: requireShapeOffset(raw.offset), nextOffset: requireShapeOffset(raw.next_offset),
|
|
99
102
|
upToDate: boolean(raw.up_to_date, "shape up_to_date"), cursor,
|
|
100
103
|
etag: headers.get("etag") ?? undefined,
|
|
104
|
+
...(raw.covered_offset === undefined ? {} : { coveredOffset: requireShapeOffset(raw.covered_offset) }),
|
|
105
|
+
...(raw.values_at === undefined ? {} : { valuesAt: requireShapeOffset(raw.values_at) }),
|
|
106
|
+
...(raw.cursor_resume === undefined ? {} : { cursorResume: string(raw.cursor_resume, "shape resume cursor") }),
|
|
101
107
|
changes: Object.freeze(array(raw.changes, "shape changes").map(value => {
|
|
102
108
|
const change = object(value, "shape change");
|
|
103
109
|
return Object.freeze({ offset: u64(change.offset, "shape change offset"), ops: Object.freeze(array(change.ops, "shape operations").map(value => {
|
|
104
110
|
const op = object(value, "shape operation");
|
|
105
|
-
|
|
106
|
-
const id = string(op.id, "shape document id");
|
|
111
|
+
const id = requireDocumentId(op.id);
|
|
107
112
|
if (op.op === "delete") return Object.freeze({ op: /** @type {const} */ ("delete"), id });
|
|
108
113
|
if (op.op !== "upsert") malformed("shape operation kind");
|
|
109
|
-
return Object.freeze({ op: /** @type {const} */ ("upsert"), id, version: requireVersion(op.version), document:
|
|
114
|
+
return Object.freeze({ op: /** @type {const} */ ("upsert"), id, version: requireVersion(op.version), document: documentObject(op.document) });
|
|
110
115
|
})) });
|
|
111
116
|
})),
|
|
112
117
|
});
|
|
@@ -131,7 +136,7 @@ export async function readShape(transport, params, request, signal) {
|
|
|
131
136
|
const target = new URL(location, source);
|
|
132
137
|
if (target.origin !== source.origin || target.pathname !== source.pathname || target.username !== "" || target.password !== "" || target.hash !== "")
|
|
133
138
|
malformed("shape redirect target");
|
|
134
|
-
const handle =
|
|
139
|
+
const handle = requireShapeHandle(target.searchParams.get("handle"));
|
|
135
140
|
const offset = requireShapeOffset(target.searchParams.get("offset"));
|
|
136
141
|
if (!offset.startsWith("snap.")) malformed("shape bootstrap offset");
|
|
137
142
|
query.set("handle", handle);
|