@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/CHANGELOG.md +67 -0
- package/README.md +95 -0
- package/dist/index.cjs +576 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +106 -0
- package/dist/index.d.ts +106 -0
- package/dist/index.js +575 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|