@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,391 @@
1
+ import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";
2
+ import {
3
+ flatten,
4
+ validate
5
+ } from "./server.js";
6
+ import {
7
+ FlareError,
8
+ errorCode
9
+ } from "./protocol.js";
10
+ import { exportData, restoreData, emptyData } from "./backup.js";
11
+ import { database } from "./database.js";
12
+ import { applySchema, schemaContract } from "./schema.js";
13
+ import { boundedJson, LIMITS } from "./protocol.js";
14
+ function authorize(mode, auth, internal = false) {
15
+ if (mode === "internal" && !internal)
16
+ throw new FlareError("UNAUTHORIZED", "Internal function");
17
+ if (auth && (!auth.subject || !auth.issuer || !Number.isFinite(auth.expiresAt) || auth.expiresAt <= Date.now()))
18
+ throw new FlareError("AUTH_EXPIRED", "Authorization expired");
19
+ if (mode === "required" && !auth)
20
+ throw new FlareError("UNAUTHORIZED", "Authentication required");
21
+ }
22
+ export function createFacet(app) {
23
+ const functions = flatten(app.functions);
24
+ const contract = schemaContract(app.schema);
25
+ return class AppFacet extends DurableObject {
26
+ tail = Promise.resolve();
27
+ constructor(ctx, env) {
28
+ super(ctx, env);
29
+ ctx.blockConcurrencyWhile(async () => {
30
+ await applySchema(ctx.storage, app.schema);
31
+ ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS _flared_revision (id INTEGER PRIMARY KEY CHECK(id=1), value INTEGER NOT NULL)");
32
+ ctx.storage.sql.exec("INSERT OR IGNORE INTO _flared_revision VALUES (1,0)");
33
+ ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS _flared_outbox (id TEXT PRIMARY KEY, release TEXT NOT NULL, path TEXT NOT NULL, args TEXT NOT NULL, auth TEXT NOT NULL, dueAt INTEGER NOT NULL,internal INTEGER NOT NULL DEFAULT 0)");
34
+ ctx.storage.sql.exec("CREATE TABLE IF NOT EXISTS _flared_changes (name TEXT PRIMARY KEY,revision INTEGER NOT NULL)");
35
+ ctx.storage.sql.exec("CREATE INDEX IF NOT EXISTS outbox_pending ON _flared_outbox(dueAt,id)");
36
+ });
37
+ }
38
+ serial(fn) {
39
+ const result = this.tail.then(fn);
40
+ this.tail = result.catch(() => {
41
+ return;
42
+ });
43
+ return result;
44
+ }
45
+ revision() {
46
+ return Number(this.ctx.storage.sql.exec("SELECT value FROM _flared_revision WHERE id=1").one().value);
47
+ }
48
+ snapshot() {
49
+ return this.serial(async () => ({
50
+ revision: this.revision(),
51
+ changes: Object.fromEntries(this.ctx.storage.sql.exec("SELECT * FROM _flared_changes").toArray().map((row) => [String(row.name), Number(row.revision)]))
52
+ }));
53
+ }
54
+ async invoke(call) {
55
+ try {
56
+ return { ok: true, value: await this.execute(call) };
57
+ } catch (error) {
58
+ return {
59
+ ok: false,
60
+ code: errorCode(error)
61
+ };
62
+ }
63
+ }
64
+ async administrative(operation, input) {
65
+ try {
66
+ const work = {
67
+ inspect: () => this.inspect(input),
68
+ edit: () => this.edit(input),
69
+ "backup-export": () => this.serial(async () => exportData(this.ctx.storage, app.schema)),
70
+ "backup-empty": () => this.serial(async () => emptyData(this.ctx.storage, app.schema)),
71
+ "backup-restore": () => this.serial(() => restoreData(this.ctx.storage, app.schema, input.id, input.snapshot))
72
+ };
73
+ if (!Object.hasOwn(work, operation))
74
+ throw new FlareError("INVALID_OPERATION", "Unknown administrative capability");
75
+ return { ok: true, value: await work[operation]() };
76
+ } catch (error) {
77
+ return {
78
+ ok: false,
79
+ code: errorCode(error)
80
+ };
81
+ }
82
+ }
83
+ execute(call) {
84
+ return this.serial(async () => {
85
+ const fn = functions[call.path];
86
+ if (!fn || fn.kind !== call.kind || call.kind === "action")
87
+ throw new FlareError("FUNCTION_NOT_FOUND", "Data function not found");
88
+ authorize(fn.auth, call.auth, call.internal);
89
+ const args = await validate(fn.input, call.args);
90
+ if (call.kind === "query") {
91
+ return this.ctx.storage.transaction(async () => {
92
+ const tables = new Set;
93
+ const capability = database(this.ctx.storage.sql, app.schema, false, tables);
94
+ try {
95
+ const result = await validate(fn.output, await fn.handler({ db: capability.db, auth: call.auth }, args), true);
96
+ await capability.finish();
97
+ return { result, revision: this.revision(), tables: [...tables] };
98
+ } finally {
99
+ capability.revoke();
100
+ }
101
+ });
102
+ }
103
+ let result = null;
104
+ const changed = new Set;
105
+ await this.ctx.storage.transaction(async () => {
106
+ let active = true;
107
+ const scheduled = [];
108
+ const jobs = Object.freeze({
109
+ cancel: (id) => {
110
+ const pending = (async () => {
111
+ if (!active)
112
+ throw new FlareError("CAPABILITY_EXPIRED", "Mutation invocation ended");
113
+ if (scheduled.length >= 100)
114
+ throw new FlareError("LIMIT_EXCEEDED", "Maximum 100 job operations per mutation");
115
+ return this.cancelPending(id);
116
+ })();
117
+ scheduled.push(pending);
118
+ pending.catch(() => {
119
+ return;
120
+ });
121
+ return pending;
122
+ },
123
+ schedule: (path, jobArgs, options = {}) => {
124
+ const pending = (async () => {
125
+ if (!active)
126
+ throw new FlareError("CAPABILITY_EXPIRED", "Mutation invocation ended");
127
+ if (scheduled.length >= 100)
128
+ throw new FlareError("LIMIT_EXCEEDED", "Maximum 100 jobs per mutation");
129
+ const target = functions[path];
130
+ if (!target || target.kind !== "action")
131
+ throw new FlareError("FUNCTION_NOT_FOUND", "Scheduled action not found");
132
+ const delay = options.delaySeconds ?? 0;
133
+ if (!Number.isFinite(delay) || delay < 0 || delay > 31536000)
134
+ throw new FlareError("INVALID_INPUT", "Invalid job delay");
135
+ const input = await validate(target.input, jobArgs);
136
+ if (!active)
137
+ throw new FlareError("CAPABILITY_EXPIRED", "Mutation invocation ended");
138
+ const id = crypto.randomUUID();
139
+ this.ctx.storage.sql.exec("INSERT INTO _flared_outbox (id,release,path,args,auth,dueAt) VALUES (?,?,?,?,?,?)", id, this.env.RELEASE_ID, path, JSON.stringify(input), JSON.stringify(call.auth), Date.now() + Math.ceil(delay * 1000));
140
+ if (target.auth === "internal")
141
+ this.ctx.storage.sql.exec("UPDATE _flared_outbox SET internal=1,auth='null' WHERE id=?", id);
142
+ return id;
143
+ })();
144
+ scheduled.push(pending);
145
+ pending.catch(() => {
146
+ return;
147
+ });
148
+ return pending;
149
+ }
150
+ });
151
+ const capability = database(this.ctx.storage.sql, app.schema, true, new Set, changed);
152
+ try {
153
+ result = await validate(fn.output, await fn.handler({ db: capability.db, auth: call.auth, jobs }, args), true);
154
+ await capability.finish();
155
+ const settled = await Promise.allSettled(scheduled);
156
+ const failed = settled.find((result) => result.status === "rejected");
157
+ if (failed?.status === "rejected")
158
+ throw failed.reason;
159
+ } finally {
160
+ active = false;
161
+ capability.revoke();
162
+ await Promise.allSettled(scheduled);
163
+ }
164
+ this.commitRevision(changed);
165
+ });
166
+ await this.ctx.storage.sync();
167
+ return { result, revision: this.revision(), tables: [...changed] };
168
+ });
169
+ }
170
+ commitRevision(changed) {
171
+ this.ctx.storage.sql.exec("UPDATE _flared_revision SET value=value+1 WHERE id=1");
172
+ for (const name of changed)
173
+ this.ctx.storage.sql.exec("INSERT INTO _flared_changes VALUES (?,?) ON CONFLICT(name) DO UPDATE SET revision=excluded.revision", name, this.revision());
174
+ }
175
+ pendingJobs(limit = 100) {
176
+ return this.serial(async () => {
177
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100)
178
+ throw new FlareError("INVALID_INPUT", "Invalid outbox page size");
179
+ return this.ctx.storage.sql.exec("SELECT * FROM _flared_outbox ORDER BY dueAt,id LIMIT ?", limit).toArray().map((row) => ({
180
+ id: String(row.id),
181
+ release: String(row.release),
182
+ path: String(row.path),
183
+ args: JSON.parse(String(row.args)),
184
+ auth: JSON.parse(String(row.auth)),
185
+ dueAt: Number(row.dueAt),
186
+ internal: Number(row.internal) === 1
187
+ }));
188
+ });
189
+ }
190
+ cancelPending(id) {
191
+ if (typeof id !== "string" || !/^[a-z0-9_-]{1,128}$/.test(id))
192
+ throw new FlareError("INVALID_INPUT", "Invalid job ID");
193
+ return this.ctx.storage.sql.exec("DELETE FROM _flared_outbox WHERE id=? RETURNING id", id).toArray().length === 1;
194
+ }
195
+ cancelJob(id) {
196
+ return this.serial(async () => {
197
+ const cancelled = this.cancelPending(id);
198
+ await this.ctx.storage.sync();
199
+ return cancelled;
200
+ });
201
+ }
202
+ pendingJobReferences() {
203
+ return this.serial(async () => {
204
+ const paths = this.ctx.storage.sql.exec("SELECT DISTINCT path FROM _flared_outbox ORDER BY path LIMIT 201").toArray().map((row) => String(row.path));
205
+ const releases = this.ctx.storage.sql.exec("SELECT DISTINCT release FROM _flared_outbox ORDER BY release LIMIT 1001").toArray().map((row) => String(row.release));
206
+ return {
207
+ paths: paths.slice(0, 200),
208
+ releases: releases.slice(0, 1000),
209
+ truncated: paths.length > 200 || releases.length > 1000
210
+ };
211
+ });
212
+ }
213
+ validateJob(path, args) {
214
+ return this.serial(async () => {
215
+ const target = functions[path];
216
+ if (!target || target.kind !== "action")
217
+ throw new FlareError("FUNCTION_NOT_FOUND", "Scheduled action not found");
218
+ await validate(target.input, args);
219
+ });
220
+ }
221
+ enqueueJob(job) {
222
+ return this.serial(async () => {
223
+ const target = functions[job.path];
224
+ if (!target || target.kind !== "action")
225
+ throw new FlareError("FUNCTION_NOT_FOUND", "Scheduled action not found");
226
+ const args = await validate(target.input, job.args);
227
+ this.ctx.storage.sql.exec("INSERT OR IGNORE INTO _flared_outbox (id,release,path,args,auth,dueAt,internal) VALUES (?,?,?,?,?,?,1)", job.id, this.env.RELEASE_ID, job.path, JSON.stringify(args), "null", job.dueAt);
228
+ await this.ctx.storage.sync();
229
+ });
230
+ }
231
+ markJobsSent(ids) {
232
+ return this.serial(async () => {
233
+ if (!Array.isArray(ids) || ids.length > 100 || ids.some((id) => typeof id !== "string" || !/^[a-z0-9_-]{1,128}$/.test(id)))
234
+ throw new FlareError("INVALID_INPUT", "Invalid outbox IDs");
235
+ if (ids.length)
236
+ this.ctx.storage.transactionSync(() => {
237
+ this.ctx.storage.sql.exec(`DELETE FROM _flared_outbox WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids);
238
+ });
239
+ await this.ctx.storage.sync();
240
+ });
241
+ }
242
+ inspect(input = {}) {
243
+ return this.serial(async () => {
244
+ if (!input.table)
245
+ return {
246
+ revision: this.revision(),
247
+ tables: Object.entries(contract).map(([name, definition]) => ({
248
+ name,
249
+ document: definition.document,
250
+ indexes: definition.indexes,
251
+ columns: this.columns(name)
252
+ }))
253
+ };
254
+ const capability = database(this.ctx.storage.sql, app.schema, false);
255
+ try {
256
+ const result = await capability.db.query(input.table).paginate({ limit: input.limit ?? 50, cursor: input.cursor });
257
+ await capability.finish();
258
+ return {
259
+ revision: this.revision(),
260
+ columns: this.columns(input.table),
261
+ rows: result.page,
262
+ next: result.continueCursor
263
+ };
264
+ } finally {
265
+ capability.revoke();
266
+ }
267
+ });
268
+ }
269
+ columns(name) {
270
+ const definition = contract[name];
271
+ if (!definition)
272
+ throw new FlareError("INVALID_TABLE", "Unknown table");
273
+ return [
274
+ { name: "_id", type: "TEXT", pk: 1, notnull: 1, dflt_value: null },
275
+ {
276
+ name: "_creationTime",
277
+ type: "REAL",
278
+ pk: 0,
279
+ notnull: 1,
280
+ dflt_value: null,
281
+ readonly: true
282
+ },
283
+ ...Object.entries(definition.document.properties).map(([field, value]) => ({
284
+ name: field,
285
+ type: value.type === "boolean" ? "BOOLEAN" : ["integer", "number"].includes(value.type) ? "REAL" : value.type === "string" ? "TEXT" : "JSON",
286
+ pk: 0,
287
+ notnull: Number((definition.document.required ?? []).includes(field)),
288
+ dflt_value: value.default === undefined ? null : JSON.stringify(value.default),
289
+ schema: value
290
+ }))
291
+ ];
292
+ }
293
+ edit(input) {
294
+ return this.serial(async () => {
295
+ boundedJson(input, LIMITS.inputBytes);
296
+ let rows = [];
297
+ const changed = new Set;
298
+ await this.ctx.storage.transaction(async () => {
299
+ if (input.expectedRevision !== this.revision())
300
+ throw new FlareError("CONFLICT", "Data changed; reload before editing");
301
+ const capability = database(this.ctx.storage.sql, app.schema, true, new Set, changed);
302
+ try {
303
+ if (input.operation === "insert") {
304
+ const id = await capability.db.insert(input.table, input.values ?? {});
305
+ rows = [await capability.db.get(input.table, id)];
306
+ } else if (input.key && input.operation === "update") {
307
+ await capability.db.patch(input.table, input.key, input.values ?? {});
308
+ rows = [await capability.db.get(input.table, input.key)];
309
+ } else if (input.key && input.operation === "delete") {
310
+ const row = await capability.db.get(input.table, input.key);
311
+ await capability.db.delete(input.table, input.key);
312
+ rows = [row];
313
+ } else
314
+ throw new FlareError("INVALID_INPUT", "Invalid edit");
315
+ await capability.finish();
316
+ boundedJson(rows, LIMITS.outputBytes);
317
+ } finally {
318
+ capability.revoke();
319
+ }
320
+ this.commitRevision(changed);
321
+ });
322
+ await this.ctx.storage.sync();
323
+ return { rows, revision: this.revision() };
324
+ });
325
+ }
326
+ };
327
+ }
328
+ async function rpcData(response) {
329
+ const result = await response;
330
+ try {
331
+ const value = structuredClone(result);
332
+ boundedJson(value, LIMITS.outputBytes);
333
+ return value;
334
+ } finally {
335
+ if (result && typeof result === "object") {
336
+ const dispose = result[Symbol.dispose];
337
+ dispose?.call(result);
338
+ }
339
+ }
340
+ }
341
+ export function createAction(app) {
342
+ const functions = flatten(app.functions);
343
+ return class Action extends WorkerEntrypoint {
344
+ async invoke(call) {
345
+ try {
346
+ return { ok: true, value: await this.execute(call) };
347
+ } catch (e) {
348
+ return {
349
+ ok: false,
350
+ code: errorCode(e)
351
+ };
352
+ }
353
+ }
354
+ async execute(call) {
355
+ const fn = functions[call.path];
356
+ if (!fn || fn.kind !== "action" || call.kind !== "action")
357
+ throw new FlareError("FUNCTION_NOT_FOUND", "Action not found");
358
+ authorize(fn.auth, call.auth, call.internal);
359
+ const args = await validate(fn.input, call.args);
360
+ const capability = this.env.CAPABILITY;
361
+ const context = {
362
+ auth: call.auth,
363
+ schedules: {
364
+ set: (input) => rpcData(capability.scheduleCommand("set", input)),
365
+ cancel: (name) => rpcData(capability.scheduleCommand("cancel", { name })),
366
+ list: () => rpcData(capability.scheduleCommand("list", {}))
367
+ },
368
+ runQuery: (ref, input) => {
369
+ if (ref.kind !== "query")
370
+ throw new FlareError("INVALID_KIND", "Expected query");
371
+ return rpcData(capability.runQuery(ref.path, input, call.auth));
372
+ },
373
+ runMutation: (ref, input) => {
374
+ if (ref.kind !== "mutation")
375
+ throw new FlareError("INVALID_KIND", "Expected mutation");
376
+ return rpcData(capability.runMutation(ref.path, input, call.auth));
377
+ },
378
+ secrets: { get: (name) => capability.secret(name) },
379
+ files: {
380
+ upload: (body, type, options) => capability.upload(body, type, options),
381
+ download: (id) => capability.download(id),
382
+ generateUploadUrl: (input) => rpcData(capability.fileCommand("reserve", input)),
383
+ getDownloadUrl: (id) => rpcData(capability.fileCommand("download", { id })),
384
+ delete: (id) => rpcData(capability.fileCommand("delete", { id })),
385
+ list: (input = {}) => rpcData(capability.fileCommand("list", input))
386
+ }
387
+ };
388
+ return validate(fn.output, await fn.handler(context, args), true);
389
+ }
390
+ };
391
+ }
@@ -0,0 +1,39 @@
1
+ import { type Schema, type TableDefinition, type Value, type Input, type Doc, type NativeSql } from "./schema.js";
2
+ export interface Range<D> {
3
+ eq<K extends keyof D & string>(field: K, value: D[K]): Range<D>;
4
+ gt<K extends keyof D & string>(field: K, value: D[K]): Range<D>;
5
+ gte<K extends keyof D & string>(field: K, value: D[K]): Range<D>;
6
+ lt<K extends keyof D & string>(field: K, value: D[K]): Range<D>;
7
+ lte<K extends keyof D & string>(field: K, value: D[K]): Range<D>;
8
+ }
9
+ export interface Page<D> {
10
+ page: D[];
11
+ continueCursor: string | null;
12
+ isDone: boolean;
13
+ }
14
+ export interface Query<T extends TableDefinition> {
15
+ withIndex(index: keyof T["indexes"] & string, range?: (q: Range<Value<T>>) => Range<Value<T>>): Query<T>;
16
+ order(direction: "asc" | "desc"): Query<T>;
17
+ take(limit: number): Promise<Doc<T>[]>;
18
+ first(): Promise<Doc<T> | null>;
19
+ paginate(options: {
20
+ limit: number;
21
+ cursor?: string | null;
22
+ }): Promise<Page<Doc<T>>>;
23
+ }
24
+ export interface ReadDatabase<S extends Schema = Schema> {
25
+ get<K extends keyof S & string>(table: K, id: string): Promise<Doc<S[K]> | null>;
26
+ query<K extends keyof S & string>(table: K): Query<S[K]>;
27
+ }
28
+ export interface WriteDatabase<S extends Schema = Schema> extends ReadDatabase<S> {
29
+ insert<K extends keyof S & string>(table: K, value: Input<S[K]>): Promise<string>;
30
+ patch<K extends keyof S & string>(table: K, id: string, value: Partial<Input<S[K]>>): Promise<void>;
31
+ replace<K extends keyof S & string>(table: K, id: string, value: Input<S[K]>): Promise<void>;
32
+ delete<K extends keyof S & string>(table: K, id: string): Promise<void>;
33
+ }
34
+ /** Storage never escapes this closure. Each invocation has a revocable capability. */
35
+ export declare function database<S extends Schema>(sql: NativeSql, schema: S, writable: boolean, touched?: Set<string>, changed?: Set<string>): {
36
+ db: ReadDatabase<S> & WriteDatabase<S>;
37
+ finish(): Promise<void>;
38
+ revoke(): void;
39
+ };