@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/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
- return { db, kv: new Kv(env.KV), files, env: env as unknown as Record<string, unknown>, identity, tasks: tasksFacade(driver) };
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