@tekki-forge/runtime-api 0.3.2 → 0.4.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.
Files changed (45) hide show
  1. package/dist/bootstrap.js +7 -0
  2. package/dist/bootstrap.js.map +1 -1
  3. package/dist/computed.js +1 -1
  4. package/dist/computed.js.map +1 -1
  5. package/dist/controllers.d.ts +52 -0
  6. package/dist/controllers.js +69 -1
  7. package/dist/controllers.js.map +1 -1
  8. package/dist/errors.js +4 -0
  9. package/dist/errors.js.map +1 -1
  10. package/dist/events.d.ts +12 -0
  11. package/dist/events.js +26 -0
  12. package/dist/events.js.map +1 -1
  13. package/dist/exports.d.ts +34 -0
  14. package/dist/exports.js +170 -0
  15. package/dist/exports.js.map +1 -0
  16. package/dist/index.d.ts +3 -0
  17. package/dist/index.js +3 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/notifications.d.ts +2 -0
  20. package/dist/notifications.js +8 -1
  21. package/dist/notifications.js.map +1 -1
  22. package/dist/observability.d.ts +20 -0
  23. package/dist/observability.js +47 -0
  24. package/dist/observability.js.map +1 -0
  25. package/dist/outbox.d.ts +21 -0
  26. package/dist/outbox.js +98 -0
  27. package/dist/outbox.js.map +1 -0
  28. package/dist/plugin.d.ts +2 -0
  29. package/dist/plugin.js.map +1 -1
  30. package/dist/query.d.ts +3 -0
  31. package/dist/query.js +13 -2
  32. package/dist/query.js.map +1 -1
  33. package/dist/search.d.ts +1 -0
  34. package/dist/search.js +14 -3
  35. package/dist/search.js.map +1 -1
  36. package/dist/seed.js +5 -3
  37. package/dist/seed.js.map +1 -1
  38. package/dist/serialize.js +4 -2
  39. package/dist/serialize.js.map +1 -1
  40. package/dist/service.d.ts +33 -1
  41. package/dist/service.js +187 -60
  42. package/dist/service.js.map +1 -1
  43. package/dist/testing.js +6 -1
  44. package/dist/testing.js.map +1 -1
  45. package/package.json +6 -6
package/dist/service.js CHANGED
@@ -5,14 +5,24 @@ import { BadRequestException, ConflictException, ForbiddenException, NotFoundExc
5
5
  import { queryableFields, unreadableFields, unwritableFields } from "./access.js";
6
6
  import { ComputedEngine } from "./computed.js";
7
7
  import { EventBus } from "./events.js";
8
+ import { EventOutbox } from "./outbox.js";
9
+ import { ExportService } from "./exports.js";
10
+ import DecimalDefault from "decimal.js";
8
11
  import { NotificationService } from "./notifications.js";
9
12
  import { QueryError, parseListQuery, searchWhere } from "./query.js";
10
13
  import { diff, label, manyIds, relationInclude, serialize, toCsv, toPrismaData } from "./serialize.js";
11
14
  import { ApiKeyService } from "./api-keys.js";
12
15
  import { teamIdsOf } from "./teams.js";
13
16
  import { SettingsService, isGlobalAdminRole } from "./settings.js";
17
+ const Decimal = (DecimalDefault.default ?? DecimalDefault);
14
18
  const EXPORT_LIMIT = 10_000;
15
- const TX = { timeout: 20_000, maxWait: 10_000 };
19
+ const TX = { timeout: 20_000, maxWait: 10_000, isolationLevel: "Serializable" };
20
+ /** Serializable transactions that lose a race are retried; the losing attempt changed nothing. */
21
+ const TX_ATTEMPTS = 6;
22
+ /** Custom actions may call other systems: no serializable retries (a handler never runs twice) and a longer budget. */
23
+ const ACTION_TX = { timeout: 60_000, maxWait: 10_000, isolationLevel: "ReadCommitted" };
24
+ /** What subscribers outside the framework receive: the internal delivery snapshot stays private. */
25
+ const publicEvent = ({ delivery: _delivery, ...event }) => event;
16
26
  const invalid = (issues) => new BadRequestException({ statusCode: 400, error: "Bad Request", message: "Validation failed", issues });
17
27
  export class ForgeService {
18
28
  options;
@@ -21,6 +31,8 @@ export class ForgeService {
21
31
  auth;
22
32
  socialProviders;
23
33
  events;
34
+ outbox;
35
+ exports;
24
36
  computed;
25
37
  notifications;
26
38
  apiKeys;
@@ -39,6 +51,11 @@ export class ForgeService {
39
51
  this.auth = options.auth;
40
52
  this.socialProviders = options.socialProviders ?? [];
41
53
  this.events = options.events ?? new EventBus();
54
+ if (options.prisma.forgeOutbox)
55
+ this.outbox = new EventOutbox(options.prisma, this.events);
56
+ this.exports = new ExportService(this);
57
+ if (options.files)
58
+ this.useFileStore(options.files);
42
59
  this.computed = new ComputedEngine(options.app);
43
60
  this.apiKeys = new ApiKeyService(options.app, options.prisma);
44
61
  this.settings = new SettingsService(options.app, options.prisma);
@@ -63,7 +80,8 @@ export class ForgeService {
63
80
  throw new Error(`[forge] ${entity.name}: extension configures unknown transition "${t}"`);
64
81
  }
65
82
  for (const [pattern, handler] of Object.entries(x.on ?? {}))
66
- this.events.on(pattern, handler, `${entity.name} extension`);
83
+ this.events.on(pattern, (event) => handler(publicEvent(event)), `${entity.name} extension`);
84
+ this.registerAfterHooks(entity, x);
67
85
  }
68
86
  for (const e of options.app.entities) {
69
87
  const rules = [...Object.values(e.permissions), ...e.fields.flatMap((f) => [f.access?.read, f.access?.write]), ...(e.workflow?.transitions.map((t) => t.permission) ?? [])];
@@ -74,6 +92,37 @@ export class ForgeService {
74
92
  }
75
93
  }
76
94
  }
95
+ onModuleInit() { this.outbox?.start(); if (this.prisma.forgeExport)
96
+ this.exports.start(); }
97
+ async onModuleDestroy() { this.exports.stop(); await this.outbox?.stop(); }
98
+ registerAfterHooks(entity, extension) {
99
+ const hydrate = (row) => {
100
+ const result = { ...row };
101
+ for (const key of ["createdAt", "updatedAt", "deletedAt", ...entity.fields.filter((f) => ["date", "datetime"].includes(f.kind)).map((f) => f.name)])
102
+ if (typeof result[key] === "string")
103
+ result[key] = new Date(result[key]);
104
+ for (const f of entity.fields)
105
+ if (["money", "decimal"].includes(f.kind) && ["string", "number"].includes(typeof result[f.name]))
106
+ result[f.name] = new Decimal(result[f.name]);
107
+ return result;
108
+ };
109
+ this.events.on(`${entity.name}.*`, async (event) => {
110
+ const d = event.delivery;
111
+ if (!d)
112
+ return;
113
+ const ctx = this.hookContext(entity, d.context);
114
+ const record = hydrate(d.record);
115
+ const previous = hydrate(d.previous);
116
+ if (event.action === "created")
117
+ await extension.hooks?.afterCreate?.({ ...ctx, record });
118
+ if (event.action === "updated")
119
+ await extension.hooks?.afterUpdate?.({ ...ctx, record, previous });
120
+ if (event.action === "deleted")
121
+ await extension.hooks?.afterDelete?.({ ...ctx, record });
122
+ if (event.action === "transitioned")
123
+ await extension.transitions?.[event.name]?.after?.({ ...ctx, record, previous, input: d.input ?? {} });
124
+ }, `${entity.name} after-hooks`);
125
+ }
77
126
  /** Plugins attach behaviour (hooks, actions, event handlers) to the entities they contribute. */
78
127
  addExtension(ext) {
79
128
  const entity = this.byName.get(ext.entity);
@@ -88,11 +137,14 @@ export class ForgeService {
88
137
  transitions: { ...existing?.transitions, ...ext.transitions },
89
138
  });
90
139
  for (const [pattern, handler] of Object.entries(ext.on ?? {}))
91
- this.events.on(pattern, handler, `${ext.entity} extension`);
140
+ this.events.on(pattern, (event) => handler(publicEvent(event)), `${ext.entity} extension`);
141
+ this.registerAfterHooks(entity, ext);
92
142
  }
93
143
  /** Used by the storage plugin during setup. */
94
144
  useFileStore(store) {
95
145
  this.options.files = store;
146
+ this.events.on("*", async (event) => { if (event.delivery?.releasedFiles?.length)
147
+ await store.release(event.delivery.releasedFiles); }, "released files");
96
148
  }
97
149
  /** Startup check once plugins have been set up. */
98
150
  assertReady() {
@@ -358,8 +410,10 @@ export class ForgeService {
358
410
  if (value === undefined || value === null || existing?.[rel.foreignKey] === value)
359
411
  return null;
360
412
  const principal = ctx.principal;
361
- if (rel.owner && value !== actingUserId(ctx) && !principal.roles.includes("admin")) {
362
- throw new ForbiddenException(`Only admins can assign the ${f.label.toLowerCase()} to another user`);
413
+ // Giving a record to someone else is an admin's call unless the relation names other roles (assignableBy);
414
+ // write access to the field alone never grants it.
415
+ if (rel.owner && value !== actingUserId(ctx) && !principal.roles.includes("admin") && !rel.assignableBy?.some((r) => principal.roles.includes(r))) {
416
+ throw new ForbiddenException(rel.assignableBy?.length ? `Only ${["admin", ...rel.assignableBy].join(", ")} can assign the ${f.label.toLowerCase()} to another user` : `Only admins can assign the ${f.label.toLowerCase()} to another user`);
363
417
  }
364
418
  if (rel.entity === "Team" && !principal.roles.includes("admin") && !(principal.teamIds ?? []).includes(value)) {
365
419
  throw new ForbiddenException("You can only assign records to teams you belong to");
@@ -400,7 +454,7 @@ export class ForgeService {
400
454
  },
401
455
  });
402
456
  }
403
- async emit(entity, action, ctx, record, extra) {
457
+ async queueEvent(db, entity, action, ctx, record, extra, previous, input, releasedFiles) {
404
458
  const event = {
405
459
  id: randomUUID(),
406
460
  type: `${entity.name}.${action}`,
@@ -410,18 +464,81 @@ export class ForgeService {
410
464
  actor: ctx.user ? { id: ctx.user.id, name: ctx.user.name } : ctx.apiKey ? { id: `apikey:${ctx.apiKey.id}`, name: `API key "${ctx.apiKey.name}"` } : null,
411
465
  organizationId: ctx.principal?.organizationId ?? record?.organizationId ?? null,
412
466
  occurredAt: new Date().toISOString(),
467
+ // Stored until delivered: who acted, never where from (no IP address).
468
+ delivery: { context: { principal: ctx.principal, user: ctx.user, session: ctx.session, ...(ctx.apiKey ? { apiKey: ctx.apiKey } : {}) }, record, ...(previous ? { previous } : {}), ...(input ? { input } : {}), ...(releasedFiles?.length ? { releasedFiles } : {}) },
413
469
  ...extra,
414
470
  };
415
- await this.events.emit(event);
416
- }
417
- tx(fn) {
418
- return this.prisma.$transaction(fn, TX);
471
+ if (this.outbox)
472
+ await this.outbox.enqueue(db, event);
473
+ return event;
474
+ }
475
+ async dispatch(event) {
476
+ if (this.outbox) {
477
+ let timer;
478
+ try {
479
+ await Promise.race([this.outbox.deliver(event.id), new Promise((resolve) => { timer = setTimeout(resolve, 250); })]);
480
+ }
481
+ finally {
482
+ clearTimeout(timer);
483
+ }
484
+ }
485
+ else
486
+ await this.events.emit(event);
487
+ }
488
+ versionInput(body, existing) {
489
+ if (!body || typeof body !== "object" || Array.isArray(body))
490
+ throw new BadRequestException("Expected a JSON object");
491
+ const input = { ...body };
492
+ if (input && "_version" in input) {
493
+ if (!Number.isSafeInteger(input._version) || input._version < 1)
494
+ throw new BadRequestException("_version must be a positive integer");
495
+ if (input._version !== existing.forgeVersion)
496
+ throw new ConflictException("This record changed while you were editing it. Reload and review the latest values before saving.");
497
+ delete input._version;
498
+ }
499
+ return input;
500
+ }
501
+ versionWhere(id, record) {
502
+ return { id, ...(typeof record.forgeVersion === "number" ? { forgeVersion: record.forgeVersion } : {}) };
503
+ }
504
+ versionIncrement(record) {
505
+ return typeof record.forgeVersion === "number" ? { forgeVersion: { increment: 1 } } : {};
506
+ }
507
+ /**
508
+ * Run `fn` in one transaction. Serializable by default: an attempt that loses a race with a concurrent
509
+ * write (P2034) is rolled back and run again, so `fn` must only change the database. A record that
510
+ * changed since it was read (P2025 on a versioned update) is a 409 at once.
511
+ */
512
+ async tx(fn, options = {}) {
513
+ const attempts = options.action ? 1 : TX_ATTEMPTS;
514
+ for (let attempt = 1;; attempt++) {
515
+ try {
516
+ return await this.prisma.$transaction(fn, options.action ? ACTION_TX : TX);
517
+ }
518
+ catch (e) {
519
+ const code = e.code ?? "";
520
+ if (code === "P2034" && attempt < attempts) {
521
+ await new Promise((r) => setTimeout(r, Math.random() * 10 * 2 ** attempt));
522
+ continue;
523
+ }
524
+ if (["P2034", "P2025"].includes(code))
525
+ throw new ConflictException("This record changed during the operation. Reload and try again.");
526
+ throw e;
527
+ }
528
+ }
419
529
  }
420
530
  // ─── CRUD ───────────────────────────────────────────────────────────────────
421
531
  async list(entity, query, ctx) {
422
532
  const decision = this.authorizeOp(entity, "read", ctx);
423
533
  const q = this.parse(entity, query, ctx);
424
534
  const where = { AND: [...this.baseWhere(entity, ctx, q.trashed), ...this.scopeWhere(entity, decision, ctx), ...q.where] };
535
+ if (q.pagination === "cursor" || q.count === false) {
536
+ if (q.after)
537
+ where.AND.push({ id: { [q.orderBy[0]?.id === "desc" ? "lt" : "gt"]: q.after } });
538
+ const rows = await this.delegate(entity).findMany({ where, orderBy: q.orderBy, ...(q.pagination ? {} : { skip: (q.page - 1) * q.pageSize }), take: q.pageSize + 1, include: relationInclude(entity) });
539
+ const data = rows.slice(0, q.pageSize);
540
+ return { data: await Promise.all(data.map((r) => this.present(entity, r, ctx))), meta: { page: q.page, pageSize: q.pageSize, total: null, pages: null, hasMore: rows.length > q.pageSize, nextCursor: rows.length > q.pageSize ? data.at(-1)?.id ?? null : null } };
541
+ }
425
542
  const [total, rows] = await this.prisma.$transaction([
426
543
  this.delegate(entity).count({ where }),
427
544
  this.delegate(entity).findMany({ where, orderBy: q.orderBy, skip: (q.page - 1) * q.pageSize, take: q.pageSize, include: relationInclude(entity) }),
@@ -467,25 +584,27 @@ export class ForgeService {
467
584
  }
468
585
  const hooks = this.extensions.get(entity.name)?.hooks;
469
586
  const include = relationInclude(entity);
587
+ let event;
470
588
  const record = await this.tx(async (db) => {
471
- data = (await hooks?.beforeCreate?.({ ...this.hookContext(entity, ctx, db), data })) ?? data;
589
+ // Each attempt starts from the validated input: a retried attempt never sees a hook's earlier output.
590
+ let input = (await hooks?.beforeCreate?.({ ...this.hookContext(entity, ctx, db), data: { ...data } })) ?? { ...data };
472
591
  if (entity.features.tenant)
473
- data.organizationId = ctx.principal.organizationId;
474
- let row = await this.delegate(entity, db).create({ data: this.finalizeJson(data), include });
592
+ input = { ...input, organizationId: ctx.principal.organizationId };
593
+ let row = await this.delegate(entity, db).create({ data: this.finalizeJson(input), include });
475
594
  row = await this.computed.refresh(db, entity, row, include);
476
595
  await this.syncFiles(db, entity, row, null);
477
596
  await this.computed.refreshParents(db, entity, null, row);
478
597
  await this.audit(db, entity, ctx, options.auditAction ?? "create", row.id, { after: serialize(entity, row) });
598
+ event = await this.queueEvent(db, entity, "created", ctx, row, { recordId: row.id });
479
599
  return row;
480
600
  });
481
- await this.emit(entity, "created", ctx, record, { recordId: record.id });
482
- await hooks?.afterCreate?.({ ...this.hookContext(entity, ctx), record });
601
+ await this.dispatch(event);
483
602
  return this.present(entity, record, ctx);
484
603
  }
485
604
  async update(entity, id, body, ctx) {
486
605
  this.authorizeOp(entity, "update", ctx);
487
606
  const existing = await this.loadFor(entity, id, "update", ctx);
488
- let data = this.validate(entity, "update", body);
607
+ let data = this.validate(entity, "update", this.versionInput(body, existing));
489
608
  const nulls = entity.fields.filter((f) => f.required && f.apiName in data && data[f.apiName] === null).map((f) => ({ path: f.apiName, message: "Required" }));
490
609
  if (nulls.length)
491
610
  throw invalid(nulls);
@@ -493,25 +612,20 @@ export class ForgeService {
493
612
  const hooks = this.extensions.get(entity.name)?.hooks;
494
613
  const include = relationInclude(entity);
495
614
  let released = [];
615
+ let event;
496
616
  const record = await this.tx(async (db) => {
497
- data = (await hooks?.beforeUpdate?.({ ...this.hookContext(entity, ctx, db), data, existing })) ?? data;
498
- let row = await this.delegate(entity, db).update({ where: { id }, data: this.finalizeJson(data), include });
617
+ const input = (await hooks?.beforeUpdate?.({ ...this.hookContext(entity, ctx, db), data: { ...data }, existing })) ?? { ...data };
618
+ let row = await this.delegate(entity, db).update({ where: this.versionWhere(id, existing), data: { ...this.finalizeJson(input), ...this.versionIncrement(existing) }, include });
499
619
  row = await this.computed.refresh(db, entity, row, include);
500
620
  released = await this.syncFiles(db, entity, row, existing);
501
621
  await this.computed.refreshParents(db, entity, existing, row);
502
622
  const changes = diff(serialize(entity, existing), serialize(entity, row));
503
623
  if (Object.keys(changes).length)
504
624
  await this.audit(db, entity, ctx, "update", id, changes);
625
+ event = await this.queueEvent(db, entity, "updated", ctx, row, { recordId: id, previous: serialize(entity, existing, { apiPrefix: this.app.api.prefix }), changes }, existing, undefined, released);
505
626
  return row;
506
627
  });
507
- if (released.length)
508
- await this.options.files.release(released);
509
- await this.emit(entity, "updated", ctx, record, {
510
- recordId: id,
511
- previous: serialize(entity, existing, { apiPrefix: this.app.api.prefix }),
512
- changes: diff(serialize(entity, existing), serialize(entity, record)),
513
- });
514
- await hooks?.afterUpdate?.({ ...this.hookContext(entity, ctx), record, previous: existing });
628
+ await this.dispatch(event);
515
629
  return this.present(entity, record, ctx);
516
630
  }
517
631
  async remove(entity, id, ctx) {
@@ -519,49 +633,60 @@ export class ForgeService {
519
633
  const record = await this.loadFor(entity, id, "delete", ctx);
520
634
  const hooks = this.extensions.get(entity.name)?.hooks;
521
635
  let released = [];
636
+ let event;
522
637
  await this.tx(async (db) => {
523
638
  await hooks?.beforeDelete?.({ ...this.hookContext(entity, ctx, db), record });
524
639
  if (entity.features.softDelete)
525
- await this.delegate(entity, db).update({ where: { id }, data: { deletedAt: new Date() } });
640
+ await this.delegate(entity, db).update({ where: this.versionWhere(id, record), data: { deletedAt: new Date(), ...this.versionIncrement(record) } });
526
641
  else {
527
- await this.delegate(entity, db).delete({ where: { id } });
642
+ await this.delegate(entity, db).delete({ where: this.versionWhere(id, record) });
528
643
  released = entity.fields.filter((f) => f.file && record[f.apiName]).map((f) => record[f.apiName]);
529
644
  }
530
645
  await this.computed.refreshParents(db, entity, record, null);
531
646
  await this.audit(db, entity, ctx, entity.features.softDelete ? "soft-delete" : "delete", id, { before: serialize(entity, record) });
647
+ event = await this.queueEvent(db, entity, "deleted", ctx, record, { recordId: id }, undefined, undefined, released);
532
648
  });
533
- if (released.length)
534
- await this.options.files.release(released);
535
- await this.emit(entity, "deleted", ctx, record, { recordId: id });
536
- await hooks?.afterDelete?.({ ...this.hookContext(entity, ctx), record });
649
+ await this.dispatch(event);
537
650
  }
538
651
  async bulkDelete(entity, body, ctx) {
539
652
  if (!entity.features.bulkDelete)
540
653
  throw new NotFoundException("Bulk delete is disabled for this resource");
654
+ this.authorizeOp(entity, "delete", ctx);
541
655
  const ids = body?.ids;
542
656
  if (!Array.isArray(ids) || ids.length === 0 || ids.length > 500 || !ids.every((i) => typeof i === "string")) {
543
657
  throw new BadRequestException("ids must be an array of 1–500 record ids");
544
658
  }
545
659
  let deleted = 0;
660
+ const failed = [];
546
661
  for (const id of new Set(ids)) {
547
- await this.remove(entity, id, ctx);
548
- deleted++;
662
+ try {
663
+ await this.remove(entity, id, ctx);
664
+ deleted++;
665
+ }
666
+ catch {
667
+ failed.push({ id, message: "Record could not be deleted; check access, references or concurrent changes" });
668
+ }
549
669
  }
550
- return { deleted };
670
+ return { deleted, failed };
551
671
  }
552
672
  async restore(entity, id, ctx) {
553
673
  if (!entity.features.softDelete)
554
674
  throw new NotFoundException(`${entity.label} does not support restore`);
555
675
  this.authorizeOp(entity, "update", ctx);
556
- await this.loadFor(entity, id, "update", ctx, { trashed: "only" });
676
+ const previous = await this.loadFor(entity, id, "update", ctx, { trashed: "only" });
677
+ let event;
557
678
  const include = relationInclude(entity);
679
+ const hooks = this.extensions.get(entity.name)?.hooks;
558
680
  const record = await this.tx(async (db) => {
559
- const row = await this.delegate(entity, db).update({ where: { id }, data: { deletedAt: null }, include });
681
+ // Restoring is an update: the rules that guard edits (locks, overlaps, parents' states) apply to it too.
682
+ const input = (await hooks?.beforeUpdate?.({ ...this.hookContext(entity, ctx, db), data: {}, existing: previous })) ?? {};
683
+ const row = await this.delegate(entity, db).update({ where: this.versionWhere(id, previous), data: { ...this.finalizeJson(input), deletedAt: null, ...this.versionIncrement(previous) }, include });
560
684
  await this.computed.refreshParents(db, entity, null, row);
561
685
  await this.audit(db, entity, ctx, "restore", id, {});
686
+ event = await this.queueEvent(db, entity, "restored", ctx, row, { recordId: id });
562
687
  return row;
563
688
  });
564
- await this.emit(entity, "restored", ctx, record, { recordId: id });
689
+ await this.dispatch(event);
565
690
  return this.present(entity, record, ctx);
566
691
  }
567
692
  async transition(entity, id, name, body, ctx) {
@@ -575,24 +700,21 @@ export class ForgeService {
575
700
  throw new ConflictException(`Cannot "${t.label}" a ${entity.label.toLowerCase()} that is ${from} (allowed from: ${t.from.join(", ")})`);
576
701
  }
577
702
  const ext = this.extensions.get(entity.name)?.transitions?.[name];
578
- const verdict = await ext?.guard?.({ ...this.hookContext(entity, ctx), record, input: (body ?? {}) });
579
- if (verdict === false || typeof verdict === "string")
580
- throw new ConflictException(typeof verdict === "string" ? verdict : `"${t.label}" is not possible right now`);
703
+ const input = this.versionInput(body ?? {}, record);
581
704
  const include = relationInclude(entity);
705
+ let event;
582
706
  const updated = await this.tx(async (db) => {
583
- let row = await this.delegate(entity, db).update({ where: { id }, data: { [wf.field]: t.to }, include });
707
+ const verdict = await ext?.guard?.({ ...this.hookContext(entity, ctx, db), record, input });
708
+ if (verdict === false || typeof verdict === "string")
709
+ throw new ConflictException(typeof verdict === "string" ? verdict : `"${t.label}" is not possible right now`);
710
+ let row = await this.delegate(entity, db).update({ where: { ...this.versionWhere(id, record), [wf.field]: from }, data: { [wf.field]: t.to, ...this.versionIncrement(record) }, include });
584
711
  row = await this.computed.refresh(db, entity, row, include);
585
712
  await this.computed.refreshParents(db, entity, record, row);
586
713
  await this.audit(db, entity, ctx, `transition:${name}`, id, { [wf.field]: { from, to: t.to } });
714
+ event = await this.queueEvent(db, entity, "transitioned", ctx, row, { recordId: id, name, previous: serialize(entity, record, { apiPrefix: this.app.api.prefix }), changes: { [wf.field]: { from, to: t.to } } }, record, input);
587
715
  return row;
588
716
  });
589
- await this.emit(entity, "transitioned", ctx, updated, {
590
- recordId: id,
591
- name,
592
- previous: serialize(entity, record, { apiPrefix: this.app.api.prefix }),
593
- changes: { [wf.field]: { from, to: t.to } },
594
- });
595
- await ext?.after?.({ ...this.hookContext(entity, ctx), record: updated, previous: record, input: (body ?? {}) });
717
+ await this.dispatch(event);
596
718
  return this.present(entity, updated, ctx);
597
719
  }
598
720
  async runAction(entity, id, name, body, ctx) {
@@ -603,17 +725,22 @@ export class ForgeService {
603
725
  if (!handler)
604
726
  throw new NotImplementedException(`Action "${name}" has no handler — implement it in ${entity.name.toLowerCase()}.extension.ts`);
605
727
  const record = await this.loadFor(entity, id, "update", ctx, { rule: action.permission });
606
- const result = await handler({ ...this.hookContext(entity, ctx), record, input: (body ?? {}) });
607
- // Actions may change the record through prisma directly; keep computed values and parents current.
608
- const fresh = await this.delegate(entity).findUnique({ where: { id }, include: relationInclude(entity) });
609
- if (fresh) {
610
- await this.tx(async (db) => {
611
- const row = await this.computed.refresh(db, entity, fresh);
612
- await this.computed.refreshParents(db, entity, record, row);
613
- await this.audit(db, entity, ctx, `action:${name}`, id, {});
614
- });
615
- }
616
- await this.emit(entity, "action", ctx, fresh, { recordId: id, name });
728
+ const input = this.versionInput(body ?? {}, record);
729
+ let event;
730
+ const result = await this.tx(async (db) => {
731
+ // Writes through ctx.prisma share this transaction and roll back if the handler throws. The record is
732
+ // not locked while the handler runs (a write through another client must not wait on us); the version
733
+ // check comes last, so a concurrent edit still turns the action into a 409.
734
+ const result = await handler({ ...this.hookContext(entity, ctx, db), record, input });
735
+ await this.delegate(entity, db).update({ where: this.versionWhere(id, record), data: this.versionIncrement(record) });
736
+ const fresh = await this.delegate(entity, db).findUnique({ where: { id }, include: relationInclude(entity) });
737
+ const row = fresh ? await this.computed.refresh(db, entity, fresh) : null;
738
+ await this.computed.refreshParents(db, entity, record, row);
739
+ await this.audit(db, entity, ctx, `action:${name}`, id, diff(serialize(entity, record), row ? serialize(entity, row) : {}));
740
+ event = await this.queueEvent(db, entity, "action", ctx, row, { recordId: id, name, result: result ?? null });
741
+ return result;
742
+ }, { action: true });
743
+ await this.dispatch(event);
617
744
  return { ok: true, result: result ?? null };
618
745
  }
619
746
  async lookup(entity, query, ctx) {