terrascale 0.3.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/LICENSE +202 -0
- package/README.md +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- package/types/ts-auth.d.ts +1826 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ReactiveMutationObserver,
|
|
4
|
+
ReactiveQueryObserver,
|
|
5
|
+
} from "./core.js";
|
|
6
|
+
/**
|
|
7
|
+
* @import {
|
|
8
|
+
* ReactiveMutationOptions,
|
|
9
|
+
* ReactiveMutationState,
|
|
10
|
+
* ReactiveQueryOptions,
|
|
11
|
+
* ReactiveQuerySource,
|
|
12
|
+
* ReactiveQueryState,
|
|
13
|
+
* } from "./core.js"
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export * from "./core.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* React's external-store primitive gives useQuery one SSR-safe snapshot and
|
|
20
|
+
* makes every server update flow through the typed reactive subscription.
|
|
21
|
+
* Keep the source object stable between renders.
|
|
22
|
+
*
|
|
23
|
+
* @template TValue
|
|
24
|
+
* @param {ReactiveQuerySource<TValue>} source
|
|
25
|
+
* @param {ReactiveQueryOptions<TValue>} [options]
|
|
26
|
+
* @returns {ReactiveQueryState<TValue> & { readonly refetch: () => void }}
|
|
27
|
+
*/
|
|
28
|
+
export function useQuery(source, options = {}) {
|
|
29
|
+
const observer = useMemo(
|
|
30
|
+
() => new ReactiveQueryObserver(source, options),
|
|
31
|
+
[source],
|
|
32
|
+
);
|
|
33
|
+
observer.updateOptions(options);
|
|
34
|
+
const state = useSyncExternalStore(
|
|
35
|
+
observer.subscribe,
|
|
36
|
+
observer.getSnapshot,
|
|
37
|
+
observer.getServerSnapshot,
|
|
38
|
+
);
|
|
39
|
+
const refetch = useCallback(() => {
|
|
40
|
+
observer.refetch();
|
|
41
|
+
}, [observer]);
|
|
42
|
+
return useMemo(() => Object.freeze({ ...state, refetch }), [state, refetch]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @template TValue, TVariables
|
|
47
|
+
* @typedef {ReactiveMutationState<TValue> & {
|
|
48
|
+
* readonly mutate: (variables: TVariables) => void;
|
|
49
|
+
* readonly mutateAsync: (variables: TVariables) => Promise<TValue>;
|
|
50
|
+
* readonly cancel: () => void;
|
|
51
|
+
* readonly reset: () => void;
|
|
52
|
+
* }} UseMutationResult
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A durable mutation always receives one idempotency identity and AbortSignal.
|
|
57
|
+
* The optional onMutate result is a reversible presentation rollback; it is
|
|
58
|
+
* never an offline queue and is removed on either acceptance or rejection.
|
|
59
|
+
*
|
|
60
|
+
* @template TValue
|
|
61
|
+
* @template [TVariables=void]
|
|
62
|
+
* @param {ReactiveMutationOptions<TValue, TVariables>} options
|
|
63
|
+
* @returns {UseMutationResult<TValue, TVariables>}
|
|
64
|
+
*/
|
|
65
|
+
export function useMutation(options) {
|
|
66
|
+
const observerRef = useRef(/** @type {ReactiveMutationObserver<TValue, TVariables> | undefined} */ (undefined));
|
|
67
|
+
if (observerRef.current === undefined) observerRef.current = new ReactiveMutationObserver(options);
|
|
68
|
+
const observer = observerRef.current;
|
|
69
|
+
observer.updateOptions(options);
|
|
70
|
+
const state = useSyncExternalStore(observer.subscribe, observer.getSnapshot, observer.getSnapshot);
|
|
71
|
+
const effectGeneration = useRef(0);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const generation = ++effectGeneration.current;
|
|
74
|
+
return () => {
|
|
75
|
+
observer.cancel();
|
|
76
|
+
// React may immediately mount this effect again in Strict Mode. Cancel
|
|
77
|
+
// owned work now, then dispose only if the effect stays unmounted.
|
|
78
|
+
queueMicrotask(() => {
|
|
79
|
+
if (effectGeneration.current === generation) observer.dispose();
|
|
80
|
+
});
|
|
81
|
+
};
|
|
82
|
+
}, [observer]);
|
|
83
|
+
const mutate = useCallback(/** @param {TVariables} variables */ variables => observer.mutate(variables), [observer]);
|
|
84
|
+
const mutateAsync = useCallback(/** @param {TVariables} variables */ variables => observer.mutateAsync(variables), [observer]);
|
|
85
|
+
const cancel = useCallback(() => observer.cancel(), [observer]);
|
|
86
|
+
const reset = useCallback(() => observer.reset(), [observer]);
|
|
87
|
+
return useMemo(() => Object.freeze({ ...state, mutate, mutateAsync, cancel, reset }), [state, mutate, mutateAsync, cancel, reset]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Explicitly disposes a mutation observer created outside a React tree.
|
|
92
|
+
*
|
|
93
|
+
* @template TValue, TVariables
|
|
94
|
+
* @param {ReactiveMutationObserver<TValue, TVariables>} observer
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
export function disposeMutation(observer) {
|
|
98
|
+
observer.dispose();
|
|
99
|
+
}
|
package/src/result.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {(
|
|
3
|
+
* | "authentication"
|
|
4
|
+
* | "authorization"
|
|
5
|
+
* | "conflict"
|
|
6
|
+
* | "domain"
|
|
7
|
+
* | "invalid-response"
|
|
8
|
+
* | "not-found"
|
|
9
|
+
* | "rate-limit"
|
|
10
|
+
* | "server"
|
|
11
|
+
* | "transport"
|
|
12
|
+
* | "unavailable"
|
|
13
|
+
* | "validation"
|
|
14
|
+
* )} TerraScaleErrorKind
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The stable category names published by the TerraScale error envelope.
|
|
19
|
+
* @typedef {(
|
|
20
|
+
* | "Validation"
|
|
21
|
+
* | "Authentication"
|
|
22
|
+
* | "Authorization"
|
|
23
|
+
* | "NotFound"
|
|
24
|
+
* | "Conflict"
|
|
25
|
+
* | "RateLimit"
|
|
26
|
+
* | "Schema"
|
|
27
|
+
* | "Query"
|
|
28
|
+
* | "Placement"
|
|
29
|
+
* | "AutoFix"
|
|
30
|
+
* | "AutoIndex"
|
|
31
|
+
* | "Sandbox"
|
|
32
|
+
* | "System"
|
|
33
|
+
* )} TerraScaleErrorCategory
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @typedef {{
|
|
38
|
+
* readonly path?: string;
|
|
39
|
+
* readonly message: string;
|
|
40
|
+
* readonly code?: string;
|
|
41
|
+
* }} TerraScaleValidationIssue
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `fix` is optional producer-provided remediation from the published error body.
|
|
46
|
+
* `retryAfter` is the retry delay from the Retry-After header, in milliseconds,
|
|
47
|
+
* when the server sent one.
|
|
48
|
+
* @typedef {{
|
|
49
|
+
* readonly kind: TerraScaleErrorKind;
|
|
50
|
+
* readonly code: string;
|
|
51
|
+
* readonly message: string;
|
|
52
|
+
* readonly fix?: string | undefined;
|
|
53
|
+
* readonly retryAfter?: number | undefined;
|
|
54
|
+
* readonly category?: TerraScaleErrorCategory | undefined;
|
|
55
|
+
* readonly detail?: string | undefined;
|
|
56
|
+
* readonly suggestedAction?: string | undefined;
|
|
57
|
+
* readonly docsUrl?: string | undefined;
|
|
58
|
+
* readonly traceId?: string | undefined;
|
|
59
|
+
* readonly status?: number | undefined;
|
|
60
|
+
* readonly target?: string | undefined;
|
|
61
|
+
* readonly requestId?: string | undefined;
|
|
62
|
+
* readonly validation?: readonly TerraScaleValidationIssue[] | undefined;
|
|
63
|
+
* readonly details?: unknown | undefined;
|
|
64
|
+
* }} TerraScaleError
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @template TValue
|
|
69
|
+
* @typedef {{
|
|
70
|
+
* readonly ok: true;
|
|
71
|
+
* readonly routeName: string;
|
|
72
|
+
* readonly status: number;
|
|
73
|
+
* readonly headers: Headers;
|
|
74
|
+
* readonly value: TValue;
|
|
75
|
+
* }} TerraScaleSuccess
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @typedef {{
|
|
80
|
+
* readonly ok: false;
|
|
81
|
+
* readonly routeName: string;
|
|
82
|
+
* readonly status?: number | undefined;
|
|
83
|
+
* readonly headers?: Headers | undefined;
|
|
84
|
+
* readonly error: TerraScaleError;
|
|
85
|
+
* readonly body?: unknown | undefined;
|
|
86
|
+
* }} TerraScaleFailure
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @template TValue
|
|
91
|
+
* @typedef {TerraScaleFailure | TerraScaleSuccess<TValue>} TerraScaleResult
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @template TValue
|
|
96
|
+
* @param {string} routeName
|
|
97
|
+
* @param {number} status
|
|
98
|
+
* @param {Headers} headers
|
|
99
|
+
* @param {TValue} value
|
|
100
|
+
* @returns {TerraScaleSuccess<TValue>}
|
|
101
|
+
*/
|
|
102
|
+
export function ok(routeName, status, headers, value) {
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
routeName,
|
|
106
|
+
status,
|
|
107
|
+
headers,
|
|
108
|
+
value,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {string} routeName
|
|
114
|
+
* @param {TerraScaleError} error
|
|
115
|
+
* @param {number} [status]
|
|
116
|
+
* @param {Headers} [headers]
|
|
117
|
+
* @param {unknown} [body]
|
|
118
|
+
* @returns {TerraScaleFailure}
|
|
119
|
+
*/
|
|
120
|
+
export function err(routeName, error, status, headers, body) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
routeName,
|
|
124
|
+
status,
|
|
125
|
+
headers,
|
|
126
|
+
error: /** @type {TerraScaleError} */ (sanitizeDiagnostic(error)),
|
|
127
|
+
...(body === undefined ? {} : { body: sanitizeDiagnostic(body) }),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const redactedDiagnostic = "[redacted]";
|
|
132
|
+
const redactedUrl = "[redacted URL]";
|
|
133
|
+
const sensitiveKeyPattern =
|
|
134
|
+
/(?:^|[._-])(api[._-]?key|access[._-]?token|refresh[._-]?token|session(?:[._-]?token)?|authorization|bearer|password|passwd|secret|credential|cookie|jwt|private[._-]?key)(?:$|[._-])/iu;
|
|
135
|
+
const sensitiveQueryKeyPattern =
|
|
136
|
+
/^(?:api[._-]?key|access[._-]?token|refresh[._-]?token|session(?:[._-]?token)?|token|authorization|bearer|password|passwd|secret|credential|cookie|query|sql|statement|parameters?)$/iu;
|
|
137
|
+
const bearerPattern = /\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+/giu;
|
|
138
|
+
const keyValuePattern =
|
|
139
|
+
/((?:api[._-]?key|access[._-]?token|refresh[._-]?token|session(?:[._-]?token)?|authorization|bearer|password|passwd|secret|credential|cookie|query|sql|statement|parameters?)\s*[:=]\s*)(["']?)([^\s,;)}\]"']+)/giu;
|
|
140
|
+
const urlPattern = /https?:\/\/[^\s<>"']+/giu;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Makes values safe to retain in a public result. Error bodies and transport
|
|
144
|
+
* failures are producer-controlled and may contain credentials even when the
|
|
145
|
+
* published error contract does not. Keep this deliberately conservative:
|
|
146
|
+
* sensitive fields are replaced and secret-bearing URLs are never preserved.
|
|
147
|
+
* @param {unknown} value
|
|
148
|
+
* @returns {unknown}
|
|
149
|
+
*/
|
|
150
|
+
export function sanitizeDiagnostic(value) {
|
|
151
|
+
return sanitizeDiagnosticValue(value, "", new WeakSet(), 0);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {unknown} value
|
|
156
|
+
* @param {string} key
|
|
157
|
+
* @param {WeakSet<object>} seen
|
|
158
|
+
* @param {number} depth
|
|
159
|
+
* @returns {unknown}
|
|
160
|
+
*/
|
|
161
|
+
function sanitizeDiagnosticValue(value, key, seen, depth) {
|
|
162
|
+
if (isSensitiveKey(key)) return redactedDiagnostic;
|
|
163
|
+
if (typeof value === "string") return sanitizeDiagnosticText(value);
|
|
164
|
+
if (value === null || typeof value !== "object") return value;
|
|
165
|
+
if (depth >= 12) return redactedDiagnostic;
|
|
166
|
+
if (seen.has(value)) return redactedDiagnostic;
|
|
167
|
+
seen.add(value);
|
|
168
|
+
|
|
169
|
+
if (value instanceof Error) {
|
|
170
|
+
return {
|
|
171
|
+
name: sanitizeDiagnosticText(value.name),
|
|
172
|
+
message: sanitizeDiagnosticText(value.message),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (value instanceof URL) return sanitizeUrl(value);
|
|
176
|
+
if (value instanceof Headers) {
|
|
177
|
+
/** @type {Record<string, unknown>} */
|
|
178
|
+
const headers = {};
|
|
179
|
+
value.forEach((entry, name) => {
|
|
180
|
+
headers[name] = sanitizeDiagnosticValue(entry, name, seen, depth + 1);
|
|
181
|
+
});
|
|
182
|
+
return headers;
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
return value.map((entry) =>
|
|
186
|
+
sanitizeDiagnosticValue(entry, "", seen, depth + 1),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @type {Record<string, unknown>} */
|
|
191
|
+
const output = {};
|
|
192
|
+
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
193
|
+
try {
|
|
194
|
+
output[entryKey] = sanitizeDiagnosticValue(
|
|
195
|
+
entryValue,
|
|
196
|
+
entryKey,
|
|
197
|
+
seen,
|
|
198
|
+
depth + 1,
|
|
199
|
+
);
|
|
200
|
+
} catch {
|
|
201
|
+
output[entryKey] = redactedDiagnostic;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return output;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @param {string} key
|
|
209
|
+
* @returns {boolean}
|
|
210
|
+
*/
|
|
211
|
+
function isSensitiveKey(key) {
|
|
212
|
+
return sensitiveKeyPattern.test(key) || sensitiveQueryKeyPattern.test(key);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {string} value
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
function sanitizeDiagnosticText(value) {
|
|
220
|
+
const withUrls = value.replace(urlPattern, (candidate) => {
|
|
221
|
+
try {
|
|
222
|
+
const url = new URL(candidate.replace(/[).,;]+$/u, ""));
|
|
223
|
+
return hasSensitiveUrlPart(url) ? redactedUrl : candidate;
|
|
224
|
+
} catch {
|
|
225
|
+
return candidate;
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
return withUrls
|
|
229
|
+
.replace(bearerPattern, "$1 [redacted]")
|
|
230
|
+
.replace(keyValuePattern, "$1$2[redacted]");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {URL} url
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
function sanitizeUrl(url) {
|
|
238
|
+
return hasSensitiveUrlPart(url) ? redactedUrl : url.toString();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {URL} url
|
|
243
|
+
* @returns {boolean}
|
|
244
|
+
*/
|
|
245
|
+
function hasSensitiveUrlPart(url) {
|
|
246
|
+
if (url.username !== "" || url.password !== "") return true;
|
|
247
|
+
for (const key of url.searchParams.keys()) {
|
|
248
|
+
if (sensitiveQueryKeyPattern.test(key)) return true;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { decodeDatabaseValue, encodeDatabaseValue } from "./database-codec.js";
|
|
2
|
+
/** @import { DatabaseField, DatabaseValue } from "./database-types.js" */
|
|
3
|
+
|
|
4
|
+
/** Pure local declarations. These are not server catalog or migration requests. */
|
|
5
|
+
export const FieldTypes = Object.freeze(
|
|
6
|
+
/** @type {const} */ ({
|
|
7
|
+
bool: "bool",
|
|
8
|
+
i64: "i64",
|
|
9
|
+
utf8: "utf8",
|
|
10
|
+
bytes: "bytes",
|
|
11
|
+
map: "map",
|
|
12
|
+
array: "array",
|
|
13
|
+
}),
|
|
14
|
+
);
|
|
15
|
+
/** @typedef {(typeof FieldTypes)[keyof typeof FieldTypes]} FieldType */
|
|
16
|
+
/** @typedef {{ readonly required?: boolean }} FieldOptions */
|
|
17
|
+
/**
|
|
18
|
+
* `required` refers to field presence; explicit native null remains distinct.
|
|
19
|
+
*
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* readonly id: number;
|
|
22
|
+
* readonly name: string;
|
|
23
|
+
* readonly type: FieldType;
|
|
24
|
+
* readonly required: boolean;
|
|
25
|
+
* }} FieldDef
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* readonly name: string;
|
|
30
|
+
* readonly fields: readonly FieldDef[];
|
|
31
|
+
* }} CollectionSchema
|
|
32
|
+
*/
|
|
33
|
+
/** @typedef {{ readonly collections: readonly CollectionSchema[] }} SchemaJson */
|
|
34
|
+
/** @typedef {{ readonly fields?: readonly FieldDef[] }} CollectionOptions */
|
|
35
|
+
/** @type {Set<string>} */
|
|
36
|
+
const types = new Set(Object.values(FieldTypes));
|
|
37
|
+
const identifier = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
38
|
+
|
|
39
|
+
export class SchemaValidationError extends Error {
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} message
|
|
42
|
+
*/
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = "SchemaValidationError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Field IDs must come from the application's reviewed collection binding.
|
|
51
|
+
*
|
|
52
|
+
* @param {number} id
|
|
53
|
+
* @param {string} name
|
|
54
|
+
* @param {FieldType} type
|
|
55
|
+
* @param {FieldOptions} [options]
|
|
56
|
+
* @returns {FieldDef}
|
|
57
|
+
*/
|
|
58
|
+
export function defineField(id, name, type, options = {}) {
|
|
59
|
+
if (!Number.isInteger(id) || id < 1 || id > 0xffffffff)
|
|
60
|
+
throw new SchemaValidationError("Field IDs must be positive u32 values.");
|
|
61
|
+
assertName(name, "field");
|
|
62
|
+
if (!types.has(type))
|
|
63
|
+
throw new SchemaValidationError(
|
|
64
|
+
`Unsupported native field type ${String(type)}.`,
|
|
65
|
+
);
|
|
66
|
+
if (options.required !== undefined && typeof options.required !== "boolean")
|
|
67
|
+
throw new SchemaValidationError("required must be boolean.");
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
id,
|
|
70
|
+
name,
|
|
71
|
+
type,
|
|
72
|
+
required: options.required !== false,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Persistent fluent builder; each branch keeps its own declarations. */
|
|
77
|
+
export class CollectionBuilder {
|
|
78
|
+
/** @type {string} */
|
|
79
|
+
#name;
|
|
80
|
+
/** @type {readonly FieldDef[]} */
|
|
81
|
+
#fields;
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} name
|
|
84
|
+
* @param {CollectionOptions} [options]
|
|
85
|
+
*/
|
|
86
|
+
constructor(name, options = {}) {
|
|
87
|
+
assertName(name, "collection");
|
|
88
|
+
this.#name = name;
|
|
89
|
+
this.#fields = Object.freeze(
|
|
90
|
+
(options.fields ?? []).map((value) =>
|
|
91
|
+
defineField(value.id, value.name, value.type, value),
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* @param {number} id
|
|
97
|
+
* @param {string} name
|
|
98
|
+
* @param {FieldType} type
|
|
99
|
+
* @param {FieldOptions} [options]
|
|
100
|
+
* @returns {CollectionBuilder}
|
|
101
|
+
*/
|
|
102
|
+
field(id, name, type, options = {}) {
|
|
103
|
+
return new CollectionBuilder(this.#name, {
|
|
104
|
+
fields: [...this.#fields, defineField(id, name, type, options)],
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* @returns {CollectionSchema}
|
|
109
|
+
*/
|
|
110
|
+
build() {
|
|
111
|
+
return normalizeCollection({ name: this.#name, fields: this.#fields });
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* @returns {string}
|
|
115
|
+
*/
|
|
116
|
+
toJSON() {
|
|
117
|
+
return serializeSchema({ collections: [this.build()] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} name
|
|
122
|
+
* @param {CollectionOptions} [options]
|
|
123
|
+
* @returns {CollectionBuilder}
|
|
124
|
+
*/
|
|
125
|
+
export function collection(name, options = {}) {
|
|
126
|
+
return new CollectionBuilder(name, options);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class SchemaBuilder {
|
|
130
|
+
/** @type {readonly CollectionSchema[]} */
|
|
131
|
+
#entries;
|
|
132
|
+
/**
|
|
133
|
+
* @param {readonly (CollectionSchema | CollectionBuilder)[]} [entries]
|
|
134
|
+
*/
|
|
135
|
+
constructor(entries = []) {
|
|
136
|
+
this.#entries = Object.freeze(
|
|
137
|
+
entries.map((entry) =>
|
|
138
|
+
entry instanceof CollectionBuilder
|
|
139
|
+
? entry.build()
|
|
140
|
+
: normalizeCollection(entry),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* @param {CollectionSchema | CollectionBuilder} entry
|
|
146
|
+
* @returns {SchemaBuilder}
|
|
147
|
+
*/
|
|
148
|
+
addCollection(entry) {
|
|
149
|
+
return new SchemaBuilder([...this.#entries, entry]);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* @param {string} name
|
|
153
|
+
* @param {CollectionOptions} [options]
|
|
154
|
+
* @returns {SchemaBuilder}
|
|
155
|
+
*/
|
|
156
|
+
collection(name, options = {}) {
|
|
157
|
+
return this.addCollection(collection(name, options));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* @returns {SchemaJson}
|
|
161
|
+
*/
|
|
162
|
+
build() {
|
|
163
|
+
return normalizeSchema({ collections: this.#entries });
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* @returns {void}
|
|
167
|
+
*/
|
|
168
|
+
validate() {
|
|
169
|
+
validateSchema(this.build());
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
toJSON() {
|
|
175
|
+
return serializeSchema(this.build());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* @param {readonly (CollectionSchema | CollectionBuilder)[]} entries
|
|
180
|
+
* @returns {SchemaBuilder}
|
|
181
|
+
*/
|
|
182
|
+
export function defineSchema(entries) {
|
|
183
|
+
return new SchemaBuilder(entries);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* @param {SchemaJson | SchemaBuilder} schema
|
|
187
|
+
* @returns {string}
|
|
188
|
+
*/
|
|
189
|
+
export function serializeSchema(schema) {
|
|
190
|
+
return JSON.stringify(
|
|
191
|
+
schema instanceof SchemaBuilder ? schema.build() : normalizeSchema(schema),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* @param {SchemaJson} schema
|
|
196
|
+
* @returns {SchemaJson}
|
|
197
|
+
*/
|
|
198
|
+
export function normalizeSchema(schema) {
|
|
199
|
+
if (
|
|
200
|
+
schema === null ||
|
|
201
|
+
typeof schema !== "object" ||
|
|
202
|
+
!Array.isArray(schema.collections) ||
|
|
203
|
+
Object.keys(schema).some((key) => key !== "collections")
|
|
204
|
+
) {
|
|
205
|
+
throw new SchemaValidationError(
|
|
206
|
+
"Local schema requires only a collections array.",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const entries = schema.collections
|
|
210
|
+
.map(normalizeCollection)
|
|
211
|
+
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
212
|
+
if (new Set(entries.map((entry) => entry.name)).size !== entries.length)
|
|
213
|
+
throw new SchemaValidationError("Duplicate collection name.");
|
|
214
|
+
return Object.freeze({ collections: Object.freeze(entries) });
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* @param {SchemaJson} schema
|
|
218
|
+
* @returns {void}
|
|
219
|
+
*/
|
|
220
|
+
export function validateSchema(schema) {
|
|
221
|
+
normalizeSchema(schema);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* @param {CollectionSchema} schema
|
|
225
|
+
* @returns {void}
|
|
226
|
+
*/
|
|
227
|
+
export function validateCollection(schema) {
|
|
228
|
+
normalizeCollection(schema);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @param {CollectionSchema} schema
|
|
233
|
+
* @returns {CollectionSchema}
|
|
234
|
+
*/
|
|
235
|
+
function normalizeCollection(schema) {
|
|
236
|
+
if (
|
|
237
|
+
schema === null ||
|
|
238
|
+
typeof schema !== "object" ||
|
|
239
|
+
!Array.isArray(schema.fields) ||
|
|
240
|
+
Object.keys(schema).some((key) => key !== "name" && key !== "fields")
|
|
241
|
+
) {
|
|
242
|
+
throw new SchemaValidationError(
|
|
243
|
+
"Local collection requires only name and fields.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
assertName(schema.name, "collection");
|
|
247
|
+
if (schema.fields.length > 1024)
|
|
248
|
+
throw new SchemaValidationError(
|
|
249
|
+
"A declaration permits at most 1024 fields.",
|
|
250
|
+
);
|
|
251
|
+
const fields = schema.fields
|
|
252
|
+
.map((value) => {
|
|
253
|
+
if (
|
|
254
|
+
Object.keys(value).some(
|
|
255
|
+
(key) => !["id", "name", "type", "required"].includes(key),
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
throw new SchemaValidationError(
|
|
259
|
+
"Unknown local field declaration property.",
|
|
260
|
+
);
|
|
261
|
+
return defineField(value.id, value.name, value.type, value);
|
|
262
|
+
})
|
|
263
|
+
.sort((a, b) => a.id - b.id);
|
|
264
|
+
if (
|
|
265
|
+
new Set(fields.map((value) => value.id)).size !== fields.length ||
|
|
266
|
+
new Set(fields.map((value) => value.name)).size !== fields.length
|
|
267
|
+
) {
|
|
268
|
+
throw new SchemaValidationError("Field names and IDs must be unique.");
|
|
269
|
+
}
|
|
270
|
+
return Object.freeze({ name: schema.name, fields: Object.freeze(fields) });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Validate native values using the accepted codec; never coerce integers or bytes.
|
|
275
|
+
*
|
|
276
|
+
* @param {CollectionSchema} schema
|
|
277
|
+
* @param {Readonly<Record<string, DatabaseValue>>} record
|
|
278
|
+
* @returns {readonly DatabaseField[]}
|
|
279
|
+
*/
|
|
280
|
+
export function encodeSchemaFields(schema, record) {
|
|
281
|
+
const declaration = normalizeCollection(schema);
|
|
282
|
+
const declared = new Set(declaration.fields.map((field) => field.name));
|
|
283
|
+
for (const key of Object.keys(record))
|
|
284
|
+
if (!declared.has(key))
|
|
285
|
+
throw new SchemaValidationError(`Undeclared field ${key}.`);
|
|
286
|
+
/** @type {DatabaseField[]} */
|
|
287
|
+
const fields = [];
|
|
288
|
+
for (const field of declaration.fields) {
|
|
289
|
+
if (!Object.hasOwn(record, field.name)) {
|
|
290
|
+
if (field.required)
|
|
291
|
+
throw new SchemaValidationError(`Missing field ${field.name}.`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const value = /** @type {DatabaseValue} */ (record[field.name]);
|
|
295
|
+
if (value.type !== "null" && value.type !== field.type)
|
|
296
|
+
throw new SchemaValidationError(
|
|
297
|
+
`Field ${field.name} requires ${field.type}.`,
|
|
298
|
+
);
|
|
299
|
+
fields.push({ id: field.id, value });
|
|
300
|
+
}
|
|
301
|
+
let nodes = 0;
|
|
302
|
+
/**
|
|
303
|
+
* @param {DatabaseValue} value
|
|
304
|
+
* @param {number} depth
|
|
305
|
+
* @returns {number}
|
|
306
|
+
*/
|
|
307
|
+
const size = (value, depth) => {
|
|
308
|
+
if (depth > 16 || ++nodes > 4096)
|
|
309
|
+
throw new SchemaValidationError("Document exceeds depth/node bounds.");
|
|
310
|
+
switch (value.type) {
|
|
311
|
+
case "null":
|
|
312
|
+
return 0;
|
|
313
|
+
case "bool":
|
|
314
|
+
return 1;
|
|
315
|
+
case "i64":
|
|
316
|
+
return 8;
|
|
317
|
+
case "utf8":
|
|
318
|
+
return new TextEncoder().encode(value.value).byteLength;
|
|
319
|
+
case "bytes":
|
|
320
|
+
return value.value.byteLength;
|
|
321
|
+
case "map":
|
|
322
|
+
return (
|
|
323
|
+
2 +
|
|
324
|
+
value.fields.reduce(
|
|
325
|
+
(total, field) => total + 9 + size(field.value, depth + 1),
|
|
326
|
+
0,
|
|
327
|
+
)
|
|
328
|
+
);
|
|
329
|
+
case "array":
|
|
330
|
+
return (
|
|
331
|
+
2 +
|
|
332
|
+
value.items.reduce(
|
|
333
|
+
(total, item) => total + 5 + size(item, depth + 1),
|
|
334
|
+
0,
|
|
335
|
+
)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
let documentSize = 98 + 13 * fields.length;
|
|
340
|
+
const result = fields.map((field) => {
|
|
341
|
+
const value = decodeDatabaseValue(encodeDatabaseValue(field.value));
|
|
342
|
+
documentSize += size(value, 1);
|
|
343
|
+
return { id: field.id, value };
|
|
344
|
+
});
|
|
345
|
+
if (documentSize > 1_048_576)
|
|
346
|
+
throw new SchemaValidationError(
|
|
347
|
+
"Document exceeds its 1 MiB encoded bound.",
|
|
348
|
+
);
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @param {string} value
|
|
354
|
+
* @param {string} kind
|
|
355
|
+
* @returns {void}
|
|
356
|
+
*/
|
|
357
|
+
function assertName(value, kind) {
|
|
358
|
+
if (
|
|
359
|
+
typeof value !== "string" ||
|
|
360
|
+
!identifier.test(value) ||
|
|
361
|
+
value.length > 255
|
|
362
|
+
)
|
|
363
|
+
throw new SchemaValidationError(
|
|
364
|
+
`${kind} name must be a bounded identifier.`,
|
|
365
|
+
);
|
|
366
|
+
}
|