@fabricorg/platform-host 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -22,8 +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
- await options.store.createActionInvocation({
25
+ const durableParameters = options.redactActionParameters ? options.redactActionParameters(input.actionId, input.parameters) : input.parameters;
26
+ const durableInvocation = await options.store.createActionInvocation({
27
27
  id: actionInvocationId,
28
28
  tenantId: input.tenantId,
29
29
  spaceId: input.spaceId,
@@ -32,29 +32,40 @@ function createGovernedActionHost(options) {
32
32
  actorId: input.actorId,
33
33
  actorType: input.actorType,
34
34
  status: "pending",
35
- parameters: input.parameters,
35
+ parameters: durableParameters,
36
36
  result: {},
37
37
  correlationId,
38
- ...input.causationId ? { causationId: input.causationId } : {}
38
+ ...input.causationId ? { causationId: input.causationId } : {},
39
+ ...input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}
39
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
+ }
40
51
  if (options.dispatcher) {
41
52
  try {
42
53
  const dispatched = await options.dispatcher.dispatch({
43
- actionInvocationId,
54
+ actionInvocationId: durableInvocation.id,
44
55
  tenantId: input.tenantId,
45
56
  spaceId: input.spaceId,
46
- workflowId
57
+ workflowId: durableWorkflowId
47
58
  });
48
59
  return {
49
- actionInvocationId,
50
- status: "pending",
60
+ actionInvocationId: durableInvocation.id,
61
+ status: durableInvocation.status,
51
62
  workflowId: dispatched.workflowId,
52
63
  ...dispatched.runId ? { runId: dispatched.runId } : {}
53
64
  };
54
65
  } catch (error) {
55
66
  const message = errorMessage(error);
56
67
  await options.store.updateActionInvocation(
57
- actionInvocationId,
68
+ durableInvocation.id,
58
69
  input.tenantId,
59
70
  input.spaceId,
60
71
  { status: "failed", error: message }
@@ -63,11 +74,11 @@ function createGovernedActionHost(options) {
63
74
  }
64
75
  }
65
76
  const executed = await executeInvocation(
66
- actionInvocationId,
77
+ durableInvocation.id,
67
78
  input.tenantId,
68
79
  input.spaceId
69
80
  );
70
- return { ...executed, workflowId };
81
+ return { ...executed, workflowId: durableWorkflowId };
71
82
  }
72
83
  async function executeInvocation(actionInvocationId, tenantId, spaceId) {
73
84
  const invocation = await options.store.getActionInvocation(
@@ -91,215 +102,219 @@ function createGovernedActionHost(options) {
91
102
  await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
92
103
  status: "running"
93
104
  });
94
- const parsed = action.schema.safeParse(invocation.parameters);
95
- if (!parsed.success) {
96
- const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
97
- return fail(invocation, "validation_failed", message);
98
- }
99
- const authorizationInput = toAuthorizationInput(action, invocation);
100
- const definitions = options.resolvePolicies ? await options.resolvePolicies({
101
- ...authorizationInput,
102
- declaredPolicyIds: action.policies ?? []
103
- }) : declaredPolicies(action.policies ?? []);
104
- const outcomes = await evaluatePolicyDefinitions(definitions, {
105
- tenantId,
106
- spaceId,
107
- actionInvocationId,
108
- actionId: action.actionId,
109
- actorId: invocation.actorId,
110
- actorType: invocation.actorType,
111
- parameters: parsed.data,
112
- db: options.store.db,
113
- services: options.services,
114
- mode: "execute"
115
- });
116
- for (const outcome of outcomes) {
117
- await options.store.appendPolicyEvaluation({
118
- id: createFabricId("pol"),
119
- actionInvocationId,
105
+ try {
106
+ const parsed = action.schema.safeParse(invocation.parameters);
107
+ if (!parsed.success) {
108
+ const message = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
109
+ return fail(invocation, "validation_failed", message);
110
+ }
111
+ const authorizationInput = toAuthorizationInput(action, invocation);
112
+ const definitions = options.resolvePolicies ? await options.resolvePolicies({
113
+ ...authorizationInput,
114
+ declaredPolicyIds: action.policies ?? []
115
+ }) : declaredPolicies(action.policies ?? []);
116
+ const outcomes = await evaluatePolicyDefinitions(definitions, {
120
117
  tenantId,
121
118
  spaceId,
122
- outcome,
123
- createdAt: now()
124
- });
125
- }
126
- const aggregate = aggregatePolicyOutcomes(outcomes);
127
- if (aggregate?.result === "block") {
128
- const reason = aggregate.reason ?? `Blocked by policy ${aggregate.policyId}`;
129
- await appendEvent(invocation, {
130
- eventType: "ComplianceBlocked",
131
- subjectType: "ActionInvocation",
132
- subjectId: actionInvocationId,
133
- payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
119
+ actionInvocationId,
120
+ actionId: action.actionId,
121
+ actorId: invocation.actorId,
122
+ actorType: invocation.actorType,
123
+ parameters: parsed.data,
124
+ db: options.store.db,
125
+ services: options.services,
126
+ mode: "execute"
134
127
  });
135
- return fail(invocation, "blocked_by_policy", reason);
136
- }
137
- const binding = action.stateMachine;
138
- if (binding) {
139
- const entityId = binding.getEntityId(parsed.data);
140
- const currentState = entityId ? await options.store.getEntityState(
141
- tenantId,
142
- spaceId,
143
- binding.entityType,
144
- entityId
145
- ) ?? initialState(binding.entityType) : initialState(binding.entityType);
146
- const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
147
- const transition = validateTransition(
148
- binding.entityType,
149
- currentState,
150
- targetState,
151
- action.actionId
152
- );
153
- if (!transition.valid) {
154
- return fail(invocation, "failed", transition.error ?? "Invalid state transition");
128
+ for (const outcome of outcomes) {
129
+ await options.store.appendPolicyEvaluation({
130
+ id: createFabricId("pol"),
131
+ actionInvocationId,
132
+ tenantId,
133
+ spaceId,
134
+ outcome,
135
+ createdAt: now()
136
+ });
155
137
  }
156
- }
157
- let data;
158
- let domainEvents = [];
159
- try {
160
- const handlerResult = await options.store.transaction(
161
- (db) => action.handler ? action.handler(
162
- {
163
- actionInvocationId,
164
- tenantId,
165
- spaceId,
166
- actorId: invocation.actorId,
167
- actorType: invocation.actorType,
168
- correlationId: invocation.correlationId,
169
- ...invocation.causationId ? { causationId: invocation.causationId } : {},
170
- db,
171
- services: options.services
172
- },
173
- parsed.data
174
- ) : Promise.resolve({ success: true, data: {} })
175
- );
176
- if (!handlerResult.success) {
177
- return fail(invocation, "failed", handlerResult.error ?? "Action handler failed");
138
+ const aggregate = aggregatePolicyOutcomes(outcomes);
139
+ if (aggregate?.result === "block") {
140
+ const reason = aggregate.reason ?? `Blocked by policy ${aggregate.policyId}`;
141
+ await appendEvent(invocation, {
142
+ eventType: "ComplianceBlocked",
143
+ subjectType: "ActionInvocation",
144
+ subjectId: actionInvocationId,
145
+ payload: { actionId: action.actionId, policyId: aggregate.policyId, reason }
146
+ });
147
+ return fail(invocation, "blocked_by_policy", reason);
178
148
  }
179
- data = handlerResult.data ?? {};
180
- domainEvents = extractEvents(data);
181
- if (action.eventPhase !== "after_adapters") {
182
- for (const event of domainEvents) await appendEvent(invocation, event);
149
+ const binding = action.stateMachine;
150
+ if (binding) {
151
+ const entityId = binding.getEntityId(parsed.data);
152
+ const currentState = entityId ? await options.store.getEntityState(
153
+ tenantId,
154
+ spaceId,
155
+ binding.entityType,
156
+ entityId
157
+ ) ?? initialState(binding.entityType) : initialState(binding.entityType);
158
+ const targetState = typeof binding.targetState === "function" ? binding.targetState(parsed.data) : binding.targetState;
159
+ const transition = validateTransition(
160
+ binding.entityType,
161
+ currentState,
162
+ targetState,
163
+ action.actionId
164
+ );
165
+ if (!transition.valid) {
166
+ return fail(invocation, "failed", transition.error ?? "Invalid state transition");
167
+ }
183
168
  }
184
- } catch (error) {
185
- return fail(invocation, "failed", errorMessage(error));
186
- }
187
- for (const step of action.adapterSteps ?? []) {
188
- const input = step.getInput(parsed.data, data);
189
- if (!input) continue;
190
- const adapter = adapters.require(step.adapterType, step.operation);
191
- const adapterInvocationId = createFabricId("adp");
192
- const adapterEventSubject = options.adapterEventSubject?.(
193
- action.actionId,
194
- parsed.data,
195
- data
196
- ) ?? { subjectType: "AdapterInvocation", subjectId: adapterInvocationId };
197
- const recordedInput = options.redactAdapterInput ? options.redactAdapterInput(
198
- action.actionId,
199
- step.adapterType,
200
- step.operation,
201
- input
202
- ) : input;
203
- const startedAt = now();
204
- const record = {
205
- id: adapterInvocationId,
206
- actionInvocationId,
207
- tenantId,
208
- spaceId,
209
- adapterType: step.adapterType,
210
- operation: step.operation,
211
- vendor: adapter.vendor,
212
- status: "running",
213
- input: recordedInput,
214
- attempt: 1,
215
- createdAt: startedAt,
216
- updatedAt: startedAt
217
- };
218
- await options.store.createAdapterInvocation(record);
219
- await appendEvent(invocation, {
220
- eventType: "AdapterInvocationStarted",
221
- subjectType: adapterEventSubject.subjectType,
222
- subjectId: adapterEventSubject.subjectId,
223
- payload: { adapterType: step.adapterType, operation: step.operation }
224
- });
169
+ let data;
170
+ let domainEvents = [];
225
171
  try {
226
- const result2 = await executeWithAdapterRetry({
227
- policy: step.retryPolicy ?? adapter.retryPolicy,
228
- defaultIdempotent: adapter.idempotent,
229
- execute: async (attempt, maxAttempts) => {
230
- await options.store.updateAdapterInvocation(adapterInvocationId, {
231
- attempt,
232
- updatedAt: now()
233
- });
234
- return adapter.execute(input, {
172
+ const handlerResult = await options.store.transaction(
173
+ (db) => action.handler ? action.handler(
174
+ {
175
+ actionInvocationId,
235
176
  tenantId,
236
177
  spaceId,
237
- actionInvocationId,
238
- adapterInvocationId,
178
+ actorId: invocation.actorId,
179
+ actorType: invocation.actorType,
239
180
  correlationId: invocation.correlationId,
240
181
  ...invocation.causationId ? { causationId: invocation.causationId } : {},
241
- attempt,
242
- maxAttempts
243
- });
244
- },
245
- isSuccessful: (result3) => result3.success,
246
- getError: (result3) => result3.error
182
+ db,
183
+ services: options.services
184
+ },
185
+ parsed.data
186
+ ) : Promise.resolve({ success: true, data: {} })
187
+ );
188
+ if (!handlerResult.success) {
189
+ return fail(invocation, "failed", handlerResult.error ?? "Action handler failed");
190
+ }
191
+ data = handlerResult.data ?? {};
192
+ domainEvents = extractEvents(data);
193
+ if (action.eventPhase !== "after_adapters") {
194
+ for (const event of domainEvents) await appendEvent(invocation, event);
195
+ }
196
+ } catch (error) {
197
+ return fail(invocation, "failed", errorMessage(error));
198
+ }
199
+ for (const step of action.adapterSteps ?? []) {
200
+ const input = step.getInput(parsed.data, data);
201
+ if (!input) continue;
202
+ const adapter = adapters.require(step.adapterType, step.operation);
203
+ const adapterInvocationId = createFabricId("adp");
204
+ const adapterEventSubject = options.adapterEventSubject?.(
205
+ action.actionId,
206
+ parsed.data,
207
+ data
208
+ ) ?? { subjectType: "AdapterInvocation", subjectId: adapterInvocationId };
209
+ const recordedInput = options.redactAdapterInput ? options.redactAdapterInput(
210
+ action.actionId,
211
+ step.adapterType,
212
+ step.operation,
213
+ input
214
+ ) : input;
215
+ const startedAt = now();
216
+ const record = {
217
+ id: adapterInvocationId,
218
+ actionInvocationId,
219
+ tenantId,
220
+ spaceId,
221
+ adapterType: step.adapterType,
222
+ operation: step.operation,
223
+ vendor: adapter.vendor,
224
+ status: "running",
225
+ input: recordedInput,
226
+ attempt: 1,
227
+ createdAt: startedAt,
228
+ updatedAt: startedAt
229
+ };
230
+ await options.store.createAdapterInvocation(record);
231
+ await appendEvent(invocation, {
232
+ eventType: "AdapterInvocationStarted",
233
+ subjectType: adapterEventSubject.subjectType,
234
+ subjectId: adapterEventSubject.subjectId,
235
+ payload: { adapterType: step.adapterType, operation: step.operation }
247
236
  });
248
- if (!result2.success) {
237
+ try {
238
+ const result2 = await executeWithAdapterRetry({
239
+ policy: step.retryPolicy ?? adapter.retryPolicy,
240
+ defaultIdempotent: adapter.idempotent,
241
+ execute: async (attempt, maxAttempts) => {
242
+ await options.store.updateAdapterInvocation(adapterInvocationId, {
243
+ attempt,
244
+ updatedAt: now()
245
+ });
246
+ return adapter.execute(input, {
247
+ tenantId,
248
+ spaceId,
249
+ actionInvocationId,
250
+ adapterInvocationId,
251
+ correlationId: invocation.correlationId,
252
+ ...invocation.causationId ? { causationId: invocation.causationId } : {},
253
+ attempt,
254
+ maxAttempts
255
+ });
256
+ },
257
+ isSuccessful: (result3) => result3.success,
258
+ getError: (result3) => result3.error
259
+ });
260
+ if (!result2.success) {
261
+ await options.store.updateAdapterInvocation(adapterInvocationId, {
262
+ status: "failed",
263
+ error: result2.error ?? "Adapter failed",
264
+ updatedAt: now()
265
+ });
266
+ await appendEvent(invocation, {
267
+ eventType: "AdapterInvocationFailed",
268
+ subjectType: adapterEventSubject.subjectType,
269
+ subjectId: adapterEventSubject.subjectId,
270
+ payload: { adapterType: step.adapterType, operation: step.operation }
271
+ });
272
+ return fail(invocation, "failed", result2.error ?? "Adapter failed");
273
+ }
274
+ const resultRecord = result2;
275
+ const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
276
+ Object.entries(resultRecord).filter(
277
+ ([key]) => key !== "success" && key !== "error"
278
+ )
279
+ );
249
280
  await options.store.updateAdapterInvocation(adapterInvocationId, {
250
- status: "failed",
251
- error: result2.error ?? "Adapter failed",
281
+ status: "succeeded",
282
+ output,
252
283
  updatedAt: now()
253
284
  });
254
285
  await appendEvent(invocation, {
255
- eventType: "AdapterInvocationFailed",
286
+ eventType: "AdapterInvocationSucceeded",
256
287
  subjectType: adapterEventSubject.subjectType,
257
288
  subjectId: adapterEventSubject.subjectId,
258
- payload: { adapterType: step.adapterType, operation: step.operation }
289
+ payload: {
290
+ adapterType: step.adapterType,
291
+ operation: step.operation,
292
+ input: recordedInput,
293
+ output
294
+ }
295
+ });
296
+ } catch (error) {
297
+ const message = errorMessage(error);
298
+ await options.store.updateAdapterInvocation(adapterInvocationId, {
299
+ status: "failed",
300
+ error: message,
301
+ updatedAt: now()
259
302
  });
260
- return fail(invocation, "failed", result2.error ?? "Adapter failed");
303
+ return fail(invocation, "failed", message);
261
304
  }
262
- const resultRecord = result2;
263
- const output = isRecord(resultRecord.output) ? resultRecord.output : Object.fromEntries(
264
- Object.entries(resultRecord).filter(
265
- ([key]) => key !== "success" && key !== "error"
266
- )
267
- );
268
- await options.store.updateAdapterInvocation(adapterInvocationId, {
269
- status: "succeeded",
270
- output,
271
- updatedAt: now()
272
- });
273
- await appendEvent(invocation, {
274
- eventType: "AdapterInvocationSucceeded",
275
- subjectType: adapterEventSubject.subjectType,
276
- subjectId: adapterEventSubject.subjectId,
277
- payload: {
278
- adapterType: step.adapterType,
279
- operation: step.operation,
280
- input: recordedInput,
281
- output
282
- }
283
- });
284
- } catch (error) {
285
- const message = errorMessage(error);
286
- await options.store.updateAdapterInvocation(adapterInvocationId, {
287
- status: "failed",
288
- error: message,
289
- updatedAt: now()
290
- });
291
- return fail(invocation, "failed", message);
292
305
  }
306
+ if (action.eventPhase === "after_adapters") {
307
+ for (const event of domainEvents) await appendEvent(invocation, event);
308
+ }
309
+ const result = withoutPrivateHostFields(data);
310
+ await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
311
+ status: "completed",
312
+ result
313
+ });
314
+ return { actionInvocationId, status: "completed", result };
315
+ } catch (error) {
316
+ return fail(invocation, "failed", errorMessage(error));
293
317
  }
294
- if (action.eventPhase === "after_adapters") {
295
- for (const event of domainEvents) await appendEvent(invocation, event);
296
- }
297
- const result = withoutPrivateHostFields(data);
298
- await options.store.updateActionInvocation(actionInvocationId, tenantId, spaceId, {
299
- status: "completed",
300
- result
301
- });
302
- return { actionInvocationId, status: "completed", result };
303
318
  }
304
319
  async function appendEvent(invocation, event) {
305
320
  const timestamp = now();
@@ -389,8 +404,14 @@ var MemoryPlatformHostStore = class {
389
404
  return run(this.db);
390
405
  }
391
406
  async createActionInvocation(input) {
407
+ if (input.idempotencyKey) {
408
+ const existing = this.invocations.find(
409
+ (record2) => record2.tenantId === input.tenantId && record2.spaceId === input.spaceId && record2.actionId === input.actionId && record2.idempotencyKey === input.idempotencyKey
410
+ );
411
+ if (existing) return existing;
412
+ }
392
413
  const now = /* @__PURE__ */ new Date();
393
- const record = { ...input, createdAt: now, updatedAt: now };
414
+ const record = { ...input, attemptCount: 0, createdAt: now, updatedAt: now };
394
415
  this.invocations.push(record);
395
416
  return record;
396
417
  }
@@ -432,6 +453,28 @@ var MemoryPlatformHostStore = class {
432
453
  }
433
454
  return state;
434
455
  }
456
+ async listActionInvocations(input = {}) {
457
+ return this.invocations.filter(
458
+ (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)
459
+ ).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()).slice(0, input.limit ?? 100);
460
+ }
461
+ async claimActionInvocations(input) {
462
+ const current = input.now ?? /* @__PURE__ */ new Date();
463
+ const eligible = this.invocations.filter(
464
+ (record) => (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId) && (record.status === "pending" || record.status === "running" && (!record.leaseExpiresAt || record.leaseExpiresAt <= current))
465
+ ).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()).slice(0, input.limit ?? 10);
466
+ for (const record of eligible) {
467
+ record.status = "running";
468
+ record.leaseOwner = input.workerId;
469
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
470
+ record.attemptCount += 1;
471
+ record.updatedAt = current;
472
+ }
473
+ return eligible;
474
+ }
475
+ async listEvents(tenantId, spaceId) {
476
+ return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).sort((left, right) => left.sequence - right.sequence);
477
+ }
435
478
  };
436
479
 
437
480
  // src/postgres-store.ts
@@ -450,9 +493,22 @@ var PostgresPlatformHostStore = class {
450
493
  action_id text NOT NULL, action_version integer NOT NULL,
451
494
  actor_id text NOT NULL, actor_type text NOT NULL, status text NOT NULL,
452
495
  parameters jsonb NOT NULL, result jsonb NOT NULL DEFAULT '{}'::jsonb,
453
- correlation_id text NOT NULL, causation_id text, error text,
496
+ correlation_id text NOT NULL, causation_id text, idempotency_key text, error text,
497
+ attempt_count integer NOT NULL DEFAULT 0, lease_owner text,
498
+ lease_expires_at timestamptz,
454
499
  created_at timestamptz NOT NULL, updated_at timestamptz NOT NULL
455
500
  );
501
+ ALTER TABLE fabric_platform.action_invocations
502
+ ADD COLUMN IF NOT EXISTS idempotency_key text,
503
+ ADD COLUMN IF NOT EXISTS attempt_count integer NOT NULL DEFAULT 0,
504
+ ADD COLUMN IF NOT EXISTS lease_owner text,
505
+ ADD COLUMN IF NOT EXISTS lease_expires_at timestamptz;
506
+ CREATE UNIQUE INDEX IF NOT EXISTS action_invocations_idempotency_idx
507
+ ON fabric_platform.action_invocations
508
+ (tenant_id, space_id, action_id, idempotency_key)
509
+ WHERE idempotency_key IS NOT NULL;
510
+ CREATE INDEX IF NOT EXISTS action_invocations_worker_idx
511
+ ON fabric_platform.action_invocations (status, lease_expires_at, created_at);
456
512
  CREATE TABLE IF NOT EXISTS fabric_platform.policy_evaluations (
457
513
  id text PRIMARY KEY, action_invocation_id text NOT NULL,
458
514
  tenant_id text NOT NULL, space_id text NOT NULL, outcome jsonb NOT NULL,
@@ -489,11 +545,14 @@ var PostgresPlatformHostStore = class {
489
545
  }
490
546
  async createActionInvocation(input) {
491
547
  const now = /* @__PURE__ */ new Date();
492
- await this.sql.query(
548
+ const result = await this.sql.query(
493
549
  `INSERT INTO fabric_platform.action_invocations
494
550
  (id,tenant_id,space_id,action_id,action_version,actor_id,actor_type,status,
495
- parameters,result,correlation_id,causation_id,error,created_at,updated_at)
496
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$14)`,
551
+ parameters,result,correlation_id,causation_id,idempotency_key,error,created_at,updated_at)
552
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10::jsonb,$11,$12,$13,$14,$15,$15)
553
+ ON CONFLICT (tenant_id,space_id,action_id,idempotency_key)
554
+ WHERE idempotency_key IS NOT NULL DO UPDATE SET id=fabric_platform.action_invocations.id
555
+ RETURNING *`,
497
556
  [
498
557
  input.id,
499
558
  input.tenantId,
@@ -507,11 +566,12 @@ var PostgresPlatformHostStore = class {
507
566
  JSON.stringify(input.result),
508
567
  input.correlationId,
509
568
  input.causationId ?? null,
569
+ input.idempotencyKey ?? null,
510
570
  input.error ?? null,
511
571
  now
512
572
  ]
513
573
  );
514
- return { ...input, createdAt: now, updatedAt: now };
574
+ return toActionRecord(result.rows[0]);
515
575
  }
516
576
  async getActionInvocation(id, tenantId, spaceId) {
517
577
  const result = await this.sql.query(
@@ -525,7 +585,12 @@ var PostgresPlatformHostStore = class {
525
585
  await this.sql.query(
526
586
  `UPDATE fabric_platform.action_invocations SET
527
587
  status=COALESCE($4,status), result=COALESCE($5::jsonb,result),
528
- error=CASE WHEN $6::boolean THEN $7 ELSE error END, updated_at=now()
588
+ error=CASE WHEN $6::boolean THEN $7 ELSE error END,
589
+ lease_owner=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
590
+ THEN NULL ELSE lease_owner END,
591
+ lease_expires_at=CASE WHEN $4 IN ('completed','failed','blocked_by_policy','validation_failed')
592
+ THEN NULL ELSE lease_expires_at END,
593
+ updated_at=now()
529
594
  WHERE id=$1 AND tenant_id=$2 AND space_id=$3`,
530
595
  [
531
596
  id,
@@ -640,6 +705,57 @@ var PostgresPlatformHostStore = class {
640
705
  );
641
706
  return result.rows[0]?.state;
642
707
  }
708
+ async listActionInvocations(input = {}) {
709
+ const conditions = [];
710
+ const values = [];
711
+ const add = (condition, value) => {
712
+ values.push(value);
713
+ conditions.push(condition.replace("?", `$${values.length}`));
714
+ };
715
+ if (input.tenantId) add("tenant_id=?", input.tenantId);
716
+ if (input.spaceId) add("space_id=?", input.spaceId);
717
+ if (input.statuses?.length) add("status = ANY(?::text[])", [...input.statuses]);
718
+ if (input.updatedBefore) add("updated_at<?", input.updatedBefore);
719
+ values.push(Math.max(1, Math.min(input.limit ?? 100, 1e3)));
720
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
721
+ const result = await this.sql.query(
722
+ `SELECT * FROM fabric_platform.action_invocations ${where}
723
+ ORDER BY created_at LIMIT $${values.length}`,
724
+ values
725
+ );
726
+ return result.rows.map(toActionRecord);
727
+ }
728
+ async claimActionInvocations(input) {
729
+ const current = input.now ?? /* @__PURE__ */ new Date();
730
+ const limit = Math.max(1, Math.min(input.limit ?? 10, 100));
731
+ const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
732
+ const result = await this.sql.query(
733
+ `WITH claimable AS (
734
+ SELECT id FROM fabric_platform.action_invocations
735
+ WHERE (status='pending' OR
736
+ (status='running' AND (lease_expires_at IS NULL OR lease_expires_at <= $1)))
737
+ AND ($2::text IS NULL OR tenant_id=$2)
738
+ AND ($3::text IS NULL OR space_id=$3)
739
+ ORDER BY created_at
740
+ FOR UPDATE SKIP LOCKED
741
+ LIMIT $4
742
+ )
743
+ UPDATE fabric_platform.action_invocations AS invocation
744
+ SET status='running', lease_owner=$5, lease_expires_at=$6,
745
+ attempt_count=attempt_count+1, updated_at=$1
746
+ FROM claimable WHERE invocation.id=claimable.id
747
+ RETURNING invocation.*`,
748
+ [
749
+ current,
750
+ input.tenantId ?? null,
751
+ input.spaceId ?? null,
752
+ limit,
753
+ input.workerId,
754
+ leaseExpiresAt
755
+ ]
756
+ );
757
+ return result.rows.map(toActionRecord);
758
+ }
643
759
  async listEvents(tenantId, spaceId) {
644
760
  const result = await this.sql.query(
645
761
  `SELECT * FROM fabric_platform.asset_events
@@ -663,6 +779,10 @@ function toActionRecord(row) {
663
779
  result: row.result,
664
780
  correlationId: String(row.correlation_id),
665
781
  ...row.causation_id ? { causationId: String(row.causation_id) } : {},
782
+ ...row.idempotency_key ? { idempotencyKey: String(row.idempotency_key) } : {},
783
+ attemptCount: Number(row.attempt_count ?? 0),
784
+ ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
785
+ ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
666
786
  ...row.error ? { error: String(row.error) } : {},
667
787
  createdAt: new Date(row.created_at),
668
788
  updatedAt: new Date(row.updated_at)
@@ -689,6 +809,66 @@ function toEventRecord(row) {
689
809
  };
690
810
  }
691
811
 
692
- export { MemoryPlatformHostStore, PostgresPlatformHostStore, createGovernedActionHost };
812
+ // src/worker.ts
813
+ var DEFAULT_BATCH_SIZE = 10;
814
+ var DEFAULT_LEASE_DURATION_MS = 5 * 6e4;
815
+ var DEFAULT_POLL_INTERVAL_MS = 1e3;
816
+ function createStoreBackedActionDispatcher() {
817
+ return {
818
+ dispatch: async (input) => ({ workflowId: input.workflowId })
819
+ };
820
+ }
821
+ async function runPlatformActionWorkerCycle(options) {
822
+ const claimed = await options.store.claimActionInvocations({
823
+ workerId: options.workerId,
824
+ limit: options.batchSize ?? DEFAULT_BATCH_SIZE,
825
+ leaseDurationMs: options.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS,
826
+ ...options.tenantId ? { tenantId: options.tenantId } : {},
827
+ ...options.spaceId ? { spaceId: options.spaceId } : {}
828
+ });
829
+ let completed = 0;
830
+ let failed = 0;
831
+ for (const invocation of claimed) {
832
+ try {
833
+ const result = await options.host.executeInvocation(
834
+ invocation.id,
835
+ invocation.tenantId,
836
+ invocation.spaceId
837
+ );
838
+ if (result.status === "completed") completed += 1;
839
+ else failed += 1;
840
+ } catch (error) {
841
+ failed += 1;
842
+ options.onError?.(error, invocation);
843
+ }
844
+ }
845
+ return { claimed: claimed.length, completed, failed };
846
+ }
847
+ async function runPlatformActionWorker(options) {
848
+ while (!options.signal?.aborted) {
849
+ try {
850
+ await runPlatformActionWorkerCycle(options);
851
+ } catch (error) {
852
+ options.onError?.(error);
853
+ }
854
+ await abortableDelay(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, options.signal);
855
+ }
856
+ }
857
+ async function abortableDelay(milliseconds, signal) {
858
+ if (signal?.aborted) return;
859
+ await new Promise((resolve) => {
860
+ const timeout = setTimeout(resolve, milliseconds);
861
+ signal?.addEventListener(
862
+ "abort",
863
+ () => {
864
+ clearTimeout(timeout);
865
+ resolve();
866
+ },
867
+ { once: true }
868
+ );
869
+ });
870
+ }
871
+
872
+ export { MemoryPlatformHostStore, PostgresPlatformHostStore, createGovernedActionHost, createStoreBackedActionDispatcher, runPlatformActionWorker, runPlatformActionWorkerCycle };
693
873
  //# sourceMappingURL=index.js.map
694
874
  //# sourceMappingURL=index.js.map