@ankhorage/flow 0.4.1 → 0.5.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +3 -1
  3. package/dist/execution.d.ts +5 -0
  4. package/dist/execution.d.ts.map +1 -0
  5. package/dist/execution.js +5 -0
  6. package/dist/execution.js.map +1 -0
  7. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.d.ts +4 -0
  8. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.d.ts.map +1 -0
  9. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.js +219 -0
  10. package/dist/features/execution/adapters/FileFlowPersistenceAdapter.js.map +1 -0
  11. package/dist/features/execution/application/cancelDurableRunAsync.d.ts +8 -0
  12. package/dist/features/execution/application/cancelDurableRunAsync.d.ts.map +1 -0
  13. package/dist/features/execution/application/cancelDurableRunAsync.js +22 -0
  14. package/dist/features/execution/application/cancelDurableRunAsync.js.map +1 -0
  15. package/dist/features/execution/application/createDurableRunAsync.d.ts +12 -0
  16. package/dist/features/execution/application/createDurableRunAsync.d.ts.map +1 -0
  17. package/dist/features/execution/application/createDurableRunAsync.js +8 -0
  18. package/dist/features/execution/application/createDurableRunAsync.js.map +1 -0
  19. package/dist/features/execution/application/runDurableWorkerAsync.d.ts +52 -0
  20. package/dist/features/execution/application/runDurableWorkerAsync.d.ts.map +1 -0
  21. package/dist/features/execution/application/runDurableWorkerAsync.js +195 -0
  22. package/dist/features/execution/application/runDurableWorkerAsync.js.map +1 -0
  23. package/dist/features/execution/domain/calculateRetryDueAt.d.ts +4 -0
  24. package/dist/features/execution/domain/calculateRetryDueAt.d.ts.map +1 -0
  25. package/dist/features/execution/domain/calculateRetryDueAt.js +14 -0
  26. package/dist/features/execution/domain/calculateRetryDueAt.js.map +1 -0
  27. package/dist/features/execution/domain/createDurableRun.d.ts +7 -0
  28. package/dist/features/execution/domain/createDurableRun.d.ts.map +1 -0
  29. package/dist/features/execution/domain/createDurableRun.js +10 -0
  30. package/dist/features/execution/domain/createDurableRun.js.map +1 -0
  31. package/dist/features/execution/domain/createRetryWork.d.ts +4 -0
  32. package/dist/features/execution/domain/createRetryWork.d.ts.map +1 -0
  33. package/dist/features/execution/domain/createRetryWork.js +17 -0
  34. package/dist/features/execution/domain/createRetryWork.js.map +1 -0
  35. package/dist/features/execution/domain/isCronDueAt.d.ts +7 -0
  36. package/dist/features/execution/domain/isCronDueAt.d.ts.map +1 -0
  37. package/dist/features/execution/domain/isCronDueAt.js +114 -0
  38. package/dist/features/execution/domain/isCronDueAt.js.map +1 -0
  39. package/dist/features/execution/ports/FlowPersistencePort.d.ts +34 -0
  40. package/dist/features/execution/ports/FlowPersistencePort.d.ts.map +1 -0
  41. package/dist/features/execution/ports/FlowPersistencePort.js +2 -0
  42. package/dist/features/execution/ports/FlowPersistencePort.js.map +1 -0
  43. package/dist/features/security/application/createFlowSecurityPort.js +2 -2
  44. package/dist/features/security/application/createFlowSecurityPort.js.map +1 -1
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +5 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/types/durableExecution.d.ts +55 -0
  50. package/dist/types/durableExecution.d.ts.map +1 -0
  51. package/dist/types/durableExecution.js +2 -0
  52. package/dist/types/durableExecution.js.map +1 -0
  53. package/package.json +5 -1
  54. package/src/execution.ts +9 -0
  55. package/src/features/execution/adapters/FileFlowPersistenceAdapter.ts +333 -0
  56. package/src/features/execution/application/cancelDurableRunAsync.ts +28 -0
  57. package/src/features/execution/application/createDurableRunAsync.ts +14 -0
  58. package/src/features/execution/application/runDurableWorkerAsync.ts +337 -0
  59. package/src/features/execution/domain/calculateRetryDueAt.ts +21 -0
  60. package/src/features/execution/domain/createDurableRun.ts +14 -0
  61. package/src/features/execution/domain/createRetryWork.ts +22 -0
  62. package/src/features/execution/domain/isCronDueAt.ts +131 -0
  63. package/src/features/execution/ports/FlowPersistencePort.ts +38 -0
  64. package/src/features/security/application/createFlowSecurityPort.ts +2 -2
  65. package/src/index.ts +14 -0
  66. package/src/types/durableExecution.ts +61 -0
@@ -0,0 +1,14 @@
1
+ import type { FlowDefinitionRef, FlowDurableRun } from '../../../types/durableExecution.js';
2
+ import type { FlowClock } from '../../../types/execution.js';
3
+ import { createDurableRun } from '../domain/createDurableRun.js';
4
+ import type { FlowPersistencePort } from '../ports/FlowPersistencePort.js';
5
+
6
+ /*** Persist a new durable run without executing an operation or provider effect. */
7
+ export const createDurableRunAsync = async (
8
+ input: { readonly flow: FlowDefinitionRef; readonly id: string },
9
+ dependencies: { readonly clock: FlowClock; readonly persistence: FlowPersistencePort },
10
+ ): Promise<FlowDurableRun> => {
11
+ const run = createDurableRun(input, dependencies.clock.now());
12
+ await dependencies.persistence.createRunAsync(run);
13
+ return run;
14
+ };
@@ -0,0 +1,337 @@
1
+ import type {
2
+ FlowDurableRun,
3
+ FlowDurableWork,
4
+ FlowRetryPolicy,
5
+ FlowRunCheckpoint,
6
+ } from '../../../types/durableExecution.js';
7
+ import type { FlowClock } from '../../../types/execution.js';
8
+ import type { FlowSecurityPort } from '../../../types/security.js';
9
+ import { FlowSecurityError } from '../../security/FlowSecurityError.js';
10
+ import { createRetryWork } from '../domain/createRetryWork.js';
11
+ import type { FlowPersistencePort } from '../ports/FlowPersistencePort.js';
12
+
13
+ /** Claim bounded durable work and execute it with fenced recovery, retry, and approval handling. */
14
+ export const runDurableWorkerAsync = async (
15
+ input: {
16
+ readonly approvalPollIntervalMs: number;
17
+ readonly concurrency: number;
18
+ readonly leaseDurationMs: number;
19
+ readonly limit: number;
20
+ readonly retry: FlowRetryPolicy;
21
+ readonly workerId: string;
22
+ },
23
+ dependencies: {
24
+ readonly clock: FlowClock;
25
+ readonly effects: FlowDurableEffectPort;
26
+ readonly persistence: FlowPersistencePort;
27
+ readonly security?: FlowSecurityPort;
28
+ },
29
+ ): Promise<FlowWorkerResult> => {
30
+ assertWorkerInput(input);
31
+ const now = dependencies.clock.now();
32
+ const recoveredWorkIds = await dependencies.persistence.recoverExpiredClaimsAsync(now);
33
+ const claimed = await dependencies.persistence.claimDueWorkAsync({
34
+ leaseDurationMs: input.leaseDurationMs,
35
+ limit: Math.min(input.limit, input.concurrency),
36
+ now,
37
+ workerId: input.workerId,
38
+ });
39
+ const outcomes = await Promise.all(
40
+ claimed.map((work) => processWorkAsync(work, input, dependencies)),
41
+ );
42
+ return { claimedWorkIds: claimed.map((work) => work.id), outcomes, recoveredWorkIds };
43
+ };
44
+
45
+ /** Executes one effect through the durable step and security boundaries. */
46
+ const processWorkAsync = async (
47
+ work: FlowDurableWork,
48
+ input: Parameters<typeof runDurableWorkerAsync>[0],
49
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
50
+ ): Promise<FlowWorkerOutcome> => {
51
+ const { lease } = work;
52
+ if (lease === undefined) return { status: 'lost_lease', workId: work.id };
53
+ const run = await dependencies.persistence.readRunAsync(work.runId);
54
+ if (run === undefined || run.status === 'cancelled') {
55
+ await completeAsync(work, input.workerId, dependencies);
56
+ return { status: 'cancelled', workId: work.id };
57
+ }
58
+ if (isCompleted(run.checkpoint, work.stepId)) {
59
+ await completeAsync(work, input.workerId, dependencies);
60
+ return { status: 'already_completed', workId: work.id };
61
+ }
62
+ try {
63
+ const outbox = await prepareOutboxAsync(work, dependencies);
64
+ if (outbox.status === 'delivered') {
65
+ const transitioned = await transitionAsync(
66
+ run,
67
+ 'succeeded',
68
+ completedCheckpoint(run.checkpoint, work.stepId),
69
+ dependencies,
70
+ );
71
+ const completed = await completeAsync(work, input.workerId, dependencies);
72
+ return {
73
+ status: transitioned && completed ? 'already_completed' : 'lost_lease',
74
+ workId: work.id,
75
+ };
76
+ }
77
+ const effectStatus = await executeEffectAsync(work, run, dependencies);
78
+ await dependencies.persistence.markOutboxDeliveredAsync(work.id);
79
+ const transitioned = await transitionAsync(
80
+ run,
81
+ 'succeeded',
82
+ completedCheckpoint(run.checkpoint, work.stepId),
83
+ dependencies,
84
+ );
85
+ if (!transitioned || !(await completeAsync(work, input.workerId, dependencies)))
86
+ return { status: 'lost_lease', workId: work.id };
87
+ return { status: effectStatus, workId: work.id };
88
+ } catch (error) {
89
+ if (isApprovalPending(error)) return waitForApprovalAsync(work, run, input, dependencies);
90
+ return retryOrFailAsync(work, run, input, dependencies, error);
91
+ }
92
+ };
93
+
94
+ /** Create an idempotency-keyed outbox record before delivery, or resume its pending record after a crash. */
95
+ const prepareOutboxAsync = async (
96
+ work: FlowDurableWork,
97
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
98
+ ) => {
99
+ const existing = await dependencies.persistence.readOutboxAsync(work.id);
100
+ if (existing !== undefined) return existing;
101
+ const now = dependencies.clock.now().toISOString();
102
+ await dependencies.persistence.createOutboxAsync({
103
+ createdAt: now,
104
+ id: work.id,
105
+ idempotencyKey: work.idempotencyKey ?? work.id,
106
+ runId: work.runId,
107
+ status: 'pending',
108
+ workId: work.id,
109
+ });
110
+ const created = await dependencies.persistence.readOutboxAsync(work.id);
111
+ if (created === undefined) throw new Error('FLOW_OUTBOX_CREATE_FAILED');
112
+ return created;
113
+ };
114
+
115
+ /** Runs internal work directly, while protected provider work requires reconciliation and a canonical permit. */
116
+ const executeEffectAsync = async (
117
+ work: FlowDurableWork,
118
+ run: FlowDurableRun,
119
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
120
+ ): Promise<'succeeded'> => {
121
+ const execute = () =>
122
+ dependencies.effects.executeAsync({
123
+ idempotencyKey: work.idempotencyKey ?? work.id,
124
+ run,
125
+ work,
126
+ });
127
+ if (work.effectKind === 'internal') {
128
+ if (work.securityOperation !== undefined)
129
+ throw new Error('FLOW_INTERNAL_EFFECT_SECURITY_OPERATION');
130
+ await execute();
131
+ return 'succeeded';
132
+ }
133
+ if (work.securityOperation === undefined)
134
+ throw new Error('FLOW_PROTECTED_EFFECT_SECURITY_OPERATION');
135
+ if (dependencies.security === undefined) throw new Error('FLOW_SECURITY_PORT_REQUIRED');
136
+ if (dependencies.effects.reconcileAsync === undefined)
137
+ throw new Error('FLOW_PROTECTED_EFFECT_RECONCILIATION_REQUIRED');
138
+ if (
139
+ (await dependencies.effects.reconcileAsync({
140
+ idempotencyKey: work.idempotencyKey ?? work.id,
141
+ run,
142
+ work,
143
+ })) === 'delivered'
144
+ )
145
+ return 'succeeded';
146
+ const permit = await dependencies.security.authorizeAsync(work.securityOperation);
147
+ await permit.executeAsync(async () => execute());
148
+ return 'succeeded';
149
+ };
150
+
151
+ /** Persist a waiting approval and defer its at-least-once delivery without consuming the lease. */
152
+ const waitForApprovalAsync = async (
153
+ work: FlowDurableWork,
154
+ run: FlowDurableRun,
155
+ input: Parameters<typeof runDurableWorkerAsync>[0],
156
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
157
+ ): Promise<FlowWorkerOutcome> => {
158
+ const transitioned = await transitionAsync(run, 'waiting_approval', run.checkpoint, dependencies);
159
+ const released = await dependencies.persistence.releaseWorkAsync({
160
+ dueAt: new Date(
161
+ dependencies.clock.now().getTime() + input.approvalPollIntervalMs,
162
+ ).toISOString(),
163
+ id: work.id,
164
+ leaseFencingToken: claimFencingToken(work),
165
+ workerId: input.workerId,
166
+ });
167
+ return { status: transitioned && released ? 'waiting_approval' : 'lost_lease', workId: work.id };
168
+ };
169
+
170
+ /** Persist a bounded retry or terminal failure before releasing the claimed delivery. */
171
+ const retryOrFailAsync = async (
172
+ work: FlowDurableWork,
173
+ run: FlowDurableRun,
174
+ input: Parameters<typeof runDurableWorkerAsync>[0],
175
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
176
+ error: unknown,
177
+ ): Promise<FlowWorkerOutcome> => {
178
+ if (work.attempt >= input.retry.maxAttempts) {
179
+ await dependencies.effects.compensateAsync?.({ run, work });
180
+ const transitioned = await transitionAsync(
181
+ run,
182
+ 'failed',
183
+ failedCheckpoint(run.checkpoint, work.stepId),
184
+ dependencies,
185
+ );
186
+ const completed = await completeAsync(work, input.workerId, dependencies);
187
+ return {
188
+ error: errorMessage(error),
189
+ status: transitioned && completed ? 'failed' : 'lost_lease',
190
+ workId: work.id,
191
+ };
192
+ }
193
+ await dependencies.persistence.enqueueWorkAsync(
194
+ createRetryWork(work, dependencies.clock.now(), input.retry),
195
+ );
196
+ const completed = await completeAsync(work, input.workerId, dependencies);
197
+ return {
198
+ error: errorMessage(error),
199
+ status: completed ? 'retry_scheduled' : 'lost_lease',
200
+ workId: work.id,
201
+ };
202
+ };
203
+
204
+ /** Compare-and-swap a run state while preserving its immutable flow revision. */
205
+ const transitionAsync = (
206
+ run: FlowDurableRun,
207
+ status: FlowDurableRun['status'],
208
+ checkpoint: FlowRunCheckpoint | undefined,
209
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
210
+ ): Promise<boolean> =>
211
+ dependencies.persistence.transitionRunAsync({
212
+ expectedRevision: run.revision,
213
+ run: {
214
+ ...run,
215
+ ...(checkpoint === undefined ? {} : { checkpoint }),
216
+ revision: run.revision + 1,
217
+ status,
218
+ updatedAt: dependencies.clock.now().toISOString(),
219
+ },
220
+ });
221
+
222
+ /** Complete a work lease only when its owner and fencing token still match. */
223
+ const completeAsync = (
224
+ work: FlowDurableWork,
225
+ workerId: string,
226
+ dependencies: Parameters<typeof runDurableWorkerAsync>[1],
227
+ ): Promise<boolean> =>
228
+ dependencies.persistence.completeWorkAsync({
229
+ id: work.id,
230
+ leaseFencingToken: claimFencingToken(work),
231
+ now: dependencies.clock.now(),
232
+ workerId,
233
+ });
234
+
235
+ /** Read the lease token only from a claimed work item. */
236
+ const claimFencingToken = (work: FlowDurableWork): number => {
237
+ if (work.lease === undefined) throw new Error('FLOW_WORK_LEASE_MISSING');
238
+ return work.lease.fencingToken;
239
+ };
240
+
241
+ /** Add a successful step exactly once to an immutable checkpoint. */
242
+ const completedCheckpoint = (
243
+ checkpoint: FlowRunCheckpoint | undefined,
244
+ stepId: string | undefined,
245
+ ): FlowRunCheckpoint => ({
246
+ completedStepIds: unique([
247
+ ...(checkpoint?.completedStepIds ?? []),
248
+ ...(stepId === undefined ? [] : [stepId]),
249
+ ]),
250
+ ...(checkpoint?.data === undefined ? {} : { data: checkpoint.data }),
251
+ failedStepIds: checkpoint?.failedStepIds ?? [],
252
+ });
253
+
254
+ /** Add a terminally failed step to an immutable checkpoint. */
255
+ const failedCheckpoint = (
256
+ checkpoint: FlowRunCheckpoint | undefined,
257
+ stepId: string | undefined,
258
+ ): FlowRunCheckpoint => ({
259
+ completedStepIds: checkpoint?.completedStepIds ?? [],
260
+ ...(checkpoint?.data === undefined ? {} : { data: checkpoint.data }),
261
+ failedStepIds: unique([
262
+ ...(checkpoint?.failedStepIds ?? []),
263
+ ...(stepId === undefined ? [] : [stepId]),
264
+ ]),
265
+ });
266
+
267
+ /** Check whether a completed checkpoint makes a duplicate delivery a no-op. */
268
+ const isCompleted = (
269
+ checkpoint: FlowRunCheckpoint | undefined,
270
+ stepId: string | undefined,
271
+ ): boolean => stepId !== undefined && checkpoint?.completedStepIds.includes(stepId) === true;
272
+
273
+ /** Validate finite worker bounds before touching persistence. */
274
+ const assertWorkerInput = (input: Parameters<typeof runDurableWorkerAsync>[0]): void => {
275
+ for (const value of [
276
+ input.approvalPollIntervalMs,
277
+ input.concurrency,
278
+ input.leaseDurationMs,
279
+ input.limit,
280
+ input.retry.maxAttempts,
281
+ ])
282
+ if (!Number.isSafeInteger(value) || value < 1) throw new Error('FLOW_WORKER_INPUT_INVALID');
283
+ };
284
+
285
+ /** Identify the canonical pending-approval error without interpreting untrusted error shapes. */
286
+ const isApprovalPending = (error: unknown): boolean =>
287
+ error instanceof FlowSecurityError && error.code === 'approval_pending';
288
+
289
+ /** Preserve a concise, provider-neutral failure signal in the worker result. */
290
+ const errorMessage = (error: unknown): string =>
291
+ error instanceof Error ? error.message : 'FLOW_EFFECT_FAILED';
292
+
293
+ /** Keep persisted checkpoint arrays deterministic. */
294
+ const unique = (values: readonly string[]): readonly string[] => [...new Set(values)];
295
+
296
+ /** Application-owned effect boundary; adapters must apply the supplied idempotency key at their side-effect boundary. */
297
+ export interface FlowDurableEffectPort {
298
+ readonly compensateAsync?: (input: {
299
+ readonly run: FlowDurableRun;
300
+ readonly work: FlowDurableWork;
301
+ }) => Promise<void>;
302
+ readonly executeAsync: (input: {
303
+ readonly idempotencyKey: string;
304
+ readonly run: FlowDurableRun;
305
+ readonly work: FlowDurableWork;
306
+ }) => Promise<void>;
307
+ /**
308
+ * Query the provider's idempotency boundary before a protected replay. A provider must return
309
+ * `delivered` only when the stable key proves that the original effect completed.
310
+ */
311
+ readonly reconcileAsync?: (input: {
312
+ readonly idempotencyKey: string;
313
+ readonly run: FlowDurableRun;
314
+ readonly work: FlowDurableWork;
315
+ }) => Promise<'delivered' | 'unknown'>;
316
+ }
317
+
318
+ /** Observable outcome for every delivery claimed by one bounded worker tick. */
319
+ export interface FlowWorkerOutcome {
320
+ readonly error?: string;
321
+ readonly status:
322
+ | 'already_completed'
323
+ | 'cancelled'
324
+ | 'failed'
325
+ | 'lost_lease'
326
+ | 'retry_scheduled'
327
+ | 'succeeded'
328
+ | 'waiting_approval';
329
+ readonly workId: string;
330
+ }
331
+
332
+ /** Summary of one worker tick; delivery remains at-least-once until fenced completion. */
333
+ export interface FlowWorkerResult {
334
+ readonly claimedWorkIds: readonly string[];
335
+ readonly outcomes: readonly FlowWorkerOutcome[];
336
+ readonly recoveredWorkIds: readonly string[];
337
+ }
@@ -0,0 +1,21 @@
1
+ import type { FlowRetryPolicy } from '../../../types/durableExecution.js';
2
+
3
+ /*** Calculate an exponential retry time from persisted attempt state without ambient time. */
4
+ export const calculateRetryDueAt = (
5
+ now: Date,
6
+ completedAttempt: number,
7
+ policy: FlowRetryPolicy,
8
+ ): string => {
9
+ if (!Number.isSafeInteger(completedAttempt) || completedAttempt < 1)
10
+ throw new Error('FLOW_RETRY_ATTEMPT_INVALID');
11
+ if (!Number.isSafeInteger(policy.baseDelayMs) || policy.baseDelayMs < 0)
12
+ throw new Error('FLOW_RETRY_BASE_DELAY_INVALID');
13
+ if (
14
+ !Number.isSafeInteger(policy.maxDelayMs) ||
15
+ policy.maxDelayMs < policy.baseDelayMs ||
16
+ policy.maxDelayMs < 0
17
+ )
18
+ throw new Error('FLOW_RETRY_MAX_DELAY_INVALID');
19
+ const delay = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (completedAttempt - 1));
20
+ return new Date(now.getTime() + delay).toISOString();
21
+ };
@@ -0,0 +1,14 @@
1
+ import type { FlowDefinitionRef, FlowDurableRun } from '../../../types/durableExecution.js';
2
+
3
+ /*** Create a revision-zero run pinned permanently to one published flow definition. */
4
+ export const createDurableRun = (
5
+ input: { readonly flow: FlowDefinitionRef; readonly id: string },
6
+ now: Date,
7
+ ): FlowDurableRun => ({
8
+ createdAt: now.toISOString(),
9
+ flow: { ...input.flow },
10
+ id: input.id,
11
+ revision: 0,
12
+ status: 'queued',
13
+ updatedAt: now.toISOString(),
14
+ });
@@ -0,0 +1,22 @@
1
+ import type { FlowDurableWork, FlowRetryPolicy } from '../../../types/durableExecution.js';
2
+ import { calculateRetryDueAt } from './calculateRetryDueAt.js';
3
+
4
+ /*** Create the next immutable work attempt from a completed attempt and controlled clock. */
5
+ export const createRetryWork = (
6
+ completed: FlowDurableWork,
7
+ now: Date,
8
+ policy: FlowRetryPolicy,
9
+ ): FlowDurableWork => ({
10
+ ...(completed.idempotencyKey === undefined ? {} : { idempotencyKey: completed.idempotencyKey }),
11
+ attempt: completed.attempt + 1,
12
+ dueAt: calculateRetryDueAt(now, completed.attempt, policy),
13
+ effectKind: completed.effectKind,
14
+ flow: { ...completed.flow },
15
+ id: `${completed.runId}:attempt:${completed.attempt + 1}`,
16
+ runId: completed.runId,
17
+ ...(completed.securityOperation === undefined
18
+ ? {}
19
+ : { securityOperation: completed.securityOperation }),
20
+ ...(completed.stepId === undefined ? {} : { stepId: completed.stepId }),
21
+ status: 'ready',
22
+ });
@@ -0,0 +1,131 @@
1
+ /** Determine whether one UTC instant matches a five-field POSIX cron expression in an IANA zone. */
2
+ export const isCronDueAt = (input: {
3
+ readonly cron: string;
4
+ readonly now: Date;
5
+ readonly timezone?: string;
6
+ }): boolean => {
7
+ const fields = input.cron.trim().split(/\s+/);
8
+ if (fields.length !== 5) throw new Error('FLOW_CRON_INVALID');
9
+ const local = localParts(input.now, input.timezone ?? 'Etc/UTC');
10
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
11
+ if (
12
+ minute === undefined ||
13
+ hour === undefined ||
14
+ dayOfMonth === undefined ||
15
+ month === undefined ||
16
+ dayOfWeek === undefined
17
+ )
18
+ throw new Error('FLOW_CRON_INVALID');
19
+ const minuteMatches = matchesField(minute, local.minute, 0, 59);
20
+ const hourMatches = matchesField(hour, local.hour, 0, 23);
21
+ const monthMatches = matchesField(month, local.month, 1, 12);
22
+ const dayOfMonthMatches = matchesField(dayOfMonth, local.day, 1, 31);
23
+ const dayOfWeekMatches = matchesField(dayOfWeek, local.dayOfWeek, 0, 7);
24
+ const dayMatches =
25
+ dayOfMonth === '*' || dayOfWeek === '*'
26
+ ? dayOfMonthMatches && dayOfWeekMatches
27
+ : dayOfMonthMatches || dayOfWeekMatches;
28
+ return minuteMatches && hourMatches && monthMatches && dayMatches;
29
+ };
30
+
31
+ /** Format a UTC instant into its real local wall-clock fields; duplicate fall-back instants remain distinct instants. */
32
+ const localParts = (
33
+ now: Date,
34
+ timezone: string,
35
+ ): {
36
+ readonly day: number;
37
+ readonly dayOfWeek: number;
38
+ readonly hour: number;
39
+ readonly minute: number;
40
+ readonly month: number;
41
+ } => {
42
+ try {
43
+ const parts = new Intl.DateTimeFormat('en-US', {
44
+ day: 'numeric',
45
+ hour: 'numeric',
46
+ hourCycle: 'h23',
47
+ minute: 'numeric',
48
+ month: 'numeric',
49
+ timeZone: timezone,
50
+ weekday: 'short',
51
+ }).formatToParts(now);
52
+ const read = (type: Intl.DateTimeFormatPartTypes): string =>
53
+ parts.find((part) => part.type === type)?.value ?? invalidTimezone();
54
+ return {
55
+ day: Number(read('day')),
56
+ dayOfWeek: weekday(read('weekday')),
57
+ hour: Number(read('hour')),
58
+ minute: Number(read('minute')),
59
+ month: Number(read('month')),
60
+ };
61
+ } catch {
62
+ throw new Error('FLOW_CRON_TIMEZONE_INVALID');
63
+ }
64
+ };
65
+
66
+ /** Match one POSIX cron field including lists, ranges, and positive step values. */
67
+ const matchesField = (field: string, value: number, minimum: number, maximum: number): boolean =>
68
+ field.split(',').some((part) => matchesPart(part, value, minimum, maximum));
69
+
70
+ /** Match one validated cron list member. */
71
+ const matchesPart = (part: string, value: number, minimum: number, maximum: number): boolean => {
72
+ const [rangePart, stepPart] = part.split('/');
73
+ if (part.split('/').length > 2 || rangePart === undefined) throw new Error('FLOW_CRON_INVALID');
74
+ const step = stepPart === undefined ? 1 : parseValue(stepPart, 1, maximum - minimum + 1);
75
+ const [start, end] = parseRange(rangePart, minimum, maximum);
76
+ return value >= start && value <= end && (value - start) % step === 0;
77
+ };
78
+
79
+ /** Parse a wildcard, number, or inclusive numeric range. */
80
+ const parseRange = (range: string, minimum: number, maximum: number): readonly [number, number] => {
81
+ if (range === '*') return [minimum, maximum];
82
+ const values = range.split('-');
83
+ if (values.length === 1) {
84
+ const [single] = values;
85
+ if (single === undefined) throw new Error('FLOW_CRON_INVALID');
86
+ const value = parseValue(single, minimum, maximum);
87
+ return [value, value];
88
+ }
89
+ if (values.length !== 2 || values[0] === undefined || values[1] === undefined)
90
+ throw new Error('FLOW_CRON_INVALID');
91
+ const start = parseValue(values[0], minimum, maximum);
92
+ const end = parseValue(values[1], minimum, maximum);
93
+ if (end < start) throw new Error('FLOW_CRON_INVALID');
94
+ return [start, end];
95
+ };
96
+
97
+ /** Parse one strict POSIX cron integer. */
98
+ const parseValue = (value: string, minimum: number, maximum: number): number => {
99
+ if (!/^\d+$/.test(value)) throw new Error('FLOW_CRON_INVALID');
100
+ const parsed = Number(value);
101
+ if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum)
102
+ throw new Error('FLOW_CRON_INVALID');
103
+ return parsed;
104
+ };
105
+
106
+ /** Map English short weekday names produced by the fixed formatter to POSIX weekday values. */
107
+ const weekday = (value: string): number => {
108
+ switch (value) {
109
+ case 'Sun':
110
+ return 0;
111
+ case 'Mon':
112
+ return 1;
113
+ case 'Tue':
114
+ return 2;
115
+ case 'Wed':
116
+ return 3;
117
+ case 'Thu':
118
+ return 4;
119
+ case 'Fri':
120
+ return 5;
121
+ case 'Sat':
122
+ return 6;
123
+ default:
124
+ return invalidTimezone();
125
+ }
126
+ };
127
+
128
+ /** Keep an impossible formatter shape distinct from a cron syntax error. */
129
+ const invalidTimezone = (): never => {
130
+ throw new Error('FLOW_CRON_TIMEZONE_INVALID');
131
+ };
@@ -0,0 +1,38 @@
1
+ import type {
2
+ FlowDurableRun,
3
+ FlowDurableWork,
4
+ FlowOutboxRecord,
5
+ } from '../../../types/durableExecution.js';
6
+
7
+ export interface FlowPersistencePort {
8
+ readonly cancelRunWorkAsync: (runId: string) => Promise<readonly string[]>;
9
+ readonly claimDueWorkAsync: (input: {
10
+ readonly leaseDurationMs: number;
11
+ readonly limit: number;
12
+ readonly now: Date;
13
+ readonly workerId: string;
14
+ }) => Promise<readonly FlowDurableWork[]>;
15
+ readonly completeWorkAsync: (input: {
16
+ readonly id: string;
17
+ readonly leaseFencingToken: number;
18
+ readonly now: Date;
19
+ readonly workerId: string;
20
+ }) => Promise<boolean>;
21
+ readonly createOutboxAsync: (record: FlowOutboxRecord) => Promise<void>;
22
+ readonly createRunAsync: (run: FlowDurableRun) => Promise<void>;
23
+ readonly enqueueWorkAsync: (work: FlowDurableWork) => Promise<void>;
24
+ readonly markOutboxDeliveredAsync: (id: string) => Promise<boolean>;
25
+ readonly readOutboxAsync: (id: string) => Promise<FlowOutboxRecord | undefined>;
26
+ readonly readRunAsync: (id: string) => Promise<FlowDurableRun | undefined>;
27
+ readonly recoverExpiredClaimsAsync: (now: Date) => Promise<readonly string[]>;
28
+ readonly releaseWorkAsync: (input: {
29
+ readonly dueAt: string;
30
+ readonly id: string;
31
+ readonly leaseFencingToken: number;
32
+ readonly workerId: string;
33
+ }) => Promise<boolean>;
34
+ readonly transitionRunAsync: (input: {
35
+ readonly expectedRevision: number;
36
+ readonly run: FlowDurableRun;
37
+ }) => Promise<boolean>;
38
+ }
@@ -54,9 +54,9 @@ const authorizeAsync = async (
54
54
  if (!isGranted(input.grants, identity.actor, identity.capability))
55
55
  return denyAsync(input.audit, identity, targetHost, 'permission_denied');
56
56
  const approval = await input.approvalStore.getAsync(identity);
57
- if (!approval || approval.status === 'pending')
57
+ if (approval?.status === 'pending')
58
58
  return denyAsync(input.audit, identity, targetHost, 'approval_pending');
59
- if (approval.status !== 'approved' || !sameIdentity(approval.operation, identity))
59
+ if (approval?.status !== 'approved' || !sameIdentity(approval.operation, identity))
60
60
  return denyAsync(input.audit, identity, targetHost, 'approval_rejected');
61
61
  if (!(await input.operationStore.claimAsync(identity)))
62
62
  return denyAsync(input.audit, identity, targetHost, 'idempotency_conflict');
package/src/index.ts CHANGED
@@ -1,9 +1,23 @@
1
1
  /*** Headless graph compilation and deterministic single-process execution. */
2
+ export { createDurableRunAsync } from './features/execution/application/createDurableRunAsync.js';
2
3
  export { executeFlowAsync } from './features/execution/application/executeFlowAsync.js';
4
+ export { calculateRetryDueAt } from './features/execution/domain/calculateRetryDueAt.js';
3
5
  export { compileFlowGraph } from './features/execution/domain/compileFlowGraph.js';
4
6
  export { createDefaultNodeRegistry } from './features/execution/domain/createDefaultNodeRegistry.js';
7
+ export { createDurableRun } from './features/execution/domain/createDurableRun.js';
8
+ export { createRetryWork } from './features/execution/domain/createRetryWork.js';
9
+ export { isCronDueAt } from './features/execution/domain/isCronDueAt.js';
10
+ export type { FlowPersistencePort } from './features/execution/ports/FlowPersistencePort.js';
5
11
  export { createFlowSecurityPort } from './features/security/application/createFlowSecurityPort.js';
6
12
  export { FlowSecurityError } from './features/security/FlowSecurityError.js';
13
+ export type {
14
+ FlowDefinitionRef,
15
+ FlowDurableRun,
16
+ FlowDurableWork,
17
+ FlowOutboxRecord,
18
+ FlowRetryPolicy,
19
+ FlowRunCheckpoint,
20
+ } from './types/durableExecution.js';
7
21
  export type {
8
22
  FlowClock,
9
23
  FlowCompiledGraph,
@@ -0,0 +1,61 @@
1
+ import type { DataContractValue } from '@ankhorage/contracts/data';
2
+
3
+ import type { FlowSecurityOperation } from './security.js';
4
+
5
+ export interface FlowDefinitionRef {
6
+ readonly id: string;
7
+ readonly version: number;
8
+ }
9
+
10
+ export interface FlowDurableRun {
11
+ readonly checkpoint?: FlowRunCheckpoint;
12
+ readonly createdAt: string;
13
+ readonly flow: FlowDefinitionRef;
14
+ readonly id: string;
15
+ readonly revision: number;
16
+ readonly status: 'cancelled' | 'failed' | 'queued' | 'running' | 'succeeded' | 'waiting_approval';
17
+ readonly updatedAt: string;
18
+ }
19
+
20
+ export interface FlowDurableWork {
21
+ readonly attempt: number;
22
+ readonly dueAt: string;
23
+ /** Explicitly separates side-effect-free internal work from a security-protected provider effect. */
24
+ readonly effectKind: 'internal' | 'protected';
25
+ readonly flow: FlowDefinitionRef;
26
+ readonly id: string;
27
+ readonly idempotencyKey?: string;
28
+ readonly lease?: {
29
+ readonly expiresAt: string;
30
+ readonly fencingToken: number;
31
+ readonly workerId: string;
32
+ };
33
+ readonly runId: string;
34
+ readonly securityOperation?: FlowSecurityOperation;
35
+ readonly stepId?: string;
36
+ readonly status: 'cancelled' | 'claimed' | 'completed' | 'ready';
37
+ }
38
+
39
+ export interface FlowRetryPolicy {
40
+ readonly baseDelayMs: number;
41
+ readonly maxAttempts: number;
42
+ readonly maxDelayMs: number;
43
+ }
44
+
45
+ /** Persisted state for each completed or in-flight Flow step. */
46
+ export interface FlowRunCheckpoint {
47
+ readonly completedStepIds: readonly string[];
48
+ readonly data?: DataContractValue;
49
+ readonly failedStepIds: readonly string[];
50
+ }
51
+
52
+ /** A durable outbox record separates at-least-once delivery from an idempotent application effect. */
53
+ export interface FlowOutboxRecord {
54
+ readonly createdAt: string;
55
+ readonly id: string;
56
+ readonly idempotencyKey: string;
57
+ readonly payload?: DataContractValue;
58
+ readonly runId: string;
59
+ readonly status: 'delivered' | 'pending';
60
+ readonly workId: string;
61
+ }