@fabricorg/platform-host 8.0.0 → 8.1.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
@@ -334,8 +334,12 @@ function redactErrorText(hook, context, message) {
334
334
  const result = tryRedactErrorText(hook, context, message);
335
335
  return result.success ? result.text : "Error text withheld: the error-text redactor failed.";
336
336
  }
337
+ const LIFECYCLE_KEY_LIMIT = 96;
337
338
  function lifecycleId(prefix, invocationId, key) {
338
- return `${prefix}_${invocationId}_${key.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 96)}`;
339
+ const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, "_");
340
+ if (safeKey.length <= LIFECYCLE_KEY_LIMIT) return `${prefix}_${invocationId}_${safeKey}`;
341
+ const digest = createHash("sha256").update(key).digest("hex").slice(0, 32);
342
+ return `${prefix}_${invocationId}_${safeKey.slice(0, LIFECYCLE_KEY_LIMIT - digest.length)}_${digest}`;
339
343
  }
340
344
  function emitInvocationStatusTelemetry(invocation, status, telemetry, occurredAt, previousStatus) {
341
345
  if (!status || status === previousStatus) return;
@@ -5537,7 +5541,7 @@ function createDurableSagaParentLifecycle(options) {
5537
5541
  }
5538
5542
  //#endregion
5539
5543
  //#region src/tenant-isolation.ts
5540
- function fail(message) {
5544
+ function fail$1(message) {
5541
5545
  throw new Error(message);
5542
5546
  }
5543
5547
  function submitInput(subject, scope, idempotencyKey) {
@@ -5553,7 +5557,7 @@ function submitInput(subject, scope, idempotencyKey) {
5553
5557
  }
5554
5558
  async function adapterProbe(subject) {
5555
5559
  await subject.reset?.();
5556
- if (subject.ours.tenantId === subject.theirs.tenantId) fail("the two scopes must be in different tenants");
5560
+ if (subject.ours.tenantId === subject.theirs.tenantId) fail$1("the two scopes must be in different tenants");
5557
5561
  const suffix = globalThis.crypto.randomUUID();
5558
5562
  const scope = {
5559
5563
  tenantId: subject.ours.tenantId,
@@ -5641,9 +5645,9 @@ function tenantIsolationChecks() {
5641
5645
  id: "fabric.tenancy.invocation-scoped-read.v1",
5642
5646
  async run(subject) {
5643
5647
  await subject.reset?.();
5644
- if (subject.ours.tenantId === subject.theirs.tenantId) fail("the two scopes must be in different tenants");
5648
+ if (subject.ours.tenantId === subject.theirs.tenantId) fail$1("the two scopes must be in different tenants");
5645
5649
  const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:read"));
5646
- if (await subject.store.getActionInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId) !== void 0) fail("an invocation was readable under another tenant's scope");
5650
+ if (await subject.store.getActionInvocation(ours.actionInvocationId, subject.theirs.tenantId, subject.theirs.spaceId) !== void 0) fail$1("an invocation was readable under another tenant's scope");
5647
5651
  const shape = (record) => JSON.stringify({
5648
5652
  status: record?.status,
5649
5653
  result: record?.result,
@@ -5652,7 +5656,7 @@ function tenantIsolationChecks() {
5652
5656
  updatedAt: record?.updatedAt
5653
5657
  });
5654
5658
  const before = await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId);
5655
- if (!before) fail("our own invocation was not readable under our scope, so the check proves nothing");
5659
+ if (!before) fail$1("our own invocation was not readable under our scope, so the check proves nothing");
5656
5660
  const untouched = shape(before);
5657
5661
  let crossExecute;
5658
5662
  try {
@@ -5660,8 +5664,8 @@ function tenantIsolationChecks() {
5660
5664
  } catch {
5661
5665
  crossExecute = void 0;
5662
5666
  }
5663
- if (crossExecute !== void 0) fail("an invocation was executable under another tenant's scope");
5664
- if (untouched !== shape(await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId))) fail("an execution attempted under another tenant's scope changed our invocation");
5667
+ if (crossExecute !== void 0) fail$1("an invocation was executable under another tenant's scope");
5668
+ if (untouched !== shape(await subject.store.getActionInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId))) fail$1("an execution attempted under another tenant's scope changed our invocation");
5665
5669
  return ["invocation readable under our scope, invisible and untouched under the other tenant's"];
5666
5670
  }
5667
5671
  },
@@ -5671,9 +5675,9 @@ function tenantIsolationChecks() {
5671
5675
  await subject.reset?.();
5672
5676
  const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
5673
5677
  const again = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:shared-key"));
5674
- if (ours.actionInvocationId !== again.actionInvocationId) fail("the same idempotency key in one tenant produced two invocations, so the key is not doing anything and the check proves nothing");
5678
+ if (ours.actionInvocationId !== again.actionInvocationId) fail$1("the same idempotency key in one tenant produced two invocations, so the key is not doing anything and the check proves nothing");
5675
5679
  const theirs = await subject.host.submitAction(submitInput(subject, subject.theirs, "iso:shared-key"));
5676
- if (ours.actionInvocationId === theirs.actionInvocationId) fail("the same idempotency key in two tenants resolved to one invocation");
5680
+ if (ours.actionInvocationId === theirs.actionInvocationId) fail$1("the same idempotency key in two tenants resolved to one invocation");
5677
5681
  return ["one idempotency key: one invocation within a tenant, two across tenants"];
5678
5682
  }
5679
5683
  },
@@ -5683,10 +5687,10 @@ function tenantIsolationChecks() {
5683
5687
  await subject.reset?.();
5684
5688
  const ours = await subject.host.submitAction(submitInput(subject, subject.ours, "iso:events"));
5685
5689
  const leaked = (await subject.store.listEvents(subject.theirs.tenantId, subject.theirs.spaceId)).filter((event) => event.tenantId === subject.ours.tenantId || event.actionInvocationId === ours.actionInvocationId);
5686
- if (leaked.length > 0) fail(`${leaked.length} of our events appeared in the other tenant's stream`);
5690
+ if (leaked.length > 0) fail$1(`${leaked.length} of our events appeared in the other tenant's stream`);
5687
5691
  const ourEvents = await subject.store.listEvents(subject.ours.tenantId, subject.ours.spaceId);
5688
- if (!ourEvents.some((event) => event.actionInvocationId === ours.actionInvocationId)) fail("our own stream does not carry the event we just caused, so the check proves nothing");
5689
- if (ourEvents.some((event) => event.tenantId !== subject.ours.tenantId)) fail("another tenant's event appeared in our stream");
5692
+ if (!ourEvents.some((event) => event.actionInvocationId === ours.actionInvocationId)) fail$1("our own stream does not carry the event we just caused, so the check proves nothing");
5693
+ if (ourEvents.some((event) => event.tenantId !== subject.ours.tenantId)) fail$1("another tenant's event appeared in our stream");
5690
5694
  return ["event streams carry their own tenant's events and only those"];
5691
5695
  }
5692
5696
  },
@@ -5715,14 +5719,14 @@ function tenantIsolationChecks() {
5715
5719
  limit: 1e3,
5716
5720
  tenantId: subject.theirs.tenantId,
5717
5721
  spaceId: probeSpace
5718
- })).some((row) => row.id === created.id)) fail("a worker scoped to another tenant claimed our pending invocation");
5722
+ })).some((row) => row.id === created.id)) fail$1("a worker scoped to another tenant claimed our pending invocation");
5719
5723
  if (!(await subject.store.claimActionInvocations({
5720
5724
  workerId: "iso-worker",
5721
5725
  leaseDurationMs: 1e3,
5722
5726
  limit: 1e3,
5723
5727
  tenantId: subject.ours.tenantId,
5724
5728
  spaceId: probeSpace
5725
- })).some((row) => row.id === created.id)) fail("a worker scoped to our own tenant did not claim our pending invocation, so the check proves nothing");
5729
+ })).some((row) => row.id === created.id)) fail$1("a worker scoped to our own tenant did not claim our pending invocation, so the check proves nothing");
5726
5730
  } finally {
5727
5731
  await subject.store.updateActionInvocation(created.id, subject.ours.tenantId, probeSpace, {
5728
5732
  status: "failed",
@@ -5748,7 +5752,7 @@ function tenantIsolationChecks() {
5748
5752
  } catch (error) {
5749
5753
  outcome = error.refusal ?? "threw";
5750
5754
  }
5751
- if (outcome !== "not_found") fail(`a completion under another tenant's scope was ${outcome}; it must be refused as not_found`);
5755
+ if (outcome !== "not_found") fail$1(`a completion under another tenant's scope was ${outcome}; it must be refused as not_found`);
5752
5756
  let mine;
5753
5757
  try {
5754
5758
  await subject.host.completeExternalInvocation(ours.actionInvocationId, subject.ours.tenantId, subject.ours.spaceId, {
@@ -5760,7 +5764,7 @@ function tenantIsolationChecks() {
5760
5764
  } catch (error) {
5761
5765
  mine = error.refusal ?? "threw";
5762
5766
  }
5763
- if (mine === "not_found") fail("a completion under our own scope was refused as not_found, so the check proves nothing");
5767
+ if (mine === "not_found") fail$1("a completion under our own scope was refused as not_found, so the check proves nothing");
5764
5768
  return ["completion under the other tenant's scope refused as not_found; found under ours"];
5765
5769
  }
5766
5770
  },
@@ -5772,11 +5776,11 @@ function tenantIsolationChecks() {
5772
5776
  await subject.host.submitAction(submitInput(subject, subject.ours, "iso:projection"));
5773
5777
  const ours = await subject.read(subject.ours);
5774
5778
  const theirs = await subject.read(subject.theirs);
5775
- if (!ours.allowed) fail("our own viewer was denied the read");
5776
- if (ours.rows.length === 0) fail("our own viewer saw nothing, so the check proves nothing");
5777
- if (ours.rows.some((row) => row?.tenantId !== subject.ours.tenantId)) fail("our own viewer saw rows that are not ours, or rows carrying no tenant");
5779
+ if (!ours.allowed) fail$1("our own viewer was denied the read");
5780
+ if (ours.rows.length === 0) fail$1("our own viewer saw nothing, so the check proves nothing");
5781
+ if (ours.rows.some((row) => row?.tenantId !== subject.ours.tenantId)) fail$1("our own viewer saw rows that are not ours, or rows carrying no tenant");
5778
5782
  const leaked = theirs.allowed ? theirs.rows.filter((row) => row?.tenantId === subject.ours.tenantId) : [];
5779
- if (leaked.length > 0) fail(`a viewer in the other tenant saw ${leaked.length} of our rows`);
5783
+ if (leaked.length > 0) fail$1(`a viewer in the other tenant saw ${leaked.length} of our rows`);
5780
5784
  return [theirs.allowed ? "other tenant's viewer sees none of our rows" : "other tenant's viewer is denied"];
5781
5785
  }
5782
5786
  },
@@ -5793,18 +5797,18 @@ function tenantIsolationChecks() {
5793
5797
  if (await subject.store.getAdapterInvocation(id, {
5794
5798
  ...scope,
5795
5799
  tenantId: subject.theirs.tenantId
5796
- })) fail("an adapter invocation was readable under another tenant's scope");
5797
- if (await subject.store.getAdapterInvocation(id, theirs)) fail("an adapter invocation was readable under another tenant's scope");
5800
+ })) fail$1("an adapter invocation was readable under another tenant's scope");
5801
+ if (await subject.store.getAdapterInvocation(id, theirs)) fail$1("an adapter invocation was readable under another tenant's scope");
5798
5802
  if (await subject.store.getAdapterInvocation(id, {
5799
5803
  ...scope,
5800
5804
  spaceId: `${scope.spaceId}-other`
5801
- })) fail("an adapter invocation was readable under another space's scope");
5805
+ })) fail$1("an adapter invocation was readable under another space's scope");
5802
5806
  if (await subject.store.getAdapterInvocation(id, {
5803
5807
  ...scope,
5804
5808
  actionInvocationId: `${scope.actionInvocationId}-other`
5805
- })) fail("an adapter invocation was readable under another action invocation's scope");
5809
+ })) fail$1("an adapter invocation was readable under another action invocation's scope");
5806
5810
  const ours = await subject.store.getAdapterInvocation(id, scope);
5807
- if (!ours || ours.id !== id || ours.tenantId !== scope.tenantId || ours.spaceId !== scope.spaceId || ours.actionInvocationId !== scope.actionInvocationId) fail("our own adapter invocation was not readable under our scope, so the check proves nothing");
5811
+ if (!ours || ours.id !== id || ours.tenantId !== scope.tenantId || ours.spaceId !== scope.spaceId || ours.actionInvocationId !== scope.actionInvocationId) fail$1("our own adapter invocation was not readable under our scope, so the check proves nothing");
5808
5812
  } finally {
5809
5813
  await retire();
5810
5814
  }
@@ -5823,7 +5827,7 @@ function tenantIsolationChecks() {
5823
5827
  });
5824
5828
  try {
5825
5829
  const before = await subject.store.getAdapterInvocation(id, scope);
5826
- if (!before) fail("our own adapter invocation was not readable under our scope, so the check proves nothing");
5830
+ if (!before) fail$1("our own adapter invocation was not readable under our scope, so the check proves nothing");
5827
5831
  const untouched = shape(before);
5828
5832
  for (const other of [
5829
5833
  {
@@ -5858,7 +5862,7 @@ function tenantIsolationChecks() {
5858
5862
  spaceId: other.spaceId,
5859
5863
  condition,
5860
5864
  patch: { error: "out-of-scope parent" }
5861
- })) fail("a settled parent update admitted another scope");
5865
+ })) fail$1("a settled parent update admitted another scope");
5862
5866
  if (await subject.store.writeAdapterInvocation({
5863
5867
  scope: other,
5864
5868
  condition,
@@ -5870,9 +5874,9 @@ function tenantIsolationChecks() {
5870
5874
  error: "iso probe"
5871
5875
  }
5872
5876
  }
5873
- })) fail("a conditional adapter update admitted another scope");
5877
+ })) fail$1("a conditional adapter update admitted another scope");
5874
5878
  const row = await subject.store.getAdapterInvocation(id, scope);
5875
- if (!row) fail("our adapter row disappeared");
5879
+ if (!row) fail$1("our adapter row disappeared");
5876
5880
  if (await subject.store.writeAdapterInvocation({
5877
5881
  scope: other,
5878
5882
  condition,
@@ -5883,10 +5887,10 @@ function tenantIsolationChecks() {
5883
5887
  id: `${id}-other`
5884
5888
  }
5885
5889
  }
5886
- })) fail("a conditional adapter create admitted another scope");
5890
+ })) fail$1("a conditional adapter create admitted another scope");
5887
5891
  }
5888
5892
  const after = await subject.store.getAdapterInvocation(id, scope);
5889
- if (!after || shape(after) !== untouched) fail("an adapter invocation changed under another scope");
5893
+ if (!after || shape(after) !== untouched) fail$1("an adapter invocation changed under another scope");
5890
5894
  }
5891
5895
  if (subject.store.writeAdapterInvocation && !await subject.store.writeAdapterInvocation({
5892
5896
  scope,
@@ -5897,12 +5901,12 @@ function tenantIsolationChecks() {
5897
5901
  id,
5898
5902
  patch: { attempt: 2 }
5899
5903
  }
5900
- })) fail("our conditional adapter update was refused, so the check proves nothing");
5904
+ })) fail$1("our conditional adapter update was refused, so the check proves nothing");
5901
5905
  await subject.store.updateAdapterInvocation(id, {
5902
5906
  status: "succeeded",
5903
5907
  updatedAt: /* @__PURE__ */ new Date()
5904
5908
  }, scope);
5905
- if ((await subject.store.getAdapterInvocation(id, scope))?.status !== "succeeded") fail("our own adapter invocation was not updated under our scope, so the check proves nothing");
5909
+ if ((await subject.store.getAdapterInvocation(id, scope))?.status !== "succeeded") fail$1("our own adapter invocation was not updated under our scope, so the check proves nothing");
5906
5910
  } finally {
5907
5911
  await retire();
5908
5912
  }
@@ -5935,6 +5939,542 @@ async function runTenantIsolationChecks(subject) {
5935
5939
  };
5936
5940
  }
5937
5941
  //#endregion
5938
- export { ActionAdmissionError, ExternalCompletionError, IdempotencyConflictError, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, applyPostgresMigrations, canonicalJson, cloneOutboxRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, getPlatformHostHealthSnapshot, outboxActorDisclosure, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, tenantIsolationChecks, toEnterpriseEventEnvelope };
5942
+ //#region src/event-append-conformance.ts
5943
+ function fail(message) {
5944
+ throw new Error(message);
5945
+ }
5946
+ function message(error) {
5947
+ return error instanceof Error ? error.message : String(error);
5948
+ }
5949
+ function normalize(value, key) {
5950
+ if (value == null) return void 0;
5951
+ if (value instanceof Date) return value.getTime();
5952
+ if (typeof value === "string") {
5953
+ if (key === "sequence" || key === "attemptCount" || key === "leaseToken") return Number(value);
5954
+ if ((key && [
5955
+ "occurredAt",
5956
+ "recordedAt",
5957
+ "availableAt",
5958
+ "leaseExpiresAt",
5959
+ "createdAt",
5960
+ "publishedAt"
5961
+ ].includes(key) || /^\d{4}-\d{2}-\d{2}(?:T|$)/.test(value)) && Number.isFinite(Date.parse(value))) return Date.parse(value);
5962
+ }
5963
+ if (Array.isArray(value)) return value.map((item) => normalize(item));
5964
+ if (typeof value === "object") return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([name, item]) => [name, normalize(item, name)]));
5965
+ return value;
5966
+ }
5967
+ function shape(event) {
5968
+ return JSON.stringify(normalize(event));
5969
+ }
5970
+ function rowShape(row) {
5971
+ return shape(row);
5972
+ }
5973
+ function keepsFirstValues(actual, first, duplicates) {
5974
+ const record = actual;
5975
+ const original = first;
5976
+ const alternatives = duplicates;
5977
+ const keys = /* @__PURE__ */ new Set([...Object.keys(first), ...duplicates.flatMap((value) => Object.keys(value))]);
5978
+ for (const key of keys) {
5979
+ const expected = shape(normalize(original[key], key));
5980
+ const changed = alternatives.map((value) => shape(normalize(value[key], key))).filter((value) => value !== expected);
5981
+ if (record[key] != null && changed.length) {
5982
+ if (shape(normalize(record[key], key)) !== expected) return false;
5983
+ }
5984
+ }
5985
+ return true;
5986
+ }
5987
+ async function append(label, run) {
5988
+ try {
5989
+ await run();
5990
+ } catch (error) {
5991
+ fail(`${label} append threw: ${message(error)}`);
5992
+ }
5993
+ }
5994
+ async function probeRows(subject, spaceId) {
5995
+ if (!subject.store.listOutbox) fail("store has outbox append but lacks listOutbox; cannot verify rows");
5996
+ return (await subject.store.listOutbox({
5997
+ tenantId: subject.tenantId,
5998
+ spaceId
5999
+ })).filter((row) => row.tenantId === subject.tenantId && row.spaceId === spaceId);
6000
+ }
6001
+ const leaseDurationMs = 3e4;
6002
+ function dueTime() {
6003
+ return /* @__PURE__ */ new Date(Math.floor(Date.now() / 1e3) * 1e3 - 5e3);
6004
+ }
6005
+ const relayInterference = "another worker claimed the probe outbox row; pause outbox relays for the probe tenant";
6006
+ function checkWorker(row, workerId) {
6007
+ if (row.leaseOwner && row.leaseOwner !== workerId) fail(relayInterference);
6008
+ }
6009
+ const clockSkewMs = 5e3;
6010
+ function leaseExpiry(row) {
6011
+ return row.leaseExpiresAt == null ? void 0 : Number(normalize(row.leaseExpiresAt, "leaseExpiresAt"));
6012
+ }
6013
+ async function waitForLeaseExpiry(row) {
6014
+ const expiry = leaseExpiry(row);
6015
+ if (expiry !== void 0 && expiry > Date.now()) await new Promise((resolve) => setTimeout(resolve, expiry - Date.now() + 100));
6016
+ }
6017
+ function checkState(row, state, workerId) {
6018
+ checkWorker(row, workerId);
6019
+ const expiry = leaseExpiry(row);
6020
+ if (state === "retry-pending" && expiry !== void 0 && expiry > Date.now() + clockSkewMs) fail("markOutboxFailed did not release the lease");
6021
+ if (row.status !== (state === "published" ? "published" : "pending")) fail(relayInterference);
6022
+ if (state === "leased" && row.leaseOwner !== workerId) fail(relayInterference);
6023
+ }
6024
+ function leaseToken(row) {
6025
+ return row.leaseToken == null ? void 0 : Number(row.leaseToken);
6026
+ }
6027
+ async function claimProbeRows(subject, scope, pendingCount) {
6028
+ if (scope.run.filterViolation) fail("outbox check skipped because of the earlier claimOutbox filter violation");
6029
+ const rows = await subject.store.claimOutbox({
6030
+ tenantId: subject.tenantId,
6031
+ spaceId: scope.spaceId,
6032
+ now: /* @__PURE__ */ new Date(),
6033
+ workerId: scope.workerId,
6034
+ leaseDurationMs,
6035
+ limit: pendingCount
6036
+ });
6037
+ const foreign = rows.filter((row) => row.tenantId !== subject.tenantId || row.spaceId !== scope.spaceId);
6038
+ const own = rows.filter((row) => row.tenantId === subject.tenantId && row.spaceId === scope.spaceId);
6039
+ for (const row of own) scope.leaseTokens.set(row.id, leaseToken(row));
6040
+ if (foreign.length) {
6041
+ scope.run.filterViolation = true;
6042
+ scope.violatingClaimRows = own;
6043
+ fail(`claimOutbox ignored the tenant/space filter; foreign row IDs: ${foreign.map((row) => row.id).join(", ")}; their leases lapse within ${leaseDurationMs}ms of the claim if the store honours leaseDurationMs; the checks write nothing to foreign rows; further claims skipped for this run after filter violation`);
6044
+ }
6045
+ return rows;
6046
+ }
6047
+ async function retireAfterFilterViolation(subject, scope) {
6048
+ const errors = [];
6049
+ for (const row of scope.violatingClaimRows) try {
6050
+ checkState(row, "leased", scope.workerId);
6051
+ await subject.store.markOutboxFailed({
6052
+ id: row.id,
6053
+ workerId: scope.workerId,
6054
+ leaseToken: scope.leaseTokens.get(row.id),
6055
+ deadLetter: true,
6056
+ error: "event append probe; never published",
6057
+ availableAt: dueTime()
6058
+ });
6059
+ } catch (error) {
6060
+ errors.push(`probe row retirement failed (${row.id}): ${message(error)}`);
6061
+ }
6062
+ const pending = (await probeRows(subject, scope.spaceId)).filter((row) => row.status === "pending");
6063
+ if (pending.length) errors.push(`probe rows still pending after filter violation: ${pending.map((row) => row.id).join(", ")}; dead-letter them by hand`);
6064
+ if (errors.length) fail(errors.join("; "));
6065
+ }
6066
+ async function retire(subject, scope) {
6067
+ const { store } = subject;
6068
+ if (!store.listOutbox) return;
6069
+ if (scope.run.filterViolation) {
6070
+ try {
6071
+ await retireAfterFilterViolation(subject, scope);
6072
+ } catch (error) {
6073
+ fail(`probe rows could not be retired: ${message(error)}`);
6074
+ }
6075
+ return;
6076
+ }
6077
+ let failure;
6078
+ try {
6079
+ let pending = (await probeRows(subject, scope.spaceId)).filter((row) => row.status === "pending");
6080
+ while (pending.length) {
6081
+ if (!store.claimOutbox || !store.markOutboxFailed) fail("missing claimOutbox/markOutboxFailed");
6082
+ for (const row of pending) checkWorker(row, scope.workerId);
6083
+ const owned = pending.filter((row) => row.leaseOwner === scope.workerId);
6084
+ const claimed = owned.length ? owned : await claimProbeRows(subject, scope, pending.length);
6085
+ if (!claimed.length) {
6086
+ for (const row of await probeRows(subject, scope.spaceId)) checkState(row, "pending", scope.workerId);
6087
+ fail("claimOutbox returned no rows for pending probe outbox rows");
6088
+ }
6089
+ const errors = [];
6090
+ for (const row of claimed) {
6091
+ checkState(row, "leased", scope.workerId);
6092
+ try {
6093
+ await store.markOutboxFailed({
6094
+ id: row.id,
6095
+ workerId: scope.workerId,
6096
+ leaseToken: scope.leaseTokens.get(row.id),
6097
+ deadLetter: true,
6098
+ error: "event append probe; never published",
6099
+ availableAt: dueTime()
6100
+ });
6101
+ } catch (error) {
6102
+ errors.push(`probe row retirement failed: ${message(error)}`);
6103
+ }
6104
+ }
6105
+ if (errors.length) fail(errors.join("; "));
6106
+ const retired = await probeRows(subject, scope.spaceId);
6107
+ const remaining = retired.filter((row) => row.status === "pending");
6108
+ if (remaining.length >= pending.length) fail("retirement made no progress");
6109
+ for (const row of claimed) if (retired.find((candidate) => candidate.id === row.id)?.status !== "dead_letter") fail(`retirement did not leave the probe outbox row in dead_letter: ${row.id}`);
6110
+ pending = remaining;
6111
+ }
6112
+ } catch (error) {
6113
+ failure = message(error);
6114
+ }
6115
+ if (scope.run.filterViolation) try {
6116
+ await retireAfterFilterViolation(subject, scope);
6117
+ } catch (error) {
6118
+ failure = [failure, message(error)].filter(Boolean).join("; ");
6119
+ }
6120
+ if (failure) fail(`probe rows could not be retired: ${failure}`);
6121
+ }
6122
+ function requireOutboxVerification(store) {
6123
+ for (const key of [
6124
+ "listOutbox",
6125
+ "claimOutbox",
6126
+ "markOutboxPublished",
6127
+ "markOutboxFailed"
6128
+ ]) if (!store[key]) fail(`store has outbox append but lacks ${key}; cannot verify or clean up probe rows`);
6129
+ }
6130
+ async function probe(subject, mode, run = { filterViolation: false }) {
6131
+ const { store, tenantId } = subject;
6132
+ const transactional = mode.startsWith("transaction");
6133
+ const afterPlain = mode === "after-plain" || mode === "transaction-after-plain";
6134
+ const outbox = mode === "outbox" || mode === "transaction-outbox" || afterPlain;
6135
+ if (transactional && !store.transactionWithEvents) return ["skipped: store has no transactionWithEvents"];
6136
+ if (!transactional && outbox && !store.appendEventWithOutbox) return ["skipped: store has no appendEventWithOutbox"];
6137
+ if (outbox && run.filterViolation) fail("outbox check skipped because of the earlier claimOutbox filter violation");
6138
+ await subject.reset?.();
6139
+ const spaceId = `event-append-probe-${globalThis.crypto.randomUUID()}`;
6140
+ const scope = {
6141
+ spaceId,
6142
+ workerId: `event-append-probe-${globalThis.crypto.randomUUID()}`,
6143
+ run,
6144
+ leaseTokens: /* @__PURE__ */ new Map(),
6145
+ violatingClaimRows: []
6146
+ };
6147
+ const recordedAt = dueTime();
6148
+ const first = {
6149
+ id: globalThis.crypto.randomUUID(),
6150
+ tenantId,
6151
+ spaceId,
6152
+ eventType: "EventAppendProbe",
6153
+ eventSchemaVersion: 1,
6154
+ subjectType: "EventAppendProbe",
6155
+ subjectId: "first",
6156
+ actorId: "event-append-probe",
6157
+ actorType: "system",
6158
+ actionInvocationId: globalThis.crypto.randomUUID(),
6159
+ correlationId: globalThis.crypto.randomUUID(),
6160
+ causationId: globalThis.crypto.randomUUID(),
6161
+ payload: {
6162
+ probe: "first",
6163
+ nested: {
6164
+ b: 2,
6165
+ a: 1
6166
+ }
6167
+ },
6168
+ sequence: 50,
6169
+ occurredAt: /* @__PURE__ */ new Date(recordedAt.getTime() - 1e3),
6170
+ recordedAt,
6171
+ provenance: {
6172
+ source: "system",
6173
+ auditAttributes: { "probe.origin": "first" }
6174
+ }
6175
+ };
6176
+ const metadata = {
6177
+ producerModuleVersion: "event-append-probe@1",
6178
+ payloadClassification: "internal",
6179
+ traceContext: { traceparent: "first" },
6180
+ includeProvenance: false,
6181
+ includeActor: false
6182
+ };
6183
+ const duplicates = ["later", "earlier"].map((direction, index) => ({
6184
+ direction,
6185
+ event: {
6186
+ ...first,
6187
+ eventType: `${direction}EventAppendProbe`,
6188
+ eventSchemaVersion: index + 2,
6189
+ subjectType: `${direction}Probe`,
6190
+ subjectId: direction,
6191
+ actorId: `${direction}-actor`,
6192
+ actorType: "service_account",
6193
+ actionInvocationId: globalThis.crypto.randomUUID(),
6194
+ correlationId: globalThis.crypto.randomUUID(),
6195
+ causationId: globalThis.crypto.randomUUID(),
6196
+ payload: {
6197
+ probe: direction,
6198
+ [direction]: true
6199
+ },
6200
+ sequence: index === 0 ? 99 : 1,
6201
+ occurredAt: new Date(first.occurredAt.getTime() + (index === 0 ? 2e3 : -2e3)),
6202
+ recordedAt: new Date(recordedAt.getTime() + (index === 0 ? 2e3 : -2e3)),
6203
+ provenance: {
6204
+ source: "api",
6205
+ auditAttributes: {
6206
+ "probe.origin": direction,
6207
+ [`probe.${direction}`]: "added"
6208
+ }
6209
+ },
6210
+ consistency: "provisional-until-reconciled"
6211
+ },
6212
+ metadata: {
6213
+ producerModuleVersion: `event-append-probe@${index + 2}`,
6214
+ payloadClassification: "confidential",
6215
+ traceContext: {
6216
+ traceparent: direction,
6217
+ [direction]: "added"
6218
+ },
6219
+ includeProvenance: true,
6220
+ includeActor: true
6221
+ }
6222
+ }));
6223
+ const firstInput = structuredClone(first);
6224
+ const outboxInput = structuredClone({
6225
+ ...first,
6226
+ ...metadata
6227
+ });
6228
+ const duplicateInputs = structuredClone(duplicates);
6229
+ let original;
6230
+ const verifyEvents = (events, context = "") => {
6231
+ events = events.filter((event) => event.id === firstInput.id);
6232
+ if (events.length === 0) fail("store kept no event, so the check proves nothing" + context);
6233
+ if (events.length !== 1) fail(`store kept ${events.length === 2 ? "two" : events.length} events instead of one` + context);
6234
+ const event = events[0];
6235
+ if (!keepsFirstValues(event, firstInput, duplicateInputs.map((value) => value.event)) || original !== void 0 && shape(event) !== original) fail("store kept the later event or changed the first event" + context);
6236
+ original ??= shape(event);
6237
+ };
6238
+ const verify = async (state) => {
6239
+ const context = state ? ` on duplicate append against the ${state} outbox row` : "";
6240
+ verifyEvents(await store.listEvents(tenantId, spaceId), context);
6241
+ if (!outbox) return void 0;
6242
+ const rows = await probeRows(subject, spaceId);
6243
+ if (afterPlain) {
6244
+ if (rows.length) fail("store added an outbox row for an already-stored event");
6245
+ return;
6246
+ }
6247
+ if (rows.length === 0) fail(state ? `store removed the existing outbox row on duplicate append (${state})` : "store added no outbox row, so the check proves nothing");
6248
+ if (rows.length !== 1) fail(`store added a second outbox row (${rows.length} rows)` + context);
6249
+ return rows[0];
6250
+ };
6251
+ let controlSequence = 100;
6252
+ const duplicate = async (afterEach) => {
6253
+ for (const { event, metadata, direction } of duplicates) {
6254
+ if (transactional) {
6255
+ const controlId = globalThis.crypto.randomUUID();
6256
+ const control = {
6257
+ ...firstInput,
6258
+ id: controlId,
6259
+ sequence: controlSequence++,
6260
+ payload: { probe: "transaction-control" }
6261
+ };
6262
+ await store.transactionWithEvents(async (tx) => {
6263
+ await append("committed duplicate transaction", () => outbox ? tx.appendEventWithOutbox(event, metadata) : tx.appendEvent(event));
6264
+ await append("committed duplicate transaction control", () => tx.appendEvent(control));
6265
+ });
6266
+ if (!(await store.listEvents(tenantId, spaceId)).some((event) => event.id === controlId)) fail(`committed duplicate transaction lost its control event: ${controlId}`);
6267
+ } else await append(direction, () => outbox ? store.appendEventWithOutbox(event, metadata) : store.appendEvent(event));
6268
+ await afterEach();
6269
+ }
6270
+ };
6271
+ let failure;
6272
+ try {
6273
+ if (transactional) {
6274
+ if (!await store.transactionWithEvents(async (tx) => {
6275
+ if (outbox && !tx.appendEventWithOutbox) return false;
6276
+ if (outbox) requireOutboxVerification(store);
6277
+ await append("first transaction", () => outbox && !afterPlain ? tx.appendEventWithOutbox(first, metadata) : tx.appendEvent(first));
6278
+ for (const { event, metadata, direction } of duplicates) await append(`${direction} transaction`, () => outbox ? tx.appendEventWithOutbox(event, metadata) : tx.appendEvent(event));
6279
+ return true;
6280
+ })) return ["skipped: transaction has no appendEventWithOutbox"];
6281
+ } else {
6282
+ if (outbox) requireOutboxVerification(store);
6283
+ await append("first", () => mode === "outbox" ? store.appendEventWithOutbox(first, metadata) : store.appendEvent(first));
6284
+ }
6285
+ if (outbox && !afterPlain) {
6286
+ let row = await verify();
6287
+ checkState(row, "pending", scope.workerId);
6288
+ if (row.event.producerModuleVersion !== metadata.producerModuleVersion) fail("store changed the first pending outbox producerModuleVersion metadata");
6289
+ if (row.event.payloadClassification !== metadata.payloadClassification) fail("store changed the first pending outbox payloadClassification metadata");
6290
+ if (row.actorDisclosure === "included") fail("store included actor disclosure although first metadata did not opt in with includeActor");
6291
+ if (row.event.actorId != null || row.event.actorType != null) fail("store put actorId or actorType on the outbox envelope although first metadata did not opt in with includeActor");
6292
+ if (!keepsFirstValues(row.event, outboxInput, duplicateInputs.map((value) => ({
6293
+ ...value.event,
6294
+ ...value.metadata
6295
+ })))) fail("store changed the first pending outbox envelope");
6296
+ for (const state of [
6297
+ "pending",
6298
+ "leased",
6299
+ "retry-pending",
6300
+ "published"
6301
+ ]) {
6302
+ row = await verify();
6303
+ checkState(row, state, scope.workerId);
6304
+ const snapshot = rowShape(row);
6305
+ await duplicate(async () => {
6306
+ row = await verify(state);
6307
+ if (rowShape(row) !== snapshot) fail(`store changed the existing ${state} outbox row on duplicate append (including delivery state, envelope and metadata)` + (row.leaseOwner && row.leaseOwner !== scope.workerId || row.status !== (state === "published" ? "published" : "pending") ? `; ${relayInterference}` : ""));
6308
+ });
6309
+ if (state === "pending" || state === "retry-pending") {
6310
+ checkState(row, state, scope.workerId);
6311
+ if (state === "retry-pending") await waitForLeaseExpiry(row);
6312
+ const claimed = await claimProbeRows(subject, scope, 1);
6313
+ row = await verify();
6314
+ checkWorker(row, scope.workerId);
6315
+ if (row.status !== "pending") fail(relayInterference);
6316
+ if (claimed.length !== 1 || claimed[0].id !== row.id || row.leaseOwner !== scope.workerId) fail("claimOutbox did not lease the pending probe outbox row");
6317
+ if (state === "retry-pending") {
6318
+ row = await verify();
6319
+ checkState(row, "leased", scope.workerId);
6320
+ await store.markOutboxPublished(row.id, scope.workerId, /* @__PURE__ */ new Date(), scope.leaseTokens.get(row.id));
6321
+ row = await verify();
6322
+ checkWorker(row, scope.workerId);
6323
+ if (row.status !== "published") fail("markOutboxPublished did not settle the probe outbox row as published");
6324
+ }
6325
+ } else if (state === "leased") {
6326
+ row = await verify();
6327
+ checkState(row, "leased", scope.workerId);
6328
+ await store.markOutboxFailed({
6329
+ id: row.id,
6330
+ workerId: scope.workerId,
6331
+ leaseToken: scope.leaseTokens.get(row.id),
6332
+ deadLetter: false,
6333
+ availableAt: dueTime(),
6334
+ error: "event append probe; retry"
6335
+ });
6336
+ row = await verify();
6337
+ checkState(row, "retry-pending", scope.workerId);
6338
+ }
6339
+ }
6340
+ } else {
6341
+ await verify();
6342
+ await duplicate(async () => {
6343
+ await verify();
6344
+ });
6345
+ if (afterPlain) {
6346
+ const control = {
6347
+ ...first,
6348
+ id: globalThis.crypto.randomUUID(),
6349
+ sequence: 51,
6350
+ payload: { probe: "control" }
6351
+ };
6352
+ if (transactional) await store.transactionWithEvents((tx) => append("control transaction", () => tx.appendEventWithOutbox(control, metadata)));
6353
+ else await append("control", () => store.appendEventWithOutbox(control, metadata));
6354
+ const rows = await probeRows(subject, spaceId);
6355
+ if (rows.length !== 1 || rows[0].event.eventId !== control.id) fail("store did not expose exactly the control event's outbox row; absence verification proves nothing");
6356
+ checkState(rows[0], "pending", scope.workerId);
6357
+ }
6358
+ }
6359
+ } catch (error) {
6360
+ failure = message(error);
6361
+ } finally {
6362
+ if (outbox) try {
6363
+ await retire(subject, scope);
6364
+ } catch (error) {
6365
+ failure = [failure, message(error)].filter(Boolean).join("; ");
6366
+ }
6367
+ if (failure) fail(failure);
6368
+ }
6369
+ return ["earlier and later duplicate appends kept one first event" + (outbox ? afterPlain ? " and added no outbox row, verified by a fresh control row" : " and preserved pending, leased, retry-pending and published outbox rows and first metadata" : "")];
6370
+ }
6371
+ /**
6372
+ * Six checks detecting common first-write-wins violations through plain,
6373
+ * outbox and transaction appends, including outbox-after-plain and committed
6374
+ * duplicates: throwing, replacing, merging, keeping both events, selecting the
6375
+ * earlier-ordered or later-ordered duplicate, adding outbox rows, and resetting
6376
+ * or rewriting existing outbox rows in the probed states. They are not exhaustive.
6377
+ * Duplicates have both earlier and later ordering fields and vary the remaining
6378
+ * envelope and outbox metadata fields. Whole-record snapshots compare duplicates
6379
+ * while pending, leased by the probe, retry-pending, and settled as published.
6380
+ * Retry-pending accepts a cleared or expired lease, the probe owner retained
6381
+ * without an expiry, or an expiry within five seconds of the probe's clock,
6382
+ * which is waited out before the row is claimed again; attempt counts and
6383
+ * error formatting are not prescribed.
6384
+ * Initial comparisons accept store-specific fields and fields the store omits
6385
+ * or returns as null; transaction verification runs after commit without requiring
6386
+ * read-your-writes. Each committed duplicate transaction also appends a fresh plain
6387
+ * control event whose presence is verified after commit.
6388
+ * Successful outbox-creating checks leave the probe row
6389
+ * published although nothing was sent; its event-append-probe space identifies
6390
+ * it. Absence checks write a control row that cleanup dead-letters. These rows can
6391
+ * be requeued by operator tooling; exclude probe spaces from real publication.
6392
+ *
6393
+ * Intended for test or staging stores with outbox relays paused for the probe
6394
+ * tenant. Event and availability inputs use whole seconds a few seconds in the
6395
+ * past; claims and publication markers use real current time. Checks leave
6396
+ * append-only events in fresh event-append-probe spaces. Final cleanup
6397
+ * attempts to dead-letter visible probe rows still pending with
6398
+ * "event append probe; never published".
6399
+ * Claims use real current time, a pending-probe-row limit, and a 30-second lease.
6400
+ * A filter-ignoring claim can lease foreign rows and make whatever writes the
6401
+ * claim itself makes (such as incrementing attempts). Checks write nothing to
6402
+ * foreign rows and stop claiming for that run (or that standalone check).
6403
+ * After a filter violation, cleanup can dead-letter only probe rows returned by
6404
+ * that claim, using the probe worker and claim tokens without further claims.
6405
+ * Failures list visible probe rows still pending and instruct manual dead-lettering.
6406
+ * Later outbox checks in the run fail as skipped because of the earlier filter
6407
+ * violation. Leases lapse within 30 seconds
6408
+ * of the claim if the store honours the lease duration.
6409
+ *
6410
+ * Missing append capabilities skip; missing listOutbox, claimOutbox,
6411
+ * markOutboxPublished or markOutboxFailed fails verification/cleanup.
6412
+ * Known limits: rules comparing ordering fields in mixed directions, store-wide
6413
+ * uniqueness across spaces or tenants, the dead-letter state, claims that
6414
+ * lease foreign rows without returning them, buffered merges into fields the
6415
+ * store never returns, and duplicate handling that only holds within one
6416
+ * process. Stores requiring gapless sequences, or unique keys over fields the
6417
+ * control events share with the probe event, can fail falsely.
6418
+ */
6419
+ function eventAppendChecks() {
6420
+ return checksForRun();
6421
+ }
6422
+ function checksForRun(run) {
6423
+ return [
6424
+ {
6425
+ id: "fabric.store.event-append-first-write-wins.v1",
6426
+ run: (subject) => probe(subject, "plain", run)
6427
+ },
6428
+ {
6429
+ id: "fabric.store.event-append-outbox-first-write-wins.v1",
6430
+ run: (subject) => probe(subject, "outbox", run)
6431
+ },
6432
+ {
6433
+ id: "fabric.store.event-append-outbox-after-plain.v1",
6434
+ run: (subject) => probe(subject, "after-plain", run)
6435
+ },
6436
+ {
6437
+ id: "fabric.store.event-append-transaction-first-write-wins.v1",
6438
+ run: (subject) => probe(subject, "transaction", run)
6439
+ },
6440
+ {
6441
+ id: "fabric.store.event-append-transaction-outbox-first-write-wins.v1",
6442
+ run: (subject) => probe(subject, "transaction-outbox", run)
6443
+ },
6444
+ {
6445
+ id: "fabric.store.event-append-transaction-outbox-after-plain.v1",
6446
+ run: (subject) => probe(subject, "transaction-after-plain", run)
6447
+ }
6448
+ ];
6449
+ }
6450
+ /**
6451
+ * Run eventAppendChecks and collect results instead of stopping at the first
6452
+ * failure. Use test or staging stores with relays paused for the probe tenant;
6453
+ * see eventAppendChecks for probe publication markers and cleanup side effects.
6454
+ */
6455
+ async function runEventAppendChecks(subject) {
6456
+ const checks = [];
6457
+ for (const check of checksForRun({ filterViolation: false })) try {
6458
+ const evidence = await check.run(subject);
6459
+ checks.push({
6460
+ id: check.id,
6461
+ status: evidence[0]?.startsWith("skipped: ") ? "skipped" : "passed",
6462
+ evidence
6463
+ });
6464
+ } catch (error) {
6465
+ checks.push({
6466
+ id: check.id,
6467
+ status: "failed",
6468
+ evidence: [],
6469
+ error: message(error)
6470
+ });
6471
+ }
6472
+ return {
6473
+ passed: checks.every((check) => check.status !== "failed"),
6474
+ checks
6475
+ };
6476
+ }
6477
+ //#endregion
6478
+ export { ActionAdmissionError, ExternalCompletionError, IdempotencyConflictError, MemoryPlatformHostStore, PARAMETER_DIGEST_ALGORITHM, PLATFORM_HOST_CONTRACT_VERSION, PLATFORM_HOST_HEALTH_CONTRACT_VERSION, PLATFORM_HOST_METRIC_NAMES, PostgresPlatformHostStore, applyPostgresMigrations, canonicalJson, cloneOutboxRecord, createDurableSagaParentLifecycle, createGovernedActionHost, createStoreBackedActionDispatcher, digestParameters, emitPlatformHostTelemetry, eventAppendChecks, getPlatformHostHealthSnapshot, outboxActorDisclosure, runEventAppendChecks, runOutboxRelayCycle, runPlatformActionWorker, runPlatformActionWorkerCycle, runTenantIsolationChecks, tenantIsolationChecks, toEnterpriseEventEnvelope };
5939
6479
 
5940
6480
  //# sourceMappingURL=index.js.map