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