@gadmin2n/schematics 0.0.150 → 0.0.151

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 (81) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/CODEBUDDY.md +125 -36
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +42 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/gitignore +2 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/server/.seed-timestamp +0 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +2 -0
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/check-dev-env.js +218 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/page-manage.ts +78 -12
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +37 -67
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/index.ts +0 -17
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agenda/agenda.controller.ts +46 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.ts +60 -1
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/external-callback.controller.ts +3 -0
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/externalInteraction/workflow-callback-url.controller.ts +9 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.ts +55 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.module.ts +2 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/page-transfer.service.ts +491 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.dto.ts +213 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/perm-transfer.module.ts +28 -0
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/resource-transfer.service.ts +137 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/role-transfer.service.ts +248 -0
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/transfer-token.service.ts +65 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/permTransfer/user-transfer.service.ts +146 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.ts +55 -0
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.module.ts +2 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.ts +55 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.module.ts +2 -0
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.spec.ts +46 -0
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/subjectProvider/providers/user-group.provider.ts +3 -2
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.ts +55 -0
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.module.ts +2 -0
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-cancellation.spec.ts +1 -0
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.spec.ts +96 -14
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-compiler.service.ts +41 -41
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.spec.ts +36 -0
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-database-function.ts +90 -0
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-catalog.spec.ts +4 -0
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-event-trigger.pg.spec.ts +1 -0
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-execution-idempotency.spec.ts +115 -63
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.spec.ts +166 -0
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-outbox.service.ts +217 -0
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.spec.ts +33 -0
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle-runtime.controller.ts +57 -0
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.spec.ts +196 -0
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-lifecycle.service.ts +125 -0
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow-node-type.controller.ts +9 -3
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +148 -4
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.module.ts +12 -1
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +14 -0
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +75 -45
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowConfig/node-config-validator.service.spec.ts +25 -0
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferExportModal.tsx +115 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/TransferImportModal.tsx +257 -0
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/hooks/useDynamicResources.tsx +0 -2
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +40 -0
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/workflow.json +15 -13
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +40 -0
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/workflow.json +15 -13
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +2 -1
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/agendaJob/list.tsx +89 -65
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/page/list.tsx +51 -127
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/permissionReadme/index.tsx +53 -109
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/resource/list.tsx +45 -41
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/role/list.tsx +51 -72
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/user/list.tsx +49 -40
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/NodePropertyPanel.test.tsx +16 -5
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/ReadonlyNodePanel.test.tsx +28 -10
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.test.tsx +60 -1
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +94 -12
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/node-instance-show.css +13 -0
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/schema-form/default-widgets.tsx +3 -3
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/show.tsx +8 -1
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/DbExecuteConfigEditor.tsx +167 -94
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerConfigEditor.tsx +95 -95
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.test.tsx +81 -0
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/EventTriggerLifecycleConfigEditor.tsx +126 -0
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/node-instances/widgets/SimplifiedNodeEditors.test.tsx +48 -52
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/forEachConnection.ts +2 -2
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/utils/newNodeConfig.ts +1 -3
  79. package/package.json +1 -1
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/config/ac.config.ts +0 -24
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/SqlModal.tsx +0 -495
@@ -20,6 +20,10 @@ import {
20
20
  DraftWorkflowNode,
21
21
  WorkflowCompilerErrorCode,
22
22
  } from './workflow-compiler.types';
23
+ import {
24
+ listWorkflowDatabaseFunctions,
25
+ type WorkflowDatabaseFunctionOption,
26
+ } from './workflow-database-function';
23
27
  import { switchCaseValidationError } from './workflow-switch-config';
24
28
 
25
29
  @Injectable()
@@ -85,6 +89,9 @@ export class WorkflowCompilerService {
85
89
  );
86
90
 
87
91
  const compiledNodes: CompiledWorkflowNode[] = [];
92
+ let databaseFunctionsPromise:
93
+ | Promise<WorkflowDatabaseFunctionOption[]>
94
+ | undefined;
88
95
  for (const node of draftDsl.nodes) {
89
96
  const nodeType = typeByName.get(node.type);
90
97
  if (!nodeType)
@@ -192,6 +199,15 @@ export class WorkflowCompilerService {
192
199
  'HTTP Request Connection',
193
200
  );
194
201
  }
202
+ if (nodeType.runtimeKey === 'event_trigger') {
203
+ const failureCallback = asObject(asObject(config).failureCallback);
204
+ if (Object.keys(failureCallback).length > 0)
205
+ await this.validateIntegrationConnection(
206
+ node,
207
+ failureCallback.connectionRef,
208
+ '业务失败通知连接',
209
+ );
210
+ }
195
211
  if (nodeType.runtimeKey === 'human_approval') {
196
212
  const approvalConfig = asObject(config);
197
213
  if (approvalConfig.external) {
@@ -224,15 +240,34 @@ export class WorkflowCompilerService {
224
240
  }
225
241
  if (nodeType.runtimeKey === 'db_execute') {
226
242
  const databaseConfig = config as Record<string, unknown>;
227
- const allowWrite = databaseConfig.allowWrite === true;
228
- const sql = String(databaseConfig.sql ?? '');
229
- if (!isDatabaseSqlAllowed(sql, allowWrite))
243
+ if ('sql' in databaseConfig || 'allowWrite' in databaseConfig)
244
+ this.fail(
245
+ 'NODE_TYPE_MISMATCH',
246
+ node,
247
+ '数据库节点不再支持 SQL,请选择 workflow_api 中的 Function',
248
+ );
249
+ const functionRef = asObject(databaseConfig.functionRef);
250
+ const argumentBindings = Array.isArray(databaseConfig.argumentBindings)
251
+ ? databaseConfig.argumentBindings
252
+ : [];
253
+ databaseFunctionsPromise ??= listWorkflowDatabaseFunctions(this.prisma);
254
+ const databaseFunction = (await databaseFunctionsPromise).find(
255
+ (item) =>
256
+ item.schema === functionRef.schema &&
257
+ item.name === functionRef.name &&
258
+ item.identityArguments === functionRef.identityArguments,
259
+ );
260
+ if (!databaseFunction)
261
+ this.fail(
262
+ 'NODE_TYPE_MISMATCH',
263
+ node,
264
+ '选择的数据库 Function 不存在或已不再开放给 Workflow',
265
+ );
266
+ if (argumentBindings.length !== databaseFunction.arguments.length)
230
267
  this.fail(
231
268
  'NODE_TYPE_MISMATCH',
232
269
  node,
233
- allowWrite
234
- ? '数据库操作只允许单条查询、新增、更新或删除语句'
235
- : '当前数据库操作节点只允许单条只读查询',
270
+ `数据库 Function 需要 ${databaseFunction.arguments.length} 个参数`,
236
271
  );
237
272
  }
238
273
  if (nodeType.runtimeKey === 'sub_workflow') {
@@ -468,41 +503,6 @@ function clone<T>(value: T): T {
468
503
  return JSON.parse(JSON.stringify(value)) as T;
469
504
  }
470
505
 
471
- function isSingleReadOnlySql(sql: string): boolean {
472
- const normalized = normalizeSqlForPolicy(sql);
473
- if (!/^(select|with)\b/i.test(normalized)) return false;
474
- return !/\b(insert|update|delete|merge|call|copy|create|alter|drop|truncate|grant|revoke|vacuum|refresh)\b/i.test(
475
- normalized,
476
- );
477
- }
478
-
479
- function isDatabaseSqlAllowed(sql: string, allowWrite: boolean): boolean {
480
- const normalized = normalizeSqlForPolicy(sql);
481
- const writableCte =
482
- /^with\b/i.test(normalized) &&
483
- /\b(insert|update|delete)\b/i.test(normalized) &&
484
- !/\b(merge|call|copy|create|alter|drop|truncate|grant|revoke|vacuum|refresh)\b/i.test(
485
- normalized,
486
- );
487
- return (
488
- isSingleReadOnlySql(sql) ||
489
- (allowWrite &&
490
- (/^(insert|update|delete)\b/i.test(normalized) || writableCte))
491
- );
492
- }
493
-
494
- function normalizeSqlForPolicy(sql: string): string {
495
- const withoutContent = sql
496
- .replace(/\$(\w*)\$[\s\S]*?\$\1\$/g, ' ')
497
- .replace(/'(?:''|[^'])*'/g, ' ')
498
- .replace(/"(?:""|[^"])*"/g, ' ')
499
- .replace(/\/\*[\s\S]*?\*\//g, ' ')
500
- .replace(/--[^\r\n]*/g, ' ')
501
- .trim();
502
- const withoutTrailingSemicolon = withoutContent.replace(/;\s*$/, '').trim();
503
- return withoutTrailingSemicolon.includes(';') ? '' : withoutTrailingSemicolon;
504
- }
505
-
506
506
  function asObject(value: unknown): Record<string, unknown> {
507
507
  return value && typeof value === 'object' && !Array.isArray(value)
508
508
  ? (value as Record<string, unknown>)
@@ -0,0 +1,36 @@
1
+ import { listWorkflowDatabaseFunctions } from './workflow-database-function';
2
+
3
+ describe('workflow database Function catalog', () => {
4
+ it('returns scalar workflow_api Functions with ordered input arguments', async () => {
5
+ const prisma = {
6
+ $queryRaw: jest.fn().mockResolvedValue([
7
+ {
8
+ schema: 'workflow_api',
9
+ name: 'approve_request',
10
+ identity_arguments: 'p_request_id bigint, p_approved boolean',
11
+ return_type: 'jsonb',
12
+ arguments: [
13
+ { position: 1, name: 'p_request_id', type: 'bigint' },
14
+ { position: 2, name: 'p_approved', type: 'boolean' },
15
+ ],
16
+ },
17
+ ]),
18
+ };
19
+
20
+ await expect(listWorkflowDatabaseFunctions(prisma as any)).resolves.toEqual(
21
+ [
22
+ {
23
+ schema: 'workflow_api',
24
+ name: 'approve_request',
25
+ identityArguments: 'p_request_id bigint, p_approved boolean',
26
+ returnType: 'jsonb',
27
+ arguments: [
28
+ { position: 1, name: 'p_request_id', type: 'bigint' },
29
+ { position: 2, name: 'p_approved', type: 'boolean' },
30
+ ],
31
+ },
32
+ ],
33
+ );
34
+ expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
35
+ });
36
+ });
@@ -0,0 +1,90 @@
1
+ import { PrismaService } from 'nestjs-prisma';
2
+
3
+ export const WORKFLOW_DATABASE_FUNCTION_SCHEMA = 'workflow_api';
4
+
5
+ export interface WorkflowDatabaseFunctionArgument {
6
+ position: number;
7
+ name: string;
8
+ type: string;
9
+ }
10
+
11
+ export interface WorkflowDatabaseFunctionOption {
12
+ schema: string;
13
+ name: string;
14
+ identityArguments: string;
15
+ returnType: string;
16
+ arguments: WorkflowDatabaseFunctionArgument[];
17
+ }
18
+
19
+ interface WorkflowDatabaseFunctionRow {
20
+ schema: string;
21
+ name: string;
22
+ identity_arguments: string;
23
+ return_type: string;
24
+ arguments: unknown;
25
+ }
26
+
27
+ export async function listWorkflowDatabaseFunctions(
28
+ prisma: PrismaService,
29
+ ): Promise<WorkflowDatabaseFunctionOption[]> {
30
+ const rows = await prisma.$queryRaw<WorkflowDatabaseFunctionRow[]>`
31
+ SELECT namespace.nspname AS schema,
32
+ proc.proname AS name,
33
+ pg_get_function_identity_arguments(proc.oid) AS identity_arguments,
34
+ pg_get_function_result(proc.oid) AS return_type,
35
+ COALESCE(
36
+ (
37
+ SELECT jsonb_agg(
38
+ jsonb_build_object(
39
+ 'position', argument.position,
40
+ 'name', COALESCE(
41
+ proc.proargnames[argument.position],
42
+ 'arg_' || argument.position::text
43
+ ),
44
+ 'type', format_type(
45
+ proc.proargtypes[argument.position - 1],
46
+ NULL
47
+ )
48
+ )
49
+ ORDER BY argument.position
50
+ )
51
+ FROM generate_series(1, proc.pronargs) AS argument(position)
52
+ ),
53
+ '[]'::jsonb
54
+ ) AS arguments
55
+ FROM pg_proc proc
56
+ JOIN pg_namespace namespace ON namespace.oid = proc.pronamespace
57
+ WHERE namespace.nspname = 'workflow_api'
58
+ AND proc.prokind = 'f'
59
+ AND proc.proretset = FALSE
60
+ ORDER BY proc.proname, identity_arguments
61
+ `;
62
+
63
+ return rows.map((row) => ({
64
+ schema: row.schema,
65
+ name: row.name,
66
+ identityArguments: row.identity_arguments,
67
+ returnType: row.return_type,
68
+ arguments: normalizeArguments(row.arguments),
69
+ }));
70
+ }
71
+
72
+ function normalizeArguments(
73
+ value: unknown,
74
+ ): WorkflowDatabaseFunctionArgument[] {
75
+ if (!Array.isArray(value)) return [];
76
+ return value.map((item, index) => {
77
+ const argument = asObject(item);
78
+ return {
79
+ position: Number(argument.position ?? index + 1),
80
+ name: String(argument.name ?? `arg_${index + 1}`),
81
+ type: String(argument.type ?? ''),
82
+ };
83
+ });
84
+ }
85
+
86
+ function asObject(value: unknown): Record<string, unknown> {
87
+ return value && typeof value === 'object' && !Array.isArray(value)
88
+ ? (value as Record<string, unknown>)
89
+ : {};
90
+ }
@@ -179,6 +179,7 @@ describe('PostgreSQL business event registration', () => {
179
179
  {} as any,
180
180
  {} as any,
181
181
  {} as any,
182
+ {} as any,
182
183
  );
183
184
 
184
185
  await expect(
@@ -224,6 +225,7 @@ describe('PostgreSQL business event registration', () => {
224
225
  {} as any,
225
226
  {} as any,
226
227
  {} as any,
228
+ {} as any,
227
229
  );
228
230
 
229
231
  await expect(
@@ -267,6 +269,7 @@ describe('PostgreSQL business event registration', () => {
267
269
  {} as any,
268
270
  {} as any,
269
271
  {} as any,
272
+ {} as any,
270
273
  );
271
274
 
272
275
  await service.registerBusinessEvent(
@@ -312,6 +315,7 @@ describe('PostgreSQL business event registration', () => {
312
315
  {} as any,
313
316
  {} as any,
314
317
  {} as any,
318
+ {} as any,
315
319
  );
316
320
 
317
321
  await expect(
@@ -35,6 +35,7 @@ pgTest(
35
35
  {} as any,
36
36
  {} as any,
37
37
  {} as any,
38
+ {} as any,
38
39
  );
39
40
  await (service as any).installBusinessEventTrigger(tx, {
40
41
  id: BigInt(1),
@@ -32,6 +32,9 @@ describe('Workflow execution idempotency', () => {
32
32
  update: jest.fn(),
33
33
  updateMany: jest.fn().mockResolvedValue({ count: 0 }),
34
34
  },
35
+ workflowEventDelivery: {
36
+ updateMany: jest.fn().mockResolvedValue({ count: 0 }),
37
+ },
35
38
  };
36
39
  prisma.$transaction = jest.fn(async (callback: any) =>
37
40
  callback({
@@ -46,8 +49,17 @@ describe('Workflow execution idempotency', () => {
46
49
  cancelWorkflow: jest.fn().mockResolvedValue(undefined),
47
50
  inspectWorkflow: jest.fn(),
48
51
  };
49
- const service = new WorkflowService(prisma, temporal, {} as any, {} as any);
50
- return { prisma, temporal, service };
52
+ const lifecycle: any = {
53
+ markFailed: jest.fn().mockResolvedValue(true),
54
+ };
55
+ const service = new WorkflowService(
56
+ prisma,
57
+ temporal,
58
+ {} as any,
59
+ {} as any,
60
+ lifecycle,
61
+ );
62
+ return { prisma, temporal, lifecycle, service };
51
63
  }
52
64
 
53
65
  it('persists the key and starts one deterministic Temporal execution', async () => {
@@ -95,7 +107,7 @@ describe('Workflow execution idempotency', () => {
95
107
  });
96
108
 
97
109
  it('rechecks enabled state while holding the Workflow row lock', async () => {
98
- const { prisma, temporal, service } = setup();
110
+ const { prisma, temporal, lifecycle, service } = setup();
99
111
  prisma.$transaction.mockImplementationOnce(async (callback: any) =>
100
112
  callback({
101
113
  $queryRaw: jest.fn().mockResolvedValue([{ isEnabled: false }]),
@@ -221,7 +233,7 @@ describe('Workflow execution idempotency', () => {
221
233
  });
222
234
 
223
235
  it('marks a reserved instance failed when Temporal cannot start it', async () => {
224
- const { prisma, temporal, service } = setup();
236
+ const { prisma, temporal, lifecycle, service } = setup();
225
237
  const created = {
226
238
  id: BigInt(21),
227
239
  workflowId,
@@ -238,14 +250,12 @@ describe('Workflow execution idempotency', () => {
238
250
  service.executeWorkflow(workflowId, {}, 'manual', temporal),
239
251
  ).rejects.toThrow('Temporal unavailable');
240
252
 
241
- expect(prisma.workflowInstance.updateMany).toHaveBeenLastCalledWith(
253
+ expect(lifecycle.markFailed).toHaveBeenCalledWith(
242
254
  expect.objectContaining({
243
- where: expect.objectContaining({
244
- id: BigInt(21),
245
- status: 'PENDING',
246
- temporalRunId: 'wf-7-21',
247
- }),
248
- data: expect.objectContaining({ status: 'FAILED' }),
255
+ instanceId: BigInt(21),
256
+ expectedStatuses: ['PENDING'],
257
+ temporalRunId: 'wf-7-21',
258
+ result: expect.objectContaining({ code: 'TEMPORAL_START_FAILED' }),
249
259
  }),
250
260
  );
251
261
  });
@@ -588,7 +598,9 @@ describe('Workflow execution idempotency', () => {
588
598
  context: { workflow: { orderId: 'O-1' }, nodes: {} },
589
599
  temporalRunId: 'wf-7-21',
590
600
  };
591
- prisma.workflowInstance.findMany.mockResolvedValueOnce([reserved]);
601
+ prisma.workflowInstance.findMany
602
+ .mockResolvedValueOnce([])
603
+ .mockResolvedValueOnce([reserved]);
592
604
  prisma.workflowVersion.findUnique.mockResolvedValueOnce({
593
605
  dsl: version.dsl,
594
606
  });
@@ -622,6 +634,46 @@ describe('Workflow execution idempotency', () => {
622
634
  data: expect.objectContaining({ status: 'RUNNING' }),
623
635
  }),
624
636
  );
637
+ expect(prisma.workflowEventDelivery.updateMany).toHaveBeenCalledWith({
638
+ where: { workflowInstanceId: BigInt(21), status: 'FAILED' },
639
+ data: {
640
+ status: 'DISPATCHED',
641
+ dispatchedAt: expect.any(Date),
642
+ lastError: null,
643
+ },
644
+ });
645
+ });
646
+
647
+ it('fails through lifecycle handling when a missing Temporal execution cannot restart', async () => {
648
+ const { prisma, temporal, lifecycle, service } = setup();
649
+ const reserved = {
650
+ id: BigInt(21),
651
+ workflowId,
652
+ versionId: version.id,
653
+ status: 'PENDING',
654
+ context: { workflow: {}, nodes: {} },
655
+ temporalRunId: 'wf-7-21',
656
+ };
657
+ prisma.workflowInstance.findMany
658
+ .mockResolvedValueOnce([])
659
+ .mockResolvedValueOnce([reserved]);
660
+ prisma.workflowVersion.findUnique.mockResolvedValueOnce({
661
+ dsl: version.dsl,
662
+ });
663
+ temporal.inspectWorkflow.mockResolvedValueOnce({ kind: 'MISSING' });
664
+ temporal.startWorkflow.mockRejectedValueOnce(new Error('Temporal down'));
665
+
666
+ await service.reconcileUnstartedInstances();
667
+
668
+ expect(lifecycle.markFailed).toHaveBeenCalledWith({
669
+ instanceId: BigInt(21),
670
+ expectedStatuses: ['PENDING'],
671
+ temporalRunId: 'wf-7-21',
672
+ result: {
673
+ code: 'TEMPORAL_START_RECONCILIATION_FAILED',
674
+ message: 'Temporal down',
675
+ },
676
+ });
625
677
  });
626
678
 
627
679
  it('cancels a reconciled execution when cancellation wins the restart race', async () => {
@@ -634,7 +686,9 @@ describe('Workflow execution idempotency', () => {
634
686
  context: { workflow: {}, nodes: {} },
635
687
  temporalRunId: 'wf-7-21',
636
688
  };
637
- prisma.workflowInstance.findMany.mockResolvedValueOnce([reserved]);
689
+ prisma.workflowInstance.findMany
690
+ .mockResolvedValueOnce([])
691
+ .mockResolvedValueOnce([reserved]);
638
692
  prisma.workflowVersion.findUnique.mockResolvedValueOnce({
639
693
  dsl: version.dsl,
640
694
  });
@@ -660,16 +714,18 @@ describe('Workflow execution idempotency', () => {
660
714
 
661
715
  it('recovers a completed Temporal result without exposing internal context', async () => {
662
716
  const { prisma, temporal, service } = setup();
663
- prisma.workflowInstance.findMany.mockResolvedValueOnce([
664
- {
665
- id: BigInt(21),
666
- workflowId,
667
- versionId: version.id,
668
- status: 'PENDING',
669
- context: { workflow: { orderId: 'O-1' }, nodes: {} },
670
- temporalRunId: 'wf-7-21',
671
- },
672
- ]);
717
+ prisma.workflowInstance.findMany
718
+ .mockResolvedValueOnce([])
719
+ .mockResolvedValueOnce([
720
+ {
721
+ id: BigInt(21),
722
+ workflowId,
723
+ versionId: version.id,
724
+ status: 'PENDING',
725
+ context: { workflow: { orderId: 'O-1' }, nodes: {} },
726
+ temporalRunId: 'wf-7-21',
727
+ },
728
+ ]);
673
729
  prisma.workflowInstance.updateMany
674
730
  .mockResolvedValueOnce({ count: 0 })
675
731
  .mockResolvedValueOnce({ count: 1 });
@@ -704,17 +760,19 @@ describe('Workflow execution idempotency', () => {
704
760
  });
705
761
 
706
762
  it('reconciles a stale RUNNING instance after Temporal reaches a terminal state', async () => {
707
- const { prisma, temporal, service } = setup();
708
- prisma.workflowInstance.findMany.mockResolvedValueOnce([
709
- {
710
- id: BigInt(21),
711
- workflowId,
712
- versionId: version.id,
713
- status: 'RUNNING',
714
- context: { workflow: { orderId: 'O-1' }, nodes: {} },
715
- temporalRunId: 'wf-7-21',
716
- },
717
- ]);
763
+ const { prisma, temporal, lifecycle, service } = setup();
764
+ prisma.workflowInstance.findMany
765
+ .mockResolvedValueOnce([])
766
+ .mockResolvedValueOnce([
767
+ {
768
+ id: BigInt(21),
769
+ workflowId,
770
+ versionId: version.id,
771
+ status: 'RUNNING',
772
+ context: { workflow: { orderId: 'O-1' }, nodes: {} },
773
+ temporalRunId: 'wf-7-21',
774
+ },
775
+ ]);
718
776
  temporal.inspectWorkflow.mockResolvedValueOnce({
719
777
  kind: 'FOUND',
720
778
  status: 'FAILED',
@@ -724,47 +782,41 @@ describe('Workflow execution idempotency', () => {
724
782
 
725
783
  await service.reconcileUnstartedInstances();
726
784
 
727
- expect(prisma.workflowInstance.updateMany).toHaveBeenLastCalledWith(
785
+ expect(lifecycle.markFailed).toHaveBeenCalledWith(
728
786
  expect.objectContaining({
729
- where: {
730
- id: BigInt(21),
731
- status: { in: ['PENDING', 'RUNNING'] },
732
- temporalRunId: 'wf-7-21',
733
- },
734
- data: expect.objectContaining({ status: 'FAILED' }),
787
+ instanceId: BigInt(21),
788
+ expectedStatuses: ['PENDING', 'RUNNING'],
789
+ temporalRunId: 'wf-7-21',
735
790
  }),
736
791
  );
737
792
  expect(temporal.startWorkflow).not.toHaveBeenCalled();
738
793
  });
739
794
 
740
795
  it('fails a stale RUNNING instance when its Temporal execution is missing', async () => {
741
- const { prisma, temporal, service } = setup();
742
- prisma.workflowInstance.findMany.mockResolvedValueOnce([
743
- {
744
- id: BigInt(21),
745
- workflowId,
746
- versionId: version.id,
747
- status: 'RUNNING',
748
- context: { workflow: {}, nodes: {} },
749
- temporalRunId: 'wf-7-21',
750
- },
751
- ]);
796
+ const { prisma, temporal, lifecycle, service } = setup();
797
+ prisma.workflowInstance.findMany
798
+ .mockResolvedValueOnce([])
799
+ .mockResolvedValueOnce([
800
+ {
801
+ id: BigInt(21),
802
+ workflowId,
803
+ versionId: version.id,
804
+ status: 'RUNNING',
805
+ context: { workflow: {}, nodes: {} },
806
+ temporalRunId: 'wf-7-21',
807
+ },
808
+ ]);
752
809
  temporal.inspectWorkflow.mockResolvedValueOnce({ kind: 'MISSING' });
753
810
 
754
811
  await service.reconcileUnstartedInstances();
755
812
 
756
- expect(prisma.workflowInstance.updateMany).toHaveBeenLastCalledWith(
813
+ expect(lifecycle.markFailed).toHaveBeenCalledWith(
757
814
  expect.objectContaining({
758
- where: {
759
- id: BigInt(21),
760
- status: 'RUNNING',
761
- temporalRunId: 'wf-7-21',
762
- },
763
- data: expect.objectContaining({
764
- status: 'FAILED',
765
- result: expect.objectContaining({
766
- code: 'TEMPORAL_EXECUTION_MISSING',
767
- }),
815
+ instanceId: BigInt(21),
816
+ expectedStatuses: ['RUNNING'],
817
+ temporalRunId: 'wf-7-21',
818
+ result: expect.objectContaining({
819
+ code: 'TEMPORAL_EXECUTION_MISSING',
768
820
  }),
769
821
  }),
770
822
  );