@tokenoftrust/storefront-runner 6.4.0 → 6.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/migrations-apps/0026_fulfillment_assignment.sql +44 -0
- package/apps/storefront/src/lib/assets/preview-pinned-image-urls.ts +29 -3
- package/apps/storefront/src/lib/checkout/commerce/cardCartForm.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +16 -0
- package/apps/storefront/src/lib/fulfillment/admin/commerceDesk.ts +21 -1
- package/apps/storefront/src/lib/fulfillment/assignmentStore.ts +284 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceRecovery.ts +18 -1
- package/apps/storefront/src/lib/fulfillment/commerceRuntime.ts +28 -0
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +429 -70
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +92 -65
- package/apps/storefront/src/lib/integrations/webhooks/commercePayloads.ts +101 -0
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +17 -34
- package/apps/storefront/src/lib/integrations/webhooks/deliveryStore.ts +130 -0
- package/apps/storefront/src/lib/integrations/webhooks/dispatcher.ts +1 -1
- package/apps/storefront/src/lib/integrations/webhooks/getDispatcher.ts +8 -3
- package/apps/storefront/src/lib/integrations/webhooks/orderFulfilledClaim.ts +75 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +65 -4
- package/apps/storefront/src/pages/products/[handle].astro +2 -0
- package/package.json +1 -1
- package/packages/public-runtime/src/checkout.ts +73 -11
- package/packages/public-runtime/src/membership.ts +3 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
-- Processor config: one row per (tenant, stage) naming the install that processes released orders.
|
|
2
|
+
-- A cleared config keeps the row with state `cleared`; rows are never deleted.
|
|
3
|
+
CREATE TABLE `fulfillment_processor_config` (
|
|
4
|
+
`tenant_id` text NOT NULL,
|
|
5
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
6
|
+
`install_id` text NOT NULL,
|
|
7
|
+
`state` text NOT NULL CHECK (`state` IN ('active', 'cleared')),
|
|
8
|
+
`updated_by` text NOT NULL,
|
|
9
|
+
`updated_at` text NOT NULL,
|
|
10
|
+
PRIMARY KEY (`tenant_id`, `stage`)
|
|
11
|
+
);
|
|
12
|
+
--> statement-breakpoint
|
|
13
|
+
-- Assignments move by state and keep history; none is deleted.
|
|
14
|
+
CREATE TABLE `fulfillment_assignments` (
|
|
15
|
+
`assignment_id` text PRIMARY KEY NOT NULL,
|
|
16
|
+
`tenant_id` text NOT NULL,
|
|
17
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
18
|
+
`order_id` text NOT NULL,
|
|
19
|
+
`install_id` text NOT NULL,
|
|
20
|
+
`state` text NOT NULL CHECK (`state` IN ('active', 'held', 'cancelled', 'released')),
|
|
21
|
+
`event_version` integer NOT NULL CHECK (`event_version` >= 0),
|
|
22
|
+
`assigned_at` text NOT NULL,
|
|
23
|
+
`updated_at` text NOT NULL
|
|
24
|
+
);
|
|
25
|
+
--> statement-breakpoint
|
|
26
|
+
CREATE UNIQUE INDEX `fulfillment_assignments_open_order`
|
|
27
|
+
ON `fulfillment_assignments` (`tenant_id`, `stage`, `order_id`)
|
|
28
|
+
WHERE `state` IN ('active', 'held');
|
|
29
|
+
--> statement-breakpoint
|
|
30
|
+
CREATE INDEX `fulfillment_assignments_install_state`
|
|
31
|
+
ON `fulfillment_assignments` (`tenant_id`, `stage`, `install_id`, `state`, `assigned_at`);
|
|
32
|
+
--> statement-breakpoint
|
|
33
|
+
-- One claim per business event across every emitter that can report it.
|
|
34
|
+
CREATE TABLE `commerce_event_claims` (
|
|
35
|
+
`tenant_id` text NOT NULL,
|
|
36
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
37
|
+
`source` text NOT NULL,
|
|
38
|
+
`source_order_id` text NOT NULL,
|
|
39
|
+
`topic` text NOT NULL CHECK (`topic` IN ('order.fulfilled')),
|
|
40
|
+
`event_id` text NOT NULL,
|
|
41
|
+
`claimed_by` text NOT NULL CHECK (`claimed_by` IN ('handoff', 'forward')),
|
|
42
|
+
`claimed_at` text NOT NULL,
|
|
43
|
+
PRIMARY KEY (`tenant_id`, `stage`, `source`, `source_order_id`, `topic`)
|
|
44
|
+
);
|
|
@@ -9,11 +9,37 @@ export async function scopePreviewPinnedImageUrls(response: Response, basePath:
|
|
|
9
9
|
return new Response(scoped, { status: response.status, statusText: response.statusText, headers });
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
const CAPTURED_ENTRY = String.raw`\/_a\/([0-9a-f]{64}(?:\.[A-Za-z0-9]+)?)`;
|
|
13
|
+
/** A URL-bearing attribute and its quoted or bare value. Form data (`value=`, `data-*`) never matches. */
|
|
14
|
+
const URL_ATTRIBUTE = /(\s(?:src|srcset|imagesrcset|xlink:href|href|poster)\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi;
|
|
15
|
+
/** A captured address that opens a URL or a srcset candidate inside an attribute value. */
|
|
16
|
+
const ATTRIBUTE_ENTRY = new RegExp(String.raw`(^["']?|[\s,])${CAPTURED_ENTRY}(?=[\s,"'?#]|$)`, "g");
|
|
17
|
+
/** A CSS `url(...)` reference, in a style element or a style attribute. */
|
|
18
|
+
const CSS_URL_ENTRY = new RegExp(String.raw`(url\(\s*["']?)${CAPTURED_ENTRY}(?=["'?#)\s])`, "gi");
|
|
19
|
+
/** A CSS `image-set(...)` / `image(...)` body, whose candidates may be bare quoted strings. */
|
|
20
|
+
const CSS_IMAGE_FUNCTION = /((?<![\w-])(?:-webkit-)?image(?:-set)?\()([^()]*(?:\([^()]*\)[^()]*)*)\)/g;
|
|
21
|
+
/** A quoted captured address inside an image function body. */
|
|
22
|
+
const QUOTED_ENTRY = new RegExp(String.raw`(["'])${CAPTURED_ENTRY}(?=["'?#])`, "g");
|
|
23
|
+
/** A social-image `<meta>` tag (og:image, twitter:image and their URL variants). */
|
|
24
|
+
const IMAGE_META = /<meta\b(?=[^>]*\b(?:property|name)\s*=\s*["']?(?:og|twitter):image(?::url|:secure_url|:src)?["'\s>/])[^>]*>/gi;
|
|
25
|
+
/** The `content` attribute of a tag and its quoted or bare value. */
|
|
26
|
+
const CONTENT_ATTRIBUTE = /(\scontent\s*=\s*)("[^"]*"|'[^']*'|[^\s"'=<>`]+)/i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scope only exact captured-image addresses in URL-bearing attributes (src, srcset, href,
|
|
30
|
+
* xlink:href, poster, a social-image meta `content`) and CSS url() / image-set() / image().
|
|
31
|
+
* Platform assets, external URLs and every other attribute — signed form values included — are
|
|
32
|
+
* left byte-for-byte.
|
|
33
|
+
*/
|
|
13
34
|
export function prefixPreviewPinnedImageUrls(html: string, basePath: string): string {
|
|
14
35
|
if (!basePath) return html;
|
|
15
|
-
|
|
16
|
-
|
|
36
|
+
const scope = (_match: string, before: string, entry: string) => `${before}${basePath}/_a/${entry}`;
|
|
37
|
+
const scopeValue = (_match: string, name: string, value: string) => `${name}${value.replace(ATTRIBUTE_ENTRY, scope)}`;
|
|
38
|
+
return html
|
|
39
|
+
.replace(URL_ATTRIBUTE, scopeValue)
|
|
40
|
+
.replace(IMAGE_META, (tag) => tag.replace(CONTENT_ATTRIBUTE, scopeValue))
|
|
41
|
+
.replace(CSS_URL_ENTRY, scope)
|
|
42
|
+
.replace(CSS_IMAGE_FUNCTION, (_match, open: string, body: string) => `${open}${body.replace(QUOTED_ENTRY, scope)})`);
|
|
17
43
|
}
|
|
18
44
|
|
|
19
45
|
/** Choose asset routing from the resolved request tenant, never a second host-membership check. */
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
sampleVariantCartPayload,
|
|
16
16
|
visiblePurchaseOptions,
|
|
17
17
|
isAutopilotEnabled,
|
|
18
|
+
tenantPublicOrigin,
|
|
18
19
|
workerCartApiPath,
|
|
19
20
|
} from "@tot/public-runtime";
|
|
20
21
|
import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
|
|
@@ -69,6 +70,7 @@ export async function buildCardCartForm(input: {
|
|
|
69
70
|
tenantId: commerceTenantId(input.tenant),
|
|
70
71
|
currency: input.tenant.currency,
|
|
71
72
|
contentVersionId: input.environment === "live" ? undefined : input.contentVersionId,
|
|
73
|
+
publicOrigin: tenantPublicOrigin(input.tenant),
|
|
72
74
|
});
|
|
73
75
|
const provider = input.tenant.serving?.provider === "s3-overwrite" ? "s3-overwrite" : "worker-r2-pointer";
|
|
74
76
|
return {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
withShippingPolicy,
|
|
33
33
|
withReturnUrl,
|
|
34
34
|
trustedCartReturnUrl,
|
|
35
|
+
tenantPublicOrigin,
|
|
35
36
|
CHECKOUT_NAVIGATION_REFERRER_POLICY,
|
|
36
37
|
SHIPPING_UNAVAILABLE_CART_MESSAGE,
|
|
37
38
|
ShippingPolicyUnavailableError,
|
|
@@ -174,6 +175,19 @@ function submittedFromUrl(request: Request, tenant: TenantConfig): string | null
|
|
|
174
175
|
return trustedCartReturnUrl(request.headers.get("referer"), hosts);
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The operator record of a refused cart: tenant, stage, denial code and the base names of the
|
|
180
|
+
* signed fields that failed. Never a field value, hash, the secret or any shopper data.
|
|
181
|
+
*/
|
|
182
|
+
function logCartRefusal(context: APIContext, code: CartDenialCode, failedFields: readonly string[]): void {
|
|
183
|
+
console.warn(`[storefront] checkout-handoff refused ${JSON.stringify({
|
|
184
|
+
tenant: context.locals.tenant.tenant_id,
|
|
185
|
+
stage: context.locals.browserEnvironment?.environment ?? "unknown",
|
|
186
|
+
code,
|
|
187
|
+
failedFields,
|
|
188
|
+
})}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
177
191
|
/** Native cart POST. Browser preflight is not consulted. Payment is never authorized. */
|
|
178
192
|
async function cartFormPost(context: APIContext): Promise<Response> {
|
|
179
193
|
const { tenant, commerce } = context.locals;
|
|
@@ -207,6 +221,7 @@ async function cartFormPost(context: APIContext): Promise<Response> {
|
|
|
207
221
|
trustedTenantId: commerceTenantId(tenant),
|
|
208
222
|
});
|
|
209
223
|
if (!result.ok) {
|
|
224
|
+
logCartRefusal(context, result.code, result.failedFields);
|
|
210
225
|
const status = result.code === "signing-unavailable" ? 503 : 422;
|
|
211
226
|
return cartDenial(result.code, shopperCartDenialMessage(result.code, result.message), status);
|
|
212
227
|
}
|
|
@@ -333,6 +348,7 @@ export async function sampleCartGet(
|
|
|
333
348
|
if (!signing.enabled) return sampleResponse('{"error":"sample_unavailable"}', 503);
|
|
334
349
|
const payload = await buildVariantCartPayload(product, variant, {
|
|
335
350
|
mode: "signed", secret: signing.secret, tenantId: commerceTenantId(tenant), currency: tenant.currency,
|
|
351
|
+
publicOrigin: tenantPublicOrigin(tenant),
|
|
336
352
|
});
|
|
337
353
|
const result = await enforceCartSubmission({
|
|
338
354
|
entries: [...payload.hidden.map((f): [string, string] => [f.name, f.value]), [payload.quantityName, quantity]],
|
|
@@ -5,6 +5,7 @@ export function mountCommerceDesk(root: HTMLElement): void {
|
|
|
5
5
|
const status = root.querySelector<HTMLElement>("[data-desk-status]")!;
|
|
6
6
|
const rows = root.querySelector<HTMLElement>("[data-desk-orders]")!;
|
|
7
7
|
const next = root.querySelector<HTMLButtonElement>("[data-desk-next]")!;
|
|
8
|
+
const processor = root.querySelector<HTMLElement>("[data-desk-processor]");
|
|
8
9
|
const api = parseCommerceDeskApiRoot(root.dataset.commerceApi, root.dataset.targetTenant);
|
|
9
10
|
if (!api) {
|
|
10
11
|
rows.replaceChildren();
|
|
@@ -38,6 +39,10 @@ export function mountCommerceDesk(root: HTMLElement): void {
|
|
|
38
39
|
text("p", `${order.payment} · ${order.fulfillment} · ${order.batch?.phase ?? "unclaimed"}`, card);
|
|
39
40
|
text("p", order.lines.map((line) => `${line.sku} × ${line.quantity}`).join(", ") || "No verified line items", card);
|
|
40
41
|
text("p", order.holds.length ? `Held: ${order.holds.join(", ")}` : "Ready", card);
|
|
42
|
+
if (order.assignment) {
|
|
43
|
+
const owner = text("p", `Assigned to processor ${order.assignment.installId} · ${order.assignment.state}`, card);
|
|
44
|
+
owner.dataset.assignmentState = order.assignment.state;
|
|
45
|
+
}
|
|
41
46
|
const actions = order.batch
|
|
42
47
|
? [
|
|
43
48
|
["pick", "Confirm pick"],
|
|
@@ -91,6 +96,17 @@ export function mountCommerceDesk(root: HTMLElement): void {
|
|
|
91
96
|
};
|
|
92
97
|
}
|
|
93
98
|
}
|
|
99
|
+
async function loadProcessor(): Promise<void> {
|
|
100
|
+
if (!processor) return;
|
|
101
|
+
try {
|
|
102
|
+
const value = (await request("/processor")) as { processor: { installId: string; notifiable: boolean } | null };
|
|
103
|
+
processor.textContent = value.processor
|
|
104
|
+
? `Processor: ${value.processor.installId}${value.processor.notifiable ? "" : " (not receiving assignments)"}`
|
|
105
|
+
: "Processor: none. Released orders use the label path.";
|
|
106
|
+
} catch {
|
|
107
|
+
processor.textContent = "Processor configuration is visible to the store owner only.";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
94
110
|
async function load(after: string | null): Promise<void> {
|
|
95
111
|
status.textContent = "Loading orders…";
|
|
96
112
|
try {
|
|
@@ -115,7 +131,11 @@ export function mountCommerceDesk(root: HTMLElement): void {
|
|
|
115
131
|
status.textContent = "Orders are unavailable. Refresh and try again.";
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
|
-
root.querySelector<HTMLButtonElement>("[data-desk-refresh]")!.onclick = () =>
|
|
134
|
+
root.querySelector<HTMLButtonElement>("[data-desk-refresh]")!.onclick = () => {
|
|
135
|
+
void loadProcessor();
|
|
136
|
+
void load(null);
|
|
137
|
+
};
|
|
119
138
|
next.onclick = () => void load(cursor);
|
|
139
|
+
void loadProcessor();
|
|
120
140
|
void load(null);
|
|
121
141
|
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Processor configuration and fulfillment assignments.
|
|
3
|
+
*
|
|
4
|
+
* A (tenant, stage) names at most one processor install. Operator release writes the order's
|
|
5
|
+
* assignment to that install; hold, cancel and refund move it by state; switching or clearing the
|
|
6
|
+
* processor, and suspending, uninstalling or descoping its install, release it. Rows are never
|
|
7
|
+
* deleted: assignments keep their history and a config clears by state.
|
|
8
|
+
*/
|
|
9
|
+
import type { CommerceStage } from "@tot/public-runtime";
|
|
10
|
+
import type { FulfillmentDatabase, SqlStatement } from "./store.js";
|
|
11
|
+
|
|
12
|
+
export const PROCESSOR_SCOPE = "fulfillment:process";
|
|
13
|
+
|
|
14
|
+
export type AssignmentState = "active" | "held" | "cancelled" | "released";
|
|
15
|
+
|
|
16
|
+
export interface ProcessorConfig {
|
|
17
|
+
installId: string;
|
|
18
|
+
/** The install is active on this tenant, has an endpoint and holds `fulfillment:process`. */
|
|
19
|
+
notifiable: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OpenAssignment {
|
|
23
|
+
assignmentId: string;
|
|
24
|
+
installId: string;
|
|
25
|
+
state: "active" | "held";
|
|
26
|
+
eventVersion: number;
|
|
27
|
+
assignedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** SQL condition: install `?` is active on tenant `?`, has an endpoint and holds the processor scope. */
|
|
31
|
+
const NOTIFIABLE_INSTALL = `EXISTS (
|
|
32
|
+
SELECT 1 FROM app_installs AS pi
|
|
33
|
+
WHERE pi.install_id = ? AND pi.tenant_id = ? AND pi.status = 'active' AND pi.managed_kind IS NULL
|
|
34
|
+
AND COALESCE(pi.webhook_endpoint, '') != ''
|
|
35
|
+
AND EXISTS (SELECT 1 FROM json_each(pi.scopes) WHERE json_each.value = '${PROCESSOR_SCOPE}')
|
|
36
|
+
)`;
|
|
37
|
+
|
|
38
|
+
export async function readProcessor(
|
|
39
|
+
db: Pick<FulfillmentDatabase, "get">,
|
|
40
|
+
tenantId: string,
|
|
41
|
+
stage: CommerceStage,
|
|
42
|
+
): Promise<ProcessorConfig | null> {
|
|
43
|
+
const config = await db.get<{ install_id: string }>(
|
|
44
|
+
`SELECT install_id FROM fulfillment_processor_config WHERE tenant_id = ? AND stage = ? AND state = 'active'`,
|
|
45
|
+
[tenantId, stage],
|
|
46
|
+
);
|
|
47
|
+
if (!config) return null;
|
|
48
|
+
const notifiable = await db.get<{ ok: number }>(`SELECT 1 AS ok WHERE ${NOTIFIABLE_INSTALL}`, [config.install_id, tenantId]);
|
|
49
|
+
return { installId: config.install_id, notifiable: notifiable?.ok === 1 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when `installId` could be the processor for `tenantId`: active there, with an endpoint and the scope. */
|
|
53
|
+
export async function installMayProcess(
|
|
54
|
+
db: Pick<FulfillmentDatabase, "get">,
|
|
55
|
+
tenantId: string,
|
|
56
|
+
installId: string,
|
|
57
|
+
): Promise<boolean> {
|
|
58
|
+
const row = await db.get<{ ok: number }>(`SELECT 1 AS ok WHERE ${NOTIFIABLE_INSTALL}`, [installId, tenantId]);
|
|
59
|
+
return row?.ok === 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sets or clears the (tenant, stage) processor. Switching or clearing releases the previous
|
|
64
|
+
* install's active and held assignments on that stage in the same batch; the config write is the
|
|
65
|
+
* batch's compare-and-set, so a concurrent change aborts it.
|
|
66
|
+
*/
|
|
67
|
+
export async function setProcessor(
|
|
68
|
+
db: FulfillmentDatabase,
|
|
69
|
+
input: { tenantId: string; stage: CommerceStage; installId: string | null; actorId: string; now: string },
|
|
70
|
+
): Promise<{ ok: true; installId: string | null } | { ok: false; code: "install-not-found" | "conflict" }> {
|
|
71
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
72
|
+
const current = await readProcessor(db, input.tenantId, input.stage);
|
|
73
|
+
if ((current?.installId ?? null) === input.installId) return { ok: true, installId: input.installId };
|
|
74
|
+
if (input.installId !== null && !(await installMayProcess(db, input.tenantId, input.installId))) {
|
|
75
|
+
return { ok: false, code: "install-not-found" };
|
|
76
|
+
}
|
|
77
|
+
let first: SqlStatement;
|
|
78
|
+
if (input.installId === null) {
|
|
79
|
+
first = {
|
|
80
|
+
sql: `UPDATE fulfillment_processor_config SET state = 'cleared', updated_by = ?, updated_at = ?
|
|
81
|
+
WHERE tenant_id = ? AND stage = ? AND state = 'active' AND install_id = ?`,
|
|
82
|
+
params: [input.actorId, input.now, input.tenantId, input.stage, current!.installId],
|
|
83
|
+
};
|
|
84
|
+
} else if (current) {
|
|
85
|
+
first = {
|
|
86
|
+
sql: `UPDATE fulfillment_processor_config SET install_id = ?, updated_by = ?, updated_at = ?
|
|
87
|
+
WHERE tenant_id = ? AND stage = ? AND state = 'active' AND install_id = ? AND ${NOTIFIABLE_INSTALL}`,
|
|
88
|
+
params: [input.installId, input.actorId, input.now, input.tenantId, input.stage, current.installId, input.installId, input.tenantId],
|
|
89
|
+
};
|
|
90
|
+
} else {
|
|
91
|
+
first = {
|
|
92
|
+
sql: `INSERT INTO fulfillment_processor_config (tenant_id, stage, install_id, state, updated_by, updated_at)
|
|
93
|
+
SELECT ?, ?, ?, 'active', ?, ? WHERE ${NOTIFIABLE_INSTALL}
|
|
94
|
+
ON CONFLICT (tenant_id, stage) DO UPDATE SET
|
|
95
|
+
install_id = excluded.install_id, state = 'active', updated_by = excluded.updated_by, updated_at = excluded.updated_at
|
|
96
|
+
WHERE fulfillment_processor_config.state = 'cleared'`,
|
|
97
|
+
params: [input.tenantId, input.stage, input.installId, input.actorId, input.now, input.installId, input.tenantId],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const statements: SqlStatement[] = [
|
|
101
|
+
first,
|
|
102
|
+
{ sql: `SELECT json(CASE WHEN changes() >= 1 THEN 'true' ELSE 'cas-not-applied' END)`, params: [] },
|
|
103
|
+
];
|
|
104
|
+
if (current) {
|
|
105
|
+
statements.push(
|
|
106
|
+
releaseInstallAssignmentsStatement({ tenantId: input.tenantId, installId: current.installId, now: input.now, stage: input.stage }),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const changes = await db.batch(statements);
|
|
111
|
+
if ((changes[0] ?? 0) > 0) return { ok: true, installId: input.installId };
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (!isCasMiss(error)) throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { ok: false, code: "conflict" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isCasMiss(error: unknown): boolean {
|
|
120
|
+
let cause: unknown = error;
|
|
121
|
+
for (let depth = 0; depth < 4 && cause instanceof Error; depth++, cause = cause.cause) {
|
|
122
|
+
if (cause.message.includes("malformed JSON")) return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function readOpenAssignment(
|
|
128
|
+
db: Pick<FulfillmentDatabase, "get">,
|
|
129
|
+
tenantId: string,
|
|
130
|
+
stage: CommerceStage,
|
|
131
|
+
orderId: string,
|
|
132
|
+
): Promise<OpenAssignment | null> {
|
|
133
|
+
const row = await db.get<{
|
|
134
|
+
assignment_id: string;
|
|
135
|
+
install_id: string;
|
|
136
|
+
state: string;
|
|
137
|
+
event_version: number;
|
|
138
|
+
assigned_at: string;
|
|
139
|
+
}>(
|
|
140
|
+
`SELECT assignment_id, install_id, state, event_version, assigned_at
|
|
141
|
+
FROM fulfillment_assignments
|
|
142
|
+
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND state IN ('active', 'held')`,
|
|
143
|
+
[tenantId, stage, orderId],
|
|
144
|
+
);
|
|
145
|
+
if (!row || (row.state !== "active" && row.state !== "held")) return null;
|
|
146
|
+
return {
|
|
147
|
+
assignmentId: row.assignment_id,
|
|
148
|
+
installId: row.install_id,
|
|
149
|
+
state: row.state,
|
|
150
|
+
eventVersion: Number(row.event_version),
|
|
151
|
+
assignedAt: row.assigned_at,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** SQL condition (no leading AND): the (tenant, stage) config names `installId` and that install is notifiable. */
|
|
156
|
+
export function processorGuard(tenantId: string, stage: CommerceStage, installId: string): SqlStatement {
|
|
157
|
+
return {
|
|
158
|
+
sql: `EXISTS (
|
|
159
|
+
SELECT 1 FROM fulfillment_processor_config AS pc
|
|
160
|
+
WHERE pc.tenant_id = ? AND pc.stage = ? AND pc.install_id = ? AND pc.state = 'active'
|
|
161
|
+
) AND ${NOTIFIABLE_INSTALL}`,
|
|
162
|
+
params: [tenantId, stage, installId, installId, tenantId],
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function orderAtVersion(tenantId: string, stage: CommerceStage, orderId: string, version: number): SqlStatement {
|
|
167
|
+
return {
|
|
168
|
+
sql: `EXISTS (
|
|
169
|
+
SELECT 1 FROM scrappy_commerce_orders AS ov
|
|
170
|
+
WHERE ov.tenant_id = ? AND ov.stage = ? AND ov.order_id = ? AND ov.event_version = ?
|
|
171
|
+
)`,
|
|
172
|
+
params: [tenantId, stage, orderId, version],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Writes the active assignment only when the order committed at `version` in this batch. */
|
|
177
|
+
export function insertAssignmentStatement(input: {
|
|
178
|
+
assignmentId: string;
|
|
179
|
+
tenantId: string;
|
|
180
|
+
stage: CommerceStage;
|
|
181
|
+
orderId: string;
|
|
182
|
+
installId: string;
|
|
183
|
+
version: number;
|
|
184
|
+
now: string;
|
|
185
|
+
}): SqlStatement {
|
|
186
|
+
const gate = orderAtVersion(input.tenantId, input.stage, input.orderId, input.version);
|
|
187
|
+
return {
|
|
188
|
+
sql: `INSERT INTO fulfillment_assignments
|
|
189
|
+
(assignment_id, tenant_id, stage, order_id, install_id, state, event_version, assigned_at, updated_at)
|
|
190
|
+
SELECT ?, ?, ?, ?, ?, 'active', ?, ?, ? WHERE ${gate.sql}`,
|
|
191
|
+
params: [
|
|
192
|
+
input.assignmentId,
|
|
193
|
+
input.tenantId,
|
|
194
|
+
input.stage,
|
|
195
|
+
input.orderId,
|
|
196
|
+
input.installId,
|
|
197
|
+
input.version,
|
|
198
|
+
input.now,
|
|
199
|
+
input.now,
|
|
200
|
+
...gate.params,
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Moves one assignment from any of `from` to `to` when the order committed at `version`, and `guard` holds. */
|
|
206
|
+
export function moveAssignmentStatement(input: {
|
|
207
|
+
assignmentId: string;
|
|
208
|
+
tenantId: string;
|
|
209
|
+
stage: CommerceStage;
|
|
210
|
+
orderId: string;
|
|
211
|
+
from: readonly AssignmentState[];
|
|
212
|
+
to: AssignmentState;
|
|
213
|
+
version: number;
|
|
214
|
+
now: string;
|
|
215
|
+
guard?: SqlStatement;
|
|
216
|
+
unlessGuard?: boolean;
|
|
217
|
+
}): SqlStatement {
|
|
218
|
+
const gate = orderAtVersion(input.tenantId, input.stage, input.orderId, input.version);
|
|
219
|
+
const marks = input.from.map(() => "?").join(", ");
|
|
220
|
+
const guard = input.guard ? ` AND ${input.unlessGuard ? "NOT " : ""}(${input.guard.sql})` : "";
|
|
221
|
+
return {
|
|
222
|
+
sql: `UPDATE fulfillment_assignments SET state = ?, event_version = ?, updated_at = ?
|
|
223
|
+
WHERE assignment_id = ? AND tenant_id = ? AND stage = ? AND order_id = ? AND state IN (${marks})
|
|
224
|
+
AND ${gate.sql}${guard}`,
|
|
225
|
+
params: [
|
|
226
|
+
input.to,
|
|
227
|
+
input.version,
|
|
228
|
+
input.now,
|
|
229
|
+
input.assignmentId,
|
|
230
|
+
input.tenantId,
|
|
231
|
+
input.stage,
|
|
232
|
+
input.orderId,
|
|
233
|
+
...input.from,
|
|
234
|
+
...gate.params,
|
|
235
|
+
...(input.guard?.params ?? []),
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Event gate: the assignment stands at `state` as of `version` — i.e. the transition it reports committed. */
|
|
241
|
+
export function assignmentAtGate(assignmentId: string, state: AssignmentState, version: number): SqlStatement {
|
|
242
|
+
return {
|
|
243
|
+
sql: `EXISTS (SELECT 1 FROM fulfillment_assignments WHERE assignment_id = ? AND state = ? AND event_version = ?)`,
|
|
244
|
+
params: [assignmentId, state, version],
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Releases every open assignment `installId` holds on `tenantId` (optionally one stage). No event. */
|
|
249
|
+
export function releaseInstallAssignmentsStatement(input: {
|
|
250
|
+
tenantId: string;
|
|
251
|
+
installId: string;
|
|
252
|
+
now: string;
|
|
253
|
+
stage?: CommerceStage;
|
|
254
|
+
}): SqlStatement {
|
|
255
|
+
return {
|
|
256
|
+
sql: `UPDATE fulfillment_assignments SET state = 'released', updated_at = ?
|
|
257
|
+
WHERE tenant_id = ? AND install_id = ? AND state IN ('active', 'held')${input.stage ? " AND stage = ?" : ""}`,
|
|
258
|
+
params: [input.now, input.tenantId, input.installId, ...(input.stage ? [input.stage] : [])],
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Clears every active processor config naming `installId` on `tenantId`. */
|
|
263
|
+
export function clearInstallProcessorStatement(input: {
|
|
264
|
+
tenantId: string;
|
|
265
|
+
installId: string;
|
|
266
|
+
actor: string;
|
|
267
|
+
now: string;
|
|
268
|
+
}): SqlStatement {
|
|
269
|
+
return {
|
|
270
|
+
sql: `UPDATE fulfillment_processor_config SET state = 'cleared', updated_by = ?, updated_at = ?
|
|
271
|
+
WHERE tenant_id = ? AND install_id = ? AND state = 'active'`,
|
|
272
|
+
params: [input.actor, input.now, input.tenantId, input.installId],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The statements that detach `installId` from fulfillment on `tenantId`: release its assignments, clear its config. */
|
|
277
|
+
export function detachInstallStatements(input: {
|
|
278
|
+
tenantId: string;
|
|
279
|
+
installId: string;
|
|
280
|
+
actor: string;
|
|
281
|
+
now: string;
|
|
282
|
+
}): SqlStatement[] {
|
|
283
|
+
return [releaseInstallAssignmentsStatement(input), clearInstallProcessorStatement(input)];
|
|
284
|
+
}
|
|
@@ -5,6 +5,7 @@ import { foldCommerceEvents, type StoredCommercePayload } from "../orders/data/o
|
|
|
5
5
|
import { admissionBindsOrder, currentCartEvent, readCommerceAdmission } from "../checkout/commerce/commerceAdmission.js";
|
|
6
6
|
import { FulfillmentService } from "./fulfillmentService.js";
|
|
7
7
|
import { fulfillmentDbFromQueryable } from "./store.js";
|
|
8
|
+
import { dispatchCommerceDeliveries } from "./commerceRuntime.js";
|
|
8
9
|
const UNKNOWN: CommerceEvidence = { verification: "UNKNOWN", tax: "UNKNOWN", risk: "UNKNOWN", stock: "UNKNOWN" };
|
|
9
10
|
|
|
10
11
|
export async function projectCommerceOrder(
|
|
@@ -87,6 +88,7 @@ export async function projectCommerceOrder(
|
|
|
87
88
|
const service = new FulfillmentService(fulfillmentDbFromQueryable(db), {
|
|
88
89
|
policy: { adultSignatureRequired: false, prohibitedCarriers: [], carrierAccountId: null },
|
|
89
90
|
now: () => input.now,
|
|
91
|
+
notify: dispatchCommerceDeliveries,
|
|
90
92
|
});
|
|
91
93
|
const applied = await service.applySourceSnapshot(
|
|
92
94
|
{ role: "owner", actorId: "commerce-source", tenantId: input.tenantId, stage: input.stage },
|
|
@@ -3,6 +3,10 @@ import { ScrappyOrderLedger } from "../orders/data/ordersStore.js";
|
|
|
3
3
|
import { COMMERCE_SCHEDULER, type CommerceStage } from "@tot/public-runtime";
|
|
4
4
|
import type { Queryable } from "../catalog/d1/catalog.js";
|
|
5
5
|
import { sweepDurableCommerce } from "../integrations/webhooks/deliveryEngine.js";
|
|
6
|
+
import { COMMERCE_DELIVERY_GRACE_MS, dueCommerceDeliveries } from "../integrations/webhooks/deliveryStore.js";
|
|
7
|
+
import { deliveryAttemptFor } from "../integrations/webhooks/getDispatcher.js";
|
|
8
|
+
import type { AttemptDeliveryFn } from "../integrations/webhooks/dispatcher.js";
|
|
9
|
+
|
|
6
10
|
import { projectCommerceOrder } from "./commerceProjection.js";
|
|
7
11
|
|
|
8
12
|
export async function recoverTenantCommerce(
|
|
@@ -10,6 +14,7 @@ export async function recoverTenantCommerce(
|
|
|
10
14
|
tenantId: string,
|
|
11
15
|
now: string,
|
|
12
16
|
deployedSha: string,
|
|
17
|
+
attempt: AttemptDeliveryFn = deliveryAttemptFor(db),
|
|
13
18
|
): Promise<number | null> {
|
|
14
19
|
let incomplete=false;
|
|
15
20
|
for (const stage of ["preview", "publication"] as const) {
|
|
@@ -48,12 +53,24 @@ export async function recoverTenantCommerce(
|
|
|
48
53
|
runId,
|
|
49
54
|
);
|
|
50
55
|
}
|
|
56
|
+
const webhooks = await dueCommerceDeliveries(db, tenantId, now, COMMERCE_DELIVERY_GRACE_MS);
|
|
57
|
+
for (const deliveryId of webhooks.due) {
|
|
58
|
+
try {
|
|
59
|
+
await attempt(deliveryId);
|
|
60
|
+
} catch {
|
|
61
|
+
incomplete = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
51
64
|
if(incomplete)throw new Error("commerce-recovery-incomplete");
|
|
52
65
|
const due = await db.first<{ available_at: string }>(
|
|
53
66
|
`SELECT MIN(available_at) available_at FROM scrappy_commerce_outbox WHERE tenant_id=? AND state='pending'`,
|
|
54
67
|
tenantId,
|
|
55
68
|
);
|
|
56
|
-
|
|
69
|
+
const after = await dueCommerceDeliveries(db, tenantId, now, COMMERCE_DELIVERY_GRACE_MS);
|
|
70
|
+
const candidates = [due?.available_at, after.nextAt, after.due.length > 0 ? now : null]
|
|
71
|
+
.filter((value): value is string => typeof value === "string")
|
|
72
|
+
.map((value) => Date.parse(value));
|
|
73
|
+
return candidates.length > 0 ? Math.max(Date.parse(now) + 1000, Math.min(...candidates)) : null;
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
export async function replayCommerceOrder(
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** Commerce reaches the existing tenant scheduler through its own narrow RPC interface. */
|
|
2
2
|
import { getWorkerEnv } from "../publication/shared/runtimeEnv.js";
|
|
3
3
|
import type { CommerceCallerConfig, SignedCommerceResult } from "../orders/data/orderForwardReceiver.js";
|
|
4
|
+
import { COMMERCE_DELIVERY_GRACE_MS } from "../integrations/webhooks/deliveryStore.js";
|
|
5
|
+
import { getDispatcher } from "../integrations/webhooks/getDispatcher.js";
|
|
4
6
|
export interface CommercePipeline {
|
|
5
7
|
commerceReceive(body: unknown, token: string, config: CommerceCallerConfig): Promise<SignedCommerceResult>;
|
|
6
8
|
commerceReplay(tenantId: string, stage: "preview" | "publication", outboxId: string): Promise<boolean>;
|
|
9
|
+
scheduleCommerce(tenantId: string, at: number): Promise<void>;
|
|
7
10
|
}
|
|
8
11
|
export async function commercePipelineFor(tenantId: string): Promise<CommercePipeline | null> {
|
|
9
12
|
const namespace = (await getWorkerEnv())?.TENANT_PIPELINE as
|
|
@@ -11,3 +14,28 @@ export async function commercePipelineFor(tenantId: string): Promise<CommercePip
|
|
|
11
14
|
| undefined;
|
|
12
15
|
return namespace && typeof namespace.idFromName === "function" ? namespace.get(namespace.idFromName(tenantId)) : null;
|
|
13
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* After a commerce commit: arm the tenant's commerce alarm past the enqueue grace, then enqueue
|
|
20
|
+
* each committed delivery. Both are best effort; a delivery neither path reaches stays pending
|
|
21
|
+
* and the alarm redelivers it under the same id.
|
|
22
|
+
*/
|
|
23
|
+
export async function dispatchCommerceDeliveries(
|
|
24
|
+
tenantId: string,
|
|
25
|
+
deliveryIds: readonly string[],
|
|
26
|
+
now: number = Date.now(),
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
if (deliveryIds.length === 0) return;
|
|
29
|
+
try {
|
|
30
|
+
await (await commercePipelineFor(tenantId))?.scheduleCommerce(tenantId, now + COMMERCE_DELIVERY_GRACE_MS + 1000);
|
|
31
|
+
} catch {
|
|
32
|
+
// The delivery rows are committed; the next alarm or commerce receipt for this tenant sweeps them.
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const dispatcher = await getDispatcher();
|
|
36
|
+
if (dispatcher instanceof Response) return;
|
|
37
|
+
for (const deliveryId of deliveryIds) await dispatcher.enqueue({ deliveryId });
|
|
38
|
+
} catch {
|
|
39
|
+
// Same: the pending rows remain the durable record.
|
|
40
|
+
}
|
|
41
|
+
}
|