@pramen/server 0.0.13 → 0.0.15
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/dist/auth.d.ts +17 -2
- package/dist/auth.js +26 -7
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +311 -0
- package/dist/durable-object.d.ts +22 -4
- package/dist/durable-object.js +121 -55
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/pramen.d.ts +6 -0
- package/dist/pramen.js +1 -1
- package/dist/runtime/acl.js +28 -7
- package/dist/runtime/db.d.ts +6 -0
- package/dist/runtime/db.js +86 -10
- package/dist/runtime/ddl.d.ts +16 -3
- package/dist/runtime/ddl.js +28 -8
- package/dist/runtime/dispatch.js +2 -0
- package/dist/runtime/driver.d.ts +41 -7
- package/dist/runtime/driver.js +38 -11
- package/dist/runtime/migrate.d.ts +1 -1
- package/dist/runtime/migrate.js +222 -33
- package/dist/runtime/outbox.js +28 -6
- package/dist/runtime/queue-consumer.d.ts +71 -0
- package/dist/runtime/queue-consumer.js +63 -0
- package/dist/runtime/queue.d.ts +72 -0
- package/dist/runtime/queue.js +110 -0
- package/dist/runtime/read-engine.js +7 -2
- package/dist/runtime/schema-diff.d.ts +28 -5
- package/dist/runtime/schema-diff.js +111 -19
- package/dist/runtime/storage.d.ts +7 -0
- package/dist/runtime/storage.js +0 -0
- package/dist/sdk/handlers.d.ts +7 -0
- package/dist/worker.d.ts +36 -0
- package/dist/worker.js +128 -18
- package/package.json +6 -2
- package/src/auth.ts +64 -21
- package/src/cli.ts +336 -0
- package/src/durable-object.ts +118 -52
- package/src/index.ts +6 -0
- package/src/pramen.ts +7 -1
- package/src/runtime/acl.ts +25 -5
- package/src/runtime/db.ts +80 -9
- package/src/runtime/ddl.ts +26 -8
- package/src/runtime/dispatch.ts +2 -0
- package/src/runtime/driver.ts +52 -9
- package/src/runtime/migrate.ts +246 -34
- package/src/runtime/outbox.ts +30 -7
- package/src/runtime/queue-consumer.ts +116 -0
- package/src/runtime/queue.ts +155 -0
- package/src/runtime/read-engine.ts +7 -2
- package/src/runtime/schema-diff.ts +137 -23
- package/src/runtime/storage.ts +0 -0
- package/src/sdk/handlers.ts +7 -0
- package/src/worker.ts +162 -19
package/dist/durable-object.js
CHANGED
|
@@ -18,6 +18,7 @@ import { DurableObject } from "cloudflare:workers";
|
|
|
18
18
|
import { migrate } from "./runtime/migrate";
|
|
19
19
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
20
20
|
import { createMail } from "./runtime/mail";
|
|
21
|
+
import { createQueue } from "./runtime/queue";
|
|
21
22
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
22
23
|
import { Db } from "./runtime/db";
|
|
23
24
|
import { digest } from "./runtime/digest";
|
|
@@ -52,6 +53,14 @@ export class PramenDOBase extends DurableObject {
|
|
|
52
53
|
* task context — a DO can't introspect its own idFromName. */
|
|
53
54
|
identityPersisted = false;
|
|
54
55
|
identityLoaded = false;
|
|
56
|
+
/** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
|
|
57
|
+
* attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
|
|
58
|
+
* JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
|
|
59
|
+
* tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
|
|
60
|
+
* entry and is treated as having no active subscriptions — acceptable because the
|
|
61
|
+
* client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
|
|
62
|
+
* cleaned up in webSocketClose. */
|
|
63
|
+
subsBySocket = new Map();
|
|
55
64
|
constructor(ctx, env, app) {
|
|
56
65
|
super(ctx, env);
|
|
57
66
|
this.app = app;
|
|
@@ -124,7 +133,8 @@ export class PramenDOBase extends DurableObject {
|
|
|
124
133
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
125
134
|
const { 0: client, 1: server } = new WebSocketPair();
|
|
126
135
|
this.ctx.acceptWebSocket(server); // hibernatable
|
|
127
|
-
this.
|
|
136
|
+
this.setAttachment(server, { identity, tenant: this.tenant, partition: this.partition });
|
|
137
|
+
this.subsBySocket.set(server, []);
|
|
128
138
|
return new Response(null, { status: 101, webSocket: client });
|
|
129
139
|
}
|
|
130
140
|
const name = new URL(request.url).pathname.replace(/^\/rpc\//, "");
|
|
@@ -134,10 +144,13 @@ export class PramenDOBase extends DurableObject {
|
|
|
134
144
|
}
|
|
135
145
|
try {
|
|
136
146
|
const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
|
|
137
|
-
|
|
138
|
-
|
|
147
|
+
// Arm the drain BEFORE broadcasting so enqueued tasks are always scheduled even
|
|
148
|
+
// if broadcast has trouble (broadcast is best-effort and never throws — a failed
|
|
149
|
+
// push must not 500 a COMMITTED write nor skip the alarm).
|
|
139
150
|
if (enqueued > 0)
|
|
140
151
|
await this.armDrain();
|
|
152
|
+
if (kind === "mutation" && touched.length > 0)
|
|
153
|
+
await this.broadcast(touched);
|
|
141
154
|
return Response.json({ ok: true, result });
|
|
142
155
|
}
|
|
143
156
|
catch (err) {
|
|
@@ -151,11 +164,13 @@ export class PramenDOBase extends DurableObject {
|
|
|
151
164
|
await this.ctx.storage.setAlarm(Date.now() + 50);
|
|
152
165
|
}
|
|
153
166
|
/** A privileged, system-scoped context for running task handlers (outside a request).
|
|
154
|
-
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
167
|
+
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks.
|
|
168
|
+
* Returns the `db` alongside the ctx so the drainer can broadcast the tables the task
|
|
169
|
+
* handlers touched (live queries would otherwise go stale after deferred/trigger work). */
|
|
155
170
|
taskCtx() {
|
|
156
171
|
const identity = { roles: ["admin"] };
|
|
157
172
|
const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true }, this.app.schema);
|
|
158
|
-
|
|
173
|
+
const ctx = {
|
|
159
174
|
db,
|
|
160
175
|
kv: this.kv,
|
|
161
176
|
files: this.filesFor(this.tenant),
|
|
@@ -163,7 +178,9 @@ export class PramenDOBase extends DurableObject {
|
|
|
163
178
|
identity,
|
|
164
179
|
tasks: tasksFacade(this.driver),
|
|
165
180
|
mail: createMail(this.envBag, this.kv),
|
|
181
|
+
queue: createQueue(this.envBag),
|
|
166
182
|
};
|
|
183
|
+
return { ctx, db };
|
|
167
184
|
}
|
|
168
185
|
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
169
186
|
* task context is scoped correctly. No-op once loaded/persisted this instance. */
|
|
@@ -182,22 +199,34 @@ export class PramenDOBase extends DurableObject {
|
|
|
182
199
|
this.identityLoaded = true;
|
|
183
200
|
}
|
|
184
201
|
/** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
|
|
185
|
-
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling
|
|
202
|
+
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling, plus
|
|
203
|
+
* the union of tables the drained task handlers touched (for a post-commit broadcast). */
|
|
186
204
|
async drainTasks() {
|
|
187
205
|
await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
|
|
188
|
-
|
|
206
|
+
const { ctx, db } = this.taskCtx();
|
|
207
|
+
const result = await drainOutbox(this.driver, bindTasks(this.app.tasks, ctx), Date.now());
|
|
208
|
+
return { result, touched: [...db.touched] };
|
|
189
209
|
}
|
|
190
210
|
async alarm() {
|
|
191
211
|
await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
|
|
192
|
-
|
|
212
|
+
// A post-deploy cold alarm may run against the old schema — reconcile it first, or a
|
|
213
|
+
// task handler writing a new column dead-letters. loadIdentity() restored the partition.
|
|
214
|
+
await this.ensureMigrated();
|
|
215
|
+
const { result, touched } = await this.drainTasks();
|
|
216
|
+
// Deferred/triggered writes are invisible to live queries unless we broadcast the
|
|
217
|
+
// tables the task handlers touched (post-commit — the drain has already committed).
|
|
218
|
+
if (touched.length > 0)
|
|
219
|
+
await this.broadcast(touched);
|
|
193
220
|
// Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
|
|
194
221
|
// the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
|
|
195
|
-
if (nextRunAt != null)
|
|
196
|
-
await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
|
|
222
|
+
if (result.nextRunAt != null)
|
|
223
|
+
await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
197
224
|
}
|
|
198
225
|
async handleDrain() {
|
|
199
226
|
await this.loadIdentity();
|
|
200
|
-
const result = await this.drainTasks();
|
|
227
|
+
const { result, touched } = await this.drainTasks();
|
|
228
|
+
if (touched.length > 0)
|
|
229
|
+
await this.broadcast(touched);
|
|
201
230
|
// Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
|
|
202
231
|
if (result.nextRunAt != null)
|
|
203
232
|
await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
@@ -224,18 +253,16 @@ export class PramenDOBase extends DurableObject {
|
|
|
224
253
|
// socket's (tenant, partition) — fixed at connect time, survives via the attachment
|
|
225
254
|
// — and ensure the schema is migrated before any handler/ctx.db work. Idempotent
|
|
226
255
|
// (the `migrated` flag), so a no-op after the first call.
|
|
227
|
-
const { tenant, partition } = this.
|
|
256
|
+
const { tenant, partition } = this.getAttachment(ws);
|
|
228
257
|
this.tenant = tenant;
|
|
229
258
|
this.partition = partition;
|
|
230
259
|
await this.ensureMigrated();
|
|
231
260
|
switch (msg.type) {
|
|
232
261
|
case "subscribe":
|
|
233
262
|
return this.onSubscribe(ws, msg.id, msg.name, msg.input);
|
|
234
|
-
case "unsubscribe":
|
|
235
|
-
|
|
236
|
-
this.setState(ws, { ...state, subs: state.subs.filter((s) => s.id !== msg.id) });
|
|
263
|
+
case "unsubscribe":
|
|
264
|
+
this.setSubs(ws, this.getSubs(ws).filter((s) => s.id !== msg.id));
|
|
237
265
|
return;
|
|
238
|
-
}
|
|
239
266
|
case "call":
|
|
240
267
|
return this.onCall(ws, msg.id, msg.name, msg.input);
|
|
241
268
|
default:
|
|
@@ -243,6 +270,7 @@ export class PramenDOBase extends DurableObject {
|
|
|
243
270
|
}
|
|
244
271
|
}
|
|
245
272
|
async webSocketClose(ws) {
|
|
273
|
+
this.subsBySocket.delete(ws); // release the in-memory subscription list for this socket
|
|
246
274
|
ws.close();
|
|
247
275
|
}
|
|
248
276
|
async webSocketError(ws, error) {
|
|
@@ -256,19 +284,20 @@ export class PramenDOBase extends DurableObject {
|
|
|
256
284
|
}
|
|
257
285
|
// --- live-query internals ---
|
|
258
286
|
async onSubscribe(ws, id, name, input) {
|
|
259
|
-
const
|
|
287
|
+
const att = this.getAttachment(ws);
|
|
288
|
+
const subs = this.getSubs(ws);
|
|
260
289
|
try {
|
|
261
|
-
const replacing =
|
|
262
|
-
if (!replacing &&
|
|
290
|
+
const replacing = subs.some((s) => s.id === id);
|
|
291
|
+
if (!replacing && subs.length >= MAX_SUBSCRIPTIONS) {
|
|
263
292
|
return this.send(ws, toWsError(id, new BadRequest("subscription limit reached")));
|
|
264
293
|
}
|
|
265
|
-
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(
|
|
294
|
+
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), name, input);
|
|
266
295
|
if (kind !== "query") {
|
|
267
296
|
return this.send(ws, toWsError(id, new BadRequest(`${name} is not a query`)));
|
|
268
297
|
}
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
this.
|
|
298
|
+
const next = subs.filter((s) => s.id !== id);
|
|
299
|
+
next.push({ id, name, input, tables: touched, digest: digest(result) });
|
|
300
|
+
this.setSubs(ws, next);
|
|
272
301
|
this.send(ws, { type: "data", id, result });
|
|
273
302
|
}
|
|
274
303
|
catch (err) {
|
|
@@ -276,44 +305,65 @@ export class PramenDOBase extends DurableObject {
|
|
|
276
305
|
}
|
|
277
306
|
}
|
|
278
307
|
async onCall(ws, id, name, input) {
|
|
279
|
-
const
|
|
308
|
+
const att = this.getAttachment(ws);
|
|
309
|
+
let outcome;
|
|
280
310
|
try {
|
|
281
|
-
|
|
282
|
-
this.send(ws, { type: "result", id, result });
|
|
283
|
-
if (kind === "mutation" && touched.length > 0)
|
|
284
|
-
await this.broadcast(touched);
|
|
285
|
-
if (enqueued > 0)
|
|
286
|
-
await this.armDrain();
|
|
311
|
+
outcome = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), name, input);
|
|
287
312
|
}
|
|
288
313
|
catch (err) {
|
|
289
|
-
this.send(ws, toWsError(id, err));
|
|
314
|
+
return this.send(ws, toWsError(id, err));
|
|
290
315
|
}
|
|
316
|
+
// The mutation is committed — send its result FIRST, then run post-commit
|
|
317
|
+
// side-effects that must never turn a committed write into a spurious error frame:
|
|
318
|
+
// arm the drain (independent of broadcast), then broadcast (best-effort, never throws).
|
|
319
|
+
const { result, kind, touched, enqueued } = outcome;
|
|
320
|
+
this.send(ws, { type: "result", id, result });
|
|
321
|
+
if (enqueued > 0)
|
|
322
|
+
await this.armDrain();
|
|
323
|
+
if (kind === "mutation" && touched.length > 0)
|
|
324
|
+
await this.broadcast(touched);
|
|
291
325
|
}
|
|
292
326
|
// Re-run every subscription whose read-set intersects the written tables, each
|
|
293
|
-
// under its own socket's identity, and push only when its result changed.
|
|
327
|
+
// under its own socket's identity, and push only when its result changed. Best-effort:
|
|
328
|
+
// a failure for one subscription or socket is logged and skipped — it must NEVER throw,
|
|
329
|
+
// because it runs after a mutation has committed (a throw here would 500 that write).
|
|
294
330
|
async broadcast(touched) {
|
|
295
331
|
const written = new Set(touched);
|
|
296
332
|
for (const ws of this.ctx.getWebSockets()) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
333
|
+
try {
|
|
334
|
+
const att = this.getAttachment(ws);
|
|
335
|
+
const subs = this.getSubs(ws);
|
|
336
|
+
let dirty = false;
|
|
337
|
+
for (const sub of subs) {
|
|
338
|
+
if (!sub.tables.some((t) => written.has(t)))
|
|
339
|
+
continue;
|
|
340
|
+
try {
|
|
341
|
+
const { result } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(att.tenant), this.envBag, this.ctxFor(att.identity, att.partition), sub.name, sub.input);
|
|
342
|
+
const next = digest(result);
|
|
343
|
+
if (next === sub.digest)
|
|
344
|
+
continue; // result unchanged for this subscription
|
|
345
|
+
sub.digest = next;
|
|
346
|
+
dirty = true;
|
|
347
|
+
this.send(ws, { type: "data", id: sub.id, result });
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
// One subscription failing (re-dispatch error, or a send to a dead socket)
|
|
351
|
+
// must not abort the other subs — surface it to that sub, swallow otherwise.
|
|
352
|
+
try {
|
|
353
|
+
this.send(ws, toWsError(sub.id, err));
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
/* socket already gone */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
313
359
|
}
|
|
360
|
+
if (dirty)
|
|
361
|
+
this.setSubs(ws, subs);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
// A bad socket must not stop the loop over the others.
|
|
365
|
+
console.error("pramen: broadcast to a socket failed", err);
|
|
314
366
|
}
|
|
315
|
-
if (dirty)
|
|
316
|
-
this.setState(ws, state);
|
|
317
367
|
}
|
|
318
368
|
}
|
|
319
369
|
// --- helpers ---
|
|
@@ -401,7 +451,9 @@ export class PramenDOBase extends DurableObject {
|
|
|
401
451
|
result = await db.count({ from: table, where: b.where });
|
|
402
452
|
break;
|
|
403
453
|
case "get":
|
|
404
|
-
|
|
454
|
+
// Resolve the PK from the schema — a custom-PK table (e.g. auth_users keyed on
|
|
455
|
+
// `username`) has no `id` column, so a hardcoded `{ id }` would 500.
|
|
456
|
+
result = (await db.find({ from: table, where: { [db.pkOf(table)]: b.id }, limit: 1 }))[0] ?? null;
|
|
405
457
|
break;
|
|
406
458
|
case "create":
|
|
407
459
|
result = await this.driver.transaction(() => db.insert(table, b.values));
|
|
@@ -418,6 +470,10 @@ export class PramenDOBase extends DurableObject {
|
|
|
418
470
|
default:
|
|
419
471
|
return Response.json({ ok: false, error: `unknown op: ${op}`, code: "bad_request" }, { status: 400 });
|
|
420
472
|
}
|
|
473
|
+
// Admin-data writes fire triggers too — arm the drain if any task was enqueued
|
|
474
|
+
// (independent of broadcast), then broadcast the touched tables to live queries.
|
|
475
|
+
if (mutated && db.taskEnqueues > 0)
|
|
476
|
+
await this.armDrain();
|
|
421
477
|
if (mutated && db.touched.size > 0)
|
|
422
478
|
await this.broadcast([...db.touched]);
|
|
423
479
|
return Response.json({ ok: true, result });
|
|
@@ -458,16 +514,26 @@ export class PramenDOBase extends DurableObject {
|
|
|
458
514
|
return null;
|
|
459
515
|
}
|
|
460
516
|
}
|
|
461
|
-
|
|
517
|
+
/** The durable per-socket auth/routing state (identity + tenant + partition), read
|
|
518
|
+
* from the WS attachment. Survives hibernation; kept tiny to stay under workerd's cap. */
|
|
519
|
+
getAttachment(ws) {
|
|
462
520
|
return (ws.deserializeAttachment() ?? {
|
|
463
521
|
identity: null,
|
|
464
522
|
tenant: this.tenant,
|
|
465
523
|
partition: this.partition,
|
|
466
|
-
subs: [],
|
|
467
524
|
});
|
|
468
525
|
}
|
|
469
|
-
|
|
470
|
-
ws.serializeAttachment(
|
|
526
|
+
setAttachment(ws, att) {
|
|
527
|
+
ws.serializeAttachment(att);
|
|
528
|
+
}
|
|
529
|
+
/** This socket's live subscriptions from the in-memory map (see `subsBySocket`). A
|
|
530
|
+
* hibernated/woken socket has no entry → no active subscriptions until the client
|
|
531
|
+
* replays them on reconnect. */
|
|
532
|
+
getSubs(ws) {
|
|
533
|
+
return this.subsBySocket.get(ws) ?? [];
|
|
534
|
+
}
|
|
535
|
+
setSubs(ws, subs) {
|
|
536
|
+
this.subsBySocket.set(ws, subs);
|
|
471
537
|
}
|
|
472
538
|
send(ws, msg) {
|
|
473
539
|
ws.send(JSON.stringify(msg));
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runt
|
|
|
13
13
|
export type { StorageAdapter, PutResult, GetResult } from "./runtime/storage";
|
|
14
14
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
15
15
|
export type { MailMessage, MailAddress, MailAdapter, SendEmailBinding } from "./runtime/mail";
|
|
16
|
+
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
17
|
+
export type { QueueAdapter, QueueProducerBinding, QueueSendOptions, QueueSendRequest, QueueBatchOptions, QueueContentType } from "./runtime/queue";
|
|
18
|
+
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
19
|
+
export type { QueueContext, QueueHandler, QueueMessage, QueueBatch, AppQueueMap } from "./runtime/queue-consumer";
|
|
16
20
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
17
21
|
export { sqliteDialect, postgresDialect, DoSqliteDriver, D1Driver } from "./runtime/driver";
|
|
18
22
|
export type { Driver, Dialect, Row } from "./runtime/driver";
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,9 @@ export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny,
|
|
|
17
17
|
export { R2Adapter, MemoryAdapter, createFiles, handleFileRequest } from "./runtime/storage";
|
|
18
18
|
// --- mail (ctx.mail) ---
|
|
19
19
|
export { Mail, CloudflareEmailAdapter, KvMailAdapter, MemoryMailAdapter, UnconfiguredMailAdapter, createMail } from "./runtime/mail";
|
|
20
|
+
// --- queue (ctx.queue — Cloudflare Queues) ---
|
|
21
|
+
export { Queue, CloudflareQueueAdapter, MemoryQueueAdapter, createQueue, discoverQueueBindings } from "./runtime/queue";
|
|
22
|
+
export { routeQueue, dispatchQueueBatch } from "./runtime/queue-consumer";
|
|
20
23
|
// --- errors ---
|
|
21
24
|
export { PramenError, BadRequest, Unauthorized, Forbidden } from "./runtime/errors";
|
|
22
25
|
// --- substrate seam (advanced: bring your own SQL backend) ---
|
package/dist/pramen.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Env } from "./worker";
|
|
|
2
2
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
3
3
|
import { type SchemaDef } from "./sdk/schema";
|
|
4
4
|
import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
|
|
5
|
+
import type { AppQueueMap, QueueBatch } from "./runtime/queue-consumer";
|
|
5
6
|
import type { Role } from "./sdk/acl";
|
|
6
7
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
7
8
|
* tenant's DO without the handler importing any deploy-side code (so app.ts stays
|
|
@@ -36,6 +37,10 @@ export interface PramenApp {
|
|
|
36
37
|
/** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
|
|
37
38
|
* mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
|
|
38
39
|
tasks?: AppTaskMap;
|
|
40
|
+
/** Cloudflare Queues consumers keyed by queue name — process messages produced via
|
|
41
|
+
* `ctx.queue.send(...)`. Dispatched by `createPramen(app).queue` (a consumer is
|
|
42
|
+
* Worker-level: no `ctx.db`, reach a tenant via `ctx.callPrivileged`). */
|
|
43
|
+
queues?: AppQueueMap;
|
|
39
44
|
}
|
|
40
45
|
export type { Env, DoEnv };
|
|
41
46
|
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
@@ -44,5 +49,6 @@ export type { Env, DoEnv };
|
|
|
44
49
|
export declare function createPramen(app: PramenApp): {
|
|
45
50
|
fetch: (request: Request, env: Env) => Promise<Response>;
|
|
46
51
|
scheduled: (event: unknown, env: Env) => Promise<void>;
|
|
52
|
+
queue: (batch: QueueBatch, env: Env) => Promise<void>;
|
|
47
53
|
PramenDO: ReturnType<typeof pramenDO>;
|
|
48
54
|
};
|
package/dist/pramen.js
CHANGED
|
@@ -20,5 +20,5 @@ import { validateTriggerTasks } from "./sdk/schema";
|
|
|
20
20
|
export function createPramen(app) {
|
|
21
21
|
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
22
22
|
const worker = makeWorker(app);
|
|
23
|
-
return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
|
|
23
|
+
return { fetch: worker.fetch, scheduled: worker.scheduled, queue: worker.queue, PramenDO: pramenDO(app) };
|
|
24
24
|
}
|
package/dist/runtime/acl.js
CHANGED
|
@@ -145,6 +145,13 @@ function resolveMarkers(rule, identity, input) {
|
|
|
145
145
|
const rv = resolveValue(v, identity, input);
|
|
146
146
|
if (rv === UNRESOLVED)
|
|
147
147
|
return null;
|
|
148
|
+
// A bare-value marker must only ever produce an EQUALITY comparison. If it
|
|
149
|
+
// resolves to a caller-controlled non-primitive (object/array), compileWhere
|
|
150
|
+
// would read it as an operator predicate (`{"gte":""}` → full-table
|
|
151
|
+
// enumeration), defeating the intended equality. Treat that as unresolvable so
|
|
152
|
+
// the branch matches nothing (the safe-deny path). Primitives resolve as today.
|
|
153
|
+
if (isMarker && rv !== null && typeof rv === "object")
|
|
154
|
+
return null;
|
|
148
155
|
out[key] = rv;
|
|
149
156
|
}
|
|
150
157
|
}
|
|
@@ -164,6 +171,26 @@ function pkOf(schema, entity) {
|
|
|
164
171
|
return n;
|
|
165
172
|
return "id";
|
|
166
173
|
}
|
|
174
|
+
/** Reject a relation `where` that filters the target on a column it can't read —
|
|
175
|
+
* anywhere in the clause, including inside nested AND/OR groups (else a hidden/
|
|
176
|
+
* unreadable column is LIKE-oracle'able through the subquery). Nested relation keys
|
|
177
|
+
* are skipped: they're re-scoped against THEIR own target's read scope downstream.
|
|
178
|
+
* Mirrors Db.assertReadableWhere's recursion for the top-level user `where`. */
|
|
179
|
+
function assertReadableRelationWhere(where, target, fields, ctx) {
|
|
180
|
+
const targetRels = (ctx.schema?.[target]?.relations ?? {});
|
|
181
|
+
for (const [k, v] of Object.entries(where)) {
|
|
182
|
+
if (k === "AND" || k === "OR") {
|
|
183
|
+
for (const g of v)
|
|
184
|
+
assertReadableRelationWhere(g, target, fields, ctx);
|
|
185
|
+
}
|
|
186
|
+
else if (targetRels[k]) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
else if (!fields.includes(k)) {
|
|
190
|
+
throw new AclDenied(target, "read", k);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
167
194
|
/** Compile a relation predicate `{ rel: { … } }` to a subquery, AND-merging the
|
|
168
195
|
* related entity's read scope (and rejecting filters on fields it can't read) so
|
|
169
196
|
* traversal can never widen access beyond a direct read of the target. */
|
|
@@ -189,13 +216,7 @@ function relationPredicate(rel, nested, parentEntity, ctx, depth) {
|
|
|
189
216
|
}
|
|
190
217
|
else {
|
|
191
218
|
if (tScope.fields !== null) {
|
|
192
|
-
|
|
193
|
-
for (const k of Object.keys(nested)) {
|
|
194
|
-
if (k === "AND" || k === "OR" || targetRels[k])
|
|
195
|
-
continue;
|
|
196
|
-
if (!tScope.fields.includes(k))
|
|
197
|
-
throw new AclDenied(rel.target, "read", k);
|
|
198
|
-
}
|
|
219
|
+
assertReadableRelationWhere(nested, rel.target, tScope.fields, ctx);
|
|
199
220
|
}
|
|
200
221
|
if (tScope.where)
|
|
201
222
|
inner = and(inner, tScope.where);
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -115,6 +115,9 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
115
115
|
aggregations: A;
|
|
116
116
|
}): Promise<AggregateResult<FieldsOf<S[T]>, G, A>[]>;
|
|
117
117
|
private readWhere;
|
|
118
|
+
/** Walk a compiled predicate and add every relation-subquery target table to
|
|
119
|
+
* `touched` (the live-query read-set). Recurses into nested subqueries. */
|
|
120
|
+
private addTouchedTables;
|
|
118
121
|
/** Reject a user `where` that filters on a column the caller cannot read (closes
|
|
119
122
|
* the same info-leak as ordering by a hidden column: a filter is an oracle for a
|
|
120
123
|
* hidden field's values). Mirrors `assertReadableCols`. Relation keys are skipped
|
|
@@ -124,6 +127,9 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
124
127
|
private assertReadableWhere;
|
|
125
128
|
private selectRaw;
|
|
126
129
|
private jsonColsOf;
|
|
130
|
+
/** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
|
|
131
|
+
* true/false on read so handlers see the `boolean` the InferRow type promises. */
|
|
132
|
+
private boolColsOf;
|
|
127
133
|
/** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
|
|
128
134
|
private hiddenColsOf;
|
|
129
135
|
/** Drop hidden columns from a row (copying only if any are present). */
|
package/dist/runtime/db.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// which the live-query layer uses to decide which subscriptions to re-check.
|
|
12
12
|
// Create a fresh Db per handler run so identity and `touched` are scoped.
|
|
13
13
|
import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
|
|
14
|
-
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
|
|
14
|
+
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, FALSE, inList, isNull, or, TRUE, } from "./read-engine";
|
|
15
15
|
import { BadRequest } from "./errors";
|
|
16
16
|
import { enqueueTask } from "./outbox";
|
|
17
17
|
import { partitionOf, triggersOf, triggerFires } from "../sdk/schema";
|
|
@@ -48,16 +48,29 @@ function decodeCursor(s) {
|
|
|
48
48
|
throw new BadRequest("invalid cursor");
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
// Strictly-after predicate for a single order column, NULL-aware. SQLite sorts
|
|
52
|
+
// NULLs FIRST for ASC and LAST for DESC, so a naive `col > v` (which is NULL, never
|
|
53
|
+
// TRUE, when col or v is NULL) would re-match already-seen NULL rows forever.
|
|
54
|
+
// ASC: v null -> any non-null row is after it (col IS NOT NULL)
|
|
55
|
+
// v non-null-> col > v (NULL cols excluded, they sort before)
|
|
56
|
+
// DESC: v null -> nothing is strictly after a null (FALSE; PK tiebreak advances)
|
|
57
|
+
// v non-null-> col < v OR col IS NULL (nulls sort after all non-nulls)
|
|
58
|
+
function keysetCmp(o, value) {
|
|
59
|
+
const desc = o.dir === "desc";
|
|
60
|
+
if (value === null)
|
|
61
|
+
return desc ? FALSE : isNull(o.column, true);
|
|
62
|
+
return desc ? or(cmp("<", o.column, value), isNull(o.column)) : cmp(">", o.column, value);
|
|
63
|
+
}
|
|
51
64
|
// Strictly-after predicate for a composite key: lexicographic comparison,
|
|
52
|
-
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <.
|
|
65
|
+
// e.g. (a,b) after (a0,b0) => a>a0 OR (a=a0 AND b>b0); DESC columns flip to <. Each
|
|
66
|
+
// column's comparison and the eq-tiebreaker are NULL-aware (eq() maps null→IS NULL).
|
|
53
67
|
function keysetAfter(order, values) {
|
|
54
68
|
const ors = [];
|
|
55
69
|
for (let i = 0; i < order.length; i++) {
|
|
56
70
|
const parts = [];
|
|
57
71
|
for (let j = 0; j < i; j++)
|
|
58
72
|
parts.push(eq(order[j].column, values[j]));
|
|
59
|
-
|
|
60
|
-
parts.push(o.dir === "desc" ? cmp("<", o.column, values[i]) : cmp(">", o.column, values[i]));
|
|
73
|
+
parts.push(keysetCmp(order[i], values[i]));
|
|
61
74
|
ors.push(parts.length === 1 ? parts[0] : and(...parts));
|
|
62
75
|
}
|
|
63
76
|
return ors.length === 1 ? ors[0] : or(...ors);
|
|
@@ -140,6 +153,12 @@ export class Db {
|
|
|
140
153
|
* and the keyset cursor would otherwise expose a hidden column's values). Columns
|
|
141
154
|
* granted only conditionally are NOT orderable. */
|
|
142
155
|
assertReadableCols(from, scope, cols) {
|
|
156
|
+
// Hidden columns are never readable through the ORM — not orderable either,
|
|
157
|
+
// regardless of scope (else the order/keyset cursor leaks the hidden value).
|
|
158
|
+
const hidden = new Set(this.hiddenColsOf(from));
|
|
159
|
+
for (const c of cols)
|
|
160
|
+
if (hidden.has(c))
|
|
161
|
+
throw new AclDenied(from, "read", c);
|
|
143
162
|
if (scope.fields === null)
|
|
144
163
|
return;
|
|
145
164
|
for (const c of cols)
|
|
@@ -223,6 +242,17 @@ export class Db {
|
|
|
223
242
|
throw new BadRequest(`cannot aggregate a json column: ${agg.column}`);
|
|
224
243
|
}
|
|
225
244
|
}
|
|
245
|
+
// Hidden columns are never readable through the ORM — reject group-by / aggregating
|
|
246
|
+
// them UNCONDITIONALLY (independent of scope.fields, which is null under full/SYSTEM
|
|
247
|
+
// read), else min/max/groupBy over a hidden column exposes its values.
|
|
248
|
+
const hiddenCols = new Set(this.hiddenColsOf(from));
|
|
249
|
+
for (const c of groupBy)
|
|
250
|
+
if (hiddenCols.has(c))
|
|
251
|
+
throw new AclDenied(from, "read", c);
|
|
252
|
+
for (const agg of Object.values(spec.aggregations)) {
|
|
253
|
+
if (agg.column && hiddenCols.has(agg.column))
|
|
254
|
+
throw new AclDenied(from, "read", agg.column);
|
|
255
|
+
}
|
|
226
256
|
if (scope.fields) {
|
|
227
257
|
const refs = new Set(groupBy);
|
|
228
258
|
for (const agg of Object.values(spec.aggregations))
|
|
@@ -243,7 +273,30 @@ export class Db {
|
|
|
243
273
|
if (userWhere)
|
|
244
274
|
this.assertReadableWhere(from, scope, userWhere);
|
|
245
275
|
const userExpr = userWhere ? compileScopedWhere(userWhere, from, this.acl) : TRUE;
|
|
246
|
-
|
|
276
|
+
const where = scope.where ? and(userExpr, scope.where) : userExpr;
|
|
277
|
+
// A relation-traversal `where` (or a relation-traversing ACL scope) compiles to a
|
|
278
|
+
// `sub` node over another table. Record those tables in `touched` so the live-query
|
|
279
|
+
// layer re-checks the subscription when the traversed table changes — else a write
|
|
280
|
+
// there never intersects the sub's read-set and the client stays stale.
|
|
281
|
+
this.addTouchedTables(where);
|
|
282
|
+
return where;
|
|
283
|
+
}
|
|
284
|
+
/** Walk a compiled predicate and add every relation-subquery target table to
|
|
285
|
+
* `touched` (the live-query read-set). Recurses into nested subqueries. */
|
|
286
|
+
addTouchedTables(expr) {
|
|
287
|
+
if (!expr)
|
|
288
|
+
return;
|
|
289
|
+
switch (expr.t) {
|
|
290
|
+
case "sub":
|
|
291
|
+
this.touched.add(expr.from);
|
|
292
|
+
this.addTouchedTables(expr.where);
|
|
293
|
+
break;
|
|
294
|
+
case "and":
|
|
295
|
+
case "or":
|
|
296
|
+
for (const p of expr.parts)
|
|
297
|
+
this.addTouchedTables(p);
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
247
300
|
}
|
|
248
301
|
/** Reject a user `where` that filters on a column the caller cannot read (closes
|
|
249
302
|
* the same info-leak as ordering by a hidden column: a filter is an oracle for a
|
|
@@ -282,6 +335,16 @@ export class Db {
|
|
|
282
335
|
.filter(([, f]) => f.type === "json" || f.type === "fileRef")
|
|
283
336
|
.map(([n]) => n);
|
|
284
337
|
}
|
|
338
|
+
/** Boolean columns — stored as INTEGER 0/1 (SQLite has no boolean), decoded back to
|
|
339
|
+
* true/false on read so handlers see the `boolean` the InferRow type promises. */
|
|
340
|
+
boolColsOf(table) {
|
|
341
|
+
const fields = this.schema[table]?.fields;
|
|
342
|
+
if (!fields)
|
|
343
|
+
return [];
|
|
344
|
+
return Object.entries(fields)
|
|
345
|
+
.filter(([, f]) => f.type === "boolean")
|
|
346
|
+
.map(([n]) => n);
|
|
347
|
+
}
|
|
285
348
|
/** Columns marked `hidden()` — never projected on an ORM read (even SYSTEM/full). */
|
|
286
349
|
hiddenColsOf(table) {
|
|
287
350
|
const fields = this.schema[table]?.fields;
|
|
@@ -369,7 +432,8 @@ export class Db {
|
|
|
369
432
|
}
|
|
370
433
|
decodeRows(table, rows) {
|
|
371
434
|
const cols = this.jsonColsOf(table);
|
|
372
|
-
|
|
435
|
+
const boolCols = this.boolColsOf(table);
|
|
436
|
+
if (cols.length === 0 && boolCols.length === 0)
|
|
373
437
|
return rows;
|
|
374
438
|
for (const row of rows) {
|
|
375
439
|
for (const c of cols) {
|
|
@@ -383,6 +447,14 @@ export class Db {
|
|
|
383
447
|
}
|
|
384
448
|
}
|
|
385
449
|
}
|
|
450
|
+
// INTEGER 0/1 -> boolean (leave NULL as null for a nullable bool column).
|
|
451
|
+
for (const c of boolCols) {
|
|
452
|
+
const v = row[c];
|
|
453
|
+
if (typeof v === "number")
|
|
454
|
+
row[c] = v !== 0;
|
|
455
|
+
else if (typeof v === "bigint")
|
|
456
|
+
row[c] = v !== 0n;
|
|
457
|
+
}
|
|
386
458
|
}
|
|
387
459
|
return rows;
|
|
388
460
|
}
|
|
@@ -441,7 +513,9 @@ export class Db {
|
|
|
441
513
|
const scope = this.acl.system ? ALLOW_ALL : resolveRelationScope(this.acl, parentEntity, relName, rel.target);
|
|
442
514
|
if (!scope.allowed)
|
|
443
515
|
throw new AclDenied(rel.target, "read");
|
|
444
|
-
|
|
516
|
+
// Strip hidden() columns from the relation load, like every other read path —
|
|
517
|
+
// projectRow alone keeps them under a full/allow()/SYSTEM scope (fields === null).
|
|
518
|
+
const project = (row) => this.stripHidden(rel.target, projectRow(row, effectiveFields(scope, row, this.acl.identity)));
|
|
445
519
|
// One IN query per relation (no N+1). Match column before projecting (which
|
|
446
520
|
// may drop the join column).
|
|
447
521
|
const fetchBy = async (col, values) => {
|
|
@@ -462,15 +536,17 @@ export class Db {
|
|
|
462
536
|
r[relName] = r[rel.column] != null ? (byId.get(r[rel.column]) ?? null) : null;
|
|
463
537
|
}
|
|
464
538
|
else {
|
|
465
|
-
// hasMany: target[column] -> parent
|
|
466
|
-
|
|
539
|
+
// hasMany: target[column] -> parent.<pk> (NOT hardcoded `id` — a parent keyed by
|
|
540
|
+
// slug/username would otherwise join on an undefined `r.id` and get []).
|
|
541
|
+
const pk = this.pkOf(parentEntity);
|
|
542
|
+
const ids = [...new Set(rows.map((r) => r[pk]).filter((v) => v != null))];
|
|
467
543
|
const grouped = new Map();
|
|
468
544
|
for (const { key, row } of await fetchBy(rel.column, ids)) {
|
|
469
545
|
const bucket = grouped.get(key) ?? grouped.set(key, []).get(key);
|
|
470
546
|
bucket.push(row);
|
|
471
547
|
}
|
|
472
548
|
for (const r of rows)
|
|
473
|
-
r[relName] = grouped.get(r
|
|
549
|
+
r[relName] = grouped.get(r[pk]) ?? [];
|
|
474
550
|
}
|
|
475
551
|
}
|
|
476
552
|
/** Insert a single row, returning the persisted row. */
|