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/CHANGELOG.md +47 -0
- package/README.md +169 -115
- package/package.json +11 -16
- package/sdk-v1-route-manifest.json +2330 -0
- package/src/database-view.js +2 -1
- package/src/database.js +2 -420
- package/src/index.js +58 -75
- package/src/local/index.js +5 -3
- package/src/local/legacy-client.js +411 -0
- package/src/postgres.js +3 -1
- package/src/tanstack/index.js +2 -1
- package/src/v1/decode.js +110 -0
- package/src/v1/errors.js +185 -0
- package/src/v1/filter.js +230 -0
- package/src/v1/ids.js +118 -0
- package/src/v1/json.js +335 -0
- package/src/v1/routes.js +57 -0
- package/src/v1/shapes.js +145 -0
- package/src/v1/terrabase-v1.js +722 -0
- package/src/v1/transport.js +324 -0
- package/types/database-view.d.ts +3 -2
- package/types/database.d.ts +2 -98
- package/types/index.d.ts +42 -51
- package/types/local/index.d.ts +6 -4
- package/types/local/legacy-client.d.ts +94 -0
- package/types/local/test-environment.d.ts +1 -1
- package/types/postgres.d.ts +1 -0
- package/types/tanstack/index.d.ts +3 -2
- package/types/v1/decode.d.ts +105 -0
- package/types/v1/errors.d.ts +107 -0
- package/types/v1/filter.d.ts +148 -0
- package/types/v1/ids.d.ts +44 -0
- package/types/v1/json.d.ts +92 -0
- package/types/v1/routes.d.ts +26 -0
- package/types/v1/shapes.d.ts +78 -0
- package/types/v1/terrabase-v1.d.ts +374 -0
- package/types/v1/transport.d.ts +117 -0
- package/sdk-current-contract.json +0 -27
- package/sdk-route-manifest.json +0 -67
- package/src/discovery.js +0 -374
- package/types/discovery.d.ts +0 -114
package/src/v1/errors.js
ADDED
|
@@ -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
|
+
}
|
package/src/v1/filter.js
ADDED
|
@@ -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
|
+
}
|