@fabricorg/platform-host 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -24,9 +24,8 @@ function createGovernedActionHost(options) {
24
24
  }
25
25
  const actionInvocationId = platform.createFabricId("act");
26
26
  const correlationId = input.correlationId ?? platform.createFabricId("corr");
27
- const workflowId = `action-invocation-${actionInvocationId}`;
28
27
  const durableParameters = options.redactActionParameters ? options.redactActionParameters(input.actionId, input.parameters) : input.parameters;
29
- await options.store.createActionInvocation({
28
+ const durableInvocation = await options.store.createActionInvocation({
30
29
  id: actionInvocationId,
31
30
  tenantId: input.tenantId,
32
31
  spaceId: input.spaceId,
@@ -38,26 +37,37 @@ function createGovernedActionHost(options) {
38
37
  parameters: durableParameters,
39
38
  result: {},
40
39
  correlationId,
41
- ...input.causationId ? { causationId: input.causationId } : {}
40
+ ...input.causationId ? { causationId: input.causationId } : {},
41
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
42
42
  });
43
+ const durableWorkflowId = `action-invocation-${durableInvocation.id}`;
44
+ if (durableInvocation.id !== actionInvocationId && isTerminal(durableInvocation.status)) {
45
+ return {
46
+ actionInvocationId: durableInvocation.id,
47
+ status: durableInvocation.status,
48
+ workflowId: durableWorkflowId,
49
+ result: durableInvocation.result,
50
+ ...durableInvocation.error ? { error: durableInvocation.error } : {}
51
+ };
52
+ }
43
53
  if (options.dispatcher) {
44
54
  try {
45
55
  const dispatched = await options.dispatcher.dispatch({
46
- actionInvocationId,
56
+ actionInvocationId: durableInvocation.id,
47
57
  tenantId: input.tenantId,
48
58
  spaceId: input.spaceId,
49
- workflowId
59
+ workflowId: durableWorkflowId
50
60
  });
51
61
  return {
52
- actionInvocationId,
53
- status: "pending",
62
+ actionInvocationId: durableInvocation.id,
63
+ status: durableInvocation.status,
54
64
  workflowId: dispatched.workflowId,
55
65
  ...dispatched.runId ? { runId: dispatched.runId } : {}
56
66
  };
57
67
  } catch (error) {
58
68
  const message = errorMessage(error);
59
69
  await options.store.updateActionInvocation(
60
- actionInvocationId,
70
+ durableInvocation.id,
61
71
  input.tenantId,
62
72
  input.spaceId,
63
73
  { status: "failed", error: message }
@@ -66,11 +76,11 @@ function createGovernedActionHost(options) {
66
76
  }
67
77
  }
68
78
  const executed = await executeInvocation(
69
- actionInvocationId,
79
+ durableInvocation.id,
70
80
  input.tenantId,
71
81
  input.spaceId
72
82
  );
73
- return { ...executed, workflowId };
83
+ return { ...executed, workflowId: durableWorkflowId };
74
84
  }
75
85
  async function executeInvocation(actionInvocationId, tenantId, spaceId) {
76
86
  const invocation = await options.store.getActionInvocation(
@@ -91,223 +101,243 @@ function createGovernedActionHost(options) {
91
101
  if (!action) {
92
102
  return fail(invocation, "failed", `Unknown action: ${invocation.actionId}`);
93
103
  }
104
+ if (invocation.attemptCount > 1 && !action.idempotent) {
105
+ return fail(
106
+ invocation,
107
+ "failed",
108
+ `Interrupted action ${invocation.actionId} is not declared idempotent; manual reconciliation is required.`
109
+ );
110
+ }
94
111
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
95
112
  status: "running"
96
113
  });
97
- const parsed = action.schema.safeParse(invocation.parameters);
98
- if (!parsed.success) {
99
- const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
100
- return fail(invocation, "validation_failed", message);
101
- }
102
- const authorizationInput = toAuthorizationInput(action, invocation);
103
- const definitions = options.resolvePolicies ? await options.resolvePolicies({
104
- ...authorizationInput,
105
- declaredPolicyIds: action.policies ?? []
106
- }) : declaredPolicies(action.policies ?? []);
107
- const outcomes = await platform.evaluatePolicyDefinitions(definitions, {
108
- tenantId,
109
- spaceId,
110
- actionInvocationId,
111
- actionId: action.actionId,
112
- actorId: invocation.actorId,
113
- actorType: invocation.actorType,
114
- parameters: parsed.data,
115
- db: options.store.db,
116
- services: options.services,
117
- mode: "execute"
118
- });
119
- for (const outcome of outcomes) {
120
- await options.store.appendPolicyEvaluation({
121
- id: platform.createFabricId("pol"),
122
- actionInvocationId,
114
+ try {
115
+ const parsed = action.schema.safeParse(invocation.parameters);
116
+ if (!parsed.success) {
117
+ const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
118
+ return fail(invocation, "validation_failed", message);
119
+ }
120
+ const authorizationInput = toAuthorizationInput(action, invocation);
121
+ const definitions = options.resolvePolicies ? await options.resolvePolicies({
122
+ ...authorizationInput,
123
+ declaredPolicyIds: action.policies ?? []
124
+ }) : declaredPolicies(action.policies ?? []);
125
+ const outcomes = await platform.evaluatePolicyDefinitions(definitions, {
123
126
  tenantId,
124
127
  spaceId,
125
- outcome,
126
- createdAt: now()
127
- });
128
- }
129
- const aggregate = platform.aggregatePolicyOutcomes(outcomes);
130
- if (aggregate?.result === "block") {
131
- const reason = aggregate.reason ?? `Blocked by policy ${aggregate.policyId}`;
132
- await appendEvent(invocation, {
133
- eventType: "ComplianceBlocked",
134
- subjectType: "ActionInvocation",
135
- subjectId: actionInvocationId,
136
- payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
128
+ actionInvocationId,
129
+ actionId: action.actionId,
130
+ actorId: invocation.actorId,
131
+ actorType: invocation.actorType,
132
+ parameters: parsed.data,
133
+ db: options.store.db,
134
+ services: options.services,
135
+ mode: "execute"
137
136
  });
138
- return fail(invocation, "blocked_by_policy", reason);
139
- }
140
- const binding = action.stateMachine;
141
- if (binding) {
142
- const entityId = binding.getEntityId(parsed.data);
143
- const currentState = entityId ? await options.store.getEntityState(
144
- tenantId,
145
- spaceId,
146
- binding.entityType,
147
- entityId
148
- ) ?? initialState(binding.entityType) : initialState(binding.entityType);
149
- const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
150
- const transition = platform.validateTransition(
151
- binding.entityType,
152
- currentState,
153
- targetState,
154
- action.actionId
155
- );
156
- if (!transition.valid) {
157
- return fail(invocation, "failed", transition.error ?? "Invalid state transition");
137
+ for (const outcome of outcomes) {
138
+ await options.store.appendPolicyEvaluation({
139
+ id: lifecycleId("pol", actionInvocationId, outcome.policyId),
140
+ actionInvocationId,
141
+ tenantId,
142
+ spaceId,
143
+ outcome,
144
+ createdAt: now()
145
+ });
158
146
  }
159
- }
160
- let data;
161
- let domainEvents = [];
162
- try {
163
- const handlerResult = await options.store.transaction(
164
- (db) => action.handler ? action.handler(
165
- {
166
- actionInvocationId,
167
- tenantId,
168
- spaceId,
169
- actorId: invocation.actorId,
170
- actorType: invocation.actorType,
171
- correlationId: invocation.correlationId,
172
- ...invocation.causationId ? { causationId: invocation.causationId } : {},
173
- db,
174
- services: options.services
175
- },
176
- parsed.data
177
- ) : Promise.resolve({ success: true, data: {} })
178
- );
179
- if (!handlerResult.success) {
180
- return fail(invocation, "failed", handlerResult.error ?? "Action handler failed");
147
+ const aggregate = platform.aggregatePolicyOutcomes(outcomes);
148
+ if (aggregate?.result === "block") {
149
+ const reason = aggregate.reason ?? `Blocked by policy ${aggregate.policyId}`;
150
+ await appendEvent(invocation, {
151
+ eventType: "ComplianceBlocked",
152
+ subjectType: "ActionInvocation",
153
+ subjectId: actionInvocationId,
154
+ payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
155
+ }, `compliance:${aggregate.policyId}`);
156
+ return fail(invocation, "blocked_by_policy", reason);
181
157
  }
182
- data = handlerResult.data ?? {};
183
- domainEvents = extractEvents(data);
184
- if (action.eventPhase !== "after_adapters") {
185
- for (const event of domainEvents) await appendEvent(invocation, event);
158
+ const binding = action.stateMachine;
159
+ if (binding) {
160
+ const entityId = binding.getEntityId(parsed.data);
161
+ const currentState = entityId ? await options.store.getEntityState(
162
+ tenantId,
163
+ spaceId,
164
+ binding.entityType,
165
+ entityId
166
+ ) ?? initialState(binding.entityType) : initialState(binding.entityType);
167
+ const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
168
+ const transition = platform.validateTransition(
169
+ binding.entityType,
170
+ currentState,
171
+ targetState,
172
+ action.actionId
173
+ );
174
+ const replayingAppliedTransition = action.idempotent && currentState === targetState;
175
+ if (!transition.valid && !replayingAppliedTransition) {
176
+ return fail(invocation, "failed", transition.error ?? "Invalid state transition");
177
+ }
186
178
  }
187
- } catch (error) {
188
- return fail(invocation, "failed", errorMessage(error));
189
- }
190
- for (const step of action.adapterSteps ?? []) {
191
- const input = step.getInput(parsed.data, data);
192
- if (!input) continue;
193
- const adapter = adapters.require(step.adapterType, step.operation);
194
- const adapterInvocationId = platform.createFabricId("adp");
195
- const adapterEventSubject = options.adapterEventSubject?.(
196
- action.actionId,
197
- parsed.data,
198
- data
199
- ) ?? { subjectType: "AdapterInvocation", subjectId: adapterInvocationId };
200
- const recordedInput = options.redactAdapterInput ? options.redactAdapterInput(
201
- action.actionId,
202
- step.adapterType,
203
- step.operation,
204
- input
205
- ) : input;
206
- const startedAt = now();
207
- const record = {
208
- id: adapterInvocationId,
209
- actionInvocationId,
210
- tenantId,
211
- spaceId,
212
- adapterType: step.adapterType,
213
- operation: step.operation,
214
- vendor: adapter.vendor,
215
- status: "running",
216
- input: recordedInput,
217
- attempt: 1,
218
- createdAt: startedAt,
219
- updatedAt: startedAt
220
- };
221
- await options.store.createAdapterInvocation(record);
222
- await appendEvent(invocation, {
223
- eventType: "AdapterInvocationStarted",
224
- subjectType: adapterEventSubject.subjectType,
225
- subjectId: adapterEventSubject.subjectId,
226
- payload: { adapterType: step.adapterType, operation: step.operation }
227
- });
179
+ let data;
180
+ let domainEvents = [];
228
181
  try {
229
- const result2 = await platform.executeWithAdapterRetry({
230
- policy: step.retryPolicy ?? adapter.retryPolicy,
231
- defaultIdempotent: adapter.idempotent,
232
- execute: async (attempt, maxAttempts) => {
233
- await options.store.updateAdapterInvocation(adapterInvocationId, {
234
- attempt,
235
- updatedAt: now()
236
- });
237
- return adapter.execute(input, {
182
+ const handlerResult = await options.store.transaction(
183
+ (db) => action.handler ? action.handler(
184
+ {
185
+ actionInvocationId,
238
186
  tenantId,
239
187
  spaceId,
240
- actionInvocationId,
241
- adapterInvocationId,
188
+ actorId: invocation.actorId,
189
+ actorType: invocation.actorType,
242
190
  correlationId: invocation.correlationId,
243
191
  ...invocation.causationId ? { causationId: invocation.causationId } : {},
244
- attempt,
245
- maxAttempts
192
+ db,
193
+ services: options.services
194
+ },
195
+ parsed.data
196
+ ) : Promise.resolve({ success: true, data: {} })
197
+ );
198
+ if (!handlerResult.success) {
199
+ return fail(invocation, "failed", handlerResult.error ?? "Action handler failed");
200
+ }
201
+ data = handlerResult.data ?? {};
202
+ domainEvents = extractEvents(data);
203
+ if (action.eventPhase !== "after_adapters") {
204
+ for (const [index, event] of domainEvents.entries()) {
205
+ await appendEvent(invocation, event, `domain:${index}`);
206
+ }
207
+ }
208
+ } catch (error) {
209
+ return fail(invocation, "failed", errorMessage(error));
210
+ }
211
+ for (const [stepIndex, step] of (action.adapterSteps ?? []).entries()) {
212
+ const input = step.getInput(parsed.data, data);
213
+ if (!input) continue;
214
+ const adapter = adapters.require(step.adapterType, step.operation);
215
+ const adapterInvocationId = lifecycleId("adp", actionInvocationId, String(stepIndex));
216
+ const previousAdapterInvocation = await options.store.getAdapterInvocation(
217
+ adapterInvocationId
218
+ );
219
+ if (previousAdapterInvocation?.status === "succeeded") continue;
220
+ const adapterEventSubject = options.adapterEventSubject?.(
221
+ action.actionId,
222
+ parsed.data,
223
+ data
224
+ ) ?? { subjectType: "AdapterInvocation", subjectId: adapterInvocationId };
225
+ const recordedInput = options.redactAdapterInput ? options.redactAdapterInput(
226
+ action.actionId,
227
+ step.adapterType,
228
+ step.operation,
229
+ input
230
+ ) : input;
231
+ const startedAt = now();
232
+ const record = {
233
+ id: adapterInvocationId,
234
+ actionInvocationId,
235
+ tenantId,
236
+ spaceId,
237
+ adapterType: step.adapterType,
238
+ operation: step.operation,
239
+ vendor: adapter.vendor,
240
+ status: "running",
241
+ input: recordedInput,
242
+ attempt: 1,
243
+ createdAt: startedAt,
244
+ updatedAt: startedAt
245
+ };
246
+ await options.store.createAdapterInvocation(record);
247
+ await appendEvent(invocation, {
248
+ eventType: "AdapterInvocationStarted",
249
+ subjectType: adapterEventSubject.subjectType,
250
+ subjectId: adapterEventSubject.subjectId,
251
+ payload: { adapterType: step.adapterType, operation: step.operation }
252
+ }, `adapter:${stepIndex}:started`);
253
+ try {
254
+ const result2 = await platform.executeWithAdapterRetry({
255
+ policy: step.retryPolicy ?? adapter.retryPolicy,
256
+ defaultIdempotent: adapter.idempotent,
257
+ execute: async (attempt, maxAttempts) => {
258
+ await options.store.updateAdapterInvocation(adapterInvocationId, {
259
+ attempt,
260
+ updatedAt: now()
261
+ });
262
+ return adapter.execute(input, {
263
+ tenantId,
264
+ spaceId,
265
+ actionInvocationId,
266
+ adapterInvocationId,
267
+ correlationId: invocation.correlationId,
268
+ ...invocation.causationId ? { causationId: invocation.causationId } : {},
269
+ attempt,
270
+ maxAttempts
271
+ });
272
+ },
273
+ isSuccessful: (result3) => result3.success,
274
+ getError: (result3) => result3.error
275
+ });
276
+ if (!result2.success) {
277
+ await options.store.updateAdapterInvocation(adapterInvocationId, {
278
+ status: "failed",
279
+ error: result2.error ?? "Adapter failed",
280
+ updatedAt: now()
246
281
  });
247
- },
248
- isSuccessful: (result3) => result3.success,
249
- getError: (result3) => result3.error
250
- });
251
- if (!result2.success) {
282
+ await appendEvent(invocation, {
283
+ eventType: "AdapterInvocationFailed",
284
+ subjectType: adapterEventSubject.subjectType,
285
+ subjectId: adapterEventSubject.subjectId,
286
+ payload: { adapterType: step.adapterType, operation: step.operation }
287
+ }, `adapter:${stepIndex}:failed`);
288
+ return fail(invocation, "failed", result2.error ?? "Adapter failed");
289
+ }
290
+ const resultRecord = result2;
291
+ const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
292
+ Object.entries(resultRecord).filter(
293
+ ([key]) => key !== "success" && key !== "error"
294
+ )
295
+ );
252
296
  await options.store.updateAdapterInvocation(adapterInvocationId, {
253
- status: "failed",
254
- error: result2.error ?? "Adapter failed",
297
+ status: "succeeded",
298
+ output,
255
299
  updatedAt: now()
256
300
  });
257
301
  await appendEvent(invocation, {
258
- eventType: "AdapterInvocationFailed",
302
+ eventType: "AdapterInvocationSucceeded",
259
303
  subjectType: adapterEventSubject.subjectType,
260
304
  subjectId: adapterEventSubject.subjectId,
261
- payload: { adapterType: step.adapterType, operation: step.operation }
305
+ payload: {
306
+ adapterType: step.adapterType,
307
+ operation: step.operation,
308
+ input: recordedInput,
309
+ output
310
+ }
311
+ }, `adapter:${stepIndex}:succeeded`);
312
+ } catch (error) {
313
+ const message = errorMessage(error);
314
+ await options.store.updateAdapterInvocation(adapterInvocationId, {
315
+ status: "failed",
316
+ error: message,
317
+ updatedAt: now()
262
318
  });
263
- return fail(invocation, "failed", result2.error ?? "Adapter failed");
319
+ return fail(invocation, "failed", message);
264
320
  }
265
- const resultRecord = result2;
266
- const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
267
- Object.entries(resultRecord).filter(
268
- ([key]) => key !== "success" && key !== "error"
269
- )
270
- );
271
- await options.store.updateAdapterInvocation(adapterInvocationId, {
272
- status: "succeeded",
273
- output,
274
- updatedAt: now()
275
- });
276
- await appendEvent(invocation, {
277
- eventType: "AdapterInvocationSucceeded",
278
- subjectType: adapterEventSubject.subjectType,
279
- subjectId: adapterEventSubject.subjectId,
280
- payload: {
281
- adapterType: step.adapterType,
282
- operation: step.operation,
283
- input: recordedInput,
284
- output
285
- }
286
- });
287
- } catch (error) {
288
- const message = errorMessage(error);
289
- await options.store.updateAdapterInvocation(adapterInvocationId, {
290
- status: "failed",
291
- error: message,
292
- updatedAt: now()
293
- });
294
- return fail(invocation, "failed", message);
295
321
  }
322
+ if (action.eventPhase === "after_adapters") {
323
+ for (const [index, event] of domainEvents.entries()) {
324
+ await appendEvent(invocation, event, `domain:${index}`);
325
+ }
326
+ }
327
+ const result = withoutPrivateHostFields(data);
328
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
329
+ status: "completed",
330
+ result
331
+ });
332
+ return { actionInvocationId, status: "completed", result };
333
+ } catch (error) {
334
+ return fail(invocation, "failed", errorMessage(error));
296
335
  }
297
- if (action.eventPhase === "after_adapters") {
298
- for (const event of domainEvents) await appendEvent(invocation, event);
299
- }
300
- const result = withoutPrivateHostFields(data);
301
- await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
302
- status: "completed",
303
- result
304
- });
305
- return { actionInvocationId, status: "completed", result };
306
336
  }
307
- async function appendEvent(invocation, event) {
337
+ async function appendEvent(invocation, event, deduplicationKey) {
308
338
  const timestamp = now();
309
339
  const envelope = {
310
- id: platform.createFabricId("evt"),
340
+ id: lifecycleId("evt", invocation.id, deduplicationKey),
311
341
  tenantId: invocation.tenantId,
312
342
  spaceId: invocation.spaceId,
313
343
  eventType: event.eventType,
@@ -377,6 +407,10 @@ function errorMessage(error) {
377
407
  function isRecord(value) {
378
408
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
379
409
  }
410
+ function lifecycleId(prefix, invocationId, key) {
411
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96);
412
+ return `${prefix}_${invocationId}_${safeKey}`;
413
+ }
380
414
 
381
415
  // src/memory-store.ts
382
416
  var MemoryPlatformHostStore = class {
@@ -392,8 +426,14 @@ var MemoryPlatformHostStore = class {
392
426
  return run(this.db);
393
427
  }
394
428
  async createActionInvocation(input) {
429
+ if (input.idempotencyKey) {
430
+ const existing = this.invocations.find(
431
+ (record2) => record2.tenantId === input.tenantId && record2.spaceId === input.spaceId && record2.actionId === input.actionId && record2.idempotencyKey === input.idempotencyKey
432
+ );
433
+ if (existing) return existing;
434
+ }
395
435
  const now = /* @__PURE__ */ new Date();
396
- const record = { ...input, createdAt: now, updatedAt: now };
436
+ const record = { ...input, attemptCount: 0, createdAt: now, updatedAt: now };
397
437
  this.invocations.push(record);
398
438
  return record;
399
439
  }
@@ -408,17 +448,23 @@ var MemoryPlatformHostStore = class {
408
448
  Object.assign(record, patch, { updatedAt: /* @__PURE__ */ new Date() });
409
449
  }
410
450
  async appendPolicyEvaluation(record) {
451
+ if (this.policyEvaluations.some((candidate) => candidate.id === record.id)) return;
411
452
  this.policyEvaluations.push(record);
412
453
  }
413
454
  async createAdapterInvocation(record) {
455
+ if (this.adapterInvocations.some((candidate) => candidate.id === record.id)) return;
414
456
  this.adapterInvocations.push(record);
415
457
  }
458
+ async getAdapterInvocation(id) {
459
+ return this.adapterInvocations.find((candidate) => candidate.id === id);
460
+ }
416
461
  async updateAdapterInvocation(id, patch) {
417
462
  const record = this.adapterInvocations.find((candidate) => candidate.id === id);
418
463
  if (!record) throw new Error(`AdapterInvocation not found: ${id}`);
419
464
  Object.assign(record, patch);
420
465
  }
421
466
  async appendEvent(event) {
467
+ if (this.events.some((candidate) => candidate.id === event.id)) return;
422
468
  this.events.push(event);
423
469
  }
424
470
  async nextEventSequence(tenantId, spaceId) {
@@ -435,6 +481,28 @@ var MemoryPlatformHostStore = class {
435
481
  }
436
482
  return state;
437
483
  }
484
+ async listActionInvocations(input = {}) {
485
+ return this.invocations.filter(
486
+ (record) => (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId) && (!input.statuses?.length || input.statuses.includes(record.status)) && (!input.updatedBefore || record.updatedAt < input.updatedBefore)
487
+ ).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()).slice(0, input.limit ?? 100);
488
+ }
489
+ async claimActionInvocations(input) {
490
+ const current = input.now ?? /* @__PURE__ */ new Date();
491
+ const eligible = this.invocations.filter(
492
+ (record) => (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId) && (record.status === "pending" || record.status === "running" && (!record.leaseExpiresAt || record.leaseExpiresAt <= current))
493
+ ).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()).slice(0, input.limit ?? 10);
494
+ for (const record of eligible) {
495
+ record.status = "running";
496
+ record.leaseOwner = input.workerId;
497
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
498
+ record.attemptCount += 1;
499
+ record.updatedAt = current;
500
+ }
501
+ return eligible;
502
+ }
503
+ async listEvents(tenantId, spaceId) {
504
+ return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).sort((left, right) => left.sequence - right.sequence);
505
+ }
438
506
  };
439
507
 
440
508
  // src/postgres-store.ts
@@ -453,9 +521,22 @@ var PostgresPlatformHostStore = class {
453
521
  action_id text NOT NULL, action_version integer NOT NULL,
454
522
  actor_id text NOT NULL, actor_type text NOT NULL, status text NOT NULL,
455
523
  parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
456
- correlation_id text NOT NULL, causation_id text, error text,
524
+ correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
525
+ attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
526
+ lease_expires_at timestamptz,
457
527
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
458
528
  );
529
+ ALTER TABLE fabric_platform.action_invocations
530
+ ADD COLUMN IF NOT EXISTS idempotency_key text,
531
+ ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
532
+ ADD COLUMN IF NOT EXISTS lease_owner text,
533
+ ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
534
+ CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
535
+ ON fabric_platform.action_invocations
536
+ (tenant_id, space_id, action_id, idempotency_key)
537
+ WHERE idempotency_key IS NOT NULL;
538
+ CREATE INDEX IF NOT EXISTS action_invocations_worker_idx
539
+ ON fabric_platform.action_invocations (status, lease_expires_at, created_at);
459
540
  CREATE TABLE IF NOT EXISTS fabric_platform.policy_evaluations (
460
541
  id text PRIMARY KEY, action_invocation_id text NOT NULL,
461
542
  tenant_id text NOT NULL, space_id text NOT NULL, outcome jsonb NOT NULL,
@@ -492,11 +573,14 @@ var PostgresPlatformHostStore = class {
492
573
  }
493
574
  async createActionInvocation(input) {
494
575
  const now = /* @__PURE__ */ new Date();
495
- await this.sql.query(
576
+ const result = await this.sql.query(
496
577
  `INSERT INTO fabric_platform.action_invocations
497
578
  (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
498
- parameters,result,correlation_id,causation_id,error,created_at,updated_at)
499
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$14)`,
579
+ parameters,result,correlation_id,causation_id,idempotency_key,error,created_at,updated_at)
580
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$15)
581
+ ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
582
+ WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
583
+ RETURNING *`,
500
584
  [
501
585
  input.id,
502
586
  input.tenantId,
@@ -510,11 +594,12 @@ var PostgresPlatformHostStore = class {
510
594
  JSON.stringify(input.result),
511
595
  input.correlationId,
512
596
  input.causationId ?? null,
597
+ input.idempotencyKey ?? null,
513
598
  input.error ?? null,
514
599
  now
515
600
  ]
516
601
  );
517
- return { ...input, createdAt: now, updatedAt: now };
602
+ return toActionRecord(result.rows[0]);
518
603
  }
519
604
  async getActionInvocation(id, tenantId, spaceId) {
520
605
  const result = await this.sql.query(
@@ -528,7 +613,12 @@ var PostgresPlatformHostStore = class {
528
613
  await this.sql.query(
529
614
  `UPDATE fabric_platform.action_invocations SET
530
615
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
531
- error=CASE WHEN $6::boolean THEN $7 ELSE error END, updated_at=now()
616
+ error=CASE WHEN $6::boolean THEN $7 ELSE error END,
617
+ lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
618
+ THEN NULL ELSE lease_owner END,
619
+ lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
620
+ THEN NULL ELSE lease_expires_at END,
621
+ updated_at=now()
532
622
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
533
623
  [
534
624
  id,
@@ -545,7 +635,7 @@ var PostgresPlatformHostStore = class {
545
635
  await this.sql.query(
546
636
  `INSERT INTO fabric_platform.policy_evaluations
547
637
  (id,action_invocation_id,tenant_id,space_id,outcome,created_at)
548
- VALUES ($1,$2,$3,$4,$5::jsonb,$6)`,
638
+ VALUES ($1,$2,$3,$4,$5::jsonb,$6) ON CONFLICT (id) DO NOTHING`,
549
639
  [
550
640
  record.id,
551
641
  record.actionInvocationId,
@@ -561,7 +651,8 @@ var PostgresPlatformHostStore = class {
561
651
  `INSERT INTO fabric_platform.adapter_invocations
562
652
  (id,action_invocation_id,tenant_id,space_id,adapter_type,operation,vendor,status,
563
653
  input,output,error,attempt,created_at,updated_at)
564
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14)`,
654
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14)
655
+ ON CONFLICT (id) DO NOTHING`,
565
656
  [
566
657
  record.id,
567
658
  record.actionInvocationId,
@@ -580,6 +671,13 @@ var PostgresPlatformHostStore = class {
580
671
  ]
581
672
  );
582
673
  }
674
+ async getAdapterInvocation(id) {
675
+ const result = await this.sql.query(
676
+ `SELECT * FROM fabric_platform.adapter_invocations WHERE id=$1`,
677
+ [id]
678
+ );
679
+ return result.rows[0] ? toAdapterRecord(result.rows[0]) : void 0;
680
+ }
583
681
  async updateAdapterInvocation(id, patch) {
584
682
  await this.sql.query(
585
683
  `UPDATE fabric_platform.adapter_invocations SET
@@ -603,7 +701,8 @@ var PostgresPlatformHostStore = class {
603
701
  (id,tenant_id,space_id,event_type,event_schema_version,subject_type,subject_id,
604
702
  actor_id,actor_type,action_invocation_id,payload,sequence,occurred_at,recorded_at,
605
703
  correlation_id,causation_id)
606
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16)`,
704
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16)
705
+ ON CONFLICT (id) DO NOTHING`,
607
706
  [
608
707
  event.id,
609
708
  event.tenantId,
@@ -643,6 +742,57 @@ var PostgresPlatformHostStore = class {
643
742
  );
644
743
  return result.rows[0]?.state;
645
744
  }
745
+ async listActionInvocations(input = {}) {
746
+ const conditions = [];
747
+ const values = [];
748
+ const add = (condition, value) => {
749
+ values.push(value);
750
+ conditions.push(condition.replace("?", `$${values.length}`));
751
+ };
752
+ if (input.tenantId) add("tenant_id=?", input.tenantId);
753
+ if (input.spaceId) add("space_id=?", input.spaceId);
754
+ if (input.statuses?.length) add("status = ANY(?::text[])", [...input.statuses]);
755
+ if (input.updatedBefore) add("updated_at<?", input.updatedBefore);
756
+ values.push(Math.max(1, Math.min(input.limit ?? 100, 1e3)));
757
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
758
+ const result = await this.sql.query(
759
+ `SELECT * FROM fabric_platform.action_invocations ${where}
760
+ ORDER BY created_at LIMIT $${values.length}`,
761
+ values
762
+ );
763
+ return result.rows.map(toActionRecord);
764
+ }
765
+ async claimActionInvocations(input) {
766
+ const current = input.now ?? /* @__PURE__ */ new Date();
767
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 100));
768
+ const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
769
+ const result = await this.sql.query(
770
+ `WITH claimable AS (
771
+ SELECT id FROM fabric_platform.action_invocations
772
+ WHERE (status='pending' OR
773
+ (status='running' AND (lease_expires_at IS NULL OR lease_expires_at <= $1)))
774
+ AND ($2::text IS NULL OR tenant_id=$2)
775
+ AND ($3::text IS NULL OR space_id=$3)
776
+ ORDER BY created_at
777
+ FOR UPDATE SKIP LOCKED
778
+ LIMIT $4
779
+ )
780
+ UPDATE fabric_platform.action_invocations AS invocation
781
+ SET status='running', lease_owner=$5, lease_expires_at=$6,
782
+ attempt_count=attempt_count+1, updated_at=$1
783
+ FROM claimable WHERE invocation.id=claimable.id
784
+ RETURNING invocation.*`,
785
+ [
786
+ current,
787
+ input.tenantId ?? null,
788
+ input.spaceId ?? null,
789
+ limit,
790
+ input.workerId,
791
+ leaseExpiresAt
792
+ ]
793
+ );
794
+ return result.rows.map(toActionRecord);
795
+ }
646
796
  async listEvents(tenantId, spaceId) {
647
797
  const result = await this.sql.query(
648
798
  `SELECT * FROM fabric_platform.asset_events
@@ -652,6 +802,24 @@ var PostgresPlatformHostStore = class {
652
802
  return result.rows.map(toEventRecord);
653
803
  }
654
804
  };
805
+ function toAdapterRecord(row) {
806
+ return {
807
+ id: String(row.id),
808
+ actionInvocationId: String(row.action_invocation_id),
809
+ tenantId: String(row.tenant_id),
810
+ spaceId: String(row.space_id),
811
+ adapterType: String(row.adapter_type),
812
+ operation: String(row.operation),
813
+ vendor: String(row.vendor),
814
+ status: String(row.status),
815
+ input: row.input,
816
+ ...row.output ? { output: row.output } : {},
817
+ ...row.error ? { error: String(row.error) } : {},
818
+ attempt: Number(row.attempt),
819
+ createdAt: new Date(row.created_at),
820
+ updatedAt: new Date(row.updated_at)
821
+ };
822
+ }
655
823
  function toActionRecord(row) {
656
824
  return {
657
825
  id: String(row.id),
@@ -666,6 +834,10 @@ function toActionRecord(row) {
666
834
  result: row.result,
667
835
  correlationId: String(row.correlation_id),
668
836
  ...row.causation_id ? { causationId: String(row.causation_id) } : {},
837
+ ...row.idempotency_key ? { idempotencyKey: String(row.idempotency_key) } : {},
838
+ attemptCount: Number(row.attempt_count ?? 0),
839
+ ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
840
+ ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
669
841
  ...row.error ? { error: String(row.error) } : {},
670
842
  createdAt: new Date(row.created_at),
671
843
  updatedAt: new Date(row.updated_at)
@@ -692,8 +864,71 @@ function toEventRecord(row) {
692
864
  };
693
865
  }
694
866
 
867
+ // src/worker.ts
868
+ var DEFAULT_BATCH_SIZE = 10;
869
+ var DEFAULT_LEASE_DURATION_MS = 5 * 6e4;
870
+ var DEFAULT_POLL_INTERVAL_MS = 1e3;
871
+ function createStoreBackedActionDispatcher() {
872
+ return {
873
+ dispatch: async (input) => ({ workflowId: input.workflowId })
874
+ };
875
+ }
876
+ async function runPlatformActionWorkerCycle(options) {
877
+ const claimed = await options.store.claimActionInvocations({
878
+ workerId: options.workerId,
879
+ limit: options.batchSize ?? DEFAULT_BATCH_SIZE,
880
+ leaseDurationMs: options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS,
881
+ ...options.tenantId ? { tenantId: options.tenantId } : {},
882
+ ...options.spaceId ? { spaceId: options.spaceId } : {}
883
+ });
884
+ let completed = 0;
885
+ let failed = 0;
886
+ for (const invocation of claimed) {
887
+ try {
888
+ const result = await options.host.executeInvocation(
889
+ invocation.id,
890
+ invocation.tenantId,
891
+ invocation.spaceId
892
+ );
893
+ if (result.status === "completed") completed += 1;
894
+ else failed += 1;
895
+ } catch (error) {
896
+ failed += 1;
897
+ options.onError?.(error, invocation);
898
+ }
899
+ }
900
+ return { claimed: claimed.length, completed, failed };
901
+ }
902
+ async function runPlatformActionWorker(options) {
903
+ while (!options.signal?.aborted) {
904
+ try {
905
+ await runPlatformActionWorkerCycle(options);
906
+ } catch (error) {
907
+ options.onError?.(error);
908
+ }
909
+ await abortableDelay(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, options.signal);
910
+ }
911
+ }
912
+ async function abortableDelay(milliseconds, signal) {
913
+ if (signal?.aborted) return;
914
+ await new Promise((resolve) => {
915
+ const timeout = setTimeout(resolve, milliseconds);
916
+ signal?.addEventListener(
917
+ "abort",
918
+ () => {
919
+ clearTimeout(timeout);
920
+ resolve();
921
+ },
922
+ { once: true }
923
+ );
924
+ });
925
+ }
926
+
695
927
  exports.MemoryPlatformHostStore = MemoryPlatformHostStore;
696
928
  exports.PostgresPlatformHostStore = PostgresPlatformHostStore;
697
929
  exports.createGovernedActionHost = createGovernedActionHost;
930
+ exports.createStoreBackedActionDispatcher = createStoreBackedActionDispatcher;
931
+ exports.runPlatformActionWorker = runPlatformActionWorker;
932
+ exports.runPlatformActionWorkerCycle = runPlatformActionWorkerCycle;
698
933
  //# sourceMappingURL=index.cjs.map
699
934
  //# sourceMappingURL=index.cjs.map