@fabricorg/platform-host 1.0.0 → 2.0.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
@@ -14,6 +14,12 @@ var RecoverableFinalizationError = class extends Error {
14
14
  name = "RecoverableFinalizationError";
15
15
  };
16
16
  function createGovernedActionHost(options) {
17
+ if (options.outbox) {
18
+ const outboxStore = asOutboxStore(options.store);
19
+ if (!outboxStore || !asAtomicMutationStore(options.store) || outboxStore.transactionalOutbox !== true) {
20
+ throw new Error("Outbox egress requires a store with transactionalOutbox and transactionWithEvents capabilities.");
21
+ }
22
+ }
17
23
  const adapters = new AdapterRegistry();
18
24
  for (const adapter of options.adapters ?? []) adapters.register(adapter);
19
25
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
@@ -338,6 +344,13 @@ function createGovernedActionHost(options) {
338
344
  throw new Error(handlerResult.error ?? "Action handler failed");
339
345
  }
340
346
  const handlerData = handlerResult.data ?? {};
347
+ if (action.resultSchema) {
348
+ const publicResult = withoutPrivateHostFields(handlerData, eventResultFields);
349
+ const parsedResult = action.resultSchema.safeParse(publicResult);
350
+ if (!parsedResult.success) {
351
+ throw new Error(`Action result validation failed at ${parsedResult.error.issues.map((issue) => issue.path.map(String).join(".") || "result").join(", ")}`);
352
+ }
353
+ }
341
354
  const events = extractEvents(handlerData);
342
355
  const undeclaredEvent = events.find(
343
356
  (event) => !action.emitsEvents.includes(event.eventType)
@@ -678,7 +691,24 @@ function createGovernedActionHost(options) {
678
691
  correlationId: invocation.correlationId,
679
692
  ...invocation.causationId ? { causationId: invocation.causationId } : {}
680
693
  };
681
- await (transaction ?? options.store).appendEvent(envelope);
694
+ if (options.outbox) {
695
+ const traceContext = options.outbox.traceContext?.(envelope);
696
+ const metadata = {
697
+ producerModuleVersion: options.outbox.producerModuleVersion(invocation.actionId, invocation.actionVersion),
698
+ payloadClassification: options.outbox.classifyPayload(envelope),
699
+ ...traceContext ? { traceContext } : {}
700
+ };
701
+ if (transaction) {
702
+ if (!transaction.appendEventWithOutbox) throw new Error("Outbox egress requires an atomic transaction with appendEventWithOutbox.");
703
+ await transaction.appendEventWithOutbox(envelope, metadata);
704
+ } else {
705
+ const outboxStore = asOutboxStore(options.store);
706
+ if (!outboxStore) throw new Error("Outbox egress requires an outbox-capable Platform Host store.");
707
+ await outboxStore.appendEventWithOutbox(envelope, metadata);
708
+ }
709
+ } else {
710
+ await (transaction ?? options.store).appendEvent(envelope);
711
+ }
682
712
  }
683
713
  async function fail(invocation, status, error) {
684
714
  await options.store.updateActionInvocation(
@@ -715,6 +745,10 @@ function asAtomicMutationStore(store) {
715
745
  const candidate = store;
716
746
  return typeof candidate.transactionWithEvents === "function" ? store : void 0;
717
747
  }
748
+ function asOutboxStore(store) {
749
+ const candidate = store;
750
+ return typeof candidate.appendEventWithOutbox === "function" && typeof candidate.claimOutbox === "function" ? store : void 0;
751
+ }
718
752
  function asGovernanceStore(store) {
719
753
  const candidate = store;
720
754
  return typeof candidate.recordMutationGovernance === "function" && typeof candidate.appendPolicyObligations === "function" && typeof candidate.appendExecutionAttestation === "function" ? store : void 0;
@@ -771,6 +805,73 @@ function lifecycleId(prefix, invocationId, key) {
771
805
  return `${prefix}_${invocationId}_${safeKey}`;
772
806
  }
773
807
 
808
+ // src/outbox.ts
809
+ function toEnterpriseEventEnvelope(event, metadata) {
810
+ return {
811
+ eventId: event.id,
812
+ eventType: event.eventType,
813
+ eventSchemaVersion: event.eventSchemaVersion,
814
+ tenantId: event.tenantId,
815
+ spaceId: event.spaceId,
816
+ subjectType: event.subjectType,
817
+ subjectId: event.subjectId,
818
+ sequence: event.sequence,
819
+ ...event.actionInvocationId ? { actionInvocationId: event.actionInvocationId } : {},
820
+ correlationId: event.correlationId,
821
+ ...event.causationId ? { causationId: event.causationId } : {},
822
+ occurredAt: event.occurredAt,
823
+ recordedAt: event.recordedAt,
824
+ producerModuleVersion: metadata.producerModuleVersion,
825
+ payloadClassification: metadata.payloadClassification,
826
+ ...metadata.traceContext ? { traceContext: { ...metadata.traceContext } } : {},
827
+ payload: event.payload
828
+ };
829
+ }
830
+ async function runOutboxRelayCycle(options) {
831
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
832
+ const maxAttempts = options.maxAttempts ?? 10;
833
+ const records = await options.store.claimOutbox({
834
+ workerId: options.workerId,
835
+ leaseDurationMs: options.leaseDurationMs ?? 3e4,
836
+ limit: options.batchSize ?? 100,
837
+ now: now()
838
+ });
839
+ const result = { claimed: records.length, published: 0, failed: 0, deadLettered: 0 };
840
+ for (const record of records) {
841
+ try {
842
+ await options.publisher.publish(record.event);
843
+ try {
844
+ await options.store.markOutboxPublished(record.id, options.workerId, now());
845
+ } catch {
846
+ result.failed += 1;
847
+ continue;
848
+ }
849
+ result.published += 1;
850
+ } catch (error) {
851
+ const deadLetter = record.attemptCount >= maxAttempts;
852
+ try {
853
+ await options.store.markOutboxFailed({
854
+ id: record.id,
855
+ workerId: options.workerId,
856
+ error: error instanceof Error ? error.message : String(error),
857
+ availableAt: new Date(now().getTime() + (options.retryDelayMs?.(record.attemptCount) ?? 1e3)),
858
+ deadLetter
859
+ });
860
+ } catch {
861
+ }
862
+ result.failed += 1;
863
+ if (deadLetter) result.deadLettered += 1;
864
+ }
865
+ }
866
+ return result;
867
+ }
868
+ function cloneOutboxRecord(record) {
869
+ return {
870
+ ...record,
871
+ event: { ...record.event, ...record.event.traceContext ? { traceContext: { ...record.event.traceContext } } : {} }
872
+ };
873
+ }
874
+
774
875
  // src/memory-store.ts
775
876
  var MemoryPlatformHostStore = class {
776
877
  constructor(db) {
@@ -784,6 +885,7 @@ var MemoryPlatformHostStore = class {
784
885
  policyObligations = [];
785
886
  executionAttestations = [];
786
887
  externalReconciliations = [];
888
+ outbox = [];
787
889
  async transaction(run) {
788
890
  return run(this.db);
789
891
  }
@@ -902,6 +1004,53 @@ var MemoryPlatformHostStore = class {
902
1004
  if (this.events.some((candidate) => candidate.id === event.id)) return;
903
1005
  this.events.push(event);
904
1006
  }
1007
+ async appendEventWithOutbox(event, metadata) {
1008
+ if (this.events.some((candidate) => candidate.id === event.id)) return;
1009
+ const createdAt = new Date(event.recordedAt);
1010
+ this.events.push(event);
1011
+ this.outbox.push({
1012
+ id: event.id,
1013
+ tenantId: event.tenantId,
1014
+ spaceId: event.spaceId,
1015
+ event: toEnterpriseEventEnvelope(event, metadata),
1016
+ status: "pending",
1017
+ attemptCount: 0,
1018
+ availableAt: createdAt,
1019
+ createdAt
1020
+ });
1021
+ }
1022
+ async claimOutbox(input) {
1023
+ const current = input.now ?? /* @__PURE__ */ new Date();
1024
+ return this.outbox.filter((record) => record.status === "pending" && record.availableAt <= current && (!record.leaseExpiresAt || record.leaseExpiresAt <= current) && (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId)).sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime() || left.id.localeCompare(right.id)).slice(0, Math.max(1, Math.min(input.limit ?? 100, 1e3))).map((record) => {
1025
+ record.leaseOwner = input.workerId;
1026
+ record.leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
1027
+ record.attemptCount += 1;
1028
+ return cloneOutboxRecord(record);
1029
+ });
1030
+ }
1031
+ async markOutboxPublished(id, workerId, publishedAt) {
1032
+ const record = this.requireLeasedOutbox(id, workerId);
1033
+ record.status = "published";
1034
+ record.publishedAt = publishedAt;
1035
+ delete record.leaseOwner;
1036
+ delete record.leaseExpiresAt;
1037
+ }
1038
+ async markOutboxFailed(input) {
1039
+ const record = this.requireLeasedOutbox(input.id, input.workerId);
1040
+ record.status = input.deadLetter ? "dead_letter" : "pending";
1041
+ record.lastError = input.error;
1042
+ record.availableAt = input.availableAt;
1043
+ delete record.leaseOwner;
1044
+ delete record.leaseExpiresAt;
1045
+ }
1046
+ async listOutbox(input = {}) {
1047
+ return this.outbox.filter((record) => (!input.tenantId || record.tenantId === input.tenantId) && (!input.spaceId || record.spaceId === input.spaceId) && (!input.statuses?.length || input.statuses.includes(record.status))).map(cloneOutboxRecord);
1048
+ }
1049
+ requireLeasedOutbox(id, workerId) {
1050
+ const record = this.outbox.find((candidate) => candidate.id === id);
1051
+ if (!record || record.leaseOwner !== workerId) throw new Error(`Outbox record ${id} is not leased by ${workerId}.`);
1052
+ return record;
1053
+ }
905
1054
  async nextEventSequence(tenantId, spaceId) {
906
1055
  return this.events.filter((event) => event.tenantId === tenantId && event.spaceId === spaceId).length + 1;
907
1056
  }
@@ -945,12 +1094,14 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
945
1094
  constructor(db, sql, transactionProvider) {
946
1095
  this.db = db;
947
1096
  this.sql = sql;
1097
+ this.transactionalOutbox = transactionProvider !== void 0;
948
1098
  if (transactionProvider) {
949
1099
  this.transactionWithEvents = async (run) => transactionProvider.run(async ({ db: db2, sql: sql2 }) => {
950
1100
  const scoped = new _PostgresPlatformHostStore(db2, sql2);
951
1101
  return run({
952
1102
  db: db2,
953
1103
  appendEvent: (event) => scoped.appendEvent(event),
1104
+ appendEventWithOutbox: (event, metadata) => scoped.appendEventWithOutbox(event, metadata),
954
1105
  nextEventSequence: (tenantId, spaceId) => scoped.nextEventSequence(tenantId, spaceId),
955
1106
  listEvents: (tenantId, spaceId) => scoped.listEvents(tenantId, spaceId),
956
1107
  updateActionInvocation: (id, tenantId, spaceId, patch) => scoped.updateActionInvocation(id, tenantId, spaceId, patch)
@@ -961,6 +1112,7 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
961
1112
  db;
962
1113
  sql;
963
1114
  transactionWithEvents;
1115
+ transactionalOutbox;
964
1116
  async ensureSchema() {
965
1117
  await this.sql.query(`
966
1118
  CREATE SCHEMA IF NOT EXISTS fabric_platform;
@@ -1044,6 +1196,15 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1044
1196
  CREATE INDEX IF NOT EXISTS asset_events_subject_idx
1045
1197
  ON fabric_platform.asset_events
1046
1198
  (tenant_id, space_id, subject_type, subject_id, sequence);
1199
+ CREATE TABLE IF NOT EXISTS fabric_platform.event_outbox (
1200
+ id text PRIMARY KEY, tenant_id text NOT NULL, space_id text NOT NULL,
1201
+ event jsonb NOT NULL, status text NOT NULL DEFAULT 'pending',
1202
+ attempt_count integer NOT NULL DEFAULT 0, available_at timestamptz NOT NULL,
1203
+ lease_owner text, lease_expires_at timestamptz, last_error text,
1204
+ created_at timestamptz NOT NULL, published_at timestamptz
1205
+ );
1206
+ CREATE INDEX IF NOT EXISTS event_outbox_claim_idx
1207
+ ON fabric_platform.event_outbox (status, available_at, lease_expires_at, created_at);
1047
1208
  `);
1048
1209
  }
1049
1210
  async transaction(run) {
@@ -1326,6 +1487,86 @@ var PostgresPlatformHostStore = class _PostgresPlatformHostStore {
1326
1487
  ]
1327
1488
  );
1328
1489
  }
1490
+ async appendEventWithOutbox(event, metadata) {
1491
+ const envelope = toEnterpriseEventEnvelope(event, metadata);
1492
+ await this.sql.query(
1493
+ `WITH inserted_event AS (
1494
+ INSERT INTO fabric_platform.asset_events
1495
+ (id,tenant_id,space_id,event_type,event_schema_version,subject_type,subject_id,
1496
+ actor_id,actor_type,action_invocation_id,payload,sequence,occurred_at,recorded_at,
1497
+ correlation_id,causation_id)
1498
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16)
1499
+ ON CONFLICT (id) DO NOTHING RETURNING id
1500
+ )
1501
+ INSERT INTO fabric_platform.event_outbox
1502
+ (id,tenant_id,space_id,event,status,attempt_count,available_at,created_at)
1503
+ SELECT $1,$2,$3,$17::jsonb,'pending',0,$14,$14 FROM inserted_event
1504
+ ON CONFLICT (id) DO NOTHING`,
1505
+ [
1506
+ event.id,
1507
+ event.tenantId,
1508
+ event.spaceId,
1509
+ event.eventType,
1510
+ event.eventSchemaVersion,
1511
+ event.subjectType,
1512
+ event.subjectId,
1513
+ event.actorId,
1514
+ event.actorType,
1515
+ event.actionInvocationId ?? null,
1516
+ JSON.stringify(event.payload),
1517
+ event.sequence,
1518
+ event.occurredAt,
1519
+ event.recordedAt,
1520
+ event.correlationId,
1521
+ event.causationId ?? null,
1522
+ JSON.stringify(envelope)
1523
+ ]
1524
+ );
1525
+ }
1526
+ async claimOutbox(input) {
1527
+ const current = input.now ?? /* @__PURE__ */ new Date();
1528
+ const leaseExpiresAt = new Date(current.getTime() + input.leaseDurationMs);
1529
+ const result = await this.sql.query(
1530
+ `WITH claimable AS (
1531
+ SELECT id FROM fabric_platform.event_outbox
1532
+ WHERE status='pending' AND available_at <= $1
1533
+ AND (lease_expires_at IS NULL OR lease_expires_at <= $1)
1534
+ AND ($2::text IS NULL OR tenant_id=$2)
1535
+ AND ($3::text IS NULL OR space_id=$3)
1536
+ ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT $4
1537
+ )
1538
+ UPDATE fabric_platform.event_outbox AS item
1539
+ SET lease_owner=$5, lease_expires_at=$6, attempt_count=attempt_count+1
1540
+ FROM claimable WHERE item.id=claimable.id RETURNING item.*`,
1541
+ [current, input.tenantId ?? null, input.spaceId ?? null, Math.max(1, Math.min(input.limit ?? 100, 1e3)), input.workerId, leaseExpiresAt]
1542
+ );
1543
+ return result.rows.map(toOutboxRecord);
1544
+ }
1545
+ async markOutboxPublished(id, workerId, publishedAt) {
1546
+ const result = await this.sql.query(
1547
+ `UPDATE fabric_platform.event_outbox SET status='published',published_at=$3,
1548
+ lease_owner=NULL,lease_expires_at=NULL WHERE id=$1 AND lease_owner=$2 RETURNING id`,
1549
+ [id, workerId, publishedAt]
1550
+ );
1551
+ if (result.rows.length === 0) throw new Error(`Outbox record ${id} is not leased by ${workerId}.`);
1552
+ }
1553
+ async markOutboxFailed(input) {
1554
+ const result = await this.sql.query(
1555
+ `UPDATE fabric_platform.event_outbox SET status=$3,last_error=$4,available_at=$5,
1556
+ lease_owner=NULL,lease_expires_at=NULL WHERE id=$1 AND lease_owner=$2 RETURNING id`,
1557
+ [input.id, input.workerId, input.deadLetter ? "dead_letter" : "pending", input.error, input.availableAt]
1558
+ );
1559
+ if (result.rows.length === 0) throw new Error(`Outbox record ${input.id} is not leased by ${input.workerId}.`);
1560
+ }
1561
+ async listOutbox(input = {}) {
1562
+ const result = await this.sql.query(
1563
+ `SELECT * FROM fabric_platform.event_outbox
1564
+ WHERE ($1::text IS NULL OR tenant_id=$1) AND ($2::text IS NULL OR space_id=$2)
1565
+ AND ($3::text[] IS NULL OR status=ANY($3::text[])) ORDER BY created_at,id`,
1566
+ [input.tenantId ?? null, input.spaceId ?? null, input.statuses?.length ? input.statuses : null]
1567
+ );
1568
+ return result.rows.map(toOutboxRecord);
1569
+ }
1329
1570
  async nextEventSequence(tenantId, spaceId) {
1330
1571
  const result = await this.sql.query(
1331
1572
  `INSERT INTO fabric_platform.event_sequences (tenant_id,space_id,next_sequence)
@@ -1514,6 +1755,41 @@ function toEventRecord(row) {
1514
1755
  ...row.causation_id ? { causationId: String(row.causation_id) } : {}
1515
1756
  };
1516
1757
  }
1758
+ function toOutboxRecord(row) {
1759
+ const event = row.event;
1760
+ return {
1761
+ id: String(row.id),
1762
+ tenantId: String(row.tenant_id),
1763
+ spaceId: String(row.space_id),
1764
+ event: {
1765
+ eventId: String(event.eventId),
1766
+ eventType: String(event.eventType),
1767
+ eventSchemaVersion: Number(event.eventSchemaVersion),
1768
+ tenantId: String(event.tenantId),
1769
+ spaceId: String(event.spaceId),
1770
+ subjectType: String(event.subjectType),
1771
+ subjectId: String(event.subjectId),
1772
+ sequence: Number(event.sequence),
1773
+ ...event.actionInvocationId ? { actionInvocationId: String(event.actionInvocationId) } : {},
1774
+ correlationId: String(event.correlationId),
1775
+ ...event.causationId ? { causationId: String(event.causationId) } : {},
1776
+ occurredAt: new Date(event.occurredAt),
1777
+ recordedAt: new Date(event.recordedAt),
1778
+ producerModuleVersion: String(event.producerModuleVersion),
1779
+ payloadClassification: String(event.payloadClassification),
1780
+ ...event.traceContext ? { traceContext: event.traceContext } : {},
1781
+ payload: event.payload
1782
+ },
1783
+ status: String(row.status),
1784
+ attemptCount: Number(row.attempt_count),
1785
+ availableAt: new Date(row.available_at),
1786
+ ...row.lease_owner ? { leaseOwner: String(row.lease_owner) } : {},
1787
+ ...row.lease_expires_at ? { leaseExpiresAt: new Date(row.lease_expires_at) } : {},
1788
+ ...row.last_error ? { lastError: String(row.last_error) } : {},
1789
+ createdAt: new Date(row.created_at),
1790
+ ...row.published_at ? { publishedAt: new Date(row.published_at) } : {}
1791
+ };
1792
+ }
1517
1793
 
1518
1794
  // src/worker.ts
1519
1795
  var DEFAULT_BATCH_SIZE = 10;
@@ -1583,6 +1859,6 @@ async function abortableDelay(milliseconds, signal) {
1583
1859
  });
1584
1860
  }
1585
1861
 
1586
- export { MemoryPlatformHostStore, PLATFORM_HOST_CONTRACT_VERSION, PostgresPlatformHostStore, createGovernedActionHost, createStoreBackedActionDispatcher, runPlatformActionWorker, runPlatformActionWorkerCycle };
1862
+ export { MemoryPlatformHostStore, PLATFORM_HOST_CONTRACT_VERSION, PostgresPlatformHostStore, cloneOutboxRecord, createGovernedActionHost, createStoreBackedActionDispatcher, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, toEnterpriseEventEnvelope };
1587
1863
  //# sourceMappingURL=index.js.map
1588
1864
  //# sourceMappingURL=index.js.map