@openlup/platform 0.24.0 → 0.25.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 +109 -210
- package/README.md +40 -11
- package/dist/communications/contracts/subscriptionPaymentExpired.d.ts +3 -2
- package/dist/communications/contracts/subscriptionPaymentExpired.js +1 -1
- package/dist/communications/contracts/subscriptionPaymentFailed.d.ts +4 -3
- package/dist/communications/contracts/subscriptionPaymentFailed.js +4 -5
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/composition/subscriptionContribution.d.ts +18 -0
- package/dist/composition/subscriptionContribution.js +19 -0
- package/dist/inventory/index.d.ts +2 -0
- package/dist/inventory/index.js +1 -0
- package/dist/inventory/postgresStockAvailability.d.ts +24 -0
- package/dist/inventory/postgresStockAvailability.js +48 -0
- package/dist/subscriptions/commandExamples.d.ts +6 -0
- package/dist/subscriptions/commandExamples.js +20 -0
- package/dist/subscriptions/contracts/commands.d.ts +8 -1
- package/dist/subscriptions/contracts/index.d.ts +2 -2
- package/dist/subscriptions/contracts/stockWait.d.ts +12 -1
- package/dist/subscriptions/index.d.ts +22 -4
- package/dist/subscriptions/index.js +22 -3
- package/dist/subscriptions/lifecycleMessages.d.ts +9 -0
- package/dist/subscriptions/lifecycleMessages.js +38 -0
- package/dist/subscriptions/paymentOutcomes.js +1 -1
- package/dist/subscriptions/postgresCommandStore.d.ts +44 -0
- package/dist/subscriptions/postgresCommandStore.js +172 -0
- package/dist/subscriptions/postgresRetryStore.d.ts +6 -0
- package/dist/subscriptions/postgresRetryStore.js +11 -0
- package/dist/subscriptions/postgresStockWaitStore.d.ts +62 -0
- package/dist/subscriptions/postgresStockWaitStore.js +174 -0
- package/dist/subscriptions/services.d.ts +31 -0
- package/dist/subscriptions/services.js +15 -0
- package/package.json +2 -2
- package/release-gates.json +2 -2
- package/sql/20261024170000_subscription_command_lifecycle.sql +404 -0
- package/sql/20261024180000_subscription_stock_wait.sql +330 -0
- package/sql/20261024183000_inventory_available_to_promise.sql +70 -0
- package/sql/20261024184000_subscription_renewal_reserve_uses_seam.sql +147 -0
- package/sql/20261024184500_subscription_retry_late_positive_stock.sql +59 -0
- package/sql/20261024184600_subscription_retry_candidate_payment_filter.sql +22 -0
- package/src/communications/contracts/subscriptionPaymentExpired.ts +4 -3
- package/src/communications/contracts/subscriptionPaymentFailed.ts +8 -8
- package/src/composition/index.ts +1 -0
- package/src/composition/subscriptionContribution.ts +25 -0
- package/src/inventory/index.ts +2 -0
- package/src/inventory/postgresStockAvailability.ts +54 -0
- package/src/subscriptions/commandExamples.ts +19 -0
- package/src/subscriptions/contracts/commands.ts +35 -2
- package/src/subscriptions/contracts/index.ts +2 -2
- package/src/subscriptions/contracts/stockWait.ts +14 -2
- package/src/subscriptions/index.ts +30 -3
- package/src/subscriptions/lifecycleMessages.ts +37 -0
- package/src/subscriptions/paymentOutcomes.ts +1 -1
- package/src/subscriptions/postgresCommandStore.ts +183 -0
- package/src/subscriptions/postgresRetryStore.ts +11 -0
- package/src/subscriptions/postgresStockWaitStore.ts +170 -0
- package/src/subscriptions/services.ts +21 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { StockWaitCase, StockWaitDeficit, StockWaitFrozenCase, StockWaitRecoveryBinding, StockWaitStorePort } from "./contracts/stockWait.js";
|
|
2
|
+
import type { RenewalSchedule } from "./contracts/renewal.js";
|
|
3
|
+
import { subscriptionRecord, type NativeRenewalCodec, type SubscriptionQueryExecutor } from "./postgresRenewalStore.js";
|
|
4
|
+
|
|
5
|
+
export interface NativeStockWaitBinding {
|
|
6
|
+
executor: SubscriptionQueryExecutor;
|
|
7
|
+
withTransaction<T>(work: (executor: SubscriptionQueryExecutor) => Promise<T>): Promise<T>;
|
|
8
|
+
codec: NativeRenewalCodec;
|
|
9
|
+
workerId: string;
|
|
10
|
+
}
|
|
11
|
+
export interface NativeStockWaitAdmission {
|
|
12
|
+
waiting: boolean; caseId: string | null; response: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
const nullable = (value: unknown): string | null => value == null ? null : String(value);
|
|
15
|
+
function binding(value: unknown): StockWaitRecoveryBinding {
|
|
16
|
+
const r = subscriptionRecord(value);
|
|
17
|
+
for (const key of ["caseId", "cycleId", "orderId", "paymentId", "engineIdempotencyKey"]) {
|
|
18
|
+
if (r[key] !== null && typeof r[key] !== "string") throw new Error("stock_wait_binding_invalid");
|
|
19
|
+
}
|
|
20
|
+
return r as unknown as StockWaitRecoveryBinding;
|
|
21
|
+
}
|
|
22
|
+
function array(value: unknown): unknown[] {
|
|
23
|
+
if (!Array.isArray(value)) throw new Error("stock_wait_array_invalid");
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/** Each multi-statement mutation uses the host transaction. Payment execution is
|
|
27
|
+
* deliberately outside this store. SQL owns stock locks and subtransactions. */
|
|
28
|
+
export function createPostgresStockWaitStore(options: NativeStockWaitBinding) {
|
|
29
|
+
const { executor, codec } = options;
|
|
30
|
+
async function invoke(db: SubscriptionQueryExecutor, name: string, values: unknown[]) {
|
|
31
|
+
return (await db.query(`SELECT public.${name}(${values.map((_, i) => `$${i + 1}`).join(",")}) AS response`, values)).rows[0]?.response;
|
|
32
|
+
}
|
|
33
|
+
async function deficits(db: SubscriptionQueryExecutor, value: unknown): Promise<StockWaitDeficit[]> {
|
|
34
|
+
const rows = array(value).map(subscriptionRecord);
|
|
35
|
+
if (rows.length === 0) return [];
|
|
36
|
+
const catalog = (await db.query("SELECT id,sku,title FROM public.catalog_skus WHERE sku=ANY($1::text[])", [rows.map(r => String(r.sku))])).rows;
|
|
37
|
+
return rows.map(value => {
|
|
38
|
+
const r = subscriptionRecord(value);
|
|
39
|
+
// Catalogue labels enrich the projection; quantities still come only from N3.
|
|
40
|
+
const item = catalog.find(item => item.sku === r.sku);
|
|
41
|
+
if (!item) throw new Error("stock_wait_catalog_sku_missing");
|
|
42
|
+
return { skuId: String(item.id), sku: String(r.sku), displayName: String(item.title), size: null,
|
|
43
|
+
required: Number(r.requiredUnits), reserved: Number(r.reservedUnits),
|
|
44
|
+
available: Number(r.availableUnits), missing: Number(r.missingUnits) };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async function readCase(db: SubscriptionQueryExecutor, caseId: string): Promise<StockWaitCase | null> {
|
|
48
|
+
const rows = (await db.query(`SELECT public.subscription_stock_wait_read(c.id) AS projection,
|
|
49
|
+
c.cycle_snapshot,op.identity_key,cycle.id AS bound_cycle_id,ord.id AS bound_order_id,intent.id AS payment_id
|
|
50
|
+
FROM public.subscription_stock_wait_cases c JOIN public.subscription_renewal_operations op ON op.id=c.renewal_operation_id
|
|
51
|
+
LEFT JOIN public.subscription_cycles cycle ON cycle.renewal_operation_id=op.id
|
|
52
|
+
LEFT JOIN public.commerce_orders ord ON ord.renewal_operation_id=op.id
|
|
53
|
+
LEFT JOIN public.commerce_settlement_intents intent ON intent.renewal_operation_id=op.id WHERE c.id=$1::uuid`, [caseId])).rows;
|
|
54
|
+
if (!rows[0]) return null;
|
|
55
|
+
const r = subscriptionRecord(rows[0].projection), snapshot = codec.decode(subscriptionRecord(rows[0].cycle_snapshot));
|
|
56
|
+
if (!["waiting", "stock_secured", "resumed", "closed"].includes(String(r.state))) throw new Error("stock_wait_state_invalid");
|
|
57
|
+
return { subscriptionId: String(r.subscription_id), scheduledAt: snapshot.scheduledAt, scheduledInstant: snapshot.scheduledInstant,
|
|
58
|
+
caseId: String(r.id), clientId: String(r.client_id), state: r.state as StockWaitCase["state"],
|
|
59
|
+
frozenSnapshot: snapshot, frozenFingerprint: String(r.fingerprint),
|
|
60
|
+
binding: { caseId, cycleId: nullable(rows[0].bound_cycle_id), orderId: nullable(rows[0].bound_order_id),
|
|
61
|
+
paymentId: nullable(rows[0].payment_id), engineIdempotencyKey: String(rows[0].identity_key) },
|
|
62
|
+
deficits: await deficits(db, r.deficits), fulfillmentRef: nullable(r.fulfillment_id), blockReason: nullable(r.block_reason),
|
|
63
|
+
openedAt: String(r.opened_at), checkedAt: nullable(r.last_checked_at), selectedAt: nullable(r.last_selected_at),
|
|
64
|
+
securedAt: nullable(r.stock_secured_at), resumedAt: nullable(r.resumed_at), closedAt: nullable(r.closed_at),
|
|
65
|
+
startedNoticeKey: `subscription_stock_wait:${caseId}:started`, resumedNoticeKey: `subscription_stock_wait:${caseId}:resumed`,
|
|
66
|
+
noticeEligible: r.waitingNoticeRelevant === true };
|
|
67
|
+
}
|
|
68
|
+
async function frozen(db: SubscriptionQueryExecutor, value: unknown): Promise<StockWaitFrozenCase> {
|
|
69
|
+
const r = subscriptionRecord(value), b = binding(r.binding), snapshot = codec.decode(subscriptionRecord(r.snapshot));
|
|
70
|
+
return { subscriptionId: snapshot.subscriptionId, scheduledAt: snapshot.scheduledAt, scheduledInstant: snapshot.scheduledInstant, operationId: String(r.operationId),
|
|
71
|
+
case: b.caseId === null ? null : await readCase(db, b.caseId), frozenSnapshot: snapshot,
|
|
72
|
+
frozenFingerprint: String(r.operationFingerprint), binding: b };
|
|
73
|
+
}
|
|
74
|
+
async function recover(db: SubscriptionQueryExecutor, input: Parameters<StockWaitStorePort["recoverRefusedSchedule"]>[0]) {
|
|
75
|
+
// Match the SQL lock order, then fence the schedule and generation not present
|
|
76
|
+
// in N3's SQL signature. No provider-owned refusal is converted to a wait.
|
|
77
|
+
await db.query(`SELECT id FROM public.subscriptions WHERE id=(SELECT subscription_id
|
|
78
|
+
FROM public.subscription_renewal_operations WHERE id=$1::uuid) FOR UPDATE`, [input.operationId]);
|
|
79
|
+
const row = (await db.query(`SELECT op.*,op.scheduled_at=$3::timestamptz AS schedule_matches,c.id AS case_id,c.state AS case_state,
|
|
80
|
+
EXISTS(SELECT 1 FROM public.fulfillment_inventory_reservations r WHERE r.renewal_operation_id=op.id
|
|
81
|
+
AND r.status='held' AND r.expires_at>$4::timestamptz) AS live_hold,
|
|
82
|
+
EXISTS(SELECT 1 FROM public.subscription_cycles WHERE renewal_operation_id=op.id) OR
|
|
83
|
+
EXISTS(SELECT 1 FROM public.commerce_orders WHERE renewal_operation_id=op.id) OR
|
|
84
|
+
EXISTS(SELECT 1 FROM public.commerce_settlement_intents WHERE renewal_operation_id=op.id) OR
|
|
85
|
+
EXISTS(SELECT 1 FROM public.subscription_renewal_outcomes WHERE renewal_operation_id=op.id AND
|
|
86
|
+
(outcome<>'refused' OR reason IS NULL OR reason NOT IN ('inventory_unavailable','reservation_expired') OR external_ref IS NOT NULL)) AS has_artifacts
|
|
87
|
+
FROM public.subscription_renewal_operations op LEFT JOIN public.subscription_stock_wait_cases c ON c.renewal_operation_id=op.id
|
|
88
|
+
WHERE op.id=$1::uuid AND c.id=$2::uuid FOR UPDATE OF op`, [input.operationId, input.caseId, input.scheduledAt, input.now])).rows[0];
|
|
89
|
+
if (!row || !row.schedule_matches || row.operation_fingerprint !== input.frozenFingerprint) {
|
|
90
|
+
return { outcome: "refused", reason: "stale_snapshot" } as const;
|
|
91
|
+
}
|
|
92
|
+
if (row.has_artifacts || row.attempt_prepared_at != null || row.external_attempt_ref != null || row.external_attempt_acknowledged_at != null) {
|
|
93
|
+
return { outcome: "refused", reason: "external_ownership" } as const;
|
|
94
|
+
}
|
|
95
|
+
if (!(row.state === "refused" && ["inventory_unavailable", "reservation_expired"].includes(String(row.terminal_reason)))
|
|
96
|
+
&& !(row.state === "reserved" && row.case_state === "stock_secured")) return { outcome: "refused", reason: "not_stock_only" } as const;
|
|
97
|
+
const securedReplay = row.case_state === "stock_secured" && row.state === "reserved" && row.live_hold === true
|
|
98
|
+
&& new Date(String(row.lease_expires_at)).getTime() > Date.parse(input.now)
|
|
99
|
+
&& [input.claimGeneration, input.claimGeneration + 1].includes(Number(row.claim_generation));
|
|
100
|
+
if (!securedReplay && Number(row.claim_generation) !== input.claimGeneration) return { outcome: "refused", reason: "stale_snapshot" } as const;
|
|
101
|
+
if (!securedReplay && new Date(String(row.lease_expires_at)).getTime() > Date.parse(input.now)) return { outcome: "refused", reason: "lease_active" } as const;
|
|
102
|
+
const result = subscriptionRecord(await invoke(db, "subscription_stock_wait_recover_refusal", [input.operationId, input.frozenFingerprint, options.workerId, input.now]));
|
|
103
|
+
if (result.waiting === true) return { outcome: "refused", reason: "inventory_unavailable" } as const;
|
|
104
|
+
return { outcome: "recovered", operation: codec.readback(result), replayed: result.replayed === true } as const;
|
|
105
|
+
}
|
|
106
|
+
const port: StockWaitStorePort = {
|
|
107
|
+
async listCandidates(input) {
|
|
108
|
+
return options.withTransaction(async db => {
|
|
109
|
+
const values = array(await invoke(db, "subscription_stock_wait_candidates", [input.limit, input.asOf]));
|
|
110
|
+
const result: StockWaitFrozenCase[] = [];
|
|
111
|
+
for (const value of values) result.push(await frozen(db, value));
|
|
112
|
+
return result;
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
async readFrozenCase(input) {
|
|
116
|
+
if (input.caseId === null) return null;
|
|
117
|
+
const result = await readCase(executor, input.caseId);
|
|
118
|
+
if (result && Object.keys(input).some(key => input[key as keyof StockWaitRecoveryBinding] !== result.binding[key as keyof StockWaitRecoveryBinding])) {
|
|
119
|
+
throw new Error("stock_wait_frozen_binding_changed");
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
},
|
|
123
|
+
async reserveAndTransition(input) {
|
|
124
|
+
return options.withTransaction(async db => {
|
|
125
|
+
const row = (await db.query(`SELECT op.id,op.claim_generation,op.scheduled_at::text FROM public.subscription_stock_wait_cases c
|
|
126
|
+
JOIN public.subscription_renewal_operations op ON op.id=c.renewal_operation_id WHERE c.id=$1::uuid`, [input.caseId])).rows[0];
|
|
127
|
+
if (!row) throw new Error("stock_wait_case_missing");
|
|
128
|
+
const result = await recover(db, { ...input, operationId: String(row.id), claimGeneration: Number(row.claim_generation), scheduledAt: String(row.scheduled_at) });
|
|
129
|
+
if (result.outcome === "refused" && result.reason !== "inventory_unavailable") throw new Error(`stock_wait_${result.reason}`);
|
|
130
|
+
const current = await readCase(db, input.caseId);
|
|
131
|
+
if (!current) throw new Error("stock_wait_case_missing");
|
|
132
|
+
return current;
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
recoverRefusedSchedule(input) { return options.withTransaction(db => recover(db, input)); },
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
...port,
|
|
139
|
+
async readFrozen(input: RenewalSchedule) {
|
|
140
|
+
return options.withTransaction(async db => {
|
|
141
|
+
const value = await invoke(db, "subscription_stock_wait_frozen", [input.subscriptionId, input.scheduledAt]);
|
|
142
|
+
return value == null ? null : frozen(db, value);
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
async listNormalDue(input: { limit: number; asOf: string }) {
|
|
146
|
+
return array(await invoke(executor, "subscription_stock_wait_normal_due", [input.limit, input.asOf])).map(value => codec.due(subscriptionRecord(value)));
|
|
147
|
+
},
|
|
148
|
+
async deficits(input: { snapshot: import("./contracts/renewal.js").RenewalFrozenSnapshot; operationId: string; now: string }) {
|
|
149
|
+
return options.withTransaction(async db => deficits(db, await invoke(db, "subscription_stock_wait_deficits", [codec.encode(input.snapshot), input.operationId, input.now])));
|
|
150
|
+
},
|
|
151
|
+
async admit(input: { operationId: string; claimToken: string; snapshot: import("./contracts/renewal.js").RenewalFrozenSnapshot; recovery?: StockWaitRecoveryBinding; now: string }): Promise<NativeStockWaitAdmission> {
|
|
152
|
+
const request = { operationId: input.operationId, claimToken: input.claimToken, snapshot: codec.encode(input.snapshot),
|
|
153
|
+
operationFingerprint: codec.fingerprint(input.snapshot), identityKey: codec.identity(input.snapshot), worker: options.workerId };
|
|
154
|
+
const due = input.recovery ? { stockWaitRecovery: input.recovery } : {};
|
|
155
|
+
const response = subscriptionRecord(await invoke(executor, "subscription_stock_wait_admit", [request, due, input.now]));
|
|
156
|
+
if (response.waiting === true && typeof response.caseId !== "string") throw new Error("stock_wait_case_identity_missing");
|
|
157
|
+
return { waiting: response.waiting === true, caseId: nullable(response.caseId), response };
|
|
158
|
+
},
|
|
159
|
+
async enqueue(caseId: string, kind: "started" | "resumed") {
|
|
160
|
+
await executor.query(`SELECT public.subscription_stock_wait_enqueue(c,$2::text)
|
|
161
|
+
FROM public.subscription_stock_wait_cases c WHERE c.id=$1::uuid`, [caseId, kind]);
|
|
162
|
+
},
|
|
163
|
+
markResumed(orderId: string) {
|
|
164
|
+
return options.withTransaction(async db => {
|
|
165
|
+
const value = await invoke(db, "subscription_stock_wait_mark_resumed", [orderId]);
|
|
166
|
+
return value == null ? null : readCase(db, String(subscriptionRecord(value).id));
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SubscriptionCapabilities } from "./contracts/commands.js";
|
|
2
|
+
|
|
3
|
+
/** One host executor for a selected population. Construction never starts work. */
|
|
4
|
+
export function createSubscriptionServices<Result>(input: {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
capabilities: SubscriptionCapabilities;
|
|
7
|
+
readiness(): Promise<{ kind: "ready" } | { kind: "refused"; code: string }>;
|
|
8
|
+
run(): Promise<Result>;
|
|
9
|
+
}) {
|
|
10
|
+
async function readiness() {
|
|
11
|
+
if (input.enabled !== true) return { kind: "refused", code: "disabled" } as const;
|
|
12
|
+
return input.readiness();
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
capabilities: input.capabilities, readiness,
|
|
16
|
+
async run() {
|
|
17
|
+
const state = await readiness();
|
|
18
|
+
return state.kind === "ready" ? input.run() : state;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|