@fabricorg/platform-host 8.0.0 → 8.1.1

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