hierarchical-approval 2.8.0 → 3.0.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/index.cjs CHANGED
@@ -122,6 +122,266 @@ var UpdateDataOptionsSchema = zod.z.object({
122
122
  /** Re-evaluate template conditions against the new data (default true). */
123
123
  recomputeChain: zod.z.boolean().default(true)
124
124
  });
125
+
126
+ // src/errors.ts
127
+ var ApprovalError = class extends Error {
128
+ constructor(message, code) {
129
+ super(message);
130
+ this.code = code;
131
+ this.name = "ApprovalError";
132
+ }
133
+ toJSON() {
134
+ return { code: this.code, message: this.message, name: this.name };
135
+ }
136
+ toHttpStatus() {
137
+ const map = {
138
+ NOT_FOUND: 404,
139
+ CONFLICT: 409,
140
+ FORBIDDEN: 403,
141
+ VALIDATION: 422,
142
+ TEMPLATE_NOT_FOUND: 404
143
+ };
144
+ return map[this.code] ?? 500;
145
+ }
146
+ };
147
+ var ApprovalNotFoundError = class extends ApprovalError {
148
+ constructor(resource, id) {
149
+ super(`${resource} "${id}" not found.`, "NOT_FOUND");
150
+ this.name = "ApprovalNotFoundError";
151
+ }
152
+ };
153
+ var ApprovalConflictError = class extends ApprovalError {
154
+ constructor(instanceId) {
155
+ super(
156
+ `Concurrent modification detected on instance "${instanceId}". The record was updated by another process. Please retry.`,
157
+ "CONFLICT"
158
+ );
159
+ this.name = "ApprovalConflictError";
160
+ }
161
+ };
162
+ var ApprovalForbiddenError = class extends ApprovalError {
163
+ constructor(message) {
164
+ super(message, "FORBIDDEN");
165
+ this.name = "ApprovalForbiddenError";
166
+ }
167
+ };
168
+ var ApprovalValidationError = class extends ApprovalError {
169
+ constructor(message, cause) {
170
+ super(message, "VALIDATION");
171
+ this.cause = cause;
172
+ this.name = "ApprovalValidationError";
173
+ }
174
+ };
175
+ var ApprovalTemplateNotFoundError = class extends ApprovalError {
176
+ constructor(name) {
177
+ super(`Template "${name}" not found.`, "TEMPLATE_NOT_FOUND");
178
+ this.name = "ApprovalTemplateNotFoundError";
179
+ }
180
+ };
181
+
182
+ // src/adapters/MemoryAdapter.ts
183
+ function deepClone(value) {
184
+ return JSON.parse(JSON.stringify(value));
185
+ }
186
+ function reviveDates(instance) {
187
+ return {
188
+ ...instance,
189
+ createdAt: new Date(instance.createdAt),
190
+ updatedAt: new Date(instance.updatedAt),
191
+ expiresAt: instance.expiresAt ? new Date(instance.expiresAt) : void 0,
192
+ slaDeadlineAt: instance.slaDeadlineAt ? new Date(instance.slaDeadlineAt) : void 0,
193
+ slaBreachedAt: instance.slaBreachedAt ? new Date(instance.slaBreachedAt) : void 0,
194
+ auditLog: instance.auditLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })),
195
+ infoRequest: instance.infoRequest ? { ...instance.infoRequest, askedAt: new Date(instance.infoRequest.askedAt) } : void 0,
196
+ attachments: instance.attachments ? instance.attachments.map((a) => ({ ...a, addedAt: new Date(a.addedAt) })) : void 0,
197
+ comments: instance.comments ? instance.comments.map((c) => ({ ...c, createdAt: new Date(c.createdAt) })) : void 0,
198
+ levels: instance.levels.map((l) => {
199
+ const level = { ...l };
200
+ if (l.escalationDueAt) level.escalationDueAt = new Date(l.escalationDueAt);
201
+ if (l.delegatedUntil) level.delegatedUntil = new Date(l.delegatedUntil);
202
+ if (l.reminderDueAt) level.reminderDueAt = new Date(l.reminderDueAt);
203
+ return level;
204
+ })
205
+ };
206
+ }
207
+ function reviveTemplateDates(template) {
208
+ return { ...template, createdAt: new Date(template.createdAt) };
209
+ }
210
+ function readPath(data, path) {
211
+ return path.split(".").reduce((obj, key) => {
212
+ if (obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key)) {
213
+ return obj[key];
214
+ }
215
+ return void 0;
216
+ }, data);
217
+ }
218
+ function deepEquals(a, b) {
219
+ if (Object.is(a, b)) return true;
220
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
221
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
222
+ const aKeys = Object.keys(a);
223
+ const bKeys = Object.keys(b);
224
+ if (aKeys.length !== bKeys.length) return false;
225
+ return aKeys.every(
226
+ (k) => Object.prototype.hasOwnProperty.call(b, k) && deepEquals(a[k], b[k])
227
+ );
228
+ }
229
+ function applyFilter(instance, filter) {
230
+ if (filter.status && instance.status !== filter.status) return false;
231
+ if (filter.documentType && instance.documentType !== filter.documentType) return false;
232
+ if (filter.submittedBy && instance.submittedBy !== filter.submittedBy) return false;
233
+ if (filter.templateName && instance.templateName !== filter.templateName) return false;
234
+ if (filter.fromDate && new Date(instance.createdAt) < filter.fromDate) return false;
235
+ if (filter.toDate && new Date(instance.createdAt) > filter.toDate) return false;
236
+ if (filter.data) {
237
+ for (const [path, expected] of Object.entries(filter.data)) {
238
+ if (!deepEquals(readPath(instance.data ?? {}, path), expected)) return false;
239
+ }
240
+ }
241
+ return true;
242
+ }
243
+ var MemoryAdapter = class {
244
+ constructor() {
245
+ // keyed by `${tenantId}:${template.name}`
246
+ this.templates = /* @__PURE__ */ new Map();
247
+ // keyed by `${tenantId}:${instance.id}`
248
+ this.instances = /* @__PURE__ */ new Map();
249
+ }
250
+ async saveTemplate(template) {
251
+ this.templates.set(`${template.tenantId}:${template.name}`, deepClone(template));
252
+ }
253
+ async getTemplate(tenantId, name) {
254
+ const template = this.templates.get(`${tenantId}:${name}`);
255
+ return template ? reviveTemplateDates(deepClone(template)) : null;
256
+ }
257
+ async listTemplates(tenantId) {
258
+ const result = [];
259
+ for (const [key, template] of this.templates) {
260
+ if (key.startsWith(`${tenantId}:`)) {
261
+ result.push(reviveTemplateDates(deepClone(template)));
262
+ }
263
+ }
264
+ return result;
265
+ }
266
+ async saveInstance(instance) {
267
+ this.instances.set(`${instance.tenantId}:${instance.id}`, deepClone(instance));
268
+ }
269
+ async updateInstance(instance, expectedVersion) {
270
+ const key = `${instance.tenantId}:${instance.id}`;
271
+ const stored = this.instances.get(key);
272
+ if (!stored) throw new ApprovalConflictError(instance.id);
273
+ if (stored.version !== expectedVersion) throw new ApprovalConflictError(instance.id);
274
+ const updated = deepClone(instance);
275
+ updated.version = expectedVersion + 1;
276
+ this.instances.set(key, updated);
277
+ }
278
+ async getInstance(tenantId, id) {
279
+ const raw = this.instances.get(`${tenantId}:${id}`);
280
+ if (!raw) return null;
281
+ return reviveDates(deepClone(raw));
282
+ }
283
+ async getInstancesByApprover(tenantId, approverId, opts) {
284
+ const all = [...this.instances.values()].filter((i) => {
285
+ if (i.tenantId !== tenantId || i.status !== "pending") return false;
286
+ const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
287
+ return currentLevel?.approverIds.includes(approverId) ?? false;
288
+ });
289
+ return paginate(
290
+ all.map((i) => reviveDates(deepClone(i))),
291
+ opts
292
+ );
293
+ }
294
+ async getInstancesByFilter(tenantId, filter, opts) {
295
+ const all = [...this.instances.values()].filter(
296
+ (i) => i.tenantId === tenantId && applyFilter(i, filter)
297
+ );
298
+ return paginate(
299
+ all.map((i) => reviveDates(deepClone(i))),
300
+ opts
301
+ );
302
+ }
303
+ async getOverdueInstances(tenantId, asOf, filter = {}) {
304
+ return [...this.instances.values()].filter((i) => {
305
+ if (i.tenantId !== tenantId || i.status !== "pending") return false;
306
+ if (filter.documentType && i.documentType !== filter.documentType) return false;
307
+ if (filter.submittedBy && i.submittedBy !== filter.submittedBy) return false;
308
+ const hasOverdueEscalation = i.levels.some(
309
+ (l) => l.escalationDueAt != null && new Date(l.escalationDueAt) <= asOf
310
+ );
311
+ const isExpired = i.expiresAt != null && new Date(i.expiresAt) <= asOf;
312
+ const hasSLABreach = i.slaDeadlineAt != null && new Date(i.slaDeadlineAt) <= asOf && !i.slaBreachedAt;
313
+ const hasDelegationExpiry = i.levels.some(
314
+ (l) => l.status === "pending" && l.delegatedUntil != null && new Date(l.delegatedUntil) <= asOf && l.delegatedFrom != null
315
+ );
316
+ const hasDueReminder = i.levels.some(
317
+ (l) => l.status === "pending" && l.reminderDueAt != null && new Date(l.reminderDueAt) <= asOf
318
+ );
319
+ return hasOverdueEscalation || isExpired || hasSLABreach || hasDelegationExpiry || hasDueReminder;
320
+ }).map((i) => reviveDates(deepClone(i)));
321
+ }
322
+ async deleteInstance(tenantId, id) {
323
+ const key = `${tenantId}:${id}`;
324
+ return this.instances.delete(key);
325
+ }
326
+ async countInstances(tenantId, filter) {
327
+ let count = 0;
328
+ for (const instance of this.instances.values()) {
329
+ if (instance.tenantId === tenantId && applyFilter(instance, filter)) count++;
330
+ }
331
+ return count;
332
+ }
333
+ async getInstancesByCursor(tenantId, filter, opts) {
334
+ const all = [...this.instances.values()].filter((i) => i.tenantId === tenantId && applyFilter(i, filter)).map((i) => reviveDates(deepClone(i))).sort((a, b) => {
335
+ const ta = a.updatedAt.getTime();
336
+ const tb = b.updatedAt.getTime();
337
+ return ta !== tb ? ta - tb : a.id.localeCompare(b.id);
338
+ });
339
+ const { cursor, limit, direction = "forward" } = opts;
340
+ let startIdx = 0;
341
+ if (cursor) {
342
+ const [ts, id] = decodeCursor(cursor);
343
+ const idx = all.findIndex(
344
+ (i) => i.updatedAt.getTime() > ts || i.updatedAt.getTime() === ts && i.id > id
345
+ );
346
+ startIdx = idx === -1 ? all.length : idx;
347
+ }
348
+ if (direction === "backward" && startIdx > 0) {
349
+ startIdx = Math.max(0, startIdx - limit - 1);
350
+ }
351
+ const slice = all.slice(startIdx, startIdx + limit);
352
+ const hasMore = startIdx + limit < all.length;
353
+ const nextCursor = hasMore ? encodeCursor(slice[slice.length - 1]) : void 0;
354
+ const prevCursor = startIdx > 0 ? encodeCursor(all[startIdx - 1]) : void 0;
355
+ return { items: slice, nextCursor, prevCursor, hasMore };
356
+ }
357
+ async getIdempotentInstance(tenantId, idempotencyKey) {
358
+ for (const instance of this.instances.values()) {
359
+ if (instance.tenantId === tenantId && instance.idempotencyKey === idempotencyKey) {
360
+ return reviveDates(deepClone(instance));
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+ async appendAuditEntry(_tenantId, _instanceId, _entry) {
366
+ }
367
+ /** Test helper — total stored instances across all tenants. */
368
+ get size() {
369
+ return this.instances.size;
370
+ }
371
+ };
372
+ function paginate(items, opts) {
373
+ const total = items.length;
374
+ if (!opts) return { items, total };
375
+ return { items: items.slice(opts.offset, opts.offset + opts.limit), total };
376
+ }
377
+ function encodeCursor(instance) {
378
+ return Buffer.from(`${instance.updatedAt.getTime()}|${instance.id}`).toString("base64");
379
+ }
380
+ function decodeCursor(cursor) {
381
+ const decoded = Buffer.from(cursor, "base64").toString("utf8");
382
+ const pipeIdx = decoded.indexOf("|");
383
+ return [Number(decoded.slice(0, pipeIdx)), decoded.slice(pipeIdx + 1)];
384
+ }
125
385
  var EventBus = class {
126
386
  constructor() {
127
387
  this.emitter = new EventEmitter__default.default();
@@ -206,62 +466,6 @@ var systemClock = { now: () => /* @__PURE__ */ new Date() };
206
466
  // src/utils/IdGenerator.ts
207
467
  var defaultIdGenerator = (prefix) => `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
208
468
 
209
- // src/errors.ts
210
- var ApprovalError = class extends Error {
211
- constructor(message, code) {
212
- super(message);
213
- this.code = code;
214
- this.name = "ApprovalError";
215
- }
216
- toJSON() {
217
- return { code: this.code, message: this.message, name: this.name };
218
- }
219
- toHttpStatus() {
220
- const map = {
221
- NOT_FOUND: 404,
222
- CONFLICT: 409,
223
- FORBIDDEN: 403,
224
- VALIDATION: 422,
225
- TEMPLATE_NOT_FOUND: 404
226
- };
227
- return map[this.code] ?? 500;
228
- }
229
- };
230
- var ApprovalNotFoundError = class extends ApprovalError {
231
- constructor(resource, id) {
232
- super(`${resource} "${id}" not found.`, "NOT_FOUND");
233
- this.name = "ApprovalNotFoundError";
234
- }
235
- };
236
- var ApprovalConflictError = class extends ApprovalError {
237
- constructor(instanceId) {
238
- super(
239
- `Concurrent modification detected on instance "${instanceId}". The record was updated by another process. Please retry.`,
240
- "CONFLICT"
241
- );
242
- this.name = "ApprovalConflictError";
243
- }
244
- };
245
- var ApprovalForbiddenError = class extends ApprovalError {
246
- constructor(message) {
247
- super(message, "FORBIDDEN");
248
- this.name = "ApprovalForbiddenError";
249
- }
250
- };
251
- var ApprovalValidationError = class extends ApprovalError {
252
- constructor(message, cause) {
253
- super(message, "VALIDATION");
254
- this.cause = cause;
255
- this.name = "ApprovalValidationError";
256
- }
257
- };
258
- var ApprovalTemplateNotFoundError = class extends ApprovalError {
259
- constructor(name) {
260
- super(`Template "${name}" not found.`, "TEMPLATE_NOT_FOUND");
261
- this.name = "ApprovalTemplateNotFoundError";
262
- }
263
- };
264
-
265
469
  // src/engine/TemplateRegistry.ts
266
470
  var TemplateRegistry = class {
267
471
  constructor(adapter, tenantId, opts) {
@@ -389,6 +593,17 @@ var LevelResolver = class {
389
593
  registerApproverType(typeName, fn) {
390
594
  this.approverTypes.set(typeName, fn);
391
595
  }
596
+ /**
597
+ * Copy every custom resolver and approver type onto another resolver.
598
+ *
599
+ * Used when the engine builds a throwaway copy of itself for a simulation: a
600
+ * simulation that could not resolve the caller's own `dynamic` approvers
601
+ * would answer a different question from the one asked.
602
+ */
603
+ copyRegistrationsTo(target) {
604
+ for (const [name, fn] of this.resolvers) target.register(name, fn);
605
+ for (const [name, fn] of this.approverTypes) target.registerApproverType(name, fn);
606
+ }
392
607
  async resolveApprovers(approvers, submittedBy, data, orgProvider, outOfOffice, at) {
393
608
  const resolved = [];
394
609
  for (const approver of approvers) {
@@ -1260,33 +1475,14 @@ var ApprovalEngine = class _ApprovalEngine {
1260
1475
  const instanceId = this.generateId("inst");
1261
1476
  const firstCfg = allLevelCfgs[0];
1262
1477
  const firstGroupKey = firstCfg ? _ApprovalEngine.groupKeyOf(firstCfg) : null;
1263
- const levels = allLevelCfgs.map((cfg) => {
1264
- const inFirstGroup = _ApprovalEngine.groupKeyOf(cfg) === firstGroupKey;
1265
- return {
1266
- level: cfg.level,
1267
- name: cfg.name,
1268
- group: cfg.group,
1269
- mode: cfg.mode,
1270
- approverConfigs: cfg.approvers,
1271
- approverIds: [],
1272
- approvedBy: [],
1273
- rejectedBy: [],
1274
- status: inFirstGroup ? "pending" : "waiting",
1275
- minApprovals: cfg.minApprovals,
1276
- threshold: cfg.threshold,
1277
- weights: cfg.weights,
1278
- escalationAfterDays: cfg.escalationAfterDays,
1279
- escalationAfterHours: cfg.escalationAfterHours,
1280
- escalationStep: 0,
1281
- escalationDueAt: inFirstGroup ? this.levelEscalationDue(now, cfg, this.firstRungOf(template.escalationSteps)) : void 0,
1282
- subWorkflowTemplate: cfg.subWorkflow?.templateName,
1283
- reminderAfterDays: cfg.reminderAfterDays,
1284
- reminderEveryDays: cfg.reminderEveryDays,
1285
- maxReminders: cfg.maxReminders,
1286
- remindersSent: 0,
1287
- reminderDueAt: inFirstGroup && cfg.reminderAfterDays ? this.deadlineFrom(now, cfg.reminderAfterDays) : void 0
1288
- };
1289
- });
1478
+ const firstRung = this.firstRungOf(template.escalationSteps);
1479
+ const levels = allLevelCfgs.map(
1480
+ (cfg) => this.buildLevelInstance(cfg, {
1481
+ open: _ApprovalEngine.groupKeyOf(cfg) === firstGroupKey,
1482
+ now,
1483
+ firstRung
1484
+ })
1485
+ );
1290
1486
  for (const lvl of levels.filter((l) => l.status === "pending")) {
1291
1487
  if (lvl.subWorkflowTemplate) {
1292
1488
  lvl.approverIds = [];
@@ -1961,7 +2157,10 @@ var ApprovalEngine = class _ApprovalEngine {
1961
2157
  cancelled = instance;
1962
2158
  return instance;
1963
2159
  });
1964
- if (cancelled) await this.propagateToParent(cancelled);
2160
+ if (cancelled) {
2161
+ await this.cancelOrphanedChildren(cancelled);
2162
+ await this.propagateToParent(cancelled);
2163
+ }
1965
2164
  return result;
1966
2165
  }
1967
2166
  async escalate(instanceId, raw, auditCtx) {
@@ -2114,20 +2313,7 @@ var ApprovalEngine = class _ApprovalEngine {
2114
2313
  const rebuiltFuture = futureCfgs.map((cfg) => {
2115
2314
  const kept = existingFuture.get(cfg.level);
2116
2315
  if (kept) return kept;
2117
- return {
2118
- level: cfg.level,
2119
- name: cfg.name,
2120
- mode: cfg.mode,
2121
- approverConfigs: cfg.approvers,
2122
- approverIds: [],
2123
- approvedBy: [],
2124
- rejectedBy: [],
2125
- status: "waiting",
2126
- minApprovals: cfg.minApprovals,
2127
- threshold: cfg.threshold,
2128
- weights: cfg.weights,
2129
- escalationAfterDays: cfg.escalationAfterDays
2130
- };
2316
+ return this.buildLevelInstance(cfg, { open: false, now: this.clock.now() });
2131
2317
  });
2132
2318
  instance.levels = [...frozen, ...rebuiltFuture];
2133
2319
  return { addedLevels: addedLevels.sort((a, b) => a - b), removedLevels };
@@ -2828,14 +3014,120 @@ var ApprovalEngine = class _ApprovalEngine {
2828
3014
  } catch (err) {
2829
3015
  base.resolutionError = err.message;
2830
3016
  }
2831
- levels.push(base);
3017
+ levels.push(base);
3018
+ }
3019
+ const skipped = template.levels.filter((l) => mutations.skipLevels.has(l.level)).map((l) => ({
3020
+ level: l.level,
3021
+ name: l.name,
3022
+ skippedByRule: skippedBy.get(l.level) ?? -1
3023
+ })).sort((a, b) => a.level - b.level);
3024
+ return { templateName: template.name, levels, skipped, rules };
3025
+ }
3026
+ /**
3027
+ * Run a document through a template against scripted decisions, without
3028
+ * persisting anything.
3029
+ *
3030
+ * `explainChain()` says what the chain will be; this says what happens to it —
3031
+ * "if the CFO rejects at level 3, does it go back to the submitter or die?" —
3032
+ * which previously meant submitting a real approval into a real store and
3033
+ * cleaning it up afterwards, or reasoning about the state machine by hand.
3034
+ *
3035
+ * The run executes against a private in-memory store seeded with a copy of
3036
+ * the template, so the caller's storage is untouched and no events reach the
3037
+ * caller's notification adapters. Custom resolvers and approver types are
3038
+ * copied across, because a simulation that could not resolve the caller's own
3039
+ * `dynamic` approvers would answer a different question from the one asked.
3040
+ *
3041
+ * A refused decision — wrong approver, wrong level, already acted — stops the
3042
+ * run and is reported in the transcript rather than thrown: the refusal is
3043
+ * usually the answer the caller was looking for.
3044
+ *
3045
+ * @param opts - Template, document data, submitter and the decisions to play.
3046
+ */
3047
+ async simulate(opts) {
3048
+ const template = await this.registry.get(opts.templateName);
3049
+ const scratch = new _ApprovalEngine({
3050
+ ...this.opts,
3051
+ adapter: new MemoryAdapter(),
3052
+ // A simulation must not page anybody, write anybody's audit log, or move
3053
+ // anybody's metrics.
3054
+ notificationAdapter: void 0,
3055
+ auditAdapter: void 0,
3056
+ metricsAdapter: void 0,
3057
+ authorizationPolicy: void 0,
3058
+ middleware: void 0,
3059
+ escalationPollIntervalMs: void 0
3060
+ });
3061
+ this.resolver.copyRegistrationsTo(scratch.resolver);
3062
+ try {
3063
+ const {
3064
+ id: _id,
3065
+ tenantId: _t,
3066
+ createdAt: _c,
3067
+ version: _v,
3068
+ previousVersionId: _p,
3069
+ ...config
3070
+ } = template;
3071
+ await scratch.registry.define(config);
3072
+ const instance = await scratch.submit({
3073
+ templateName: opts.templateName,
3074
+ documentId: `simulation-${opts.templateName}`,
3075
+ documentType: template.documentType,
3076
+ submittedBy: opts.submittedBy,
3077
+ data: opts.data,
3078
+ metadata: {}
3079
+ });
3080
+ const transcript = [];
3081
+ let current = instance;
3082
+ for (const [i, decision] of (opts.decisions ?? []).entries()) {
3083
+ const isApprove = "approve" in decision;
3084
+ const actorId = isApprove ? decision.approve : decision.reject;
3085
+ try {
3086
+ current = isApprove ? await scratch.approve(current.id, {
3087
+ approverId: actorId,
3088
+ level: decision.level,
3089
+ comment: decision.comment
3090
+ }) : await scratch.reject(current.id, {
3091
+ approverId: actorId,
3092
+ level: decision.level,
3093
+ reason: decision.reason ?? "simulated rejection"
3094
+ });
3095
+ transcript.push({
3096
+ step: i + 1,
3097
+ action: isApprove ? "approve" : "reject",
3098
+ actorId,
3099
+ level: decision.level,
3100
+ status: current.status
3101
+ });
3102
+ } catch (err) {
3103
+ transcript.push({
3104
+ step: i + 1,
3105
+ action: isApprove ? "approve" : "reject",
3106
+ actorId,
3107
+ level: decision.level,
3108
+ status: current.status,
3109
+ error: err.message
3110
+ });
3111
+ break;
3112
+ }
3113
+ if (TERMINAL_STATUSES.has(current.status)) break;
3114
+ }
3115
+ const finalInstance = await scratch.getInstance(current.id);
3116
+ return {
3117
+ finalStatus: finalInstance.status,
3118
+ levels: finalInstance.levels.map((l) => ({
3119
+ level: l.level,
3120
+ name: l.name,
3121
+ status: l.status,
3122
+ approvers: l.approverIds
3123
+ })),
3124
+ transcript,
3125
+ unreachedLevels: finalInstance.levels.filter((l) => l.status === "waiting").map((l) => l.level),
3126
+ incomplete: finalInstance.status === "pending"
3127
+ };
3128
+ } finally {
3129
+ await scratch.shutdown();
2832
3130
  }
2833
- const skipped = template.levels.filter((l) => mutations.skipLevels.has(l.level)).map((l) => ({
2834
- level: l.level,
2835
- name: l.name,
2836
- skippedByRule: skippedBy.get(l.level) ?? -1
2837
- })).sort((a, b) => a.level - b.level);
2838
- return { templateName: template.name, levels, skipped, rules };
2839
3131
  }
2840
3132
  async canApprove(instanceId, userId) {
2841
3133
  let instance;
@@ -3338,6 +3630,7 @@ var ApprovalEngine = class _ApprovalEngine {
3338
3630
  );
3339
3631
  }
3340
3632
  const result = { purged: [], scanned: 0, dryRun };
3633
+ const handled = /* @__PURE__ */ new Set();
3341
3634
  for (const status of requested) {
3342
3635
  if (result.purged.length >= limit) break;
3343
3636
  const page = await this.opts.adapter.getInstancesByFilter(
@@ -3351,15 +3644,21 @@ var ApprovalEngine = class _ApprovalEngine {
3351
3644
  );
3352
3645
  for (const instance of page.items) {
3353
3646
  if (result.purged.length >= limit) break;
3647
+ if (handled.has(instance.id)) continue;
3354
3648
  result.scanned++;
3355
3649
  if (!TERMINAL_STATUSES.has(instance.status)) continue;
3356
3650
  if (new Date(instance.createdAt) > opts.olderThan) continue;
3357
- if (!dryRun) await deleteInstance(this.tenantId, instance.id);
3358
- result.purged.push({
3359
- instanceId: instance.id,
3360
- documentId: instance.documentId,
3361
- status: instance.status
3362
- });
3651
+ const family = await this.collectSubWorkflowFamily(instance);
3652
+ for (const member of family) {
3653
+ if (handled.has(member.id)) continue;
3654
+ handled.add(member.id);
3655
+ if (!dryRun) await deleteInstance(this.tenantId, member.id);
3656
+ result.purged.push({
3657
+ instanceId: member.id,
3658
+ documentId: member.documentId,
3659
+ status: member.status
3660
+ });
3661
+ }
3363
3662
  }
3364
3663
  }
3365
3664
  this.logger.info("purgeInstances: sweep complete", {
@@ -3735,6 +4034,47 @@ var ApprovalEngine = class _ApprovalEngine {
3735
4034
  const addHours = this.calendar?.addBusinessHours?.bind(this.calendar);
3736
4035
  return addHours ? addHours(from, hours) : new Date(from.getTime() + hours * 36e5);
3737
4036
  }
4037
+ /**
4038
+ * Build a level instance from its template config.
4039
+ *
4040
+ * The single place a level is constructed. It previously happened twice — in
4041
+ * `submit()` and again in `recomputeFutureChain()` — and the second copy was
4042
+ * missing `group`, `subWorkflowTemplate`, `escalationAfterHours` and the
4043
+ * reminder fields, so a level added by a condition during `updateData()` came
4044
+ * out silently different from the same level created at submit.
4045
+ *
4046
+ * @param cfg - The template's configuration for this level.
4047
+ * @param opts - `open` activates the level now, computing deadlines from `now`.
4048
+ */
4049
+ buildLevelInstance(cfg, opts) {
4050
+ const level = {
4051
+ level: cfg.level,
4052
+ name: cfg.name,
4053
+ group: cfg.group,
4054
+ mode: cfg.mode,
4055
+ approverConfigs: cfg.approvers,
4056
+ approverIds: [],
4057
+ approvedBy: [],
4058
+ rejectedBy: [],
4059
+ status: opts.open ? "pending" : "waiting",
4060
+ minApprovals: cfg.minApprovals,
4061
+ threshold: cfg.threshold,
4062
+ weights: cfg.weights,
4063
+ subWorkflowTemplate: cfg.subWorkflow?.templateName,
4064
+ escalationAfterDays: cfg.escalationAfterDays,
4065
+ escalationAfterHours: cfg.escalationAfterHours,
4066
+ escalationStep: 0,
4067
+ reminderAfterDays: cfg.reminderAfterDays,
4068
+ reminderEveryDays: cfg.reminderEveryDays,
4069
+ maxReminders: cfg.maxReminders,
4070
+ remindersSent: 0
4071
+ };
4072
+ if (opts.open) {
4073
+ level.escalationDueAt = this.levelEscalationDue(opts.now, cfg, opts.firstRung);
4074
+ this.scheduleReminder(level, opts.now);
4075
+ }
4076
+ return level;
4077
+ }
3738
4078
  /** Level deadline from whichever of days/hours the template configured. */
3739
4079
  levelEscalationDue(from, level, firstRung) {
3740
4080
  if (level.escalationAfterHours) return this.deadlineFromHours(from, level.escalationAfterHours);
@@ -4186,8 +4526,68 @@ var ApprovalEngine = class _ApprovalEngine {
4186
4526
  * about them, so a slow child template would surface as a spurious conflict
4187
4527
  * on the decision the user just made.
4188
4528
  */
4529
+ /**
4530
+ * An instance and every sub-workflow descendant beneath it, parents first.
4531
+ *
4532
+ * A child is only reachable through its parent's `childInstanceId`, so a
4533
+ * purge that removed the parent alone would strand the rest of the tree.
4534
+ * Depth is bounded by the same cap that limits spawning, and an already-seen
4535
+ * id is skipped so a corrupted link cannot loop.
4536
+ */
4537
+ async collectSubWorkflowFamily(root) {
4538
+ const family = [root];
4539
+ const seen = /* @__PURE__ */ new Set([root.id]);
4540
+ const queue = [root];
4541
+ for (let depth = 0; queue.length > 0 && depth <= MAX_SUBWORKFLOW_DEPTH; depth++) {
4542
+ const generation = queue.splice(0, queue.length);
4543
+ for (const parent of generation) {
4544
+ for (const level of parent.levels) {
4545
+ const childId = level.childInstanceId;
4546
+ if (!childId || seen.has(childId)) continue;
4547
+ seen.add(childId);
4548
+ const child = await this.opts.adapter.getInstance(this.tenantId, childId);
4549
+ if (!child) continue;
4550
+ family.push(child);
4551
+ queue.push(child);
4552
+ }
4553
+ }
4554
+ }
4555
+ return family;
4556
+ }
4557
+ /**
4558
+ * Cancel sub-workflow children whose parent has finished.
4559
+ *
4560
+ * A child outlives its parent otherwise: it stays pending, keeps notifying
4561
+ * and escalating, and keeps appearing in {@link getWorkload} — asking people
4562
+ * to decide something whose outcome nobody will ever read, because
4563
+ * {@link propagateToParent} ignores a parent that is no longer pending.
4564
+ *
4565
+ * Cancelling rather than deleting keeps the child's own audit trail intact:
4566
+ * the people who were asked, and why the request stopped, stay on the record.
4567
+ */
4568
+ async cancelOrphanedChildren(parent) {
4569
+ const childIds = parent.levels.map((l) => l.childInstanceId).filter((id) => typeof id === "string");
4570
+ if (childIds.length === 0) return;
4571
+ for (const childId of childIds) {
4572
+ try {
4573
+ const child = await this.opts.adapter.getInstance(this.tenantId, childId);
4574
+ if (!child || TERMINAL_STATUSES.has(child.status)) continue;
4575
+ await this.cancel(childId, {
4576
+ cancelledBy: "system",
4577
+ reason: `Parent approval ${parent.id} ended as "${parent.status}".`
4578
+ });
4579
+ } catch (err) {
4580
+ this.logger.error("subWorkflow: failed to cancel orphaned child", err, {
4581
+ tenantId: this.tenantId,
4582
+ parentInstanceId: parent.id,
4583
+ childInstanceId: childId
4584
+ });
4585
+ }
4586
+ }
4587
+ }
4189
4588
  async afterDecision(instance) {
4190
4589
  if (TERMINAL_STATUSES.has(instance.status)) {
4590
+ await this.cancelOrphanedChildren(instance);
4191
4591
  await this.propagateToParent(instance);
4192
4592
  return;
4193
4593
  }
@@ -4307,210 +4707,6 @@ function parseOrThrow(fn) {
4307
4707
  }
4308
4708
  }
4309
4709
 
4310
- // src/adapters/MemoryAdapter.ts
4311
- function deepClone(value) {
4312
- return JSON.parse(JSON.stringify(value));
4313
- }
4314
- function reviveDates(instance) {
4315
- return {
4316
- ...instance,
4317
- createdAt: new Date(instance.createdAt),
4318
- updatedAt: new Date(instance.updatedAt),
4319
- expiresAt: instance.expiresAt ? new Date(instance.expiresAt) : void 0,
4320
- slaDeadlineAt: instance.slaDeadlineAt ? new Date(instance.slaDeadlineAt) : void 0,
4321
- slaBreachedAt: instance.slaBreachedAt ? new Date(instance.slaBreachedAt) : void 0,
4322
- auditLog: instance.auditLog.map((e) => ({ ...e, timestamp: new Date(e.timestamp) })),
4323
- infoRequest: instance.infoRequest ? { ...instance.infoRequest, askedAt: new Date(instance.infoRequest.askedAt) } : void 0,
4324
- attachments: instance.attachments ? instance.attachments.map((a) => ({ ...a, addedAt: new Date(a.addedAt) })) : void 0,
4325
- comments: instance.comments ? instance.comments.map((c) => ({ ...c, createdAt: new Date(c.createdAt) })) : void 0,
4326
- levels: instance.levels.map((l) => {
4327
- const level = { ...l };
4328
- if (l.escalationDueAt) level.escalationDueAt = new Date(l.escalationDueAt);
4329
- if (l.delegatedUntil) level.delegatedUntil = new Date(l.delegatedUntil);
4330
- if (l.reminderDueAt) level.reminderDueAt = new Date(l.reminderDueAt);
4331
- return level;
4332
- })
4333
- };
4334
- }
4335
- function reviveTemplateDates(template) {
4336
- return { ...template, createdAt: new Date(template.createdAt) };
4337
- }
4338
- function readPath(data, path) {
4339
- return path.split(".").reduce((obj, key) => {
4340
- if (obj !== null && typeof obj === "object" && Object.prototype.hasOwnProperty.call(obj, key)) {
4341
- return obj[key];
4342
- }
4343
- return void 0;
4344
- }, data);
4345
- }
4346
- function deepEquals(a, b) {
4347
- if (Object.is(a, b)) return true;
4348
- if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
4349
- if (Array.isArray(a) !== Array.isArray(b)) return false;
4350
- const aKeys = Object.keys(a);
4351
- const bKeys = Object.keys(b);
4352
- if (aKeys.length !== bKeys.length) return false;
4353
- return aKeys.every(
4354
- (k) => Object.prototype.hasOwnProperty.call(b, k) && deepEquals(a[k], b[k])
4355
- );
4356
- }
4357
- function applyFilter(instance, filter) {
4358
- if (filter.status && instance.status !== filter.status) return false;
4359
- if (filter.documentType && instance.documentType !== filter.documentType) return false;
4360
- if (filter.submittedBy && instance.submittedBy !== filter.submittedBy) return false;
4361
- if (filter.templateName && instance.templateName !== filter.templateName) return false;
4362
- if (filter.fromDate && new Date(instance.createdAt) < filter.fromDate) return false;
4363
- if (filter.toDate && new Date(instance.createdAt) > filter.toDate) return false;
4364
- if (filter.data) {
4365
- for (const [path, expected] of Object.entries(filter.data)) {
4366
- if (!deepEquals(readPath(instance.data ?? {}, path), expected)) return false;
4367
- }
4368
- }
4369
- return true;
4370
- }
4371
- var MemoryAdapter = class {
4372
- constructor() {
4373
- // keyed by `${tenantId}:${template.name}`
4374
- this.templates = /* @__PURE__ */ new Map();
4375
- // keyed by `${tenantId}:${instance.id}`
4376
- this.instances = /* @__PURE__ */ new Map();
4377
- }
4378
- async saveTemplate(template) {
4379
- this.templates.set(`${template.tenantId}:${template.name}`, deepClone(template));
4380
- }
4381
- async getTemplate(tenantId, name) {
4382
- const template = this.templates.get(`${tenantId}:${name}`);
4383
- return template ? reviveTemplateDates(deepClone(template)) : null;
4384
- }
4385
- async listTemplates(tenantId) {
4386
- const result = [];
4387
- for (const [key, template] of this.templates) {
4388
- if (key.startsWith(`${tenantId}:`)) {
4389
- result.push(reviveTemplateDates(deepClone(template)));
4390
- }
4391
- }
4392
- return result;
4393
- }
4394
- async saveInstance(instance) {
4395
- this.instances.set(`${instance.tenantId}:${instance.id}`, deepClone(instance));
4396
- }
4397
- async updateInstance(instance, expectedVersion) {
4398
- const key = `${instance.tenantId}:${instance.id}`;
4399
- const stored = this.instances.get(key);
4400
- if (!stored) throw new ApprovalConflictError(instance.id);
4401
- if (stored.version !== expectedVersion) throw new ApprovalConflictError(instance.id);
4402
- const updated = deepClone(instance);
4403
- updated.version = expectedVersion + 1;
4404
- this.instances.set(key, updated);
4405
- }
4406
- async getInstance(tenantId, id) {
4407
- const raw = this.instances.get(`${tenantId}:${id}`);
4408
- if (!raw) return null;
4409
- return reviveDates(deepClone(raw));
4410
- }
4411
- async getInstancesByApprover(tenantId, approverId, opts) {
4412
- const all = [...this.instances.values()].filter((i) => {
4413
- if (i.tenantId !== tenantId || i.status !== "pending") return false;
4414
- const currentLevel = i.levels.find((l) => l.level === i.currentLevel);
4415
- return currentLevel?.approverIds.includes(approverId) ?? false;
4416
- });
4417
- return paginate(
4418
- all.map((i) => reviveDates(deepClone(i))),
4419
- opts
4420
- );
4421
- }
4422
- async getInstancesByFilter(tenantId, filter, opts) {
4423
- const all = [...this.instances.values()].filter(
4424
- (i) => i.tenantId === tenantId && applyFilter(i, filter)
4425
- );
4426
- return paginate(
4427
- all.map((i) => reviveDates(deepClone(i))),
4428
- opts
4429
- );
4430
- }
4431
- async getOverdueInstances(tenantId, asOf, filter = {}) {
4432
- return [...this.instances.values()].filter((i) => {
4433
- if (i.tenantId !== tenantId || i.status !== "pending") return false;
4434
- if (filter.documentType && i.documentType !== filter.documentType) return false;
4435
- if (filter.submittedBy && i.submittedBy !== filter.submittedBy) return false;
4436
- const hasOverdueEscalation = i.levels.some(
4437
- (l) => l.escalationDueAt != null && new Date(l.escalationDueAt) <= asOf
4438
- );
4439
- const isExpired = i.expiresAt != null && new Date(i.expiresAt) <= asOf;
4440
- const hasSLABreach = i.slaDeadlineAt != null && new Date(i.slaDeadlineAt) <= asOf && !i.slaBreachedAt;
4441
- const hasDelegationExpiry = i.levels.some(
4442
- (l) => l.status === "pending" && l.delegatedUntil != null && new Date(l.delegatedUntil) <= asOf && l.delegatedFrom != null
4443
- );
4444
- const hasDueReminder = i.levels.some(
4445
- (l) => l.status === "pending" && l.reminderDueAt != null && new Date(l.reminderDueAt) <= asOf
4446
- );
4447
- return hasOverdueEscalation || isExpired || hasSLABreach || hasDelegationExpiry || hasDueReminder;
4448
- }).map((i) => reviveDates(deepClone(i)));
4449
- }
4450
- async deleteInstance(tenantId, id) {
4451
- const key = `${tenantId}:${id}`;
4452
- return this.instances.delete(key);
4453
- }
4454
- async countInstances(tenantId, filter) {
4455
- let count = 0;
4456
- for (const instance of this.instances.values()) {
4457
- if (instance.tenantId === tenantId && applyFilter(instance, filter)) count++;
4458
- }
4459
- return count;
4460
- }
4461
- async getInstancesByCursor(tenantId, filter, opts) {
4462
- const all = [...this.instances.values()].filter((i) => i.tenantId === tenantId && applyFilter(i, filter)).map((i) => reviveDates(deepClone(i))).sort((a, b) => {
4463
- const ta = a.updatedAt.getTime();
4464
- const tb = b.updatedAt.getTime();
4465
- return ta !== tb ? ta - tb : a.id.localeCompare(b.id);
4466
- });
4467
- const { cursor, limit, direction = "forward" } = opts;
4468
- let startIdx = 0;
4469
- if (cursor) {
4470
- const [ts, id] = decodeCursor(cursor);
4471
- const idx = all.findIndex(
4472
- (i) => i.updatedAt.getTime() > ts || i.updatedAt.getTime() === ts && i.id > id
4473
- );
4474
- startIdx = idx === -1 ? all.length : idx;
4475
- }
4476
- if (direction === "backward" && startIdx > 0) {
4477
- startIdx = Math.max(0, startIdx - limit - 1);
4478
- }
4479
- const slice = all.slice(startIdx, startIdx + limit);
4480
- const hasMore = startIdx + limit < all.length;
4481
- const nextCursor = hasMore ? encodeCursor(slice[slice.length - 1]) : void 0;
4482
- const prevCursor = startIdx > 0 ? encodeCursor(all[startIdx - 1]) : void 0;
4483
- return { items: slice, nextCursor, prevCursor, hasMore };
4484
- }
4485
- async getIdempotentInstance(tenantId, idempotencyKey) {
4486
- for (const instance of this.instances.values()) {
4487
- if (instance.tenantId === tenantId && instance.idempotencyKey === idempotencyKey) {
4488
- return reviveDates(deepClone(instance));
4489
- }
4490
- }
4491
- return null;
4492
- }
4493
- async appendAuditEntry(_tenantId, _instanceId, _entry) {
4494
- }
4495
- /** Test helper — total stored instances across all tenants. */
4496
- get size() {
4497
- return this.instances.size;
4498
- }
4499
- };
4500
- function paginate(items, opts) {
4501
- const total = items.length;
4502
- if (!opts) return { items, total };
4503
- return { items: items.slice(opts.offset, opts.offset + opts.limit), total };
4504
- }
4505
- function encodeCursor(instance) {
4506
- return Buffer.from(`${instance.updatedAt.getTime()}|${instance.id}`).toString("base64");
4507
- }
4508
- function decodeCursor(cursor) {
4509
- const decoded = Buffer.from(cursor, "base64").toString("utf8");
4510
- const pipeIdx = decoded.indexOf("|");
4511
- return [Number(decoded.slice(0, pipeIdx)), decoded.slice(pipeIdx + 1)];
4512
- }
4513
-
4514
4710
  // src/utils/BusinessCalendar.ts
4515
4711
  var DAY_MS = 864e5;
4516
4712
  function dayKey(d) {