@openlup/platform 0.22.0 → 0.23.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 +222 -99
- package/README.md +59 -589
- package/dist/fulfillment/adapterConformance.js +1 -1
- package/dist/fulfillment/bindings.js +1 -1
- package/dist/fulfillment/contracts/conformance.d.ts +3 -1
- package/dist/fulfillment/contracts/dispatch.d.ts +41 -3
- package/dist/fulfillment/contracts/index.d.ts +3 -3
- package/dist/fulfillment/contracts/observation.d.ts +21 -1
- package/dist/fulfillment/contracts/readback.d.ts +21 -0
- package/dist/fulfillment/contracts/store.d.ts +3 -0
- package/dist/fulfillment/contracts/sync.d.ts +58 -2
- package/dist/fulfillment/dispatchProjection.d.ts +20 -1
- package/dist/fulfillment/dispatchProjection.js +95 -6
- package/dist/fulfillment/index.d.ts +3 -2
- package/dist/fulfillment/index.js +2 -2
- package/dist/fulfillment/migrations.d.ts +5 -0
- package/dist/fulfillment/migrations.js +1 -0
- package/dist/fulfillment/observationProjection.d.ts +24 -3
- package/dist/fulfillment/observationProjection.js +65 -12
- package/dist/fulfillment/omnipack/client.d.ts +3 -1
- package/dist/fulfillment/omnipack/client.js +17 -7
- package/dist/fulfillment/omnipack/index.d.ts +4 -1
- package/dist/fulfillment/omnipack/index.js +104 -45
- package/dist/fulfillment/omnipack/mappers.d.ts +7 -1
- package/dist/fulfillment/omnipack/mappers.js +24 -2
- package/dist/fulfillment/omnipack/observation.d.ts +11 -4
- package/dist/fulfillment/omnipack/observation.js +44 -29
- package/dist/fulfillment/omnipack/reconciliation.d.ts +15 -0
- package/dist/fulfillment/omnipack/reconciliation.js +55 -0
- package/dist/fulfillment/postgresDispatchStore.js +1 -1
- package/dist/fulfillment/postgresShipmentSpine.d.ts +8 -1
- package/dist/fulfillment/postgresShipmentSpine.js +17 -3
- package/dist/fulfillment/postgresStockSyncPort.js +6 -3
- package/dist/fulfillment/providerDispatchAcceptance.d.ts +1 -0
- package/dist/fulfillment/providerDispatchAcceptance.js +8 -2
- package/dist/fulfillment/providerDispatchWorker.d.ts +13 -0
- package/dist/fulfillment/providerDispatchWorker.js +47 -20
- package/dist/fulfillment/providerReconciliationWorker.d.ts +27 -19
- package/dist/fulfillment/providerReconciliationWorker.js +75 -19
- package/dist/fulfillment/providerStockSyncWorker.d.ts +5 -1
- package/dist/fulfillment/providerStockSyncWorker.js +229 -120
- package/dist/fulfillment/providerTrackingReferences.d.ts +3 -2
- package/dist/fulfillment/verifiedIngress.d.ts +38 -11
- package/dist/fulfillment/verifiedIngress.js +36 -8
- package/dist/invoicing/capabilities.d.ts +11 -0
- package/dist/invoicing/capabilities.js +10 -0
- package/dist/invoicing/contracts/capabilities.d.ts +40 -0
- package/dist/invoicing/contracts/capabilities.js +1 -0
- package/dist/invoicing/contracts/companyLookup.d.ts +49 -0
- package/dist/invoicing/contracts/companyLookup.js +1 -0
- package/dist/invoicing/contracts/delivery.d.ts +74 -0
- package/dist/invoicing/contracts/delivery.js +1 -0
- package/dist/invoicing/contracts/facts.d.ts +100 -0
- package/dist/invoicing/contracts/facts.js +1 -0
- package/dist/invoicing/contracts/host.d.ts +246 -0
- package/dist/invoicing/contracts/host.js +1 -0
- package/dist/invoicing/contracts/index.d.ts +16 -0
- package/dist/invoicing/contracts/index.js +1 -0
- package/dist/invoicing/contracts/provider.d.ts +126 -0
- package/dist/invoicing/contracts/provider.js +1 -0
- package/dist/invoicing/contracts/settlement.d.ts +66 -0
- package/dist/invoicing/contracts/settlement.js +1 -0
- package/dist/invoicing/contracts/store.d.ts +267 -0
- package/dist/invoicing/contracts/store.js +1 -0
- package/dist/invoicing/index.d.ts +1 -0
- package/dist/invoicing/index.js +1 -0
- package/dist/subscriptions/automaticRenewal.d.ts +44 -0
- package/dist/subscriptions/automaticRenewal.js +81 -0
- package/dist/subscriptions/automaticRenewalExecution.d.ts +21 -0
- package/dist/subscriptions/automaticRenewalExecution.js +44 -0
- package/dist/subscriptions/contracts/commands.d.ts +115 -0
- package/dist/subscriptions/contracts/commands.js +1 -0
- package/dist/subscriptions/contracts/dunning.d.ts +133 -0
- package/dist/subscriptions/contracts/dunning.js +1 -0
- package/dist/subscriptions/contracts/index.d.ts +6 -0
- package/dist/subscriptions/contracts/index.js +1 -0
- package/dist/subscriptions/contracts/messages.d.ts +43 -0
- package/dist/subscriptions/contracts/messages.js +1 -0
- package/dist/subscriptions/contracts/renewal.d.ts +533 -0
- package/dist/subscriptions/contracts/renewal.js +1 -0
- package/dist/subscriptions/contracts/stockWait.d.ts +73 -0
- package/dist/subscriptions/contracts/stockWait.js +1 -0
- package/dist/subscriptions/index.d.ts +39 -0
- package/dist/subscriptions/index.js +17 -0
- package/dist/subscriptions/paymentOutcomes.d.ts +8 -0
- package/dist/subscriptions/paymentOutcomes.js +27 -0
- package/dist/subscriptions/postgresRenewalStore.d.ts +20 -0
- package/dist/subscriptions/postgresRenewalStore.js +61 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +78 -0
- package/dist/subscriptions/postgresRetryStore.js +142 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.d.ts +34 -0
- package/dist/subscriptions/renewalCycleFailureMechanism.js +79 -0
- package/dist/subscriptions/renewalCycleMechanism.d.ts +46 -0
- package/dist/subscriptions/renewalCycleMechanism.js +157 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.d.ts +41 -0
- package/dist/subscriptions/renewalCycleRefusalMechanism.js +160 -0
- package/dist/subscriptions/renewalIdentity.d.ts +3 -0
- package/dist/subscriptions/renewalIdentity.js +4 -0
- package/package.json +22 -2
- package/release-gates.json +56 -3
- package/sql/20261022160000_subscription_retry_lifecycle.sql +328 -0
- package/sql/20261022165000_fulfillment_stock_evidence_replay_reopen.sql +30 -0
- package/src/fulfillment/adapterConformance.ts +1 -1
- package/src/fulfillment/bindings.ts +1 -1
- package/src/fulfillment/contracts/conformance.ts +3 -1
- package/src/fulfillment/contracts/dispatch.ts +20 -5
- package/src/fulfillment/contracts/index.ts +3 -3
- package/src/fulfillment/contracts/observation.ts +11 -3
- package/src/fulfillment/contracts/readback.ts +13 -0
- package/src/fulfillment/contracts/store.ts +1 -0
- package/src/fulfillment/contracts/sync.ts +13 -2
- package/src/fulfillment/dispatchProjection.ts +92 -7
- package/src/fulfillment/index.ts +3 -2
- package/src/fulfillment/migrations.ts +1 -0
- package/src/fulfillment/observationProjection.ts +64 -13
- package/src/fulfillment/omnipack/client.ts +19 -7
- package/src/fulfillment/omnipack/index.ts +96 -39
- package/src/fulfillment/omnipack/mappers.ts +23 -2
- package/src/fulfillment/omnipack/observation.ts +45 -27
- package/src/fulfillment/omnipack/reconciliation.ts +56 -0
- package/src/fulfillment/postgresDispatchStore.ts +1 -1
- package/src/fulfillment/postgresShipmentSpine.ts +21 -4
- package/src/fulfillment/postgresStockSyncPort.ts +6 -3
- package/src/fulfillment/providerDispatchAcceptance.ts +9 -3
- package/src/fulfillment/providerDispatchWorker.ts +48 -20
- package/src/fulfillment/providerReconciliationWorker.ts +81 -25
- package/src/fulfillment/providerStockSyncWorker.ts +222 -126
- package/src/fulfillment/providerTrackingReferences.ts +3 -2
- package/src/fulfillment/verifiedIngress.ts +44 -11
- package/src/invoicing/capabilities.ts +9 -0
- package/src/invoicing/contracts/capabilities.ts +15 -0
- package/src/invoicing/contracts/companyLookup.ts +13 -0
- package/src/invoicing/contracts/delivery.ts +24 -0
- package/src/invoicing/contracts/facts.ts +60 -0
- package/src/invoicing/contracts/host.ts +85 -0
- package/src/invoicing/contracts/index.ts +16 -0
- package/src/invoicing/contracts/provider.ts +78 -0
- package/src/invoicing/contracts/settlement.ts +39 -0
- package/src/invoicing/contracts/store.ts +98 -0
- package/src/invoicing/index.ts +1 -0
- package/src/subscriptions/automaticRenewal.ts +87 -0
- package/src/subscriptions/automaticRenewalExecution.ts +39 -0
- package/src/subscriptions/contracts/commands.ts +46 -0
- package/src/subscriptions/contracts/dunning.ts +48 -0
- package/src/subscriptions/contracts/index.ts +6 -0
- package/src/subscriptions/contracts/messages.ts +18 -0
- package/src/subscriptions/contracts/renewal.ts +325 -0
- package/src/subscriptions/contracts/stockWait.ts +34 -0
- package/src/subscriptions/index.ts +24 -0
- package/src/subscriptions/paymentOutcomes.ts +23 -0
- package/src/subscriptions/postgresRenewalStore.ts +76 -0
- package/src/subscriptions/postgresRetryStore.ts +174 -0
- package/src/subscriptions/renewalCycleFailureMechanism.ts +119 -0
- package/src/subscriptions/renewalCycleMechanism.ts +225 -0
- package/src/subscriptions/renewalCycleRefusalMechanism.ts +215 -0
- package/src/subscriptions/renewalIdentity.ts +5 -0
|
@@ -1,16 +1,22 @@
|
|
|
1
|
+
import { admittedReference, reference } from "../dispatchProjection.js";
|
|
2
|
+
import { projectObservation, publicTrackingUrl } from "../observationProjection.js";
|
|
3
|
+
export { reference } from "../dispatchProjection.js";
|
|
1
4
|
import { createHash } from "node:crypto";
|
|
2
5
|
import { parseNormalizedFulfillmentFact } from "@openlup/core/fulfillment";
|
|
3
|
-
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentRefusal } from "../contracts/index.js";
|
|
6
|
+
import type { CanonicalLocalFulfillmentStatus, FulfillmentObservation, FulfillmentObservationCodecs, FulfillmentRefusal } from "../contracts/index.js";
|
|
4
7
|
import type { OmnipackWebhookEvidence } from "./mappers.js";
|
|
5
8
|
import type { OmnipackTrackingReferenceEvidence } from "./trackingReferences.js";
|
|
6
|
-
import { EVENT_TO_STATUS, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
9
|
+
import { buildProviderEventId, EVENT_TO_STATUS, type OmnipackWebhookRouteEvent } from "./omnipackWebhookPayload.js";
|
|
7
10
|
import { localStatusFor } from "./omnipackStatusVocabulary.js";
|
|
8
11
|
|
|
9
12
|
export const refusal = (reason: FulfillmentRefusal["reason"], phase: FulfillmentRefusal["phase"], retryable = false): FulfillmentRefusal => ({ reason, phase, retryable });
|
|
10
13
|
export const digest = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
|
|
11
|
-
/**
|
|
12
|
-
export function
|
|
13
|
-
return
|
|
14
|
+
/** Share admission with the durable sink, including explicit deny allowlists. */
|
|
15
|
+
export function correlationReference(value: string | null | undefined, codecs: OmnipackObservationCodecs = {}): string | null {
|
|
16
|
+
return admittedReference(value, codecs.approveReference);
|
|
17
|
+
}
|
|
18
|
+
export interface OmnipackObservationCodecs extends FulfillmentObservationCodecs {
|
|
19
|
+
normalizeCarrier?(input: { carrierRef: string | null; serviceRef: string | null }): string | null;
|
|
14
20
|
}
|
|
15
21
|
export function timestamp(value: string | null): string | null {
|
|
16
22
|
return value && /^\d{4}-\d\d-\d\dT/u.test(value) && Number.isFinite(Date.parse(value)) ? new Date(value).toISOString() : null;
|
|
@@ -18,44 +24,56 @@ export function timestamp(value: string | null): string | null {
|
|
|
18
24
|
export function eventKind(value: string): OmnipackWebhookRouteEvent | null {
|
|
19
25
|
return Object.hasOwn(EVENT_TO_STATUS, value) ? value as OmnipackWebhookRouteEvent : null;
|
|
20
26
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
27
|
+
function tracking(refs: OmnipackTrackingReferenceEvidence[], codecs: OmnipackObservationCodecs, approve: (url: string) => boolean) {
|
|
28
|
+
let rejectedTrackingRefs = 0, rejectedServices = 0, rejectedTrackingUrls = 0;
|
|
29
|
+
const trackingRefs: FulfillmentObservation["trackingRefs"][number][] = [];
|
|
30
|
+
for (const item of refs) {
|
|
31
|
+
if (item.service !== null && !correlationReference(item.service, codecs)) rejectedServices++;
|
|
32
|
+
if (item.trackingUrl !== null && !publicTrackingUrl(item.trackingUrl, approve, codecs.trackingUrl)) rejectedTrackingUrls++;
|
|
33
|
+
const trackingNumber = correlationReference(item.trackingNumber, codecs);
|
|
34
|
+
if (!trackingNumber) { rejectedTrackingRefs++; continue; }
|
|
35
|
+
// Service admission precedes any derivation. A refused service cannot leak a prefix.
|
|
36
|
+
const serviceRef = correlationReference(item.service, codecs);
|
|
37
|
+
const carrierRef = item.service !== null && !serviceRef ? null : reference(codecs.normalizeCarrier
|
|
38
|
+
? codecs.normalizeCarrier({ carrierRef: item.carrier, serviceRef })
|
|
39
|
+
: serviceRef?.split("_")[0]?.toLowerCase());
|
|
40
|
+
trackingRefs.push({ trackingNumber, carrierRef, serviceRef: item.service, publicUrl: item.trackingUrl });
|
|
41
|
+
}
|
|
42
|
+
return { trackingRefs, rejectedTrackingRefs, rejectedServices, rejectedTrackingUrls };
|
|
34
43
|
}
|
|
35
44
|
export function observation(input: {
|
|
36
45
|
evidence: OmnipackWebhookEvidence; event: FulfillmentObservation["eventKind"]; status?: CanonicalLocalFulfillmentStatus;
|
|
37
46
|
receivedAt: string; rawDigest: string; evidenceRef: `evidence:${string}`;
|
|
38
47
|
source: "provider_webhook" | "provider_reconciliation"; fulfillmentRef?: string;
|
|
39
48
|
approveTrackingUrl: (url: string) => boolean;
|
|
49
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
40
50
|
}): FulfillmentObservation {
|
|
41
51
|
const evidence = input.evidence;
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
52
|
+
const codecs = input.observationCodecs ?? {};
|
|
53
|
+
const externalRef = correlationReference(evidence.providerOrderId, codecs);
|
|
54
|
+
const providerFulfillmentRef = correlationReference(evidence.fulfilmentNumber, codecs);
|
|
55
|
+
const orderNumber = correlationReference(evidence.orderNumber, codecs);
|
|
45
56
|
const fulfillmentRef = reference(input.fulfillmentRef) ?? providerFulfillmentRef ?? externalRef ?? orderNumber;
|
|
46
|
-
if (!fulfillmentRef) throw refusal("missing_correlation", "normalize");
|
|
57
|
+
if (!fulfillmentRef || input.source === "provider_webhook" && [evidence.providerOrderId, evidence.orderNumber, evidence.fulfilmentNumber].some(value => value !== null && !correlationReference(value, codecs))) throw refusal("missing_correlation", "normalize");
|
|
47
58
|
const occurredAt = timestamp(evidence.occurredAt);
|
|
48
59
|
if (evidence.occurredAt && !occurredAt) throw refusal("malformed", "normalize");
|
|
49
|
-
|
|
60
|
+
// Compose the key with the provider's
|
|
61
|
+
// original bytes, including minute precision and offsets, rather than the UTC fact.
|
|
62
|
+
const key = buildProviderEventId(evidence);
|
|
63
|
+
const normalizedTracking = tracking(evidence.trackingReferences, codecs, input.approveTrackingUrl);
|
|
64
|
+
const rejectedTrackingRefs = normalizedTracking.rejectedTrackingRefs + (evidence.rejectedTrackingRefs ?? 0)
|
|
65
|
+
+ (evidence.rejectedServices ?? normalizedTracking.rejectedServices);
|
|
50
66
|
const status = input.status ?? localStatusFor(EVENT_TO_STATUS[input.event as OmnipackWebhookRouteEvent]);
|
|
51
67
|
const fact = parseNormalizedFulfillmentFact({
|
|
52
|
-
schemaVersion: 1, mapperVersion: "1", factId: key, providerKind: "omnipack", fulfillmentReference: fulfillmentRef,
|
|
68
|
+
schemaVersion: 1, mapperVersion: "1", factId: digest(key), providerKind: "omnipack", fulfillmentReference: reference(fulfillmentRef) ?? digest(fulfillmentRef),
|
|
53
69
|
canonicalStatus: status, provenance: { source: input.source, authority: "provider_asserted" },
|
|
54
70
|
occurredAt, receivedAt: input.receivedAt,
|
|
55
71
|
rawEvent: { reference: input.evidenceRef, digestAlgorithm: "sha256", digest: input.rawDigest },
|
|
56
72
|
}, { isCanonicalStatus: (value): value is CanonicalLocalFulfillmentStatus =>
|
|
57
73
|
["provider_received", "picking", "packed", "in_transit", "delivered", "exception", "cancelled"].includes(value) });
|
|
58
|
-
return { fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
|
|
59
|
-
correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef, orderNumber, providerFulfillmentRef, parcelRef: null },
|
|
60
|
-
trackingRefs:
|
|
74
|
+
return projectObservation({ fact, eventKind: input.event, eventKey: key, firstReceivedAt: input.receivedAt,
|
|
75
|
+
correlation: { orderRef: null, fulfillmentRef: reference(input.fulfillmentRef), externalRef: evidence.providerOrderId, orderNumber: evidence.orderNumber, providerFulfillmentRef: evidence.fulfilmentNumber, parcelRef: null },
|
|
76
|
+
trackingRefs: normalizedTracking.trackingRefs, diagnostics: { admission: {
|
|
77
|
+
rejectedTrackingRefs, rejectedTrackingUrls: evidence.rejectedTrackingUrls ?? normalizedTracking.rejectedTrackingUrls,
|
|
78
|
+
} } }, input.approveTrackingUrl, codecs);
|
|
61
79
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FulfillmentObservationPagePort, FulfillmentReconciliationObservation } from "../contracts/index.js";
|
|
2
|
+
import { operationError } from "../dispatchProjection.js";
|
|
3
|
+
import { projectPollEvidence, projectObservation } from "../observationProjection.js";
|
|
4
|
+
import { normalizeReconciliationTimestamp } from "../providerReconciliationTimestamp.js";
|
|
5
|
+
import type { OmnipackClient } from "./client.js";
|
|
6
|
+
import { OmnipackProviderError } from "./client.js";
|
|
7
|
+
import type { OmnipackFulfilmentEvidence } from "./mappers.js";
|
|
8
|
+
import { digest, observation, reference, refusal, timestamp, type OmnipackObservationCodecs } from "./observation.js";
|
|
9
|
+
import { localStatusFor, normalizeStatus } from "./omnipackStatusVocabulary.js";
|
|
10
|
+
|
|
11
|
+
type Config = {
|
|
12
|
+
observationCodecs?: OmnipackObservationCodecs;
|
|
13
|
+
clock(): string; mintEvidenceReference(): `evidence:${string}`; approveTrackingUrl(url: string): boolean;
|
|
14
|
+
};
|
|
15
|
+
export function reconciliationObservation(row: OmnipackFulfilmentEvidence, config: Config, fulfillmentRef?: string): Extract<FulfillmentReconciliationObservation, { kind: "found" | "unmapped" }> {
|
|
16
|
+
const providerStatus = { primary: row.status, subStatus: row.subStatus };
|
|
17
|
+
// Original status and occurrence bytes remain part of identity, even for aliases
|
|
18
|
+
// mapping to the same canonical event. Received time never changes that identity.
|
|
19
|
+
const eventKey = digest(JSON.stringify([row.providerOrderId, row.fulfilmentNumber, row.orderNumber, row.status, row.subStatus, row.updatedAt]));
|
|
20
|
+
const correlation = { orderRef: null, fulfillmentRef: fulfillmentRef ?? null, externalRef: reference(row.providerOrderId),
|
|
21
|
+
orderNumber: reference(row.orderNumber), providerFulfillmentRef: reference(row.fulfilmentNumber), parcelRef: null };
|
|
22
|
+
const normalized = normalizeStatus(row.status);
|
|
23
|
+
if (!normalized) return { kind: "unmapped", externalRef: correlation.externalRef, evidence: projectPollEvidence({ eventKey, correlation: { ...correlation, externalRef: row.providerOrderId, orderNumber: row.orderNumber, providerFulfillmentRef: row.fulfilmentNumber }, providerStatus }) };
|
|
24
|
+
const status = localStatusFor(normalized) as "provider_received" | "picking" | "packed" | "in_transit" | "delivered" | "exception";
|
|
25
|
+
const event = ({ provider_received: "shipment.accepted", picking: "order.processing_started", packed: "order.picked", in_transit: "order.shipped", delivered: "order.delivered", exception: "parcel.exception" } as const)[status];
|
|
26
|
+
const occurredAt = normalizeReconciliationTimestamp(row.updatedAt);
|
|
27
|
+
const evidence = { provider: "omnipack" as const, event, providerOrderId: row.providerOrderId, orderNumber: row.orderNumber,
|
|
28
|
+
fulfilmentNumber: row.fulfilmentNumber, occurredAt, trackingNumbers: row.trackingNumbers,
|
|
29
|
+
trackingReferences: row.trackingReferences, shippingMethods: [] };
|
|
30
|
+
const mapped = observation({ evidence, event, status, fulfillmentRef, receivedAt: config.clock(),
|
|
31
|
+
rawDigest: digest(JSON.stringify({ ...evidence, providerStatus })), evidenceRef: config.mintEvidenceReference(), source: "provider_reconciliation", approveTrackingUrl: config.approveTrackingUrl, observationCodecs: config.observationCodecs });
|
|
32
|
+
return { kind: "found", externalRef: row.providerOrderId ?? "", createdAt: timestamp(row.createdAt), updatedAt: timestamp(occurredAt),
|
|
33
|
+
observation: projectObservation({ ...mapped, eventKey, fact: { ...mapped.fact, factId: eventKey }, providerStatus }, config.approveTrackingUrl, config.observationCodecs) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createReconciliationPagePort(client: OmnipackClient, config: Config, ready: () => ReturnType<typeof refusal> | null): FulfillmentObservationPagePort {
|
|
37
|
+
return {
|
|
38
|
+
async readPage(input) {
|
|
39
|
+
const inactive = ready();
|
|
40
|
+
if (inactive) return { kind: "failed", refusal: inactive };
|
|
41
|
+
if (!Number.isSafeInteger(input.page) || input.page < 0 || !Number.isSafeInteger(input.size) || input.size <= 0)
|
|
42
|
+
return { kind: "failed", refusal: refusal("malformed", "readback") };
|
|
43
|
+
try {
|
|
44
|
+
const rows = await client.getFulfilments(input);
|
|
45
|
+
return { kind: "page", rows: rows.map(row => {
|
|
46
|
+
try { return reconciliationObservation(row, config); }
|
|
47
|
+
catch { return { kind: "unknown" as const, refusal: refusal("malformed", "normalize") }; }
|
|
48
|
+
}) };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error instanceof OmnipackProviderError && error.status === 400 && error.code === "ERR_PAGE_MUST_BE_LESS_THAN_TOTAL_PAGES") return { kind: "page_exhausted" };
|
|
51
|
+
return { kind: "failed", refusal: refusal("io_failure", "readback", error instanceof OmnipackProviderError && error.retryable),
|
|
52
|
+
error: operationError(error instanceof OmnipackProviderError ? error : { code: "provider_read_failed", status: null, retryable: false, mayHaveSucceeded: false }) };
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -21,7 +21,7 @@ export function createPostgresFulfillmentDispatchStore(db: PgExecutor, host: {
|
|
|
21
21
|
readCandidate: ref => host.readCandidate(ref),
|
|
22
22
|
prepare: ref => host.prepare(ref),
|
|
23
23
|
async read(key) {
|
|
24
|
-
const result = await db.query(
|
|
24
|
+
const result = await db.query("SELECT public.fulfillment_dispatch_read(ref) || jsonb_build_object('acceptance',ref.acceptance) AS response FROM public.fulfillment_dispatch_refs ref WHERE request_key=$1", [key]);
|
|
25
25
|
return result.rows[0]?.response as DispatchRecord | undefined ?? null;
|
|
26
26
|
},
|
|
27
27
|
claim: input => nativeCall(db, 'fulfillment_dispatch_claim', [input.requestKey, input.fulfillmentRef, input.orderRef, input.parcelRef, input.mode, JSON.stringify(dispatchEvidence(input.evidence))]),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FulfillmentObservationCodecs } from "./contracts/index.js";
|
|
2
|
+
import { safeDiagnosticCode } from "./dispatchProjection.js";
|
|
1
3
|
import { nativeCall } from './postgresDispatchStore.js';
|
|
2
4
|
import { projectObservation } from './observationProjection.js';
|
|
3
5
|
import type { PgExecutor as PgQueryExecutor } from './postgresDispatchStore.js';
|
|
@@ -64,6 +66,8 @@ const RAISE_EXCEPTION_SQL = `
|
|
|
64
66
|
|
|
65
67
|
export interface PostgresFulfillmentShipmentSpineOptions {
|
|
66
68
|
portKey: string;
|
|
69
|
+
approveTrackingUrl?(url: string): boolean;
|
|
70
|
+
observationCodecs?: FulfillmentObservationCodecs;
|
|
67
71
|
evidenceChannel?: "poll" | "push";
|
|
68
72
|
requiredProviderType?: "simulator";
|
|
69
73
|
}
|
|
@@ -115,7 +119,7 @@ export function createPostgresFulfillmentShipmentSpinePort(
|
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
return {
|
|
118
|
-
localPersistence: localPersistence(executor),
|
|
122
|
+
localPersistence: localPersistence(executor, options.approveTrackingUrl, options.observationCodecs),
|
|
119
123
|
createShipment(request) {
|
|
120
124
|
return callCreate(executor, portKey, [
|
|
121
125
|
request.idempotencyKey,
|
|
@@ -296,7 +300,7 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
/** G17 native local persistence, composed on the same injected executor. */
|
|
299
|
-
function localPersistence(executor: PgQueryExecutor) {
|
|
303
|
+
function localPersistence(executor: PgQueryExecutor, approveTrackingUrl?: (url: string) => boolean, observationCodecs?: FulfillmentObservationCodecs) {
|
|
300
304
|
return {
|
|
301
305
|
async readLocalFulfillmentState(ref: string) {
|
|
302
306
|
const result = await executor.query(`SELECT shipment.status,shipment.handed_over_at,
|
|
@@ -305,15 +309,28 @@ function localPersistence(executor: PgQueryExecutor) {
|
|
|
305
309
|
const row = result.rows[0];
|
|
306
310
|
return { status: typeof row?.status === 'string' ? row.status : null, latestStatusOccurredAt: date(row?.latest_status_occurred_at), handedOverAt: date(row?.handed_over_at) };
|
|
307
311
|
},
|
|
312
|
+
async readLocalFulfillmentStatus(ref: string) {
|
|
313
|
+
const result = await executor.query('SELECT status FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
|
|
314
|
+
return typeof result.rows[0]?.status === 'string' ? result.rows[0].status : null;
|
|
315
|
+
},
|
|
316
|
+
async latestStatusOccurredAt(ref: string) {
|
|
317
|
+
const result = await executor.query("SELECT max((observation#>>'{fact,occurredAt}')::timestamptz) AS latest_status_occurred_at FROM public.fulfillment_observations WHERE shipment_id=$1::uuid AND status_recorded", [ref]);
|
|
318
|
+
return date(result.rows[0]?.latest_status_occurred_at);
|
|
319
|
+
},
|
|
320
|
+
async readHandedOverAt(ref: string) {
|
|
321
|
+
const result = await executor.query('SELECT handed_over_at FROM public.fulfillment_shipments WHERE id=$1::uuid', [ref]);
|
|
322
|
+
return date(result.rows[0]?.handed_over_at);
|
|
323
|
+
},
|
|
308
324
|
async recordObservation(observation: import('./contracts/index.js').FulfillmentObservation) {
|
|
309
|
-
const safe = projectObservation(observation);
|
|
325
|
+
const safe = projectObservation(observation, approveTrackingUrl, observationCodecs);
|
|
310
326
|
return nativeCall<{ replayed: boolean }>(executor,'fulfillment_record_observation',[safe.eventKey,safe.correlation.fulfillmentRef,JSON.stringify(safe),safe.firstReceivedAt]);
|
|
311
327
|
},
|
|
312
328
|
async recordStatusEvidence(ref: { fulfillmentRef: string }, observation: import('./contracts/index.js').FulfillmentObservation) {
|
|
313
329
|
return nativeCall<{ replayed: boolean }>(executor,'fulfillment_record_status_evidence',[observation.eventKey,ref.fulfillmentRef]);
|
|
314
330
|
},
|
|
315
|
-
async markProcessed(eventKey: string, outcome: { status: 'processed' | 'ignored'; refusal?: import('./contracts/index.js').FulfillmentRefusal }) {
|
|
331
|
+
async markProcessed(eventKey: string, outcome: { status: 'processed' | 'ignored'; refusal?: import('./contracts/index.js').FulfillmentRefusal; supersededCause?: string }) {
|
|
316
332
|
await nativeCall(executor,'fulfillment_mark_observation_processed',[eventKey,JSON.stringify({ status: outcome.status,
|
|
333
|
+
...(outcome.supersededCause ? { supersededCause: safeDiagnosticCode(outcome.supersededCause) ?? 'fulfillment_state_conflict' } : {}),
|
|
317
334
|
...(outcome.refusal ? { refusal: { reason: outcome.refusal.reason,phase: outcome.refusal.phase,retryable: outcome.refusal.retryable } } : {}) })]);
|
|
318
335
|
},
|
|
319
336
|
};
|
|
@@ -17,14 +17,14 @@ export function createPostgresFulfillmentStockSyncPort(db: PgExecutor, options:
|
|
|
17
17
|
const row = result.rows[0];
|
|
18
18
|
if (!row) return null;
|
|
19
19
|
const cursor = row.cursor as Partial<SyncCursor>;
|
|
20
|
-
return { status: row.status as SyncCursor['status'], lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
|
|
20
|
+
return { ...cursor, status: row.status as SyncCursor['status'], lastStockSyncedAt: timestamp(row.last_stock_synced_at), lastMovementOccurredAt: timestamp(row.last_movement_occurred_at),
|
|
21
21
|
continuation: cursor.continuation ?? { nextRef: null, startLocalDate: null, endLocalDate: null }, coverageGapSkus: cursor.coverageGapSkus ?? [], refusal: cursor.refusal ?? null };
|
|
22
22
|
},
|
|
23
23
|
async recordCursor(v) {
|
|
24
24
|
await db.query(`INSERT INTO public.fulfillment_stock_sync_cursors(source_key,status,last_stock_synced_at,last_movement_occurred_at,cursor,error_code,updated_at)
|
|
25
25
|
VALUES($1,$2,$3,$4,$5::jsonb,$6,now()) ON CONFLICT(source_key) DO UPDATE SET status=EXCLUDED.status,
|
|
26
26
|
last_stock_synced_at=EXCLUDED.last_stock_synced_at,last_movement_occurred_at=EXCLUDED.last_movement_occurred_at,cursor=EXCLUDED.cursor,error_code=EXCLUDED.error_code,updated_at=now()`,
|
|
27
|
-
[source,v.status,v.lastStockSyncedAt,v.lastMovementOccurredAt,JSON.stringify({ continuation: { nextRef: v.continuation.nextRef, startLocalDate: v.continuation.startLocalDate, endLocalDate: v.continuation.endLocalDate }, coverageGapSkus: [...(v.coverageGapSkus ?? [])], refusal: v.refusal }),v.refusal?.reason ?? null]);
|
|
27
|
+
[source,v.status,v.lastStockSyncedAt,v.lastMovementOccurredAt,JSON.stringify({ syncRunId: v.syncRunId, counts: v.counts, items: v.items, alerts: v.alerts, staleSync: v.staleSync, error: v.error, continuation: { nextRef: v.continuation.nextRef, startLocalDate: v.continuation.startLocalDate, endLocalDate: v.continuation.endLocalDate }, coverageGapSkus: [...(v.coverageGapSkus ?? [])], refusal: v.refusal }),v.error?.reason ?? v.refusal?.reason ?? null]);
|
|
28
28
|
},
|
|
29
29
|
async readSkuInventoryClasses(skus) {
|
|
30
30
|
if (!skus.length) return new Map();
|
|
@@ -52,7 +52,10 @@ export function createPostgresFulfillmentStockSyncPort(db: PgExecutor, options:
|
|
|
52
52
|
const movement = { providerRef, syncRunId, collectedAt, sourceRevision, revisionSource, skuRef, quantityKind, quantity, occurredAt, operationType, documentRef, lotRef, expirationDate };
|
|
53
53
|
return nativeCall(db,'fulfillment_record_stock_movement',[source,v.requestKey,fingerprint({ requestKey: v.requestKey, movement }),JSON.stringify(movement)]);
|
|
54
54
|
},
|
|
55
|
-
recordLowStockEvidence: v => nativeCall(db,'fulfillment_record_stock_evidence',[source,v.requestKey,
|
|
55
|
+
recordLowStockEvidence: v => nativeCall(db,'fulfillment_record_stock_evidence',[source,v.requestKey,
|
|
56
|
+
// Quantities, severity and run evidence are observations of the same alert identity.
|
|
57
|
+
fingerprint({ requestKey: v.requestKey, skuRef: v.skuRef, thresholdKind: v.thresholdKind }),
|
|
58
|
+
v.skuRef,v.thresholdKind,v.severity,v.providerForSaleQuantity,v.localAvailableQuantity]),
|
|
56
59
|
resolveLowStockEvidence: v => nativeCall(db,'fulfillment_resolve_stock_evidence',[source,v.skuRef,v.activeThresholdKinds,v.syncRunId]),
|
|
57
60
|
};
|
|
58
61
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DispatchRecord, FulfillmentDispatchResult, FulfillmentStorePort } from "./contracts/index.js";
|
|
2
|
-
import { dispatchEvidence } from "./dispatchProjection.js";
|
|
2
|
+
import { durableStrings, dispatchEvidence, dispatchResult, localFailureReason } from "./dispatchProjection.js";
|
|
3
3
|
|
|
4
4
|
export function acknowledgeAcceptedDispatch(input: {
|
|
5
5
|
store: FulfillmentStorePort;
|
|
@@ -7,22 +7,28 @@ export function acknowledgeAcceptedDispatch(input: {
|
|
|
7
7
|
keys: { providerAttemptKey: string; labelKey: string };
|
|
8
8
|
}) {
|
|
9
9
|
return input.store.acknowledge({ dispatchRef: input.record.dispatchRef, acceptance: {
|
|
10
|
+
...(input.record.acceptance ? dispatchResult(input.record.acceptance) : {}),
|
|
11
|
+
...(input.record.status === "uncertain" ? { recovery: { source: "persisted_acceptance" as const, proof: "persisted_external_ref" as const, noProviderPost: true as const } } : {}),
|
|
10
12
|
kind: "accepted", externalRef: input.record.externalRef, evidence: dispatchEvidence(input.record.evidence), ...input.keys,
|
|
11
13
|
} });
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export async function recoverDispatchAcceptanceWrite(input: {
|
|
15
17
|
store: FulfillmentStorePort; requestKey: string; dispatchRef: string;
|
|
16
|
-
acceptance: Extract<FulfillmentDispatchResult, { kind: "accepted" }>;
|
|
18
|
+
acceptance: Extract<FulfillmentDispatchResult, { kind: "accepted" }>; error: unknown;
|
|
17
19
|
}): Promise<{ committed: boolean; readBacks: number }> {
|
|
18
20
|
let record = await input.store.read(input.requestKey).catch(() => null);
|
|
19
21
|
let readBacks = record ? 1 : 0;
|
|
20
22
|
const committed = () => record?.status === "created" && record.externalRef === input.acceptance.externalRef;
|
|
21
23
|
if (committed()) return { committed: true, readBacks };
|
|
24
|
+
const privacy = durableStrings();
|
|
25
|
+
const cause = localFailureReason("ack", input.error, privacy);
|
|
22
26
|
try {
|
|
23
27
|
// Accepted proof survives a failed atomic ACK; finalization records uncertainty,
|
|
24
28
|
// and the next worker repairs the ACK locally without another provider call.
|
|
25
|
-
record = await input.store.finalize({ dispatchRef: input.dispatchRef, result: input.acceptance
|
|
29
|
+
record = await input.store.finalize({ dispatchRef: input.dispatchRef, result: { ...input.acceptance, error: {
|
|
30
|
+
code: "dispatch_acceptance_write_failed", cause, ...(privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {}), status: null, retryable: false, mayHaveSucceeded: true,
|
|
31
|
+
} } });
|
|
26
32
|
readBacks += 1;
|
|
27
33
|
} catch {
|
|
28
34
|
// Submitting remains fenced; the stale sweep moves it to uncertain.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { DispatchRecord, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentStorePort } from "./contracts/index.js";
|
|
2
|
-
import { acceptedProviderProof, dispatchEvidence, dispatchResult } from "./dispatchProjection.js";
|
|
1
|
+
import type { DispatchRecord, FulfillmentDispatchPort, FulfillmentDispatchRequest, FulfillmentDispatchResult, FulfillmentStorePort } from "./contracts/index.js";
|
|
2
|
+
import { durableStrings, acceptedProviderProof, dispatchEvidence, dispatchResult, localFailureReason, shadowDispatchEvidence } from "./dispatchProjection.js";
|
|
3
3
|
import { acknowledgeAcceptedDispatch, recoverDispatchAcceptanceWrite } from "./providerDispatchAcceptance.js";
|
|
4
4
|
import { unknownDispatchFailure } from "./providerDispatchError.js";
|
|
5
5
|
|
|
@@ -16,16 +16,20 @@ export async function runFulfillmentDispatchWorker(input: {
|
|
|
16
16
|
config: { mode: DispatchRecord["mode"]; batchLimit: number };
|
|
17
17
|
now?: () => Date;
|
|
18
18
|
}) {
|
|
19
|
+
const privacy = durableStrings();
|
|
20
|
+
const failure = (stage: string, error: unknown) => localFailureReason(stage, error, privacy);
|
|
21
|
+
const diagnostics = () => privacy.redactedFieldCount ? { redactedFieldCount: privacy.redactedFieldCount } : {};
|
|
19
22
|
const result = { ok: true, skipped: false, checked: 0, updated: 0, failures: 0,
|
|
20
23
|
mode: input.config.mode, replayed: 0, providerCalls: 0, readBacks: 0, reason: undefined as string | undefined };
|
|
21
24
|
if (input.config.mode !== "shadow") {
|
|
22
25
|
try { await input.store.markStaleSubmissionsUncertain(new Date((input.now?.() ?? new Date()).getTime() - 5 * 60_000).toISOString()); }
|
|
23
|
-
catch { return { ...result, ok: false, failures: 1, reason: "dispatch_stale_submission_sweep_failed" }; }
|
|
26
|
+
catch (error) { return { ...result, ok: false, failures: 1, reason: failure("dispatch_stale_submission_sweep_failed", error), ...diagnostics() }; }
|
|
24
27
|
}
|
|
25
28
|
const candidates = await input.listCandidates(input.config.batchLimit);
|
|
26
29
|
result.checked = candidates.length;
|
|
27
30
|
candidateLoop: for (const candidate of candidates) {
|
|
28
31
|
const requestKey = input.keys.request(candidate);
|
|
32
|
+
let stage = "dispatch_read_failed";
|
|
29
33
|
try {
|
|
30
34
|
let readBack = await input.store.read(requestKey);
|
|
31
35
|
if (readBack) result.readBacks += 1;
|
|
@@ -35,26 +39,35 @@ export async function runFulfillmentDispatchWorker(input: {
|
|
|
35
39
|
result.updated += ack.replayed ? 0 : 1;
|
|
36
40
|
continue;
|
|
37
41
|
}
|
|
42
|
+
stage = "dispatch_prepare_failed";
|
|
38
43
|
const preparation = await input.store.prepare(candidate.fulfillmentRef);
|
|
39
44
|
if (preparation.disposition === "withheld") continue;
|
|
45
|
+
stage = "dispatch_candidate_read_failed";
|
|
40
46
|
const preparedCandidate = await input.store.readCandidate(candidate.fulfillmentRef);
|
|
41
47
|
if (!preparedCandidate) throw new Error("dispatch_prepared_candidate_missing");
|
|
42
48
|
if (input.config.mode !== "shadow" && !input.provider) {
|
|
43
49
|
result.ok = false; result.failures += 1; result.reason ??= "provider_not_configured"; continue;
|
|
44
50
|
}
|
|
45
|
-
// Shadow needs command preparation too, but never calls dispatch.
|
|
46
|
-
if (!input.provider) throw new Error("provider_command_preparation_not_configured");
|
|
47
51
|
let mappedCandidate = preparedCandidate;
|
|
48
52
|
let dispatchRef: DispatchRecord;
|
|
49
53
|
let evidence: DispatchRecord["evidence"];
|
|
54
|
+
let providerEvidence: DispatchRecord["evidence"];
|
|
50
55
|
let mappedAttemptCount = 0;
|
|
51
56
|
for (let remapCount = 0; ; remapCount += 1) {
|
|
52
57
|
const request = { ...mappedCandidate, requestKey };
|
|
53
|
-
|
|
58
|
+
stage = "dispatch_command_prepare_failed";
|
|
59
|
+
const prepared = input.config.mode === "shadow"
|
|
60
|
+
? { kind: "prepared" as const, evidence: shadowDispatchEvidence(request) }
|
|
61
|
+
: await input.provider!.prepare(request);
|
|
54
62
|
if (prepared.kind === "refused") {
|
|
55
|
-
result.ok = false; result.failures += 1; result.reason ??= prepared.refusal.reason; continue candidateLoop;
|
|
63
|
+
result.ok = false; result.failures += 1; result.reason ??= privacy.token(prepared.refusal.reason) ?? "redacted"; continue candidateLoop;
|
|
56
64
|
}
|
|
57
|
-
|
|
65
|
+
providerEvidence = dispatchEvidence(prepared.evidence);
|
|
66
|
+
evidence = { ...providerEvidence, mapping: { source: "dispatch_worker", mode: input.config.mode, shadowMode: input.config.mode === "shadow" } };
|
|
67
|
+
// Native claims count any JSON change as a remap; attempts compare
|
|
68
|
+
// the whole JSON. Enrichment must not rewrite an existing identity.
|
|
69
|
+
if (readBack && sameCommandIdentity(readBack.evidence, evidence)) evidence = dispatchEvidence(readBack.evidence);
|
|
70
|
+
stage = "dispatch_claim_failed";
|
|
58
71
|
const claim = await input.store.claim({ requestKey, fulfillmentRef: mappedCandidate.fulfillmentRef,
|
|
59
72
|
orderRef: mappedCandidate.orderRef, parcelRef: mappedCandidate.parcelRef, mode: input.config.mode, evidence });
|
|
60
73
|
if (claim.kind === "refused") {
|
|
@@ -63,14 +76,16 @@ export async function runFulfillmentDispatchWorker(input: {
|
|
|
63
76
|
if (!refreshed) throw new Error("dispatch_contact_remap_candidate_missing");
|
|
64
77
|
mappedCandidate = refreshed; continue;
|
|
65
78
|
}
|
|
66
|
-
result.ok = false; result.failures += 1; result.reason ??= claim.refusal.reason; continue candidateLoop;
|
|
79
|
+
result.ok = false; result.failures += 1; result.reason ??= privacy.token(claim.refusal.reason) ?? "redacted"; continue candidateLoop;
|
|
67
80
|
}
|
|
68
81
|
dispatchRef = claim.record;
|
|
69
82
|
if (dispatchRef.replayed) result.replayed += 1;
|
|
70
83
|
// Re-claim may refresh a correctable draft atomically; readback is authoritative.
|
|
84
|
+
stage = "dispatch_readback_failed";
|
|
71
85
|
readBack = await input.store.read(requestKey);
|
|
72
86
|
if (readBack) result.readBacks += 1;
|
|
73
87
|
if (!readBack || readBack.fulfillmentRef !== mappedCandidate.fulfillmentRef) throw new Error("dispatch_readback_failed");
|
|
88
|
+
stage = "dispatch_attempt_write_failed";
|
|
74
89
|
await input.store.recordProviderAttempt(input.keys.mappedAttempt(requestKey, mappedAttemptCount),
|
|
75
90
|
mappedCandidate.fulfillmentRef, evidence, input.config.mode, "recorded");
|
|
76
91
|
mappedAttemptCount += 1;
|
|
@@ -80,6 +95,7 @@ export async function runFulfillmentDispatchWorker(input: {
|
|
|
80
95
|
if (readBack.status !== "draft") {
|
|
81
96
|
result.ok = false; result.failures += 1; result.reason ??= `dispatch_not_safe_to_submit:${readBack.status}`; continue candidateLoop;
|
|
82
97
|
}
|
|
98
|
+
stage = "dispatch_begin_failed";
|
|
83
99
|
const submission = await input.store.begin({ dispatchRef: dispatchRef.dispatchRef, commandFingerprint: evidence.commandFingerprint });
|
|
84
100
|
if (submission.kind === "refused") {
|
|
85
101
|
if (remapCount === 0 && submission.refusal.reason === "contact_stale") {
|
|
@@ -87,9 +103,10 @@ export async function runFulfillmentDispatchWorker(input: {
|
|
|
87
103
|
if (!refreshed) throw new Error("dispatch_contact_remap_candidate_missing");
|
|
88
104
|
mappedCandidate = refreshed; continue;
|
|
89
105
|
}
|
|
90
|
-
result.ok = false; result.failures += 1; result.reason ??= submission.refusal.reason; continue candidateLoop;
|
|
106
|
+
result.ok = false; result.failures += 1; result.reason ??= privacy.token(submission.refusal.reason) ?? "redacted"; continue candidateLoop;
|
|
91
107
|
}
|
|
92
108
|
if (!submission.begun) {
|
|
109
|
+
stage = "dispatch_concurrent_read_failed";
|
|
93
110
|
const concurrent = await input.store.read(requestKey);
|
|
94
111
|
if (concurrent) result.readBacks += 1;
|
|
95
112
|
if (acceptedProviderProof(concurrent)) {
|
|
@@ -103,29 +120,40 @@ export async function runFulfillmentDispatchWorker(input: {
|
|
|
103
120
|
break;
|
|
104
121
|
}
|
|
105
122
|
result.providerCalls += 1;
|
|
106
|
-
let response;
|
|
107
|
-
try { response = dispatchResult(await input.provider
|
|
123
|
+
let response: FulfillmentDispatchResult;
|
|
124
|
+
try { response = dispatchResult(await input.provider!.dispatch({ ...mappedCandidate, requestKey }, providerEvidence)); }
|
|
108
125
|
catch { response = unknownDispatchFailure(); }
|
|
126
|
+
if (response.kind === "accepted" && JSON.stringify(response.evidence) !== JSON.stringify(providerEvidence)) {
|
|
127
|
+
response = { kind: "unknown", effectMayHaveSucceeded: true, refusal: { reason: "malformed", phase: "acknowledge", retryable: false },
|
|
128
|
+
error: { code: "dispatch_acceptance_evidence_mismatch", status: null, retryable: false, mayHaveSucceeded: true } };
|
|
129
|
+
}
|
|
109
130
|
if (response.kind !== "accepted") {
|
|
131
|
+
stage = "dispatch_finalize_failed";
|
|
110
132
|
await input.store.finalize({ dispatchRef: dispatchRef.dispatchRef, result: response });
|
|
111
|
-
result.ok = false; result.failures += 1; result.reason ??= response.refusal.reason; continue;
|
|
133
|
+
result.ok = false; result.failures += 1; result.reason ??= response.error?.code ?? response.refusal.reason; continue;
|
|
112
134
|
}
|
|
113
135
|
// All ACK paths use the original host keys, ignoring adapter key fields.
|
|
114
|
-
const acceptance = { ...response, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
|
|
136
|
+
const acceptance = { ...response, evidence, ...input.keys.acceptance(mappedCandidate.fulfillmentRef) };
|
|
115
137
|
try {
|
|
116
138
|
const ack = await input.store.acknowledge({ dispatchRef: dispatchRef.dispatchRef, acceptance });
|
|
117
139
|
if (ack.replayed) result.replayed += 1;
|
|
118
|
-
} catch {
|
|
119
|
-
const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance });
|
|
140
|
+
} catch (error) {
|
|
141
|
+
const recovery = await recoverDispatchAcceptanceWrite({ store: input.store, requestKey, dispatchRef: dispatchRef.dispatchRef, acceptance, error });
|
|
120
142
|
result.readBacks += recovery.readBacks;
|
|
121
143
|
if (recovery.committed) { result.updated += 1; continue; }
|
|
122
144
|
result.ok = false; result.failures += 1; result.reason ??= "dispatch_proof_write_failed_after_provider_acceptance"; break;
|
|
123
145
|
}
|
|
124
146
|
result.updated += 1;
|
|
125
|
-
} catch {
|
|
126
|
-
|
|
127
|
-
result.ok = false; result.failures += 1; result.reason ??= "dispatch_local_operation_failed";
|
|
147
|
+
} catch (error) {
|
|
148
|
+
result.ok = false; result.failures += 1; result.reason ??= failure(stage, error);
|
|
128
149
|
}
|
|
129
150
|
}
|
|
130
|
-
return result;
|
|
151
|
+
return { ...result, ...diagnostics() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sameCommandIdentity(a: DispatchRecord["evidence"], b: DispatchRecord["evidence"]): boolean {
|
|
155
|
+
return a.commandFingerprint === b.commandFingerprint && a.providerRef === b.providerRef
|
|
156
|
+
&& a.carrierRef === b.carrierRef && a.serviceRef === b.serviceRef
|
|
157
|
+
&& a.contactRevision.schemaVersion === b.contactRevision.schemaVersion
|
|
158
|
+
&& a.contactRevision.source === b.contactRevision.source && a.contactRevision.revision === b.contactRevision.revision;
|
|
131
159
|
}
|