@auersperg/flare 0.0.0-stage → 0.3.1

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,265 @@
1
+ import { FlareError, boundedJson, stableJson } from "./protocol.js";
2
+ import {
3
+ documentValue,
4
+ fieldSql,
5
+ quote
6
+ } from "./schema.js";
7
+ const scalar = (v) => v === null || typeof v === "string" || typeof v === "boolean" || typeof v === "number" && Number.isFinite(v);
8
+ const bind = (v) => typeof v === "boolean" ? Number(v) : v;
9
+ const checkedId = (id) => {
10
+ if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))
11
+ throw new FlareError("INVALID_INPUT", "Invalid document ID");
12
+ };
13
+ const size = (limit) => {
14
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
15
+ throw new FlareError("INVALID_INPUT", "Page size must be 1..100");
16
+ };
17
+ export function database(sql, schema, writable, touched = new Set, changed = new Set) {
18
+ let active = true;
19
+ const pending = new Set;
20
+ let operations = 0;
21
+ const check = () => {
22
+ if (!active)
23
+ throw new FlareError("CAPABILITY_EXPIRED", "Database invocation ended");
24
+ };
25
+ const writing = () => {
26
+ check();
27
+ if (!writable)
28
+ throw new FlareError("READ_ONLY", "Queries cannot write");
29
+ };
30
+ function table(name) {
31
+ check();
32
+ if (!Object.hasOwn(schema, name))
33
+ throw new FlareError("INVALID_TABLE", "Unknown table");
34
+ touched.add(name);
35
+ return schema[name];
36
+ }
37
+ const task = (fn, count = true) => {
38
+ if (count && ++operations > 1000) {
39
+ const rejected = Promise.reject(new FlareError("LIMIT_EXCEEDED", "Maximum 1000 database operations per invocation"));
40
+ rejected.catch(() => {
41
+ return;
42
+ });
43
+ pending.add(rejected);
44
+ return rejected;
45
+ }
46
+ const promise = fn();
47
+ pending.add(promise);
48
+ promise.then(() => pending.delete(promise), () => {
49
+ return;
50
+ });
51
+ return promise;
52
+ };
53
+ const decode = (row) => ({
54
+ ...JSON.parse(String(row._data)),
55
+ _id: String(row._id),
56
+ _creationTime: Number(row._creationTime)
57
+ });
58
+ const get = (name, id) => task(async () => {
59
+ table(name);
60
+ checkedId(id);
61
+ const row = sql.exec(`SELECT * FROM ${quote(name)} WHERE _id=?`, id).toArray()[0];
62
+ return row ? decode(row) : null;
63
+ });
64
+ function query(name) {
65
+ const definition = table(name);
66
+ function make(index, filters, direction) {
67
+ const page = (options) => task(async () => {
68
+ table(name);
69
+ size(options.limit);
70
+ const fields = [
71
+ ...index ? definition.indexes[index] : [],
72
+ "_creationTime",
73
+ "_id"
74
+ ];
75
+ const expressions = fields.map(fieldSql);
76
+ const signature = stableJson({ name, index, filters, direction });
77
+ const where = filters.map((f) => `${fieldSql(f.field)} ${f.operator === "=" ? "IS" : f.operator} ?`);
78
+ const bindings = filters.map((f) => bind(f.value));
79
+ if (options.cursor != null) {
80
+ let token;
81
+ try {
82
+ if (options.cursor.length > 8192)
83
+ throw Error();
84
+ token = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(options.cursor), (c) => c.charCodeAt(0))));
85
+ } catch {
86
+ throw new FlareError("INVALID_CURSOR", "Invalid cursor");
87
+ }
88
+ if (token.signature !== signature || !Array.isArray(token.values) || token.values.length !== fields.length || !token.values.every(scalar))
89
+ throw new FlareError("INVALID_CURSOR", "Cursor belongs to a different query");
90
+ const terms = expressions.map((expr, i) => {
91
+ const prefix = expressions.slice(0, i).map((e) => `${e} IS ?`);
92
+ bindings.push(...token.values.slice(0, i).map(bind));
93
+ const value = token.values[i];
94
+ if (value === null)
95
+ prefix.push(direction === "asc" ? `${expr} IS NOT NULL` : "0");
96
+ else {
97
+ prefix.push(direction === "asc" ? `${expr} > ?` : `(${expr} < ? OR ${expr} IS NULL)`);
98
+ bindings.push(bind(value));
99
+ }
100
+ return "(" + prefix.join(" AND ") + ")";
101
+ });
102
+ where.push("(" + terms.join(" OR ") + ")");
103
+ }
104
+ const rows = sql.exec(`SELECT *,${expressions.map((e, i) => `${e} AS __k${i}`).join(",")} FROM ${quote(name)} ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY ${expressions.map((e) => `${e} ${direction}`).join(",")} LIMIT ?`, ...bindings, options.limit + 1).toArray();
105
+ let more = rows.length > options.limit;
106
+ if (more)
107
+ rows.pop();
108
+ const docs = [];
109
+ let bytes = 2;
110
+ for (const row of rows) {
111
+ const doc = decode(row), length = new TextEncoder().encode(JSON.stringify(doc)).length + (docs.length ? 1 : 0);
112
+ if (bytes + length > 262144) {
113
+ more = true;
114
+ break;
115
+ }
116
+ docs.push(doc);
117
+ bytes += length;
118
+ }
119
+ const last = rows[docs.length - 1];
120
+ const continueCursor = more && last ? btoa(String.fromCharCode(...new TextEncoder().encode(boundedJson({
121
+ signature,
122
+ values: expressions.map((_, i) => last[`__k${i}`])
123
+ }, 6144)))) : null;
124
+ boundedJson(docs, 262144);
125
+ return { page: docs, continueCursor, isDone: !more };
126
+ });
127
+ return Object.freeze({
128
+ withIndex(next, range) {
129
+ check();
130
+ if (index || !Object.hasOwn(definition.indexes, next))
131
+ throw new FlareError("INVALID_INDEX", "Unknown or duplicate index");
132
+ const selected = [], fields = definition.indexes[next];
133
+ let pos = 0, open = true;
134
+ const bounds = new Set;
135
+ const add = (operator) => (field, value) => {
136
+ if (!open)
137
+ throw new FlareError("CAPABILITY_EXPIRED", "Index range declaration ended");
138
+ if (!scalar(value) || operator !== "=" && value === null || !fields.includes(field) || field !== fields[pos])
139
+ throw new FlareError("INVALID_INPUT", "Index range must follow its field order");
140
+ const bound = operator === "=" ? "equal" : operator.startsWith(">") ? "lower" : "upper";
141
+ if (bound === "equal" && bounds.size || bounds.has(bound))
142
+ throw new FlareError("INVALID_INPUT", "Duplicate or mixed index bounds");
143
+ selected.push({ field, operator, value });
144
+ if (operator === "=") {
145
+ pos++;
146
+ bounds.clear();
147
+ } else
148
+ bounds.add(bound);
149
+ if (selected.length > fields.length + 2)
150
+ throw new FlareError("INVALID_INPUT", "Index range exceeds limits");
151
+ return q;
152
+ };
153
+ const q = Object.freeze({
154
+ eq: add("="),
155
+ gt: add(">"),
156
+ gte: add(">="),
157
+ lt: add("<"),
158
+ lte: add("<=")
159
+ });
160
+ try {
161
+ if (range && range(q) !== q)
162
+ throw new FlareError("INVALID_INPUT", "Invalid index range");
163
+ } finally {
164
+ open = false;
165
+ }
166
+ return make(next, Object.freeze([...selected]), direction);
167
+ },
168
+ order(next) {
169
+ check();
170
+ if (!["asc", "desc"].includes(next))
171
+ throw new FlareError("INVALID_INPUT", "Invalid order");
172
+ return make(index, filters, next);
173
+ },
174
+ paginate: page,
175
+ take(limit) {
176
+ return task(async () => {
177
+ const result = await page({ limit });
178
+ if (!result.isDone && result.page.length < limit)
179
+ throw new FlareError("TOO_LARGE", "Use paginate for large documents");
180
+ return result.page;
181
+ }, false);
182
+ },
183
+ first() {
184
+ return task(async () => (await page({ limit: 1 })).page[0] ?? null, false);
185
+ }
186
+ });
187
+ }
188
+ return make(null, [], "asc");
189
+ }
190
+ const read = { get, query };
191
+ const write = {
192
+ insert(name, input) {
193
+ return task(async () => {
194
+ writing();
195
+ const definition = table(name), value = await documentValue(definition, input);
196
+ writing();
197
+ const id = crypto.randomUUID();
198
+ sql.exec(`INSERT INTO ${quote(name)} VALUES (?,?,?)`, id, Date.now(), stableJson(value));
199
+ changed.add(name);
200
+ return id;
201
+ });
202
+ },
203
+ patch(name, id, patch) {
204
+ return task(async () => {
205
+ writing();
206
+ const definition = table(name);
207
+ checkedId(id);
208
+ boundedJson(patch, 65536);
209
+ if (!patch || Array.isArray(patch) || typeof patch !== "object")
210
+ throw new FlareError("INVALID_INPUT", "Invalid patch");
211
+ const row = sql.exec(`SELECT _data FROM ${quote(name)} WHERE _id=?`, id).toArray()[0];
212
+ if (!row)
213
+ throw new FlareError("NOT_FOUND", "Document not found");
214
+ const value = await documentValue(definition, {
215
+ ...JSON.parse(String(row._data)),
216
+ ...patch
217
+ });
218
+ writing();
219
+ sql.exec(`UPDATE ${quote(name)} SET _data=? WHERE _id=?`, stableJson(value), id);
220
+ changed.add(name);
221
+ });
222
+ },
223
+ replace(name, id, input) {
224
+ return task(async () => {
225
+ writing();
226
+ const definition = table(name);
227
+ checkedId(id);
228
+ const value = await documentValue(definition, input);
229
+ writing();
230
+ const rows = sql.exec(`UPDATE ${quote(name)} SET _data=? WHERE _id=? RETURNING _id`, stableJson(value), id).toArray();
231
+ if (!rows.length)
232
+ throw new FlareError("NOT_FOUND", "Document not found");
233
+ changed.add(name);
234
+ });
235
+ },
236
+ delete(name, id) {
237
+ return task(async () => {
238
+ writing();
239
+ table(name);
240
+ checkedId(id);
241
+ if (!sql.exec(`DELETE FROM ${quote(name)} WHERE _id=? RETURNING _id`, id).toArray().length)
242
+ throw new FlareError("NOT_FOUND", "Document not found");
243
+ changed.add(name);
244
+ });
245
+ }
246
+ };
247
+ return {
248
+ db: Object.freeze(writable ? { ...read, ...write } : read),
249
+ async finish() {
250
+ try {
251
+ while (pending.size) {
252
+ const settled = await Promise.allSettled([...pending]);
253
+ const failed = settled.find((result) => result.status === "rejected");
254
+ if (failed?.status === "rejected")
255
+ throw failed.reason;
256
+ }
257
+ } finally {
258
+ active = false;
259
+ }
260
+ },
261
+ revoke() {
262
+ active = false;
263
+ }
264
+ };
265
+ }
@@ -0,0 +1,47 @@
1
+ export type Json = null | boolean | number | string | Json[] | {
2
+ [key: string]: Json;
3
+ };
4
+ export type FunctionKind = "query" | "mutation" | "action";
5
+ export type Auth = {
6
+ subject: string;
7
+ issuer: string;
8
+ expiresAt: number;
9
+ claims: Record<string, Json>;
10
+ };
11
+ export declare const LIMITS: Readonly<{
12
+ inputBytes: 65536;
13
+ outputBytes: 262144;
14
+ frameBytes: 8192;
15
+ subscriptions: 64;
16
+ fileBytes: 10485760;
17
+ }>;
18
+ export interface FunctionRef<I = Json, O = Json> {
19
+ readonly path: string;
20
+ readonly kind: FunctionKind;
21
+ readonly __input?: I;
22
+ readonly __output?: O;
23
+ }
24
+ export declare class FlareError extends Error {
25
+ readonly code: string;
26
+ readonly ambiguous: boolean;
27
+ constructor(code: string, message: string, ambiguous?: boolean);
28
+ }
29
+ /** SDK entrypoints and retained pre-rename releases are separately bundled.
30
+ * Keep their bounded error-code wire contract, never their private messages. */
31
+ export declare function errorCode(error: unknown): string;
32
+ export declare function assertPath(path: string): void;
33
+ export declare function reference<I, O>(path: string, kind: FunctionKind): FunctionRef<I, O>;
34
+ /** Lossless JSON only: no implicit Date, undefined, NaN or prototype coercion. */
35
+ export declare function stableJson(value: unknown): string;
36
+ export declare function boundedJson(value: unknown, maxBytes: number): string;
37
+ export interface FunctionContract {
38
+ kind: FunctionKind;
39
+ auth: "required" | "public" | "internal";
40
+ input: Record<string, unknown>;
41
+ output: Record<string, unknown>;
42
+ }
43
+ export interface Manifest {
44
+ protocol: 1;
45
+ runtimeAbi: 4;
46
+ functions: Record<string, FunctionContract>;
47
+ }
@@ -0,0 +1,76 @@
1
+ export const LIMITS = Object.freeze({
2
+ inputBytes: 65536,
3
+ outputBytes: 262144,
4
+ frameBytes: 8192,
5
+ subscriptions: 64,
6
+ fileBytes: 10485760
7
+ });
8
+
9
+ export class FlareError extends Error {
10
+ code;
11
+ ambiguous;
12
+ constructor(code, message, ambiguous = false) {
13
+ super(message);
14
+ this.code = code;
15
+ this.ambiguous = ambiguous;
16
+ this.name = "FlareError";
17
+ }
18
+ }
19
+ export function errorCode(error) {
20
+ if (error instanceof Error && ["FlareError", "FlaredError"].includes(error.name)) {
21
+ const code = error.code;
22
+ if (typeof code === "string" && /^[A-Z][A-Z0-9_]{0,63}$/.test(code))
23
+ return code;
24
+ }
25
+ return "OPERATION_FAILED";
26
+ }
27
+ export function assertPath(path) {
28
+ if (path.length > 256 || !/^[A-Za-z][A-Za-z0-9_]*(\/[A-Za-z][A-Za-z0-9_]*)*$/.test(path) || path.split("/").some((p) => ["__proto__", "prototype", "constructor"].includes(p))) {
29
+ throw new FlareError("INVALID_PATH", "Invalid function path");
30
+ }
31
+ }
32
+ export function reference(path, kind) {
33
+ assertPath(path);
34
+ return Object.freeze({ path, kind });
35
+ }
36
+ export function stableJson(value) {
37
+ const ancestors = new Set;
38
+ function visit(v, depth) {
39
+ if (depth > 40)
40
+ throw new FlareError("INVALID_JSON", "JSON nesting limit exceeded");
41
+ if (v === null || typeof v === "string" || typeof v === "boolean")
42
+ return v;
43
+ if (typeof v === "number" && Number.isFinite(v))
44
+ return v;
45
+ if (typeof v !== "object" || v === null || ancestors.has(v))
46
+ throw new FlareError("INVALID_JSON", "Expected lossless JSON");
47
+ if (!Array.isArray(v) && Object.getPrototypeOf(v) !== Object.prototype && Object.getPrototypeOf(v) !== null) {
48
+ throw new FlareError("INVALID_JSON", "Expected plain JSON object");
49
+ }
50
+ if (Object.getOwnPropertySymbols(v).length)
51
+ throw new FlareError("INVALID_JSON", "Symbol keys are unsupported");
52
+ ancestors.add(v);
53
+ let result;
54
+ if (Array.isArray(v)) {
55
+ result = Array.from(v, (item) => visit(item, depth + 1));
56
+ } else {
57
+ const out = Object.create(null);
58
+ for (const key of Object.keys(v).sort()) {
59
+ const descriptor = Object.getOwnPropertyDescriptor(v, key);
60
+ if (!("value" in descriptor))
61
+ throw new FlareError("INVALID_JSON", "Getters are unsupported");
62
+ out[key] = visit(descriptor.value, depth + 1);
63
+ }
64
+ result = out;
65
+ }
66
+ ancestors.delete(v);
67
+ return result;
68
+ }
69
+ return JSON.stringify(visit(value, 0));
70
+ }
71
+ export function boundedJson(value, maxBytes) {
72
+ const json = stableJson(value);
73
+ if (new TextEncoder().encode(json).byteLength > maxBytes)
74
+ throw new FlareError("TOO_LARGE", "JSON payload exceeds limit");
75
+ return json;
76
+ }
@@ -0,0 +1,18 @@
1
+ import { type ReactNode } from "react";
2
+ import type { FlareClient } from "./client.js";
3
+ import { FlareError, type FunctionRef } from "./protocol.js";
4
+ /** The application owns the client; the provider never creates or disposes it. */
5
+ export declare function FlareProvider({ client, children, }: {
6
+ client: FlareClient;
7
+ children: ReactNode;
8
+ }): import("react").FunctionComponentElement<import("react").ProviderProps<{
9
+ query: <I, O>(ref: FunctionRef<I, O>, args: I) => Promise<O>;
10
+ mutation: <I, O>(ref: FunctionRef<I, O>, args: I) => Promise<O>;
11
+ action: <I, O>(ref: FunctionRef<I, O>, args: I) => Promise<O>;
12
+ onUpdate<I, O>(ref: FunctionRef<I, O>, args: I, next: (value: O) => void, error?: (error: FlareError) => void): () => void;
13
+ refreshAuth(): void;
14
+ dispose(): void;
15
+ } | null>>;
16
+ export declare function useFlare(): FlareClient;
17
+ /** Initial snapshot and updates in one subscription. Undefined means loading/skipped. */
18
+ export declare function useQuery<I, O>(ref: FunctionRef<I, O>, args: I | "skip"): O | undefined;
@@ -0,0 +1,46 @@
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState
8
+ } from "react";
9
+ import { FlareError, stableJson } from "./protocol.js";
10
+ const ClientContext = createContext(null);
11
+ export function FlareProvider({
12
+ client,
13
+ children
14
+ }) {
15
+ return createElement(ClientContext.Provider, { value: client }, children);
16
+ }
17
+ export function useFlare() {
18
+ const client = useContext(ClientContext);
19
+ if (!client)
20
+ throw new FlareError("MISSING_PROVIDER", "Wrap your app in FlareProvider");
21
+ return client;
22
+ }
23
+ export function useQuery(ref, args) {
24
+ const client = useFlare();
25
+ if (ref.kind !== "query")
26
+ throw new FlareError("INVALID_KIND", "useQuery requires a query");
27
+ const key = args === "skip" ? undefined : stableJson(args);
28
+ const stableArgs = useMemo(() => key === undefined ? undefined : JSON.parse(key), [key]);
29
+ const identity = useMemo(() => ({ client, path: ref.path, args: key }), [client, ref.path, key]);
30
+ const [state, setState] = useState({ identity });
31
+ useEffect(() => {
32
+ setState({ identity });
33
+ if (key === undefined)
34
+ return;
35
+ return client.onUpdate(ref, stableArgs, (value) => setState({ identity, value }), (error) => {
36
+ if (["LIVE_DISCONNECTED", "LIVE_UNAVAILABLE"].includes(error.code))
37
+ return;
38
+ setState({ identity, error });
39
+ });
40
+ }, [client, ref.path, stableArgs, key, identity]);
41
+ if (key === undefined || state.identity !== identity)
42
+ return;
43
+ if (state.error)
44
+ throw state.error;
45
+ return state.value;
46
+ }
@@ -0,0 +1,43 @@
1
+ import type { StandardSchemaV1, StandardJSONSchemaV1 } from "@standard-schema/spec";
2
+ import { type Json } from "./protocol.js";
3
+ export type SqlValue = ArrayBuffer | string | number | null;
4
+ /** The small structural portion of native storage needed by the adapter; no Worker globals in public types. */
5
+ export interface NativeSql {
6
+ exec<T extends Record<string, SqlValue> = Record<string, SqlValue>>(query: string, ...bindings: SqlValue[]): {
7
+ toArray(): T[];
8
+ one(): T;
9
+ };
10
+ }
11
+ export interface NativeStorage {
12
+ sql: NativeSql;
13
+ transaction<T>(fn: () => Promise<T>): Promise<T>;
14
+ sync(): Promise<void>;
15
+ }
16
+ export type Validator<I = unknown, O = I> = StandardSchemaV1<I, O> & StandardJSONSchemaV1<I, O>;
17
+ export interface Table<V extends Validator = Validator<any, any>, I extends Record<string, readonly string[]> = Record<string, readonly string[]>> {
18
+ readonly validator: V;
19
+ readonly indexes: I;
20
+ index<const N extends string, const F extends readonly (keyof StandardSchemaV1.InferOutput<V> & string)[]>(name: N, fields: F): Table<V, I & Record<N, F>>;
21
+ }
22
+ /** Runtime schema needs only immutable validators and indexes, not builder methods. */
23
+ export type TableDefinition = Pick<Table, "validator" | "indexes">;
24
+ export type Schema = Record<string, TableDefinition>;
25
+ export type Value<T extends TableDefinition> = StandardSchemaV1.InferOutput<T["validator"]>;
26
+ export type Input<T extends TableDefinition> = StandardSchemaV1.InferInput<T["validator"]>;
27
+ export type Doc<T extends TableDefinition> = Value<T> & {
28
+ readonly _id: string;
29
+ readonly _creationTime: number;
30
+ };
31
+ export type SchemaContract = Record<string, {
32
+ document: Record<string, any>;
33
+ indexes: Record<string, readonly string[]>;
34
+ }>;
35
+ export declare function identifier(name: string): string;
36
+ export declare const quote: (name: string) => string;
37
+ export declare const fieldSql: (field: string) => string;
38
+ export declare function defineTable<V extends Validator>(validator: V): Table<V, {}>;
39
+ export declare function schemaContract(schema: Schema): SchemaContract;
40
+ export declare function assertSchemaContract(contract: SchemaContract): void;
41
+ export declare function defineSchema<const S extends Schema>(schema: S): S;
42
+ export declare function documentValue(table: TableDefinition, input: unknown): Promise<Record<string, Json>>;
43
+ export declare function applySchema(storage: NativeStorage, schema: Schema): Promise<void>;
@@ -0,0 +1,143 @@
1
+ import { FlareError, boundedJson, stableJson } from "./protocol.js";
2
+ export function identifier(name) {
3
+ if (!/^[a-zA-Z][a-zA-Z0-9_]{0,47}$/.test(name) || name.toLowerCase().startsWith("sqlite_"))
4
+ throw new FlareError("INVALID_SCHEMA", "Invalid table, index or field name");
5
+ return name;
6
+ }
7
+ export const quote = (name) => {
8
+ if (!/^[a-zA-Z][a-zA-Z0-9_]{0,127}$/.test(name))
9
+ throw new FlareError("INVALID_SCHEMA", "Invalid SQL identifier");
10
+ return `"${name}"`;
11
+ };
12
+ export const fieldSql = (field) => field === "_id" || field === "_creationTime" ? `"${field}"` : `json_extract("_data", '$.${identifier(field)}')`;
13
+ const indexName = (table, index) => `idx_${table.length}_${table}_${index}`;
14
+ const creationIndex = (table) => `creation_${table}`;
15
+ export function defineTable(validator) {
16
+ function table(indexes) {
17
+ return Object.freeze({
18
+ validator,
19
+ indexes: Object.freeze(indexes),
20
+ index(name, fields) {
21
+ identifier(name);
22
+ if (Object.hasOwn(indexes, name) || !fields.length || fields.length > 4 || new Set(fields).size !== fields.length)
23
+ throw new FlareError("INVALID_SCHEMA", "Invalid index");
24
+ fields.forEach(identifier);
25
+ return table({ ...indexes, [name]: Object.freeze([...fields]) });
26
+ }
27
+ });
28
+ }
29
+ return table({});
30
+ }
31
+ export function schemaContract(schema) {
32
+ const out = Object.create(null);
33
+ if (Object.keys(schema).length > 100)
34
+ throw new FlareError("INVALID_SCHEMA", "Maximum 100 tables");
35
+ for (const [name, table] of Object.entries(schema)) {
36
+ identifier(name);
37
+ const document = table.validator["~standard"].jsonSchema.output({
38
+ target: "draft-2020-12"
39
+ });
40
+ out[name] = { document, indexes: table.indexes };
41
+ }
42
+ assertSchemaContract(out);
43
+ return out;
44
+ }
45
+ export function assertSchemaContract(contract) {
46
+ boundedJson(contract, 1048576);
47
+ if (!contract || Array.isArray(contract) || typeof contract !== "object" || Object.keys(contract).length > 100)
48
+ throw new FlareError("INVALID_SCHEMA", "Invalid schema");
49
+ const names = new Set;
50
+ for (const [name, table] of Object.entries(contract)) {
51
+ if (names.has(name.toLowerCase()))
52
+ throw new FlareError("INVALID_SCHEMA", "SQLite table names are case-insensitive");
53
+ names.add(name.toLowerCase());
54
+ identifier(name);
55
+ const doc = table?.document;
56
+ if (!doc || doc.type !== "object" || !doc.properties || doc.additionalProperties !== false)
57
+ throw new FlareError("INVALID_SCHEMA", "Tables require a closed object validator");
58
+ for (const field of Object.keys(doc.properties))
59
+ identifier(field);
60
+ if (Object.keys(doc.properties).length > 64 || !table.indexes || Object.keys(table.indexes).length > 20)
61
+ throw new FlareError("INVALID_SCHEMA", "Schema exceeds limits");
62
+ const indexes = new Set;
63
+ for (const [index, fields] of Object.entries(table.indexes)) {
64
+ if (indexes.has(index.toLowerCase()))
65
+ throw new FlareError("INVALID_SCHEMA", "SQLite index names are case-insensitive");
66
+ indexes.add(index.toLowerCase());
67
+ identifier(index);
68
+ if (!Array.isArray(fields) || fields.length < 1 || fields.length > 4 || new Set(fields).size !== fields.length)
69
+ throw new FlareError("INVALID_SCHEMA", "Invalid index fields");
70
+ for (const field of fields) {
71
+ identifier(field);
72
+ const p = doc.properties[field];
73
+ const scalar = (s) => Array.isArray(s?.type) && s.type.every((type) => ["string", "number", "integer", "boolean", "null"].includes(type)) || ["string", "number", "integer", "boolean", "null"].includes(s?.type) || Array.isArray(s?.anyOf) && s.anyOf.every(scalar);
74
+ if (!p || !scalar(p))
75
+ throw new FlareError("INVALID_SCHEMA", "Indexes require declared scalar fields");
76
+ }
77
+ }
78
+ }
79
+ }
80
+ export function defineSchema(schema) {
81
+ schemaContract(schema);
82
+ return Object.freeze(schema);
83
+ }
84
+ const documents = new WeakMap;
85
+ function documentSchema(table) {
86
+ let doc = documents.get(table.validator);
87
+ if (!doc) {
88
+ doc = table.validator["~standard"].jsonSchema.output({
89
+ target: "draft-2020-12"
90
+ });
91
+ documents.set(table.validator, doc);
92
+ }
93
+ return doc;
94
+ }
95
+ export async function documentValue(table, input) {
96
+ boundedJson(input, 65536);
97
+ const properties = documentSchema(table).properties ?? {};
98
+ if (!input || Array.isArray(input) || typeof input !== "object" || Object.keys(input).some((k) => !Object.hasOwn(properties, k)))
99
+ throw new FlareError("INVALID_INPUT", "Unknown document field");
100
+ const result = await table.validator["~standard"].validate(input);
101
+ if (result.issues || !result.value || Array.isArray(result.value) || typeof result.value !== "object" || Object.keys(result.value).some((k) => !Object.hasOwn(properties, k)))
102
+ throw new FlareError("INVALID_INPUT", "Document failed schema validation");
103
+ return JSON.parse(boundedJson(result.value, 65536));
104
+ }
105
+ export async function applySchema(storage, schema) {
106
+ const contract = schemaContract(schema), encoded = stableJson(contract);
107
+ await storage.transaction(async () => {
108
+ storage.sql.exec("CREATE TABLE IF NOT EXISTS _flared_schema (id INTEGER PRIMARY KEY CHECK(id=1), contract TEXT NOT NULL)");
109
+ const oldRow = storage.sql.exec("SELECT contract FROM _flared_schema WHERE id=1").toArray()[0];
110
+ if (oldRow?.contract === encoded)
111
+ return;
112
+ const old = oldRow ? JSON.parse(String(oldRow.contract)) : {};
113
+ for (const name of Object.keys(old))
114
+ if (!Object.hasOwn(schema, name)) {
115
+ if (storage.sql.exec(`SELECT 1 FROM ${quote(name)} LIMIT 1`).toArray().length)
116
+ throw new FlareError("INVALID_SCHEMA", "Cannot remove a nonempty table; explicitly clear it first");
117
+ storage.sql.exec(`DROP TABLE ${quote(name)}`);
118
+ }
119
+ for (const [name, definition] of Object.entries(contract)) {
120
+ storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${quote(name)} (_id TEXT PRIMARY KEY, _creationTime REAL NOT NULL, _data TEXT NOT NULL CHECK(json_valid(_data)))`);
121
+ let cursor = "";
122
+ while (!old[name] || stableJson(old[name].document) !== stableJson(definition.document)) {
123
+ const rows = storage.sql.exec(`SELECT _id,_data FROM ${quote(name)} WHERE _id > ? ORDER BY _id LIMIT 100`, cursor).toArray();
124
+ for (const row of rows) {
125
+ const value = await documentValue(schema[name], JSON.parse(String(row._data)));
126
+ if (stableJson(value) !== stableJson(JSON.parse(String(row._data))))
127
+ storage.sql.exec(`UPDATE ${quote(name)} SET _data=? WHERE _id=?`, stableJson(value), row._id);
128
+ }
129
+ if (rows.length < 100)
130
+ break;
131
+ cursor = String(rows.at(-1)._id);
132
+ }
133
+ storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${quote(creationIndex(name))} ON ${quote(name)} (_creationTime,_id)`);
134
+ for (const [index, fields] of Object.entries(old[name]?.indexes ?? {}))
135
+ if (stableJson(definition.indexes[index] ?? null) !== stableJson(fields))
136
+ storage.sql.exec(`DROP INDEX IF EXISTS ${quote(indexName(name, index))}`);
137
+ for (const [index, fields] of Object.entries(definition.indexes))
138
+ storage.sql.exec(`CREATE INDEX IF NOT EXISTS ${quote(indexName(name, index))} ON ${quote(name)} (${[...fields.map(fieldSql), "_creationTime", "_id"].join(",")})`);
139
+ }
140
+ storage.sql.exec("INSERT INTO _flared_schema VALUES (1,?) ON CONFLICT(id) DO UPDATE SET contract=excluded.contract", encoded);
141
+ });
142
+ await storage.sync();
143
+ }