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.
@@ -0,0 +1,185 @@
1
+ import { isJsonObject, parseExactJson } from "./json.js";
2
+ /** @import { JsonObject } from "./json.js" */
3
+
4
+ /**
5
+ * @typedef {object} TerraBaseV1ErrorInit
6
+ * @property {number} status
7
+ * @property {string} code
8
+ * @property {string | undefined} [message]
9
+ * @property {number | undefined} [op]
10
+ * @property {string | undefined} [handle]
11
+ * @property {number | undefined} [retryAfter]
12
+ * @property {string | undefined} [etag]
13
+ * @property {JsonObject | undefined} [details]
14
+ */
15
+
16
+ /**
17
+ * A non-2xx TerraBase v1 response. `code` is the contract's `error.code`, or
18
+ * `not_modified` for a 304 and `http_<status>` when the body carried no code.
19
+ */
20
+ export class TerraBaseV1Error extends Error {
21
+ /** @type {number} HTTP status. */
22
+ status;
23
+ /** @type {string} */
24
+ code;
25
+ /** @type {number | undefined} Failing transaction op index. */
26
+ op;
27
+ /** @type {string | undefined} New shape handle for `must_refetch`. */
28
+ handle;
29
+ /** @type {number | undefined} Parsed `Retry-After`, in seconds. */
30
+ retryAfter;
31
+ /** @type {string | undefined} Unquoted `ETag`; a deleted document's 404 carries its tombstone version. */
32
+ etag;
33
+ /** @type {JsonObject | undefined} The raw error object, for route-specific metadata. */
34
+ details;
35
+
36
+ /** @param {TerraBaseV1ErrorInit} init */
37
+ constructor(init) {
38
+ const suffix = [
39
+ init.message === undefined ? "" : `: ${init.message}`,
40
+ init.op === undefined ? "" : ` (op ${init.op})`,
41
+ ].join("");
42
+ super(`TerraBase ${init.status} ${init.code}${suffix}`);
43
+ this.name = "TerraBaseV1Error";
44
+ this.status = init.status;
45
+ this.code = init.code;
46
+ this.op = init.op;
47
+ this.handle = init.handle;
48
+ this.retryAfter = init.retryAfter;
49
+ this.etag = init.etag;
50
+ this.details = init.details;
51
+ }
52
+ }
53
+
54
+ /** Current document, collection and shape producer codes, plus client not_modified. */
55
+ export const V1ErrorCode = Object.freeze({
56
+ invalidRequest: "invalid_request", invalidQuery: "invalid_query", invalidCursor: "invalid_cursor",
57
+ invalidPatch: "invalid_patch", sortRequiresIndex: "sort_requires_index", assertRequiresIndex: "assert_requires_index",
58
+ invalidKey: "invalid_key", forbiddenScope: "forbidden_scope", notFound: "not_found",
59
+ unknownDatabase: "unknown_database", unknownCollection: "unknown_collection", unknownRoute: "unknown_route",
60
+ methodNotAllowed: "method_not_allowed", patchConflict: "patch_conflict", nameTaken: "name_taken",
61
+ mustRefetch: "must_refetch", preconditionFailed: "precondition_failed", payloadTooLarge: "payload_too_large",
62
+ unsupportedMediaType: "unsupported_media_type", uriTooLong: "uri_too_long", shapeBusy: "shape_busy",
63
+ internalError: "internal_error", capacity: "capacity", notModified: "not_modified",
64
+ });
65
+
66
+ /** `412 precondition_failed`: an If-Match, If-None-Match, assert or assert_count failed. */
67
+ export class V1PreconditionFailedError extends TerraBaseV1Error {
68
+ /** @param {TerraBaseV1ErrorInit} init */
69
+ constructor(init) {
70
+ super(init);
71
+ this.name = "V1PreconditionFailedError";
72
+ }
73
+ }
74
+
75
+ /** `401 invalid_key`: the API key is unknown or revoked. */
76
+ export class V1InvalidKeyError extends TerraBaseV1Error {
77
+ /** @param {TerraBaseV1ErrorInit} init */
78
+ constructor(init) {
79
+ super(init);
80
+ this.name = "V1InvalidKeyError";
81
+ }
82
+ }
83
+
84
+ /** `403 forbidden_scope`: the key's scopes do not cover the request. */
85
+ export class V1ForbiddenScopeError extends TerraBaseV1Error {
86
+ /** @param {TerraBaseV1ErrorInit} init */
87
+ constructor(init) {
88
+ super(init);
89
+ this.name = "V1ForbiddenScopeError";
90
+ }
91
+ }
92
+
93
+ /** `404 not_found`: absent or deleted; `etag` carries a tombstone version. */
94
+ export class V1NotFoundError extends TerraBaseV1Error {
95
+ /** @param {TerraBaseV1ErrorInit} init */
96
+ constructor(init) {
97
+ super(init);
98
+ this.name = "V1NotFoundError";
99
+ }
100
+ }
101
+
102
+ /**
103
+ * @param {TerraBaseV1ErrorInit} init
104
+ * @returns {TerraBaseV1Error}
105
+ */
106
+ function typedError(init) {
107
+ switch (init.code) {
108
+ case "precondition_failed": return new V1PreconditionFailedError(init);
109
+ case "invalid_key": return new V1InvalidKeyError(init);
110
+ case "forbidden_scope": return new V1ForbiddenScopeError(init);
111
+ case "not_found": return new V1NotFoundError(init);
112
+ default: return new TerraBaseV1Error(init);
113
+ }
114
+ }
115
+
116
+ /**
117
+ * @param {unknown} error
118
+ * @param {string} [code] Also require this `error.code`.
119
+ * @returns {error is TerraBaseV1Error}
120
+ */
121
+ export function isTerraBaseV1Error(error, code) {
122
+ return error instanceof TerraBaseV1Error && (code === undefined || error.code === code);
123
+ }
124
+
125
+ /**
126
+ * @param {string | null} value
127
+ * @returns {string | undefined}
128
+ */
129
+ export function unquoteEtag(value) {
130
+ if (value === null) return undefined;
131
+ const trimmed = value.trim().replace(/^W\//u, "");
132
+ return trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
133
+ ? trimmed.slice(1, -1)
134
+ : trimmed;
135
+ }
136
+
137
+ /**
138
+ * @param {Headers} headers
139
+ * @returns {number | undefined} Seconds.
140
+ */
141
+ export function parseRetryAfter(headers) {
142
+ const value = headers.get("retry-after")?.trim();
143
+ if (value === undefined || value === "") return undefined;
144
+ if (/^\d{1,9}$/u.test(value)) return Number(value);
145
+ const date = Date.parse(value);
146
+ return Number.isNaN(date) ? undefined : Math.max(0, (date - Date.now()) / 1000);
147
+ }
148
+
149
+ /**
150
+ * @param {number} status
151
+ * @param {Headers} headers
152
+ * @param {string} text
153
+ * @returns {TerraBaseV1Error}
154
+ */
155
+ export function parseV1Error(status, headers, text) {
156
+ /** @type {JsonObject | undefined} */
157
+ let details;
158
+ try {
159
+ const parsed = text === "" ? undefined : parseExactJson(text);
160
+ if (isJsonObject(parsed) && isJsonObject(parsed.error)) details = parsed.error;
161
+ } catch {
162
+ details = undefined;
163
+ }
164
+ /**
165
+ * @param {string} name
166
+ * @returns {string | undefined}
167
+ */
168
+ const text_ = (name) => {
169
+ const value = details?.[name];
170
+ return typeof value === "string" ? value : undefined;
171
+ };
172
+ const op = details?.op;
173
+ const code =
174
+ text_("code") ?? (status === 304 ? "not_modified" : `http_${status}`);
175
+ return typedError({
176
+ status,
177
+ code,
178
+ message: text_("message"),
179
+ op: typeof op === "number" && Number.isInteger(op) ? op : undefined,
180
+ handle: text_("handle"),
181
+ retryAfter: parseRetryAfter(headers),
182
+ etag: unquoteEtag(headers.get("etag")),
183
+ details,
184
+ });
185
+ }
@@ -0,0 +1,230 @@
1
+ // Query v1 filter grammar builders (query-v1.md). Filters are plain wire
2
+ // objects, shared by queries, transaction asserts and shapes.
3
+ import { ExactDecimal } from "./json.js";
4
+
5
+ /**
6
+ * A filter or sort path: an RFC 6901 JSON Pointer or "$id".
7
+ * @typedef {string} V1Path
8
+ */
9
+ /**
10
+ * A filter operand: string, exact number, boolean or null.
11
+ * @typedef {null | boolean | string | number | bigint | ExactDecimal} V1Scalar
12
+ */
13
+ /**
14
+ * @typedef {{ readonly and: readonly V1Filter[] }
15
+ * | { readonly or: readonly V1Filter[] }
16
+ * | { readonly not: V1Filter }
17
+ * | { readonly eq: readonly [V1Path, V1Scalar] }
18
+ * | { readonly ne: readonly [V1Path, V1Scalar] }
19
+ * | { readonly lt: readonly [V1Path, V1Scalar] }
20
+ * | { readonly lte: readonly [V1Path, V1Scalar] }
21
+ * | { readonly gt: readonly [V1Path, V1Scalar] }
22
+ * | { readonly gte: readonly [V1Path, V1Scalar] }
23
+ * | { readonly in: readonly [V1Path, readonly V1Scalar[]] }
24
+ * | { readonly nin: readonly [V1Path, readonly V1Scalar[]] }
25
+ * | { readonly exists: V1Path }
26
+ * | { readonly prefix: readonly [V1Path, string] }
27
+ * | { readonly contains: readonly [V1Path, V1Scalar] }} V1Filter
28
+ */
29
+
30
+ /**
31
+ * @param {unknown} pointer
32
+ * @returns {pointer is string}
33
+ */
34
+ function isPointer(pointer) {
35
+ return isJsonPointer(pointer) && pointer.split("/").length - 1 <= 16;
36
+ }
37
+
38
+ /**
39
+ * An RFC 6901 JSON Pointer of at most 4 KiB with at least one segment: `~`
40
+ * only as the escapes `~0` and `~1`.
41
+ * @param {unknown} pointer
42
+ * @returns {pointer is string}
43
+ */
44
+ export function isJsonPointer(pointer) {
45
+ return typeof pointer === "string" && pointer.length <= 4096 && pointer.split("/").length - 1 <= 16 && !/[\uD800-\uDFFF]/u.test(pointer) && /^(?:\/(?:[^~/]|~[01])*)+$/u.test(pointer);
46
+ }
47
+
48
+ /**
49
+ * @param {V1Path} path
50
+ * @returns {V1Path}
51
+ */
52
+ export function requirePath(path) {
53
+ if (path === "$id" || isPointer(path)) return path;
54
+ throw new TypeError('A TerraBase path is an RFC 6901 JSON Pointer of 1-16 segments, "$id".');
55
+ }
56
+
57
+ /**
58
+ * @param {V1Scalar} value
59
+ * @returns {V1Scalar}
60
+ */
61
+ function requireScalar(value) {
62
+ if (
63
+ value === null ||
64
+ typeof value === "boolean" ||
65
+ typeof value === "string" ||
66
+ typeof value === "bigint" ||
67
+ value instanceof ExactDecimal ||
68
+ (typeof value === "number" && Number.isFinite(value))
69
+ )
70
+ return value;
71
+ throw new TypeError("Filter values are JSON scalars: string, finite number, bigint, ExactDecimal, boolean or null.");
72
+ }
73
+
74
+ /**
75
+ * @param {"and" | "or"} op
76
+ * @param {readonly V1Filter[]} filters
77
+ * @returns {readonly V1Filter[]}
78
+ */
79
+ function children(op, filters) {
80
+ if (filters.length < 1 || filters.length > 64) throw new TypeError(`${op} takes 1-64 filters.`);
81
+ return Object.freeze([...filters]);
82
+ }
83
+
84
+ /**
85
+ * @param {"in" | "nin"} op
86
+ * @param {readonly V1Scalar[]} values
87
+ * @returns {readonly V1Scalar[]}
88
+ */
89
+ function scalars(op, values) {
90
+ if (!Array.isArray(values) || values.length < 1 || values.length > 256)
91
+ throw new TypeError(`${op} takes 1-256 values.`);
92
+ return Object.freeze(values.map(requireScalar));
93
+ }
94
+
95
+ const binaryOperators = new Set(["eq", "ne", "lt", "lte", "gt", "gte", "contains"]);
96
+
97
+ /**
98
+ * Validates a filter against the query-v1 grammar (operators, arity, paths,
99
+ * scalar operands, depth 8 and 256 nodes), so a malformed filter fails locally
100
+ * instead of as a server `invalid_query`. The v1Filter builders always pass.
101
+ * @param {unknown} filter
102
+ * @returns {V1Filter}
103
+ */
104
+ export function requireFilter(filter) {
105
+ let nodes = 0;
106
+ /**
107
+ * @param {unknown} node
108
+ * @param {number} depth
109
+ * @returns {void}
110
+ */
111
+ const visit = (node, depth) => {
112
+ if (++nodes > 256) throw new TypeError("A filter has at most 256 nodes.");
113
+ if (depth > 8) throw new TypeError("A filter nests at most 8 levels.");
114
+ if (typeof node !== "object" || node === null || Array.isArray(node) || Object.keys(node).length !== 1)
115
+ throw new TypeError("A filter node is an object with exactly one operator.");
116
+ const [op, args] = /** @type {[string, unknown]} */ (Object.entries(node)[0]);
117
+ if (op === "and" || op === "or") {
118
+ if (!Array.isArray(args) || args.length < 1 || args.length > 64) throw new TypeError(`${op} takes 1-64 filters.`);
119
+ for (const child of args) visit(child, depth + 1);
120
+ } else if (op === "not") visit(args, depth + 1);
121
+ else if (op === "exists") requirePath(/** @type {V1Path} */ (args));
122
+ else if (binaryOperators.has(op) || op === "prefix" || op === "in" || op === "nin") {
123
+ if (!Array.isArray(args) || args.length !== 2) throw new TypeError(`${op} takes [path, value].`);
124
+ requirePath(args[0]);
125
+ if (op === "in" || op === "nin") scalars(op, args[1]);
126
+ else if (op === "prefix") {
127
+ if (typeof args[1] !== "string") throw new TypeError("prefix takes a string.");
128
+ } else {
129
+ requireScalar(args[1]);
130
+ if (op !== "eq" && op !== "ne" && op !== "contains" && args[1] === null) throw new TypeError(`${op} does not compare null.`);
131
+ }
132
+ } else throw new TypeError(`Unknown filter operator ${op}.`);
133
+ };
134
+ visit(filter, 1);
135
+ return /** @type {V1Filter} */ (filter);
136
+ }
137
+
138
+ /**
139
+ * Query v1 filter builders. `v1Filter.id` is the `"$id"` pseudo-path.
140
+ */
141
+ export const v1Filter = Object.freeze({
142
+ /** The document ID pseudo-path. */
143
+ id: /** @type {"$id"} */ ("$id"),
144
+ /**
145
+ * @param {...V1Filter} filters
146
+ * @returns {V1Filter}
147
+ */
148
+ and: (...filters) => Object.freeze({ and: children("and", filters) }),
149
+ /**
150
+ * @param {...V1Filter} filters
151
+ * @returns {V1Filter}
152
+ */
153
+ or: (...filters) => Object.freeze({ or: children("or", filters) }),
154
+ /**
155
+ * @param {V1Filter} filter
156
+ * @returns {V1Filter}
157
+ */
158
+ not: (filter) => Object.freeze({ not: filter }),
159
+ /**
160
+ * @param {V1Path} path
161
+ * @param {V1Scalar} value
162
+ * @returns {V1Filter}
163
+ */
164
+ eq: (path, value) => Object.freeze({ eq: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
165
+ /**
166
+ * Not equal; also matches a missing member.
167
+ * @param {V1Path} path
168
+ * @param {V1Scalar} value
169
+ * @returns {V1Filter}
170
+ */
171
+ ne: (path, value) => Object.freeze({ ne: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
172
+ /**
173
+ * @param {V1Path} path
174
+ * @param {V1Scalar} value
175
+ * @returns {V1Filter}
176
+ */
177
+ lt: (path, value) => Object.freeze({ lt: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
178
+ /**
179
+ * @param {V1Path} path
180
+ * @param {V1Scalar} value
181
+ * @returns {V1Filter}
182
+ */
183
+ lte: (path, value) => Object.freeze({ lte: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
184
+ /**
185
+ * @param {V1Path} path
186
+ * @param {V1Scalar} value
187
+ * @returns {V1Filter}
188
+ */
189
+ gt: (path, value) => Object.freeze({ gt: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
190
+ /**
191
+ * @param {V1Path} path
192
+ * @param {V1Scalar} value
193
+ * @returns {V1Filter}
194
+ */
195
+ gte: (path, value) => Object.freeze({ gte: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
196
+ /**
197
+ * @param {V1Path} path
198
+ * @param {readonly V1Scalar[]} values 1-256 values.
199
+ * @returns {V1Filter}
200
+ */
201
+ in: (path, values) => Object.freeze({ in: Object.freeze(/** @type {const} */ ([requirePath(path), scalars("in", values)])) }),
202
+ /**
203
+ * @param {V1Path} path
204
+ * @param {readonly V1Scalar[]} values 1-256 values.
205
+ * @returns {V1Filter}
206
+ */
207
+ nin: (path, values) => Object.freeze({ nin: Object.freeze(/** @type {const} */ ([requirePath(path), scalars("nin", values)])) }),
208
+ /**
209
+ * The member is present (including null).
210
+ * @param {V1Path} path
211
+ * @returns {V1Filter}
212
+ */
213
+ exists: (path) => Object.freeze({ exists: requirePath(path) }),
214
+ /**
215
+ * @param {V1Path} path
216
+ * @param {string} prefix
217
+ * @returns {V1Filter}
218
+ */
219
+ prefix: (path, prefix) => {
220
+ if (typeof prefix !== "string") throw new TypeError("prefix takes a string.");
221
+ return Object.freeze({ prefix: Object.freeze(/** @type {const} */ ([requirePath(path), prefix])) });
222
+ },
223
+ /**
224
+ * The value is an array containing the scalar.
225
+ * @param {V1Path} path
226
+ * @param {V1Scalar} value
227
+ * @returns {V1Filter}
228
+ */
229
+ contains: (path, value) => Object.freeze({ contains: Object.freeze(/** @type {const} */ ([requirePath(path), requireScalar(value)])) }),
230
+ });
package/src/v1/ids.js ADDED
@@ -0,0 +1,118 @@
1
+ // Identifiers for the TerraBase v1 Documents API (api-v1.md).
2
+
3
+ const namePattern = /^[a-z][a-z0-9_-]{0,62}$/u;
4
+ const versionPattern = /^tbv1-[0-9a-f]{32}$/u;
5
+ const offsetPattern = /^(?:0|[1-9]\d{0,19})$/u;
6
+ const maximumU64 = (1n << 64n) - 1n;
7
+
8
+ /** RFC 9562 NAMESPACE_URL, 6ba7b811-9dad-11d1-80b4-00c04fd430c8. */
9
+ const namespaceUrl = Uint8Array.of(
10
+ 0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1,
11
+ 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
12
+ );
13
+
14
+ /**
15
+ * @param {"database" | "collection"} kind
16
+ * @param {unknown} name
17
+ * @returns {string}
18
+ */
19
+ export function requireName(kind, name) {
20
+ if (typeof name !== "string" || !namePattern.test(name))
21
+ throw new TypeError(`TerraBase ${kind} names must match ^[a-z][a-z0-9_-]{0,62}$.`);
22
+ return name;
23
+ }
24
+
25
+ /**
26
+ * The producer resolves this opaque catalog project id (normally a UUID), not its display name.
27
+ * @param {unknown} project
28
+ * @returns {string}
29
+ */
30
+ export function requireProject(project) {
31
+ if (typeof project !== "string" || project === "" || project === "." || project === ".." || /[\p{Cc}\uD800-\uDFFF]/u.test(project))
32
+ throw new TypeError("project must be a non-empty catalog project ID without control characters or dot path segments.");
33
+ return project;
34
+ }
35
+
36
+ /**
37
+ * @param {unknown} id
38
+ * @returns {string}
39
+ */
40
+ export function requireDocumentId(id) {
41
+ if (typeof id !== "string" || id.length < 1 || id.length > 256 || /[\p{Cc}]/u.test(id) || /[\uD800-\uDFFF]/u.test(id) || id === "." || id === "..")
42
+ throw new TypeError("Document IDs must be 1-256 characters without control characters or dot path segments; new IDs must be lowercase UUIDs.");
43
+ return id;
44
+ }
45
+
46
+ /**
47
+ * @param {unknown} version
48
+ * @returns {string}
49
+ */
50
+ export function requireVersion(version) {
51
+ if (typeof version !== "string" || !versionPattern.test(version))
52
+ throw new TypeError("TerraBase versions are tbv1- followed by 32 lowercase hex characters.");
53
+ return version;
54
+ }
55
+
56
+ /**
57
+ * Parses a canonical decimal u64 (an offset), or returns undefined.
58
+ * @param {unknown} value
59
+ * @returns {bigint | undefined}
60
+ */
61
+ export function parseU64(value) {
62
+ if (typeof value !== "string" || !offsetPattern.test(value)) return undefined;
63
+ const parsed = BigInt(value);
64
+ return parsed <= maximumU64 ? parsed : undefined;
65
+ }
66
+
67
+ /**
68
+ * A caller-supplied u64 (a decimal string or bigint), as a bigint.
69
+ * @param {unknown} value
70
+ * @param {string} what
71
+ * @returns {bigint}
72
+ */
73
+ export function requireU64(value, what) {
74
+ const parsed = parseU64(typeof value === "bigint" ? value.toString() : value);
75
+ if (parsed === undefined) throw new TypeError(`${what} must be a u64: a canonical decimal string or bigint.`);
76
+ return parsed;
77
+ }
78
+
79
+ /**
80
+ * @param {Uint8Array} namespace
81
+ * @param {string} name
82
+ * @returns {Promise<Uint8Array>}
83
+ */
84
+ async function uuidV5(namespace, name) {
85
+ const encoded = new TextEncoder().encode(name);
86
+ const input = new Uint8Array(namespace.length + encoded.length);
87
+ input.set(namespace);
88
+ input.set(encoded, namespace.length);
89
+ const digest = new Uint8Array(await globalThis.crypto.subtle.digest("SHA-1", input));
90
+ const uuid = digest.slice(0, 16);
91
+ uuid[6] = ((uuid[6] ?? 0) & 0x0f) | 0x50;
92
+ uuid[8] = ((uuid[8] ?? 0) & 0x3f) | 0x80;
93
+ return uuid;
94
+ }
95
+
96
+ /**
97
+ * @param {Uint8Array} bytes
98
+ * @returns {string}
99
+ */
100
+ function formatUuid(bytes) {
101
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
102
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
103
+ }
104
+
105
+ /**
106
+ * The deterministic document ID for a natural key in a collection:
107
+ * `uuidv5(uuidv5(NAMESPACE_URL, "terrabase:collection:" + collection), key)`.
108
+ * Uses WebCrypto, so it works in Node.js, browsers and workers.
109
+ * @param {string} collection
110
+ * @param {string} key
111
+ * @returns {Promise<string>}
112
+ */
113
+ export async function documentIdFor(collection, key) {
114
+ requireName("collection", collection);
115
+ if (typeof key !== "string") throw new TypeError("The natural key must be a string.");
116
+ const namespace = await uuidV5(namespaceUrl, `terrabase:collection:${collection}`);
117
+ return formatUuid(await uuidV5(namespace, key));
118
+ }