@tokenoftrust/storefront-runner 6.5.0 → 6.5.2
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/migrations-apps/0027_app_contact_authority.sql +70 -0
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +56 -1
- 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/appContactAuthority.ts +27 -0
- package/apps/storefront/src/lib/integrations/apps/appContactConsents.ts +112 -0
- package/apps/storefront/src/lib/integrations/apps/registryService.ts +101 -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/lib/orders/vault/appRecipientRead.ts +217 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +4 -3
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +57 -0
- package/package.json +1 -1
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- Owner consent for a Private App install to read order recipients for one purpose. Opaque ids and
|
|
2
|
+
-- times only; granted_by is sha256 of the owner actor id. Revoke sets revoked_at once; rows are never
|
|
3
|
+
-- deleted, and re-granting inserts a new row.
|
|
4
|
+
CREATE TABLE `app_contact_consents` (
|
|
5
|
+
`consent_id` text PRIMARY KEY NOT NULL,
|
|
6
|
+
`install_id` text NOT NULL,
|
|
7
|
+
`tenant_id` text NOT NULL,
|
|
8
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
9
|
+
`purpose` text NOT NULL CHECK (`purpose` = 'fulfillment-shipping'),
|
|
10
|
+
`granted_by` text NOT NULL CHECK (length(`granted_by`) = 64 AND `granted_by` NOT GLOB '*[^0-9a-f]*'),
|
|
11
|
+
`granted_at` text NOT NULL,
|
|
12
|
+
`expires_at` text NOT NULL,
|
|
13
|
+
`revoked_at` text,
|
|
14
|
+
CHECK (`expires_at` > `granted_at`),
|
|
15
|
+
CHECK (julianday(`expires_at`) IS NOT NULL AND julianday(`granted_at`) IS NOT NULL
|
|
16
|
+
AND julianday(`expires_at`) - julianday(`granted_at`) <= 365)
|
|
17
|
+
);
|
|
18
|
+
--> statement-breakpoint
|
|
19
|
+
CREATE UNIQUE INDEX `app_contact_consents_live`
|
|
20
|
+
ON `app_contact_consents` (`install_id`, `tenant_id`, `stage`, `purpose`)
|
|
21
|
+
WHERE `revoked_at` IS NULL;
|
|
22
|
+
--> statement-breakpoint
|
|
23
|
+
CREATE TRIGGER `app_contact_consents_no_delete`
|
|
24
|
+
BEFORE DELETE ON `app_contact_consents`
|
|
25
|
+
BEGIN
|
|
26
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
27
|
+
END;
|
|
28
|
+
--> statement-breakpoint
|
|
29
|
+
CREATE TRIGGER `app_contact_consents_revoke_only`
|
|
30
|
+
BEFORE UPDATE ON `app_contact_consents`
|
|
31
|
+
WHEN OLD.`revoked_at` IS NOT NULL
|
|
32
|
+
OR NEW.`revoked_at` IS NULL
|
|
33
|
+
OR NEW.`consent_id` IS NOT OLD.`consent_id`
|
|
34
|
+
OR NEW.`install_id` IS NOT OLD.`install_id`
|
|
35
|
+
OR NEW.`tenant_id` IS NOT OLD.`tenant_id`
|
|
36
|
+
OR NEW.`stage` IS NOT OLD.`stage`
|
|
37
|
+
OR NEW.`purpose` IS NOT OLD.`purpose`
|
|
38
|
+
OR NEW.`granted_by` IS NOT OLD.`granted_by`
|
|
39
|
+
OR NEW.`granted_at` IS NOT OLD.`granted_at`
|
|
40
|
+
OR NEW.`expires_at` IS NOT OLD.`expires_at`
|
|
41
|
+
BEGIN
|
|
42
|
+
SELECT RAISE(ABORT, 'revoke-only');
|
|
43
|
+
END;
|
|
44
|
+
--> statement-breakpoint
|
|
45
|
+
-- Every admitted app read of an order recipient, written before anything is decrypted. Opaque ids
|
|
46
|
+
-- only; append-only.
|
|
47
|
+
CREATE TABLE `app_contact_access_audit` (
|
|
48
|
+
`audit_id` text PRIMARY KEY NOT NULL,
|
|
49
|
+
`install_id` text NOT NULL,
|
|
50
|
+
`tenant_id` text NOT NULL,
|
|
51
|
+
`stage` text NOT NULL CHECK (`stage` IN ('preview', 'publication')),
|
|
52
|
+
`order_id` text NOT NULL,
|
|
53
|
+
`purpose` text NOT NULL CHECK (`purpose` = 'fulfillment-shipping'),
|
|
54
|
+
`at` text NOT NULL,
|
|
55
|
+
`outcome` text NOT NULL CHECK (`outcome` = 'admitted')
|
|
56
|
+
);
|
|
57
|
+
--> statement-breakpoint
|
|
58
|
+
CREATE INDEX `idx_app_contact_access_audit_quota` ON `app_contact_access_audit` (`install_id`, `tenant_id`, `at`);
|
|
59
|
+
--> statement-breakpoint
|
|
60
|
+
CREATE TRIGGER `app_contact_access_audit_no_update`
|
|
61
|
+
BEFORE UPDATE ON `app_contact_access_audit`
|
|
62
|
+
BEGIN
|
|
63
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
64
|
+
END;
|
|
65
|
+
--> statement-breakpoint
|
|
66
|
+
CREATE TRIGGER `app_contact_access_audit_no_delete`
|
|
67
|
+
BEFORE DELETE ON `app_contact_access_audit`
|
|
68
|
+
BEGIN
|
|
69
|
+
SELECT RAISE(ABORT, 'append-only');
|
|
70
|
+
END;
|
|
@@ -18,7 +18,8 @@ export function auditRecordIds(ids: readonly string[]): string | null {
|
|
|
18
18
|
if (ids.length > AUDIT_RECORD_IDS_MAX) throw new Error("contact audit: too many record ids");
|
|
19
19
|
return ids.length === 0 ? null : ids.join(",");
|
|
20
20
|
}
|
|
21
|
-
|
|
21
|
+
/** The quota window. A row counts while its `at` is strictly after `now - HOUR_MS`: one exactly an hour old no longer counts. */
|
|
22
|
+
export const HOUR_MS = 3_600_000;
|
|
22
23
|
|
|
23
24
|
export interface ContactActor {
|
|
24
25
|
actorId: string;
|
|
@@ -88,6 +89,60 @@ export async function admitAudit(db: AuditDb, statement: AuditStatement): Promis
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/** Admitted app recipient reads per install and tenant per hour. */
|
|
93
|
+
export const APP_CONTACT_READS_PER_HOUR = 300;
|
|
94
|
+
export const APP_CONTACT_PURPOSE = "fulfillment-shipping";
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The app-access audit insert (`app_contact_access_audit`): the recipient read's admission and its
|
|
98
|
+
* only write. It inserts one row only when, at that instant, the install is active on the tenant
|
|
99
|
+
* holding `scope`, the assignment is the install's active assignment of the order, the order still
|
|
100
|
+
* stands at `eventVersion` (the version its releasability was computed at), a live consent covers
|
|
101
|
+
* (install, tenant, stage, purpose), and the install has fewer than
|
|
102
|
+
* {@link APP_CONTACT_READS_PER_HOUR} admitted rows on the tenant in the last hour.
|
|
103
|
+
*/
|
|
104
|
+
export function appAuditStatement(input: {
|
|
105
|
+
auditId: string;
|
|
106
|
+
installId: string;
|
|
107
|
+
tenantId: string;
|
|
108
|
+
stage: VaultStage;
|
|
109
|
+
orderId: string;
|
|
110
|
+
assignmentId: string;
|
|
111
|
+
eventVersion: number;
|
|
112
|
+
scope: string;
|
|
113
|
+
now: Date;
|
|
114
|
+
}): AuditStatement {
|
|
115
|
+
const { auditId, installId, tenantId, stage, orderId, assignmentId, eventVersion, scope, now } = input;
|
|
116
|
+
const at = now.toISOString();
|
|
117
|
+
const hourAgo = new Date(now.getTime() - HOUR_MS).toISOString();
|
|
118
|
+
return {
|
|
119
|
+
sql: `INSERT INTO app_contact_access_audit (audit_id, install_id, tenant_id, stage, order_id, purpose, at, outcome)
|
|
120
|
+
SELECT ?, ?, ?, ?, ?, '${APP_CONTACT_PURPOSE}', ?, 'admitted'
|
|
121
|
+
WHERE EXISTS (SELECT 1 FROM app_installs AS ai
|
|
122
|
+
WHERE ai.install_id = ? AND ai.tenant_id = ? AND ai.status = 'active'
|
|
123
|
+
AND EXISTS (SELECT 1 FROM json_each(ai.scopes) WHERE json_each.value = ?))
|
|
124
|
+
AND EXISTS (SELECT 1 FROM fulfillment_assignments AS fa
|
|
125
|
+
WHERE fa.assignment_id = ? AND fa.tenant_id = ? AND fa.stage = ? AND fa.order_id = ?
|
|
126
|
+
AND fa.install_id = ? AND fa.state = 'active')
|
|
127
|
+
AND EXISTS (SELECT 1 FROM scrappy_commerce_orders AS o
|
|
128
|
+
WHERE o.tenant_id = ? AND o.stage = ? AND o.order_id = ? AND o.event_version = ?)
|
|
129
|
+
AND EXISTS (SELECT 1 FROM app_contact_consents AS c
|
|
130
|
+
WHERE c.install_id = ? AND c.tenant_id = ? AND c.stage = ? AND c.purpose = '${APP_CONTACT_PURPOSE}'
|
|
131
|
+
AND c.revoked_at IS NULL AND c.expires_at > ?)
|
|
132
|
+
AND (SELECT COUNT(*) FROM app_contact_access_audit AS q
|
|
133
|
+
WHERE q.install_id = ? AND q.tenant_id = ? AND q.at > ?) < ?
|
|
134
|
+
RETURNING audit_id`,
|
|
135
|
+
params: [
|
|
136
|
+
auditId, installId, tenantId, stage, orderId, at,
|
|
137
|
+
installId, tenantId, scope,
|
|
138
|
+
assignmentId, tenantId, stage, orderId, installId,
|
|
139
|
+
tenantId, stage, orderId, eventVersion,
|
|
140
|
+
installId, tenantId, stage, at,
|
|
141
|
+
installId, tenantId, hourAgo, APP_CONTACT_READS_PER_HOUR,
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
/** The same insert without RETURNING, for use inside a batch whose later statements check the row. */
|
|
92
147
|
export function batchAuditStatement(statement: AuditStatement): AuditStatement {
|
|
93
148
|
return { sql: statement.sql.replace(/\s*RETURNING audit_id$/, ""), params: statement.params };
|
|
@@ -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
|
+
}
|