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.
Files changed (71) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +143 -0
  3. package/package.json +159 -0
  4. package/sdk-current-contract.json +27 -0
  5. package/sdk-route-manifest.json +67 -0
  6. package/src/admin.js +9 -0
  7. package/src/better-auth.js +14 -0
  8. package/src/config.js +121 -0
  9. package/src/database-codec.js +845 -0
  10. package/src/database-types.js +237 -0
  11. package/src/database-view.js +422 -0
  12. package/src/database.js +420 -0
  13. package/src/discovery.js +374 -0
  14. package/src/http.js +887 -0
  15. package/src/index.js +79 -0
  16. package/src/local/authentication.js +47 -0
  17. package/src/local/better-auth.js +517 -0
  18. package/src/local/cli.js +51 -0
  19. package/src/local/context.js +23 -0
  20. package/src/local/environment.js +109 -0
  21. package/src/local/index.js +204 -0
  22. package/src/local/router.js +999 -0
  23. package/src/local/server.js +664 -0
  24. package/src/local/store.js +530 -0
  25. package/src/local/test-environment.js +74 -0
  26. package/src/management-contracts.js +72 -0
  27. package/src/management.js +12 -0
  28. package/src/native-origin.js +75 -0
  29. package/src/postgres.js +494 -0
  30. package/src/react/core.js +743 -0
  31. package/src/react/index.js +99 -0
  32. package/src/result.js +251 -0
  33. package/src/schema.js +366 -0
  34. package/src/sql.js +996 -0
  35. package/src/svelte/index.js +129 -0
  36. package/src/tanstack/index.js +511 -0
  37. package/src/ts-auth-discovery.js +190 -0
  38. package/src/ts-auth.js +3497 -0
  39. package/types/admin.d.ts +6 -0
  40. package/types/better-auth.d.ts +8 -0
  41. package/types/config.d.ts +58 -0
  42. package/types/database-codec.d.ts +111 -0
  43. package/types/database-types.d.ts +213 -0
  44. package/types/database-view.d.ts +183 -0
  45. package/types/database.d.ts +98 -0
  46. package/types/discovery.d.ts +114 -0
  47. package/types/http.d.ts +46 -0
  48. package/types/index.d.ts +52 -0
  49. package/types/local/authentication.d.ts +11 -0
  50. package/types/local/better-auth.d.ts +33 -0
  51. package/types/local/cli.d.ts +2 -0
  52. package/types/local/context.d.ts +14 -0
  53. package/types/local/environment.d.ts +23 -0
  54. package/types/local/index.d.ts +94 -0
  55. package/types/local/router.d.ts +66 -0
  56. package/types/local/server.d.ts +54 -0
  57. package/types/local/store.d.ts +106 -0
  58. package/types/local/test-environment.d.ts +25 -0
  59. package/types/management-contracts.d.ts +44 -0
  60. package/types/management.d.ts +6 -0
  61. package/types/native-origin.d.ts +23 -0
  62. package/types/postgres.d.ts +123 -0
  63. package/types/react/core.d.ts +366 -0
  64. package/types/react/index.d.ts +54 -0
  65. package/types/result.d.ts +161 -0
  66. package/types/schema.d.ts +145 -0
  67. package/types/sql.d.ts +288 -0
  68. package/types/svelte/index.d.ts +81 -0
  69. package/types/tanstack/index.d.ts +165 -0
  70. package/types/ts-auth-discovery.d.ts +11 -0
  71. 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
+ }