@pramen/server 0.0.10 → 0.0.12
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.js +24 -17
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/pramen.d.ts +1 -1
- package/dist/pramen.js +2 -0
- 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.js +12 -2
- package/dist/runtime/mail.d.ts +80 -0
- package/dist/runtime/mail.js +102 -0
- package/dist/runtime/migrate.js +12 -1
- package/dist/sdk/handlers.d.ts +6 -0
- package/dist/sdk/schema.d.ts +32 -0
- package/dist/sdk/schema.js +48 -0
- package/dist/worker.js +4 -2
- package/package.json +1 -1
- package/src/durable-object.ts +24 -21
- package/src/index.ts +6 -1
- package/src/pramen.ts +2 -1
- package/src/runtime/acl.ts +5 -0
- package/src/runtime/db.ts +70 -7
- package/src/runtime/dispatch.ts +12 -2
- package/src/runtime/mail.ts +136 -0
- package/src/runtime/migrate.ts +11 -1
- package/src/sdk/handlers.ts +6 -0
- package/src/sdk/schema.ts +69 -1
- package/src/worker.ts +4 -2
package/src/sdk/schema.ts
CHANGED
|
@@ -95,26 +95,82 @@ export type RelationBuilders = typeof relationBuilders;
|
|
|
95
95
|
/** The default partition name for entities that don't declare one. */
|
|
96
96
|
export const DEFAULT_PARTITION = "default";
|
|
97
97
|
|
|
98
|
+
// --- triggers — declarative "on write → enqueue a task" (layered on the outbox) ---
|
|
99
|
+
|
|
100
|
+
export type TriggerOp = "create" | "update" | "delete";
|
|
101
|
+
|
|
102
|
+
/** A declarative trigger on an entity: when a matching write commits, the `Db` write
|
|
103
|
+
* path enqueues a task of `task` (handled by `app.tasks[task]`) IN THE SAME transaction
|
|
104
|
+
* as the write, with payload `{ entity, op, id, row }`. So a side effect (webhook,
|
|
105
|
+
* notification email) fires reliably after the write, off the single-writer path —
|
|
106
|
+
* reusing the whole outbox machinery (retry, idempotency, drain). Only ORM writes
|
|
107
|
+
* (`ctx.db` insert/update/delete) fire triggers; the raw `ctx.db.exec` escape hatch
|
|
108
|
+
* does not. */
|
|
109
|
+
export interface TriggerDef {
|
|
110
|
+
/** The `app.tasks` handler kind that runs the side effect. */
|
|
111
|
+
readonly task: string;
|
|
112
|
+
/** Which ops fire it. For `update`, an array names the columns to watch — fire only
|
|
113
|
+
* when the update writes one of them; `true` fires on any update. */
|
|
114
|
+
readonly on: { create?: boolean; update?: boolean | readonly string[]; delete?: boolean };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Declare an entity trigger (sugar; returns the def). */
|
|
118
|
+
export function trigger(def: TriggerDef): TriggerDef {
|
|
119
|
+
return def;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Does this trigger fire for `op` given the columns the write touched? */
|
|
123
|
+
export function triggerFires(t: TriggerDef, op: TriggerOp, writtenCols: readonly string[]): boolean {
|
|
124
|
+
if (op === "create") return t.on.create === true;
|
|
125
|
+
if (op === "delete") return t.on.delete === true;
|
|
126
|
+
const u = t.on.update;
|
|
127
|
+
if (u === true) return true;
|
|
128
|
+
if (Array.isArray(u)) return u.some((f) => writtenCols.includes(f));
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
export interface EntityDef<F extends EntityFields = EntityFields, R extends RelationDefs = Record<string, never>> {
|
|
99
133
|
readonly fields: F;
|
|
100
134
|
readonly relations: R;
|
|
101
135
|
/** The partition (Durable Object class) this entity lives in. Always populated;
|
|
102
136
|
* defaults to `"default"` so downstream code never branches on `undefined`. */
|
|
103
137
|
readonly partition: string;
|
|
138
|
+
/** Declarative write-triggers (see TriggerDef). Always an array (possibly empty). */
|
|
139
|
+
readonly triggers: readonly TriggerDef[];
|
|
104
140
|
}
|
|
105
141
|
|
|
106
142
|
export function Entity<F extends EntityFields, R extends RelationDefs = Record<string, never>>(
|
|
107
143
|
build: (t: FieldBuilders) => F,
|
|
108
144
|
relations?: (r: RelationBuilders) => R,
|
|
109
|
-
opts?: { partition?: string },
|
|
145
|
+
opts?: { partition?: string; triggers?: readonly TriggerDef[] },
|
|
110
146
|
): EntityDef<F, R> {
|
|
111
147
|
return {
|
|
112
148
|
fields: build(builders),
|
|
113
149
|
relations: (relations ? relations(relationBuilders) : {}) as R,
|
|
114
150
|
partition: opts?.partition ?? DEFAULT_PARTITION,
|
|
151
|
+
triggers: opts?.triggers ?? [],
|
|
115
152
|
};
|
|
116
153
|
}
|
|
117
154
|
|
|
155
|
+
/** The triggers declared on an entity (empty if none / unknown entity). */
|
|
156
|
+
export function triggersOf(schema: SchemaDef, entity: string): readonly TriggerDef[] {
|
|
157
|
+
return schema[entity]?.triggers ?? [];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Throw if any declarative trigger names a `task` not in `taskNames` — caught at
|
|
161
|
+
* deploy/load by createPramen, so a typo can't silently enqueue a task that never runs
|
|
162
|
+
* (it would retry then dead-letter). */
|
|
163
|
+
export function validateTriggerTasks(schema: SchemaDef, taskNames: Iterable<string>): void {
|
|
164
|
+
const known = new Set(taskNames);
|
|
165
|
+
for (const [entity, def] of Object.entries(schema)) {
|
|
166
|
+
for (const t of def.triggers) {
|
|
167
|
+
if (!known.has(t.task)) {
|
|
168
|
+
throw new Error(`trigger on '${entity}' references task '${t.task}', but app.tasks has no such handler.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
118
174
|
/** Annotate a field as renamed from a previous column name (migration hint). Wraps
|
|
119
175
|
* a builder result, preserving its literal field type: `title: renamedFrom(t.text(), "name")`. */
|
|
120
176
|
export function renamedFrom<F extends FieldDef>(field: F, from: string): F & { readonly renamedFrom: string } {
|
|
@@ -254,5 +310,17 @@ export function validateSchema(schema: SchemaDef): void {
|
|
|
254
310
|
);
|
|
255
311
|
}
|
|
256
312
|
}
|
|
313
|
+
for (const t of def.triggers) {
|
|
314
|
+
if (!t.task) throw new Error(`trigger on '${entity}' is missing a 'task'.`);
|
|
315
|
+
if (!t.on.create && !t.on.update && !t.on.delete) {
|
|
316
|
+
throw new Error(`trigger '${t.task}' on '${entity}' fires on nothing — set on.create/update/delete.`);
|
|
317
|
+
}
|
|
318
|
+
const watched = Array.isArray(t.on.update) ? t.on.update : [];
|
|
319
|
+
for (const f of watched) {
|
|
320
|
+
if (!(f in def.fields)) {
|
|
321
|
+
throw new Error(`trigger '${t.task}' on '${entity}' watches unknown column '${f}'.`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
257
325
|
}
|
|
258
326
|
}
|
package/src/worker.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { authorizeTenant, HmacStrategy, isAdmin, JwksStrategy, resolveIdentity, type VerifyStrategy } from "./auth";
|
|
8
8
|
import { dispatch, tasksFacade, bindTasks } from "./runtime/dispatch";
|
|
9
9
|
import { ensureOutbox, drainOutbox, listTasks } from "./runtime/outbox";
|
|
10
|
+
import { createMail } from "./runtime/mail";
|
|
10
11
|
import { migrate } from "./runtime/migrate";
|
|
11
12
|
import { compileAcl } from "./runtime/acl";
|
|
12
13
|
import { Db } from "./runtime/db";
|
|
@@ -148,8 +149,9 @@ export function makeWorker(app: PramenApp) {
|
|
|
148
149
|
const d1TaskCtx = (driver: Driver, env: Env): HandlerContext => {
|
|
149
150
|
const identity: Identity = { roles: ["admin"] };
|
|
150
151
|
const files = createFiles({ tenant: "main", secret: filesSecret(env), adapter: new R2Adapter(env.FILES) });
|
|
151
|
-
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema }, app.schema);
|
|
152
|
-
|
|
152
|
+
const db = new Db(driver, { acl: d1Acl, identity, system: true, schema: app.schema, suppressTriggers: true }, app.schema);
|
|
153
|
+
const kv = new Kv(env.KV);
|
|
154
|
+
return { db, kv, files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver), mail: createMail(env as unknown as Record<string, unknown>, kv) };
|
|
153
155
|
};
|
|
154
156
|
|
|
155
157
|
/** Drain the D1 outbox in the Worker (no DO/alarm on this path) — called by the
|