@tekki-forge/runtime-api 0.3.0 → 0.4.0
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/bootstrap.d.ts +39 -0
- package/dist/bootstrap.js +89 -4
- package/dist/bootstrap.js.map +1 -1
- package/dist/computed.js +1 -1
- package/dist/computed.js.map +1 -1
- package/dist/controllers.d.ts +52 -0
- package/dist/controllers.js +69 -1
- package/dist/controllers.js.map +1 -1
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +12 -0
- package/dist/events.js +26 -0
- package/dist/events.js.map +1 -1
- package/dist/exports.d.ts +34 -0
- package/dist/exports.js +170 -0
- package/dist/exports.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/notifications.d.ts +2 -0
- package/dist/notifications.js +8 -1
- package/dist/notifications.js.map +1 -1
- package/dist/observability.d.ts +20 -0
- package/dist/observability.js +47 -0
- package/dist/observability.js.map +1 -0
- package/dist/outbox.d.ts +21 -0
- package/dist/outbox.js +98 -0
- package/dist/outbox.js.map +1 -0
- package/dist/plugin.d.ts +6 -0
- package/dist/plugin.js.map +1 -1
- package/dist/query.d.ts +3 -0
- package/dist/query.js +13 -2
- package/dist/query.js.map +1 -1
- package/dist/search.d.ts +1 -0
- package/dist/search.js +14 -3
- package/dist/search.js.map +1 -1
- package/dist/seed.js +5 -3
- package/dist/seed.js.map +1 -1
- package/dist/serialize.js +4 -2
- package/dist/serialize.js.map +1 -1
- package/dist/service.d.ts +33 -1
- package/dist/service.js +187 -60
- package/dist/service.js.map +1 -1
- package/dist/testing.js +6 -1
- package/dist/testing.js.map +1 -1
- package/package.json +5 -5
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
|
-
|
|
362
|
-
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
let row = await this.delegate(entity, db).create({ data: this.finalizeJson(
|
|
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.
|
|
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
|
-
|
|
498
|
-
let row = await this.delegate(entity, db).update({ where:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
});
|
|
615
|
-
|
|
616
|
-
|
|
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) {
|