@pramen/server 0.0.9 → 0.0.11
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/durable-object.d.ts +20 -0
- package/dist/durable-object.js +97 -15
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -1
- package/dist/pramen.d.ts +9 -3
- package/dist/pramen.js +7 -2
- package/dist/runtime/acl.d.ts +5 -0
- package/dist/runtime/db.d.ts +18 -0
- package/dist/runtime/db.js +73 -7
- package/dist/runtime/dispatch.d.ts +9 -1
- package/dist/runtime/dispatch.js +22 -2
- package/dist/runtime/migrate.js +12 -1
- package/dist/runtime/outbox.d.ts +57 -0
- package/dist/runtime/outbox.js +128 -0
- package/dist/sdk/handlers.d.ts +28 -0
- package/dist/sdk/schema.d.ts +32 -0
- package/dist/sdk/schema.js +48 -0
- package/dist/worker.d.ts +1 -0
- package/dist/worker.js +89 -1
- package/package.json +1 -1
- package/src/durable-object.ts +106 -20
- package/src/index.ts +3 -2
- package/src/pramen.ts +12 -4
- package/src/runtime/acl.ts +5 -0
- package/src/runtime/db.ts +70 -7
- package/src/runtime/dispatch.ts +26 -3
- package/src/runtime/migrate.ts +11 -1
- package/src/runtime/outbox.ts +204 -0
- package/src/sdk/handlers.ts +27 -0
- package/src/sdk/schema.ts +69 -1
- package/src/worker.ts +91 -1
package/dist/durable-object.d.ts
CHANGED
|
@@ -30,9 +30,29 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
|
|
|
30
30
|
* constructor — see `ensureMigrated`. Guards against re-running. */
|
|
31
31
|
private migrated;
|
|
32
32
|
private files?;
|
|
33
|
+
/** Have we persisted (tenant, partition) to DO storage this instance? They're
|
|
34
|
+
* persisted so a COLD alarm wake (no request header) can build a correctly-scoped
|
|
35
|
+
* task context — a DO can't introspect its own idFromName. */
|
|
36
|
+
private identityPersisted;
|
|
37
|
+
private identityLoaded;
|
|
33
38
|
constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
|
|
34
39
|
private ensureMigrated;
|
|
35
40
|
fetch(request: Request): Promise<Response>;
|
|
41
|
+
/** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
|
|
42
|
+
* pending alarm; a near-future time batches a burst of enqueues into one drain. */
|
|
43
|
+
private armDrain;
|
|
44
|
+
/** A privileged, system-scoped context for running task handlers (outside a request).
|
|
45
|
+
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
|
|
46
|
+
private taskCtx;
|
|
47
|
+
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
48
|
+
* task context is scoped correctly. No-op once loaded/persisted this instance. */
|
|
49
|
+
private loadIdentity;
|
|
50
|
+
/** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
|
|
51
|
+
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
|
|
52
|
+
private drainTasks;
|
|
53
|
+
alarm(): Promise<void>;
|
|
54
|
+
private handleDrain;
|
|
55
|
+
private handleTasksList;
|
|
36
56
|
webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise<void>;
|
|
37
57
|
webSocketClose(ws: WebSocket): Promise<void>;
|
|
38
58
|
webSocketError(ws: WebSocket, error: unknown): Promise<void>;
|
package/dist/durable-object.js
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// (identity + subscriptions) is stored via serializeAttachment().
|
|
17
17
|
import { DurableObject } from "cloudflare:workers";
|
|
18
18
|
import { migrate } from "./runtime/migrate";
|
|
19
|
-
import { dispatch } from "./runtime/dispatch";
|
|
19
|
+
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
20
|
+
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
20
21
|
import { Db } from "./runtime/db";
|
|
21
22
|
import { digest } from "./runtime/digest";
|
|
22
23
|
import { compileAcl } from "./runtime/acl";
|
|
@@ -45,6 +46,11 @@ export class PramenDOBase extends DurableObject {
|
|
|
45
46
|
* constructor — see `ensureMigrated`. Guards against re-running. */
|
|
46
47
|
migrated = false;
|
|
47
48
|
files;
|
|
49
|
+
/** Have we persisted (tenant, partition) to DO storage this instance? They're
|
|
50
|
+
* persisted so a COLD alarm wake (no request header) can build a correctly-scoped
|
|
51
|
+
* task context — a DO can't introspect its own idFromName. */
|
|
52
|
+
identityPersisted = false;
|
|
53
|
+
identityLoaded = false;
|
|
48
54
|
constructor(ctx, env, app) {
|
|
49
55
|
super(ctx, env);
|
|
50
56
|
this.app = app;
|
|
@@ -78,6 +84,7 @@ export class PramenDOBase extends DurableObject {
|
|
|
78
84
|
if (this.migrated)
|
|
79
85
|
return; // a concurrent first request already migrated
|
|
80
86
|
await this.driver.transaction(() => migrate(this.driver, this.app.schema, { allowDestructive, partition }).then(() => { }));
|
|
87
|
+
await ensureOutbox(this.driver); // the deferred-tasks table (internal, all partitions)
|
|
81
88
|
this.migrated = true;
|
|
82
89
|
});
|
|
83
90
|
}
|
|
@@ -92,6 +99,15 @@ export class PramenDOBase extends DurableObject {
|
|
|
92
99
|
this.partition = partitionHeader;
|
|
93
100
|
await this.ensureMigrated();
|
|
94
101
|
await this.ensureRegistered(request);
|
|
102
|
+
// Persist (tenant, partition) once per instance so a cold alarm can rebuild the
|
|
103
|
+
// right task context (it has no request header to learn them from). Stored in the
|
|
104
|
+
// SQL store (_pramen_meta), NOT ctx.storage.put — mixing the KV-style storage API
|
|
105
|
+
// with raw `PRAGMA` trips workerd's DO SQLite authorizer (SQLITE_AUTH).
|
|
106
|
+
if (!this.identityPersisted) {
|
|
107
|
+
await this.driver.exec(`INSERT OR REPLACE INTO _pramen_meta (key, value) VALUES (?, ?), (?, ?)`, ["_pramen_tenant", this.tenant, "_pramen_partition", this.partition].map((v) => this.driver.dialect.encode(v)));
|
|
108
|
+
this.identityPersisted = true;
|
|
109
|
+
this.identityLoaded = true;
|
|
110
|
+
}
|
|
95
111
|
const path = new URL(request.url).pathname;
|
|
96
112
|
if (path === "/__recover")
|
|
97
113
|
return this.handleRecover(request);
|
|
@@ -99,6 +115,10 @@ export class PramenDOBase extends DurableObject {
|
|
|
99
115
|
return this.handleSchema();
|
|
100
116
|
if (path === "/__admin/data")
|
|
101
117
|
return this.handleAdminData(request);
|
|
118
|
+
if (path === "/__admin/tasks/drain")
|
|
119
|
+
return this.handleDrain();
|
|
120
|
+
if (path === "/__admin/tasks/list")
|
|
121
|
+
return this.handleTasksList(request);
|
|
102
122
|
const identity = this.identityOf(request);
|
|
103
123
|
if (request.headers.get("Upgrade") === "websocket") {
|
|
104
124
|
const { 0: client, 1: server } = new WebSocketPair();
|
|
@@ -112,9 +132,11 @@ export class PramenDOBase extends DurableObject {
|
|
|
112
132
|
input = await request.json().catch(() => undefined);
|
|
113
133
|
}
|
|
114
134
|
try {
|
|
115
|
-
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(this.tenant), this.envBag, this.ctxFor(identity), name, input);
|
|
135
|
+
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);
|
|
116
136
|
if (kind === "mutation" && touched.length > 0)
|
|
117
137
|
await this.broadcast(touched);
|
|
138
|
+
if (enqueued > 0)
|
|
139
|
+
await this.armDrain();
|
|
118
140
|
return Response.json({ ok: true, result });
|
|
119
141
|
}
|
|
120
142
|
catch (err) {
|
|
@@ -122,6 +144,63 @@ export class PramenDOBase extends DurableObject {
|
|
|
122
144
|
return Response.json(body, { status });
|
|
123
145
|
}
|
|
124
146
|
}
|
|
147
|
+
/** Arm the drain alarm soon after a mutation enqueued task(s). setAlarm replaces any
|
|
148
|
+
* pending alarm; a near-future time batches a burst of enqueues into one drain. */
|
|
149
|
+
async armDrain() {
|
|
150
|
+
await this.ctx.storage.setAlarm(Date.now() + 50);
|
|
151
|
+
}
|
|
152
|
+
/** A privileged, system-scoped context for running task handlers (outside a request).
|
|
153
|
+
* Task handlers get full db access + env (e.g. ctx.env.EMAIL) + their own ctx.tasks. */
|
|
154
|
+
taskCtx() {
|
|
155
|
+
const identity = { roles: ["admin"] };
|
|
156
|
+
const db = new Db(this.driver, { acl: this.acl, identity, system: true, schema: this.app.schema, partition: this.partition, suppressTriggers: true }, this.app.schema);
|
|
157
|
+
return { db, kv: this.kv, files: this.filesFor(this.tenant), env: this.envBag, identity, tasks: tasksFacade(this.driver) };
|
|
158
|
+
}
|
|
159
|
+
/** Restore (tenant, partition) on a cold wake (e.g. an alarm with no request) so the
|
|
160
|
+
* task context is scoped correctly. No-op once loaded/persisted this instance. */
|
|
161
|
+
async loadIdentity() {
|
|
162
|
+
if (this.identityLoaded)
|
|
163
|
+
return;
|
|
164
|
+
// _pramen_meta exists: an alarm only fires after a fetch armed it, and that fetch
|
|
165
|
+
// ran the boot migration which creates the table.
|
|
166
|
+
const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN ('_pramen_tenant', '_pramen_partition')`, []));
|
|
167
|
+
for (const r of rows) {
|
|
168
|
+
if (r.key === "_pramen_tenant" && r.value)
|
|
169
|
+
this.tenant = r.value;
|
|
170
|
+
if (r.key === "_pramen_partition" && r.value)
|
|
171
|
+
this.partition = r.value;
|
|
172
|
+
}
|
|
173
|
+
this.identityLoaded = true;
|
|
174
|
+
}
|
|
175
|
+
/** Drain due tasks. Called by the alarm (DO path) and the /__admin/tasks/drain route
|
|
176
|
+
* (manual / cron). Returns the drain stats incl. `nextRunAt` for rescheduling. */
|
|
177
|
+
async drainTasks() {
|
|
178
|
+
await ensureOutbox(this.driver); // idempotent — the table may predate this instance (cold alarm)
|
|
179
|
+
return drainOutbox(this.driver, bindTasks(this.app.tasks, this.taskCtx()), Date.now());
|
|
180
|
+
}
|
|
181
|
+
async alarm() {
|
|
182
|
+
await this.loadIdentity(); // cold wake: restore tenant/partition before building taskCtx
|
|
183
|
+
const { nextRunAt } = await this.drainTasks();
|
|
184
|
+
// Reschedule to the NEXT task's due time (a backed-off retry, or the next batch if
|
|
185
|
+
// the drain hit its limit) so a failed task can't stall waiting for a new enqueue.
|
|
186
|
+
if (nextRunAt != null)
|
|
187
|
+
await this.ctx.storage.setAlarm(Math.max(nextRunAt, Date.now() + 250));
|
|
188
|
+
}
|
|
189
|
+
async handleDrain() {
|
|
190
|
+
await this.loadIdentity();
|
|
191
|
+
const result = await this.drainTasks();
|
|
192
|
+
// Keep the alarm honest even when drained manually: ensure a backed-off retry wakes.
|
|
193
|
+
if (result.nextRunAt != null)
|
|
194
|
+
await this.ctx.storage.setAlarm(Math.max(result.nextRunAt, Date.now() + 250));
|
|
195
|
+
return Response.json({ ok: true, result });
|
|
196
|
+
}
|
|
197
|
+
async handleTasksList(request) {
|
|
198
|
+
await ensureOutbox(this.driver);
|
|
199
|
+
const url = new URL(request.url);
|
|
200
|
+
const status = url.searchParams.get("status") ?? undefined;
|
|
201
|
+
const limit = Number(url.searchParams.get("limit")) || undefined;
|
|
202
|
+
return Response.json({ ok: true, result: await listTasks(this.driver, { status, limit }) });
|
|
203
|
+
}
|
|
125
204
|
// --- Hibernatable WebSocket handlers ---
|
|
126
205
|
async webSocketMessage(ws, raw) {
|
|
127
206
|
let msg;
|
|
@@ -190,10 +269,12 @@ export class PramenDOBase extends DurableObject {
|
|
|
190
269
|
async onCall(ws, id, name, input) {
|
|
191
270
|
const state = this.getState(ws);
|
|
192
271
|
try {
|
|
193
|
-
const { result, kind, touched } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
|
|
272
|
+
const { result, kind, touched, enqueued } = await dispatch(this.app.handlers, this.app.schema, this.driver, this.kv, this.filesFor(state.tenant), this.envBag, this.ctxFor(state.identity, state.partition), name, input);
|
|
194
273
|
this.send(ws, { type: "result", id, result });
|
|
195
274
|
if (kind === "mutation" && touched.length > 0)
|
|
196
275
|
await this.broadcast(touched);
|
|
276
|
+
if (enqueued > 0)
|
|
277
|
+
await this.armDrain();
|
|
197
278
|
}
|
|
198
279
|
catch (err) {
|
|
199
280
|
this.send(ws, toWsError(id, err));
|
|
@@ -272,20 +353,21 @@ export class PramenDOBase extends DurableObject {
|
|
|
272
353
|
return Response.json({ ok: false, error: "point-in-time recovery is unavailable in this environment", code: "unavailable" }, { status: 501 });
|
|
273
354
|
}
|
|
274
355
|
}
|
|
275
|
-
// Introspection: this tenant's applied schema hash +
|
|
276
|
-
//
|
|
356
|
+
// Introspection: this tenant's applied schema hash + table/column shape (admin-gated
|
|
357
|
+
// at the Worker). Powers the CLI's `schema status`. Both are read from _pramen_meta
|
|
358
|
+
// (written by migrate on boot) — NOT a request-time `PRAGMA`/introspection: once the
|
|
359
|
+
// DO-storage alarm API has run in this object, workerd's SQLite authorizer rejects
|
|
360
|
+
// PRAGMA (SQLITE_AUTH), so the outbox's alarm would otherwise break this endpoint.
|
|
277
361
|
async handleSchema() {
|
|
278
|
-
// The applied-schema hash is stored per-partition (migrate keys it
|
|
279
|
-
// `schema_hash:<partition>` whenever a partition is scoped, which the DO always
|
|
280
|
-
// does). Read this DO's partition's key.
|
|
281
362
|
const hashKey = `schema_hash:${this.partition}`;
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
363
|
+
const tablesKey = `schema_tables:${this.partition}`;
|
|
364
|
+
const rows = (await this.driver.exec(`SELECT key, value FROM _pramen_meta WHERE key IN (?, ?)`, [
|
|
365
|
+
hashKey,
|
|
366
|
+
tablesKey,
|
|
367
|
+
]));
|
|
368
|
+
const byKey = new Map(rows.map((r) => [r.key, r.value]));
|
|
369
|
+
const tables = byKey.has(tablesKey) ? JSON.parse(byKey.get(tablesKey)) : {};
|
|
370
|
+
return Response.json({ ok: true, result: { hash: byKey.get(hashKey) ?? null, tables } });
|
|
289
371
|
}
|
|
290
372
|
// Generic admin data ops (admin-gated at the Worker). Runs through a SYSTEM-mode
|
|
291
373
|
// Db, so ACL is bypassed — admin can browse/edit any row of any table — while the
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
|
|
1
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
2
|
+
export type { TriggerDef, TriggerOp } from "./sdk/schema";
|
|
2
3
|
export { isValidUuid } from "./sdk/uuid";
|
|
3
4
|
export type { DefaultValue, FieldType, FieldDef, EntityFields, EntityDef, SchemaDef, RelationDef, RelationDefs, BelongsToDef, HasManyDef, } from "./sdk/schema";
|
|
4
5
|
export { createApp } from "./sdk/app";
|
|
5
6
|
export { query, mutation } from "./sdk/handlers";
|
|
6
|
-
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts } from "./sdk/handlers";
|
|
7
|
+
export type { Handler, HandlerContext, HandlerKind, HandlerMap, HandlerOpts, Tasks, TaskHandler, AppTaskMap } from "./sdk/handlers";
|
|
7
8
|
export { $identity, $input, allow, deny, policy, resolve, role, isAllow, isDeny, isResolver, isIdentityMarker, isInputMarker } from "./sdk/acl";
|
|
8
9
|
export type { Action, Identity, IdentityMarker, InputMarker, Policy, PolicyRule, PolicyRules, Role, Validator, WhereRule, ConditionalFields, FieldsFn, RelationAclRule, SetValue, ResolverFn, ResolverContext, ResolverDb, } from "./sdk/acl";
|
|
9
10
|
export type { Cell, FieldsOf, InferInsert, InferRow, InferUpdate, JsonValue, ProjectedRow, RelationsOf, RelationsResult, WhereClause, WhereInput, WhereOps, } from "./sdk/infer";
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// which only exists in the Workers runtime; keeping it separate lets the CLI, tests,
|
|
8
8
|
// and codegen load an app.ts for its schema without dragging in the DO runtime.
|
|
9
9
|
// --- schema authoring ---
|
|
10
|
-
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault } from "./sdk/schema";
|
|
10
|
+
export { Entity, defineSchema, renamedFrom, notNull, unique, indexed, hidden, defaultTo, primaryKey, generated, expr, ExprDefault, trigger } from "./sdk/schema";
|
|
11
11
|
export { isValidUuid } from "./sdk/uuid";
|
|
12
12
|
// --- app + handlers ---
|
|
13
13
|
export { createApp } from "./sdk/app";
|
package/dist/pramen.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Env } from "./worker";
|
|
2
2
|
import { pramenDO, type DoEnv } from "./durable-object";
|
|
3
|
-
import type
|
|
4
|
-
import type { HandlerMap } from "./sdk/handlers";
|
|
3
|
+
import { type SchemaDef } from "./sdk/schema";
|
|
4
|
+
import type { AppTaskMap, HandlerMap } from "./sdk/handlers";
|
|
5
5
|
import type { Role } from "./sdk/acl";
|
|
6
6
|
/** Injected into a public route's handler — forward a privileged mutation into the
|
|
7
7
|
* tenant's DO without the handler importing any deploy-side code (so app.ts stays
|
|
@@ -33,10 +33,16 @@ export interface PramenApp {
|
|
|
33
33
|
handlers: HandlerMap;
|
|
34
34
|
acl?: Role[];
|
|
35
35
|
routes?: PublicRoute[];
|
|
36
|
+
/** Deferred side-effect handlers keyed by `kind` — drained from the outbox after a
|
|
37
|
+
* mutation enqueues via `ctx.tasks.enqueue`. For notification email, webhooks, etc. */
|
|
38
|
+
tasks?: AppTaskMap;
|
|
36
39
|
}
|
|
37
40
|
export type { Env, DoEnv };
|
|
38
|
-
/** Build the deployable pair for an app.
|
|
41
|
+
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
42
|
+
* drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
|
|
43
|
+
* use the D1 store with deferred tasks. */
|
|
39
44
|
export declare function createPramen(app: PramenApp): {
|
|
40
45
|
fetch: (request: Request, env: Env) => Promise<Response>;
|
|
46
|
+
scheduled: (event: unknown, env: Env) => Promise<void>;
|
|
41
47
|
PramenDO: ReturnType<typeof pramenDO>;
|
|
42
48
|
};
|
package/dist/pramen.js
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
// type-only by worker.ts / durable-object.ts, so there is no runtime import cycle.
|
|
14
14
|
import { makeWorker } from "./worker";
|
|
15
15
|
import { pramenDO } from "./durable-object";
|
|
16
|
-
|
|
16
|
+
import { validateTriggerTasks } from "./sdk/schema";
|
|
17
|
+
/** Build the deployable pair for an app. `scheduled` is a Cron Trigger entry that
|
|
18
|
+
* drains the D1 outbox (the DO path self-drains via an alarm) — wire it only if you
|
|
19
|
+
* use the D1 store with deferred tasks. */
|
|
17
20
|
export function createPramen(app) {
|
|
18
|
-
|
|
21
|
+
validateTriggerTasks(app.schema, Object.keys(app.tasks ?? {})); // fail fast on a typo'd trigger task
|
|
22
|
+
const worker = makeWorker(app);
|
|
23
|
+
return { fetch: worker.fetch, scheduled: worker.scheduled, PramenDO: pramenDO(app) };
|
|
19
24
|
}
|
package/dist/runtime/acl.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export interface AclContext {
|
|
|
30
30
|
* lives in a different partition (a partition-DO only owns its own tables). Unset
|
|
31
31
|
* (e.g. the D1/Worker shared-store path) disables the guard — a no-op. */
|
|
32
32
|
readonly partition?: string;
|
|
33
|
+
/** Suppress declarative write-triggers for this Db. Set on the privileged context
|
|
34
|
+
* that DRAINS tasks, so a task handler's writes don't re-fire triggers (which would
|
|
35
|
+
* cascade — a trigger → task → write → trigger loop). Triggers fire on request-path
|
|
36
|
+
* writes, not on task-handler writes. */
|
|
37
|
+
readonly suppressTriggers?: boolean;
|
|
33
38
|
}
|
|
34
39
|
/** Evaluate every resolver reachable by the identity's roles, once per request.
|
|
35
40
|
* Resolvers read through a SYSTEM-mode db (ACL bypassed) to avoid recursion. */
|
package/dist/runtime/db.d.ts
CHANGED
|
@@ -62,6 +62,10 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
62
62
|
private readonly schema;
|
|
63
63
|
/** Tables read or written during this Db's lifetime. */
|
|
64
64
|
readonly touched: Set<string>;
|
|
65
|
+
/** Tasks enqueued by declarative triggers during this Db's lifetime — the DO adds
|
|
66
|
+
* this to ctx.tasks enqueues to decide whether to arm its drain alarm. */
|
|
67
|
+
private taskEnqueueCount;
|
|
68
|
+
get taskEnqueues(): number;
|
|
65
69
|
private readonly dialect;
|
|
66
70
|
private readonly acl;
|
|
67
71
|
constructor(driver: Driver, acl: AclContext, schema: SchemaDef);
|
|
@@ -124,6 +128,20 @@ export declare class Db<S extends SchemaDef = SchemaDef> {
|
|
|
124
128
|
private hiddenColsOf;
|
|
125
129
|
/** Drop hidden columns from a row (copying only if any are present). */
|
|
126
130
|
private stripHidden;
|
|
131
|
+
/** Fire declarative write-triggers for `op` on `entity`: enqueue a task per matching
|
|
132
|
+
* trigger into the outbox, in THIS mutation's transaction (atomic with the write).
|
|
133
|
+
* `row` is the affected row (decoded — new values for create/update, the removed row
|
|
134
|
+
* for delete); `writtenCols` are the columns the write touched; `before` is the prior
|
|
135
|
+
* row (update only) for value-change detection on a field-filtered trigger.
|
|
136
|
+
*
|
|
137
|
+
* - Hidden columns are STRIPPED from the payload row — `hidden()` ("never readable via
|
|
138
|
+
* the ORM, even under SYSTEM") must hold here too, or a secret like passwordHash
|
|
139
|
+
* would leak to a task handler / webhook.
|
|
140
|
+
* - A field-filtered update trigger fires only when a watched column's value actually
|
|
141
|
+
* CHANGED (not merely was written to the same value).
|
|
142
|
+
* - Suppressed in the task-drain context, so a task's own writes don't re-fire
|
|
143
|
+
* triggers (preventing a trigger→task→write→trigger cascade). */
|
|
144
|
+
private fireTriggers;
|
|
127
145
|
/** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
|
|
128
146
|
* `vals`. Returns the columns it filled — server-minted, so the insert path treats
|
|
129
147
|
* them like forced `set` values (bypassing the writable-field ACL check). */
|
package/dist/runtime/db.js
CHANGED
|
@@ -13,9 +13,21 @@
|
|
|
13
13
|
import { AclDenied, ALLOW_ALL, compileScopedWhere, effectiveFields, projectRow, resolveRelationScope, resolveScope, resolveWriteRules, } from "./acl";
|
|
14
14
|
import { and, cmp, compileAggregate, compileCount, compileExpr, compileSelect, eq, inList, or, TRUE, } from "./read-engine";
|
|
15
15
|
import { BadRequest } from "./errors";
|
|
16
|
-
import {
|
|
16
|
+
import { enqueueTask } from "./outbox";
|
|
17
|
+
import { partitionOf, triggersOf, triggerFires } from "../sdk/schema";
|
|
17
18
|
import { isValidUuid } from "../sdk/uuid";
|
|
18
19
|
const DEFAULT_PAGE_SIZE = 50;
|
|
20
|
+
/** Compare two decoded cell values for trigger change-detection. Primitives by ===;
|
|
21
|
+
* json/object cells (already parsed) by structural JSON equality. */
|
|
22
|
+
function cellEqual(a, b) {
|
|
23
|
+
if (a === b)
|
|
24
|
+
return true;
|
|
25
|
+
if (a == null || b == null)
|
|
26
|
+
return false;
|
|
27
|
+
if (typeof a === "object" || typeof b === "object")
|
|
28
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
19
31
|
function normalizeOrder(orderBy) {
|
|
20
32
|
if (!orderBy)
|
|
21
33
|
return undefined;
|
|
@@ -55,6 +67,12 @@ export class Db {
|
|
|
55
67
|
schema;
|
|
56
68
|
/** Tables read or written during this Db's lifetime. */
|
|
57
69
|
touched = new Set();
|
|
70
|
+
/** Tasks enqueued by declarative triggers during this Db's lifetime — the DO adds
|
|
71
|
+
* this to ctx.tasks enqueues to decide whether to arm its drain alarm. */
|
|
72
|
+
taskEnqueueCount = 0;
|
|
73
|
+
get taskEnqueues() {
|
|
74
|
+
return this.taskEnqueueCount;
|
|
75
|
+
}
|
|
58
76
|
dialect;
|
|
59
77
|
acl;
|
|
60
78
|
constructor(driver, acl, schema) {
|
|
@@ -283,6 +301,41 @@ export class Db {
|
|
|
283
301
|
delete out[c];
|
|
284
302
|
return out;
|
|
285
303
|
}
|
|
304
|
+
/** Fire declarative write-triggers for `op` on `entity`: enqueue a task per matching
|
|
305
|
+
* trigger into the outbox, in THIS mutation's transaction (atomic with the write).
|
|
306
|
+
* `row` is the affected row (decoded — new values for create/update, the removed row
|
|
307
|
+
* for delete); `writtenCols` are the columns the write touched; `before` is the prior
|
|
308
|
+
* row (update only) for value-change detection on a field-filtered trigger.
|
|
309
|
+
*
|
|
310
|
+
* - Hidden columns are STRIPPED from the payload row — `hidden()` ("never readable via
|
|
311
|
+
* the ORM, even under SYSTEM") must hold here too, or a secret like passwordHash
|
|
312
|
+
* would leak to a task handler / webhook.
|
|
313
|
+
* - A field-filtered update trigger fires only when a watched column's value actually
|
|
314
|
+
* CHANGED (not merely was written to the same value).
|
|
315
|
+
* - Suppressed in the task-drain context, so a task's own writes don't re-fire
|
|
316
|
+
* triggers (preventing a trigger→task→write→trigger cascade). */
|
|
317
|
+
async fireTriggers(entity, op, row, writtenCols, before) {
|
|
318
|
+
if (this.acl.suppressTriggers)
|
|
319
|
+
return;
|
|
320
|
+
const triggers = triggersOf(this.schema, entity);
|
|
321
|
+
if (triggers.length === 0)
|
|
322
|
+
return;
|
|
323
|
+
const id = row[this.pkOf(entity)];
|
|
324
|
+
let safeRow;
|
|
325
|
+
for (const t of triggers) {
|
|
326
|
+
if (!triggerFires(t, op, writtenCols))
|
|
327
|
+
continue;
|
|
328
|
+
if (op === "update" && Array.isArray(t.on.update) && before) {
|
|
329
|
+
const changed = t.on.update.some((c) => writtenCols.includes(c) && !cellEqual(before[c], row[c]));
|
|
330
|
+
if (!changed)
|
|
331
|
+
continue; // watched column(s) written, but value unchanged
|
|
332
|
+
}
|
|
333
|
+
if (!safeRow)
|
|
334
|
+
safeRow = this.stripHidden(entity, row); // never leak hidden columns
|
|
335
|
+
await enqueueTask(this.driver, Date.now(), { kind: t.task, payload: { entity, op, id, row: safeRow } });
|
|
336
|
+
this.taskEnqueueCount++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
286
339
|
/** Mint a UUID for every `generated()` uuid column the caller omitted, mutating
|
|
287
340
|
* `vals`. Returns the columns it filled — server-minted, so the insert path treats
|
|
288
341
|
* them like forced `set` values (bypassing the writable-field ACL check). */
|
|
@@ -443,7 +496,9 @@ export class Db {
|
|
|
443
496
|
const params = cols.map((c) => this.encodeCell(jsonCols, c, vals[c]));
|
|
444
497
|
const sql = `INSERT INTO ${this.dialect.id(table)} (${colList}) VALUES (${phs})${this.returningClause("*")}`;
|
|
445
498
|
const rows = await this.driver.exec(sql, params);
|
|
446
|
-
|
|
499
|
+
const persisted = this.decodeRow(table, rows[0]);
|
|
500
|
+
await this.fireTriggers(table, "create", persisted, cols);
|
|
501
|
+
return this.projectWrite(table, persisted, cols);
|
|
447
502
|
}
|
|
448
503
|
/** Project a mutation's RETURNING row so the echo never reveals more than a read
|
|
449
504
|
* would: the caller's readable fields for this row, PLUS the columns they just
|
|
@@ -481,14 +536,20 @@ export class Db {
|
|
|
481
536
|
const cols = Object.keys(p);
|
|
482
537
|
if (cols.length === 0)
|
|
483
538
|
return undefined;
|
|
484
|
-
//
|
|
485
|
-
//
|
|
539
|
+
// Fetch the existing row when we need it: for per-row field permission (evaluated
|
|
540
|
+
// against the FINAL post-merge row) OR for a field-filtered update trigger's
|
|
541
|
+
// value-change detection (so it fires only on an actual change, not a same-value write).
|
|
542
|
+
const needCellEval = scope.fields !== null && (scope.conditional.length > 0 || scope.fieldsFns.length > 0);
|
|
543
|
+
const needBefore = !this.acl.suppressTriggers && triggersOf(this.schema, table).some((t) => Array.isArray(t.on.update));
|
|
486
544
|
let evalRow = p;
|
|
487
|
-
|
|
545
|
+
let before;
|
|
546
|
+
if (needCellEval || needBefore) {
|
|
488
547
|
const existing = await this.fetchOne(table, id, scope.where);
|
|
489
548
|
if (!existing)
|
|
490
549
|
return undefined; // out of update scope -> no-op
|
|
491
|
-
|
|
550
|
+
before = existing;
|
|
551
|
+
if (needCellEval)
|
|
552
|
+
evalRow = { ...existing, ...p };
|
|
492
553
|
}
|
|
493
554
|
this.checkWriteFields(table, "update", scope, cols, evalRow, new Set(Object.keys(set)));
|
|
494
555
|
this.runValidators(validators, p);
|
|
@@ -506,6 +567,8 @@ export class Db {
|
|
|
506
567
|
sql += this.scopeClause(scope.where, params);
|
|
507
568
|
sql += this.returningClause("*");
|
|
508
569
|
const updated = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
570
|
+
if (updated)
|
|
571
|
+
await this.fireTriggers(table, "update", updated, cols, before);
|
|
509
572
|
return (updated ? this.projectWrite(table, updated, cols) : undefined);
|
|
510
573
|
}
|
|
511
574
|
/** Delete a row by id within scope. Returns whether a row was deleted. */
|
|
@@ -519,7 +582,10 @@ export class Db {
|
|
|
519
582
|
let sql = `DELETE FROM ${this.dialect.id(table)} WHERE ${this.dialect.id(this.pkOf(table))} = ${this.dialect.placeholder(1)}`;
|
|
520
583
|
sql += this.scopeClause(scope.where, params);
|
|
521
584
|
sql += this.returningClause("*");
|
|
522
|
-
|
|
585
|
+
const deleted = this.decodeRow(table, (await this.driver.exec(sql, params))[0]);
|
|
586
|
+
if (deleted)
|
|
587
|
+
await this.fireTriggers(table, "delete", deleted, []);
|
|
588
|
+
return deleted != null;
|
|
523
589
|
}
|
|
524
590
|
/** Escape hatch — raw SQL, NOT ACL-checked. For system/internal use only. */
|
|
525
591
|
async exec(sql, ...params) {
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { type AclContext } from "./acl";
|
|
2
|
+
import { type TaskMap } from "./outbox";
|
|
2
3
|
import type { Driver } from "./driver";
|
|
3
4
|
import type { Kv } from "./kv";
|
|
4
5
|
import type { Files } from "../sdk/files";
|
|
5
6
|
import type { SchemaDef } from "../sdk/schema";
|
|
6
|
-
import type { HandlerKind, HandlerMap } from "../sdk/handlers";
|
|
7
|
+
import type { AppTaskMap, HandlerContext, HandlerKind, HandlerMap, Tasks } from "../sdk/handlers";
|
|
7
8
|
export interface DispatchResult {
|
|
8
9
|
readonly result: unknown;
|
|
9
10
|
readonly kind: HandlerKind;
|
|
10
11
|
readonly touched: string[];
|
|
12
|
+
/** Number of tasks the handler enqueued — the DO uses this to arm its drain alarm. */
|
|
13
|
+
readonly enqueued: number;
|
|
11
14
|
}
|
|
15
|
+
/** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
|
|
16
|
+
* so it can wake the drainer. */
|
|
17
|
+
export declare function tasksFacade(driver: Driver, onEnqueue?: () => void): Tasks;
|
|
18
|
+
/** Bind `app.tasks` (which take a ctx) into the ctx-free `TaskMap` the drainer calls. */
|
|
19
|
+
export declare function bindTasks(appTasks: AppTaskMap | undefined, ctx: HandlerContext): TaskMap;
|
|
12
20
|
export declare function dispatch(handlers: HandlerMap, schema: SchemaDef, driver: Driver, kv: Kv, files: Files, env: Readonly<Record<string, unknown>>, acl: AclContext, name: string, input: unknown): Promise<DispatchResult>;
|
package/dist/runtime/dispatch.js
CHANGED
|
@@ -10,6 +10,24 @@
|
|
|
10
10
|
import { Db } from "./db";
|
|
11
11
|
import { warmup } from "./acl";
|
|
12
12
|
import { BadRequest } from "./errors";
|
|
13
|
+
import { enqueueTask } from "./outbox";
|
|
14
|
+
/** The `ctx.tasks` facade over the outbox. `onEnqueue` lets the caller count enqueues
|
|
15
|
+
* so it can wake the drainer. */
|
|
16
|
+
export function tasksFacade(driver, onEnqueue) {
|
|
17
|
+
return {
|
|
18
|
+
enqueue: async (opts) => {
|
|
19
|
+
await enqueueTask(driver, Date.now(), opts);
|
|
20
|
+
onEnqueue?.();
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Bind `app.tasks` (which take a ctx) into the ctx-free `TaskMap` the drainer calls. */
|
|
25
|
+
export function bindTasks(appTasks, ctx) {
|
|
26
|
+
const out = {};
|
|
27
|
+
for (const [kind, handler] of Object.entries(appTasks ?? {}))
|
|
28
|
+
out[kind] = (payload, meta) => handler(ctx, payload, meta);
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
13
31
|
export async function dispatch(handlers, schema, driver, kv, files, env, acl, name, input) {
|
|
14
32
|
const handler = handlers[name];
|
|
15
33
|
if (!handler)
|
|
@@ -29,9 +47,11 @@ export async function dispatch(handlers, schema, driver, kv, files, env, acl, na
|
|
|
29
47
|
const systemDb = new Db(driver, { acl: acl.acl, identity: acl.identity, system: true, schema, partition: acl.partition }, schema);
|
|
30
48
|
const resolved = await warmup(acl.acl, acl.identity, systemDb);
|
|
31
49
|
const db = new Db(driver, { acl: acl.acl, identity: acl.identity, input: parsed, resolved, schema, partition: acl.partition }, schema);
|
|
32
|
-
|
|
50
|
+
let enqueued = 0;
|
|
51
|
+
const ctx = { db, kv, files, env, identity: acl.identity, tasks: tasksFacade(driver, () => enqueued++) };
|
|
33
52
|
const result = handler.kind === "query"
|
|
34
53
|
? await handler.run(ctx, parsed)
|
|
35
54
|
: await driver.transaction(async () => handler.run(ctx, parsed));
|
|
36
|
-
|
|
55
|
+
// Both explicit ctx.tasks.enqueue and declarative trigger enqueues (in db) count.
|
|
56
|
+
return { result, kind: handler.kind, touched: [...db.touched], enqueued: enqueued + db.taskEnqueues };
|
|
37
57
|
}
|
package/dist/runtime/migrate.js
CHANGED
|
@@ -98,9 +98,16 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
98
98
|
// `schema_hash` key for backward compatibility (existing stores + the D1 path).
|
|
99
99
|
const subset = Object.fromEntries(entries);
|
|
100
100
|
const hashKey = opts.partition === undefined ? "schema_hash" : `schema_hash:${opts.partition}`;
|
|
101
|
+
const tablesKey = opts.partition === undefined ? "schema_tables" : `schema_tables:${opts.partition}`;
|
|
102
|
+
const tablesValue = () => JSON.stringify(Object.fromEntries(entries.map(([table, def]) => [table, Object.keys(def.fields)])));
|
|
101
103
|
const current = schemaHash(subset);
|
|
102
|
-
if ((await readMeta(driver, hashKey)) === current)
|
|
104
|
+
if ((await readMeta(driver, hashKey)) === current) {
|
|
105
|
+
// Backfill the table map for stores migrated before this key existed (the schema
|
|
106
|
+
// is unchanged, so it's exactly what's applied).
|
|
107
|
+
if ((await readMeta(driver, tablesKey)) == null)
|
|
108
|
+
await writeMeta(driver, tablesKey, tablesValue());
|
|
103
109
|
return { changed: false, created: [], added: [], rebuilt: [], droppedTables: [], skipped: [] };
|
|
110
|
+
}
|
|
104
111
|
const created = [];
|
|
105
112
|
const added = [];
|
|
106
113
|
const rebuilt = [];
|
|
@@ -186,6 +193,10 @@ export async function migrate(driver, schema, opts = {}) {
|
|
|
186
193
|
// additive work is idempotent, so re-running is safe.
|
|
187
194
|
if (skipped.length === 0) {
|
|
188
195
|
await writeMeta(driver, hashKey, current);
|
|
196
|
+
// Persist the applied table→columns so /admin/schema reports it without a raw
|
|
197
|
+
// PRAGMA at request time — workerd's SQLite authorizer rejects PRAGMA once the
|
|
198
|
+
// DO-storage alarm API has run in the object. Migrate runs on boot, before any.
|
|
199
|
+
await writeMeta(driver, tablesKey, tablesValue());
|
|
189
200
|
}
|
|
190
201
|
else {
|
|
191
202
|
console.warn(`pramen: ${skipped.length} destructive migration(s) skipped (set PRAMEN_ALLOW_DESTRUCTIVE=true to apply): ${skipped.join("; ")}`);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Driver } from "./driver";
|
|
2
|
+
export declare const OUTBOX_TABLE = "_pramen_outbox";
|
|
3
|
+
/** Create the outbox table if absent. Idempotent — run on DO boot (and lazily on the
|
|
4
|
+
* D1 path). Internal table (`_pramen_` prefix), never part of the user schema. */
|
|
5
|
+
export declare function ensureOutbox(driver: Driver): Promise<void>;
|
|
6
|
+
export interface EnqueueOpts {
|
|
7
|
+
kind: string;
|
|
8
|
+
payload?: unknown;
|
|
9
|
+
/** Delay before the task becomes due (ms from now). Default 0 (drain ASAP). */
|
|
10
|
+
delayMs?: number;
|
|
11
|
+
}
|
|
12
|
+
/** Insert one task row. Uses the driver directly, so inside a mutation it joins that
|
|
13
|
+
* mutation's transaction (atomic with the data write). `now` is stamped by the caller. */
|
|
14
|
+
export declare function enqueueTask(driver: Driver, now: number, opts: EnqueueOpts): Promise<void>;
|
|
15
|
+
/** Idempotency metadata handed to a task handler. `id` is stable across retries — a
|
|
16
|
+
* handler can record it and skip a duplicate delivery (at-least-once). */
|
|
17
|
+
export interface TaskMeta {
|
|
18
|
+
id: string;
|
|
19
|
+
/** 1-based attempt number for this delivery. */
|
|
20
|
+
attempts: number;
|
|
21
|
+
}
|
|
22
|
+
/** A task handler: runs the side effect for one `kind`. Throwing schedules a retry. */
|
|
23
|
+
export type TaskHandler = (payload: unknown, meta: TaskMeta) => void | Promise<void>;
|
|
24
|
+
export type TaskMap = Record<string, TaskHandler>;
|
|
25
|
+
export interface DrainResult {
|
|
26
|
+
processed: number;
|
|
27
|
+
succeeded: number;
|
|
28
|
+
failed: number;
|
|
29
|
+
/** Tasks still pending+due after this pass (a caller may loop to clear a backlog). */
|
|
30
|
+
remaining: number;
|
|
31
|
+
/** Epoch-ms of the earliest not-yet-run task (due or backed-off), or null if none —
|
|
32
|
+
* the DO schedules its next alarm here so a backed-off retry can't stall. */
|
|
33
|
+
nextRunAt: number | null;
|
|
34
|
+
}
|
|
35
|
+
/** Run every due task once (claimed batch, up to `limit`). On success mark it done; on
|
|
36
|
+
* throw, bump attempts and back off, or dead-letter ('failed') past MAX_ATTEMPTS.
|
|
37
|
+
*
|
|
38
|
+
* Substrate-agnostic and concurrency-safe: the claim UPDATE (pending→processing) is
|
|
39
|
+
* atomic, so two drainers (the D1/Cron path) get disjoint batches; a crashed drainer's
|
|
40
|
+
* claim is reclaimed after STALE_MS. The DO path is single-writer so claims never
|
|
41
|
+
* contend, but the same code runs there too. */
|
|
42
|
+
export declare function drainOutbox(driver: Driver, tasks: TaskMap, now: number, limit?: number): Promise<DrainResult>;
|
|
43
|
+
export interface TaskRow {
|
|
44
|
+
id: string;
|
|
45
|
+
kind: string;
|
|
46
|
+
status: string;
|
|
47
|
+
attempts: number;
|
|
48
|
+
runAt: number;
|
|
49
|
+
createdAt: number;
|
|
50
|
+
lastError: string | null;
|
|
51
|
+
}
|
|
52
|
+
/** List outbox rows for admin visibility (e.g. inspect dead-lettered tasks). Newest
|
|
53
|
+
* first; optionally filter by `status` (e.g. "failed"). Excludes the payload. */
|
|
54
|
+
export declare function listTasks(driver: Driver, opts?: {
|
|
55
|
+
status?: string;
|
|
56
|
+
limit?: number;
|
|
57
|
+
}): Promise<TaskRow[]>;
|