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