@porulle/core 0.9.0 → 0.10.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/dist/config/defaults.js +1 -1
- package/dist/config/types.d.ts +4 -3
- package/dist/config/types.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.d.ts +3 -0
- package/dist/generated/plugin-manifest.d.ts.map +1 -1
- package/dist/generated/plugin-manifest.js +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/interfaces/rest/router.d.ts.map +1 -1
- package/dist/interfaces/rest/router.js +2 -0
- package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/checkout.js +1 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +391 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/responses.d.ts +51 -0
- package/dist/interfaces/rest/schemas/responses.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +17 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.d.ts.map +1 -1
- package/dist/kernel/database/scoped-db.js +21 -23
- package/dist/kernel/errors.d.ts +12 -0
- package/dist/kernel/errors.d.ts.map +1 -1
- package/dist/kernel/errors.js +16 -0
- package/dist/kernel/jobs/adapter.d.ts +27 -8
- package/dist/kernel/jobs/adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/adapter.js +1 -2
- package/dist/kernel/jobs/drizzle-adapter.d.ts +13 -3
- package/dist/kernel/jobs/drizzle-adapter.d.ts.map +1 -1
- package/dist/kernel/jobs/drizzle-adapter.js +46 -5
- package/dist/kernel/jobs/runner.d.ts +2 -1
- package/dist/kernel/jobs/runner.d.ts.map +1 -1
- package/dist/kernel/jobs/runner.js +100 -15
- package/dist/kernel/jobs/types.d.ts +13 -0
- package/dist/kernel/jobs/types.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.d.ts +2 -0
- package/dist/kernel/plugin/manifest.d.ts.map +1 -1
- package/dist/kernel/plugin/manifest.js +9 -0
- package/dist/modules/catalog/entity-service.d.ts +1 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +15 -1
- package/dist/modules/catalog/repository/index.d.ts +2 -2
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +9 -1
- package/dist/modules/catalog/schema.d.ts +51 -0
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +12 -1
- package/dist/modules/catalog/schemas.d.ts +3 -1
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/channels/adapter.d.ts +117 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -0
- package/dist/modules/channels/adapter.js +3 -0
- package/dist/modules/inventory/repository/index.js +4 -4
- package/dist/modules/inventory/service.d.ts.map +1 -1
- package/dist/modules/inventory/service.js +7 -3
- package/dist/modules/orders/service.d.ts.map +1 -1
- package/dist/modules/orders/service.js +7 -4
- package/dist/modules/orders/stale-order-cleanup.d.ts.map +1 -1
- package/dist/modules/orders/stale-order-cleanup.js +25 -16
- package/dist/modules/promotions/repository/index.d.ts +1 -1
- package/dist/modules/promotions/repository/index.d.ts.map +1 -1
- package/dist/modules/promotions/repository/index.js +6 -3
- package/dist/modules/promotions/service.d.ts +1 -0
- package/dist/modules/promotions/service.d.ts.map +1 -1
- package/dist/modules/promotions/service.js +1 -1
- package/dist/modules/webhooks/repository/index.d.ts +1 -1
- package/dist/modules/webhooks/repository/index.d.ts.map +1 -1
- package/dist/modules/webhooks/repository/index.js +16 -10
- package/dist/runtime/kernel.d.ts.map +1 -1
- package/dist/runtime/kernel.js +14 -2
- package/dist/runtime/server.d.ts +1 -4
- package/dist/runtime/server.d.ts.map +1 -1
- package/dist/runtime/server.js +16 -34
- package/package.json +1 -1
- package/src/config/defaults.ts +1 -1
- package/src/config/types.ts +7 -3
- package/src/generated/plugin-capabilities.d.ts +1 -0
- package/src/generated/plugin-manifest.ts +1 -0
- package/src/generated/plugin-repositories.d.ts +1 -0
- package/src/index.ts +31 -1
- package/src/interfaces/rest/router.ts +2 -0
- package/src/interfaces/rest/routes/checkout.ts +1 -0
- package/src/kernel/database/scoped-db.ts +25 -26
- package/src/kernel/errors.ts +20 -0
- package/src/kernel/jobs/adapter.ts +39 -8
- package/src/kernel/jobs/drizzle-adapter.ts +72 -9
- package/src/kernel/jobs/runner.ts +194 -72
- package/src/kernel/jobs/types.ts +25 -0
- package/src/kernel/plugin/manifest.ts +12 -0
- package/src/modules/catalog/entity-service.ts +9 -1
- package/src/modules/catalog/repository/index.ts +13 -3
- package/src/modules/catalog/schema.ts +33 -18
- package/src/modules/catalog/schemas.ts +3 -1
- package/src/modules/channels/adapter.ts +139 -0
- package/src/modules/inventory/repository/index.ts +4 -4
- package/src/modules/inventory/service.ts +12 -2
- package/src/modules/orders/service.ts +8 -12
- package/src/modules/orders/stale-order-cleanup.ts +35 -24
- package/src/modules/promotions/repository/index.ts +6 -2
- package/src/modules/promotions/service.ts +2 -0
- package/src/modules/webhooks/repository/index.ts +16 -14
- package/src/runtime/kernel.ts +16 -2
- package/src/runtime/server.ts +22 -42
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
2
3
|
import {
|
|
3
4
|
boolean,
|
|
4
5
|
index,
|
|
@@ -17,6 +18,8 @@ export const sellableEntities = pgTable(
|
|
|
17
18
|
{
|
|
18
19
|
id: uuid("id").defaultRandom().primaryKey(),
|
|
19
20
|
organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
|
|
21
|
+
// Connected-store id when externally sourced; null means platform-native.
|
|
22
|
+
sourceStoreId: text("source_store_id"),
|
|
20
23
|
type: text("type").notNull(),
|
|
21
24
|
slug: text("slug").notNull(),
|
|
22
25
|
status: text("status", {
|
|
@@ -179,24 +182,36 @@ export const optionValues = pgTable("option_values", {
|
|
|
179
182
|
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
180
183
|
});
|
|
181
184
|
|
|
182
|
-
export const variants = pgTable(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
})
|
|
185
|
+
export const variants = pgTable(
|
|
186
|
+
"variants",
|
|
187
|
+
{
|
|
188
|
+
id: uuid("id").defaultRandom().primaryKey(),
|
|
189
|
+
entityId: uuid("entity_id")
|
|
190
|
+
.references(() => sellableEntities.id, { onDelete: "cascade" })
|
|
191
|
+
.notNull(),
|
|
192
|
+
organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
|
|
193
|
+
sourceStoreId: text("source_store_id"),
|
|
194
|
+
sku: text("sku"),
|
|
195
|
+
barcode: text("barcode"),
|
|
196
|
+
// Overrides the entity's taxClass when set (issue #57).
|
|
197
|
+
taxClass: text("tax_class"),
|
|
198
|
+
status: text("status", { enum: ["active", "discontinued"] })
|
|
199
|
+
.notNull()
|
|
200
|
+
.default("active"),
|
|
201
|
+
sortOrder: integer("sort_order").notNull().default(0),
|
|
202
|
+
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
203
|
+
},
|
|
204
|
+
(table) => ({
|
|
205
|
+
barcodeIdx: index("idx_variants_barcode").on(table.barcode),
|
|
206
|
+
skuIdx: index("idx_variants_sku").on(table.sku),
|
|
207
|
+
nativeSkuUnique: uniqueIndex("variants_native_org_sku_unique")
|
|
208
|
+
.on(table.organizationId, table.sku)
|
|
209
|
+
.where(sql`${table.sourceStoreId} IS NULL AND ${table.sku} IS NOT NULL`),
|
|
210
|
+
sourceSkuUnique: uniqueIndex("variants_source_store_sku_unique")
|
|
211
|
+
.on(table.sourceStoreId, table.sku)
|
|
212
|
+
.where(sql`${table.sourceStoreId} IS NOT NULL AND ${table.sku} IS NOT NULL`),
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
200
215
|
|
|
201
216
|
export const variantOptionValues = pgTable("variant_option_values", {
|
|
202
217
|
variantId: uuid("variant_id")
|
|
@@ -92,7 +92,9 @@ export const GenerateVariantsBodySchema = z
|
|
|
92
92
|
|
|
93
93
|
// ─── Derived Input Types ─────────────────────────────────────────────────────
|
|
94
94
|
|
|
95
|
-
export type CreateEntityInput = z.infer<typeof CreateEntityBodySchema
|
|
95
|
+
export type CreateEntityInput = z.infer<typeof CreateEntityBodySchema> & {
|
|
96
|
+
sourceStoreId?: string;
|
|
97
|
+
};
|
|
96
98
|
|
|
97
99
|
export type UpdateEntityInput = z.infer<typeof UpdateEntityBodySchema>;
|
|
98
100
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Result } from "../../kernel/result.js";
|
|
2
|
+
|
|
3
|
+
export interface ChannelConnectorError {
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
retriable?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ChannelConnectorCapabilities {
|
|
10
|
+
readonly importCatalog: boolean;
|
|
11
|
+
readonly importInventory: boolean;
|
|
12
|
+
readonly pushOrder: boolean;
|
|
13
|
+
readonly receiveWebhooks: boolean;
|
|
14
|
+
readonly reserve?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ChannelStore {
|
|
18
|
+
id: string;
|
|
19
|
+
organizationId: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
credentials: Record<string, unknown>;
|
|
22
|
+
storeDomain: string;
|
|
23
|
+
status: "connected" | "disconnected" | "error";
|
|
24
|
+
webhookSecret: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChannelCatalogVariant {
|
|
28
|
+
externalId: string;
|
|
29
|
+
sku?: string;
|
|
30
|
+
barcode?: string;
|
|
31
|
+
metadata?: Record<string, unknown>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChannelCatalogItem {
|
|
35
|
+
externalId: string;
|
|
36
|
+
slug: string;
|
|
37
|
+
title: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
variants: ChannelCatalogVariant[];
|
|
40
|
+
metadata?: Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ChannelCatalogPage {
|
|
44
|
+
items: ChannelCatalogItem[];
|
|
45
|
+
nextCursor?: string | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ChannelInventoryLevel {
|
|
49
|
+
externalId: string;
|
|
50
|
+
available: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ChannelOrderLine {
|
|
54
|
+
externalVariantId: string;
|
|
55
|
+
sku?: string;
|
|
56
|
+
title: string;
|
|
57
|
+
quantity: number;
|
|
58
|
+
unitPrice: number;
|
|
59
|
+
totalPrice: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ChannelOrderSlice {
|
|
63
|
+
orderId: string;
|
|
64
|
+
currency: string;
|
|
65
|
+
grandTotal: number;
|
|
66
|
+
lines: ChannelOrderLine[];
|
|
67
|
+
customer: {
|
|
68
|
+
name: string;
|
|
69
|
+
email: string;
|
|
70
|
+
shippingAddress: Record<string, unknown>;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ChannelPushOrderResult {
|
|
75
|
+
remoteOrderId: string;
|
|
76
|
+
remoteUrl?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface ChannelOrderStatus {
|
|
80
|
+
status: "pending" | "confirmed" | "failed" | "cancelled" | "fulfilled";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ChannelWebhookEvent {
|
|
84
|
+
id: string;
|
|
85
|
+
type: string;
|
|
86
|
+
data: unknown;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface ChannelReservation {
|
|
90
|
+
id: string;
|
|
91
|
+
expiresAt?: Date;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ChannelRefundResult {
|
|
95
|
+
remoteRefundId: string;
|
|
96
|
+
status: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ChannelConnector {
|
|
100
|
+
readonly providerId: string;
|
|
101
|
+
readonly capabilities: ChannelConnectorCapabilities;
|
|
102
|
+
buildAuthUrl?(params: {
|
|
103
|
+
storeDomain: string;
|
|
104
|
+
state: string;
|
|
105
|
+
redirectUri: string;
|
|
106
|
+
callbackUri: string;
|
|
107
|
+
scopes: string[];
|
|
108
|
+
}): Result<string, ChannelConnectorError>;
|
|
109
|
+
completeAuth?(
|
|
110
|
+
request: Request,
|
|
111
|
+
ctx: { storeDomain: string },
|
|
112
|
+
): Promise<Result<{ credentials: Record<string, unknown>; storeDomain: string }, ChannelConnectorError>>;
|
|
113
|
+
importCatalog(store: ChannelStore, cursor?: string): Promise<Result<ChannelCatalogPage>>;
|
|
114
|
+
fetchInventory(store: ChannelStore, ids?: string[]): Promise<Result<ChannelInventoryLevel[]>>;
|
|
115
|
+
pushOrder(store: ChannelStore, slice: ChannelOrderSlice): Promise<Result<ChannelPushOrderResult, ChannelConnectorError>>;
|
|
116
|
+
fetchOrderStatus(store: ChannelStore, remoteId: string): Promise<Result<ChannelOrderStatus, ChannelConnectorError>>;
|
|
117
|
+
verifyWebhook(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent>>;
|
|
118
|
+
verifyAppWebhook?(
|
|
119
|
+
request: Request,
|
|
120
|
+
): Promise<Result<{ topic: string; shopDomain: string; data: unknown }, ChannelConnectorError>>;
|
|
121
|
+
registerWebhooks?(
|
|
122
|
+
store: ChannelStore,
|
|
123
|
+
topics: string[],
|
|
124
|
+
callbackUrl: string,
|
|
125
|
+
): Promise<Result<{ registered: number }, ChannelConnectorError>>;
|
|
126
|
+
reserve?(
|
|
127
|
+
store: ChannelStore,
|
|
128
|
+
lines: ChannelOrderLine[],
|
|
129
|
+
): Promise<Result<ChannelReservation>>;
|
|
130
|
+
refundExecute(
|
|
131
|
+
store: ChannelStore,
|
|
132
|
+
slice: ChannelOrderSlice,
|
|
133
|
+
amount: number,
|
|
134
|
+
): Promise<Result<ChannelRefundResult>>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function defineChannelConnector<T extends ChannelConnector>(connector: T): T {
|
|
138
|
+
return connector;
|
|
139
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { eq, and, inArray, isNull, sql } from "drizzle-orm";
|
|
2
|
-
import { CommerceValidationError } from "../../../kernel/errors.js";
|
|
2
|
+
import { CommerceValidationError, INVENTORY_RECORD_NOT_FOUND_MESSAGE } from "../../../kernel/errors.js";
|
|
3
3
|
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
4
4
|
import type {
|
|
5
5
|
DrizzleDatabase,
|
|
@@ -495,7 +495,7 @@ export class InventoryRepository {
|
|
|
495
495
|
if (!level) {
|
|
496
496
|
return {
|
|
497
497
|
ok: false,
|
|
498
|
-
reason:
|
|
498
|
+
reason: INVENTORY_RECORD_NOT_FOUND_MESSAGE,
|
|
499
499
|
};
|
|
500
500
|
}
|
|
501
501
|
|
|
@@ -550,7 +550,7 @@ export class InventoryRepository {
|
|
|
550
550
|
if (!level) {
|
|
551
551
|
return {
|
|
552
552
|
ok: false,
|
|
553
|
-
reason:
|
|
553
|
+
reason: INVENTORY_RECORD_NOT_FOUND_MESSAGE,
|
|
554
554
|
};
|
|
555
555
|
}
|
|
556
556
|
|
|
@@ -600,7 +600,7 @@ export class InventoryRepository {
|
|
|
600
600
|
if (!level) {
|
|
601
601
|
return {
|
|
602
602
|
ok: false,
|
|
603
|
-
reason:
|
|
603
|
+
reason: INVENTORY_RECORD_NOT_FOUND_MESSAGE,
|
|
604
604
|
};
|
|
605
605
|
}
|
|
606
606
|
|
|
@@ -3,8 +3,10 @@ import { assertPermission } from "../../auth/permissions.js";
|
|
|
3
3
|
import type { Actor } from "../../auth/types.js";
|
|
4
4
|
import type { CommerceConfig } from "../../config/types.js";
|
|
5
5
|
import {
|
|
6
|
+
CommerceInventoryRecordNotFoundError,
|
|
6
7
|
CommerceNotFoundError,
|
|
7
8
|
CommerceValidationError,
|
|
9
|
+
INVENTORY_RECORD_NOT_FOUND_MESSAGE,
|
|
8
10
|
toCommerceError,
|
|
9
11
|
} from "../../kernel/errors.js";
|
|
10
12
|
import { runAfterHooks } from "../../kernel/hooks/executor.js";
|
|
@@ -232,7 +234,11 @@ export class InventoryService {
|
|
|
232
234
|
);
|
|
233
235
|
|
|
234
236
|
if (!reserveResult.ok) {
|
|
235
|
-
return Err(
|
|
237
|
+
return Err(
|
|
238
|
+
reserveResult.reason === INVENTORY_RECORD_NOT_FOUND_MESSAGE
|
|
239
|
+
? new CommerceInventoryRecordNotFoundError()
|
|
240
|
+
: new CommerceValidationError(reserveResult.reason),
|
|
241
|
+
);
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
await this.repo.createMovement(
|
|
@@ -281,7 +287,11 @@ export class InventoryService {
|
|
|
281
287
|
);
|
|
282
288
|
|
|
283
289
|
if (!releaseResult.ok) {
|
|
284
|
-
return Err(
|
|
290
|
+
return Err(
|
|
291
|
+
releaseResult.reason === INVENTORY_RECORD_NOT_FOUND_MESSAGE
|
|
292
|
+
? new CommerceInventoryRecordNotFoundError()
|
|
293
|
+
: new CommerceValidationError(releaseResult.reason),
|
|
294
|
+
);
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
await this.repo.createMovement(
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "../../kernel/errors.js";
|
|
11
11
|
import { runAfterHooks, runBeforeHooks } from "../../kernel/hooks/executor.js";
|
|
12
12
|
import { createHookContext } from "../../kernel/hooks/create-context.js";
|
|
13
|
+
import type { JobsAdapter } from "../../kernel/jobs/adapter.js";
|
|
13
14
|
import type {
|
|
14
15
|
AfterHook,
|
|
15
16
|
BeforeHook,
|
|
@@ -130,6 +131,7 @@ function context(
|
|
|
130
131
|
tx,
|
|
131
132
|
logger: createLogger("orders"),
|
|
132
133
|
services,
|
|
134
|
+
...(services.jobs ? { jobs: services.jobs as JobsAdapter } : {}),
|
|
133
135
|
context: { moduleName: "orders" },
|
|
134
136
|
database: { db: database.db as PluginDb },
|
|
135
137
|
});
|
|
@@ -808,12 +810,9 @@ export class OrderService {
|
|
|
808
810
|
);
|
|
809
811
|
if (!releaseResult.ok) {
|
|
810
812
|
const err = toCommerceError(releaseResult.error);
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
err.message === "No inventory record found for this entity."
|
|
815
|
-
)
|
|
816
|
-
) {
|
|
813
|
+
// Tolerate a missing inventory record (nothing to release); surface
|
|
814
|
+
// any other inventory error. Check the typed code, not the message.
|
|
815
|
+
if (err.code !== "INVENTORY_RECORD_NOT_FOUND") {
|
|
817
816
|
return Err(err);
|
|
818
817
|
}
|
|
819
818
|
}
|
|
@@ -935,12 +934,9 @@ export class OrderService {
|
|
|
935
934
|
);
|
|
936
935
|
if (!releaseResult.ok) {
|
|
937
936
|
const err = toCommerceError(releaseResult.error);
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
err.message === "No inventory record found for this entity."
|
|
942
|
-
)
|
|
943
|
-
) {
|
|
937
|
+
// Tolerate a missing inventory record (nothing to release); surface
|
|
938
|
+
// any other inventory error. Check the typed code, not the message.
|
|
939
|
+
if (err.code !== "INVENTORY_RECORD_NOT_FOUND") {
|
|
944
940
|
return Err(err);
|
|
945
941
|
}
|
|
946
942
|
}
|
|
@@ -45,17 +45,6 @@ export const staleOrderCleanupTask: TaskDefinition<
|
|
|
45
45
|
const thresholdHours = input.thresholdHours ?? 48;
|
|
46
46
|
const cutoff = new Date(Date.now() - thresholdHours * 60 * 60 * 1000);
|
|
47
47
|
|
|
48
|
-
// Find stale pending orders
|
|
49
|
-
const staleOrders = await ctx.db
|
|
50
|
-
.select()
|
|
51
|
-
.from(orders)
|
|
52
|
-
.where(
|
|
53
|
-
and(
|
|
54
|
-
eq(orders.status, "pending"),
|
|
55
|
-
lt(orders.placedAt, cutoff),
|
|
56
|
-
),
|
|
57
|
-
);
|
|
58
|
-
|
|
59
48
|
const cancelledIds: string[] = [];
|
|
60
49
|
|
|
61
50
|
// Cancel each stale order via the service (triggers inventory release + payment refund)
|
|
@@ -63,20 +52,42 @@ export const staleOrderCleanupTask: TaskDefinition<
|
|
|
63
52
|
cancel(orderId: string, actor: Actor | null, reason: string): Promise<unknown>;
|
|
64
53
|
};
|
|
65
54
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
55
|
+
// This is a cross-org system sweep, but the row reads stay org-scoped: first
|
|
56
|
+
// enumerate the orgs that have stale orders (id-only projection), then read
|
|
57
|
+
// each org's stale orders under an explicit organizationId predicate. No
|
|
58
|
+
// query returns another tenant's order rows.
|
|
59
|
+
const staleOrgs = await ctx.db
|
|
60
|
+
.selectDistinct({ organizationId: orders.organizationId })
|
|
61
|
+
.from(orders)
|
|
62
|
+
.where(and(eq(orders.status, "pending"), lt(orders.placedAt, cutoff)));
|
|
63
|
+
|
|
64
|
+
for (const { organizationId } of staleOrgs) {
|
|
65
|
+
const staleOrders = await ctx.db
|
|
66
|
+
.select()
|
|
67
|
+
.from(orders)
|
|
68
|
+
.where(
|
|
69
|
+
and(
|
|
70
|
+
eq(orders.organizationId, organizationId),
|
|
71
|
+
eq(orders.status, "pending"),
|
|
72
|
+
lt(orders.placedAt, cutoff),
|
|
73
|
+
),
|
|
72
74
|
);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
|
|
76
|
+
for (const order of staleOrders) {
|
|
77
|
+
try {
|
|
78
|
+
await orderService.cancel(
|
|
79
|
+
order.id,
|
|
80
|
+
systemActorForOrg(order.organizationId),
|
|
81
|
+
`Auto-cancelled: pending for >${thresholdHours}h`,
|
|
82
|
+
);
|
|
83
|
+
cancelledIds.push(order.id);
|
|
84
|
+
ctx.logger.info(`Stale order ${order.orderNumber} auto-cancelled`, {
|
|
85
|
+
orderId: order.id,
|
|
86
|
+
placedAt: order.placedAt,
|
|
87
|
+
});
|
|
88
|
+
} catch (error) {
|
|
89
|
+
ctx.logger.error(`Failed to cancel stale order ${order.orderNumber}`, { error });
|
|
90
|
+
}
|
|
80
91
|
}
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -182,6 +182,7 @@ export class PromotionsRepository {
|
|
|
182
182
|
|
|
183
183
|
async createUsage(
|
|
184
184
|
data: PromotionUsageInsert,
|
|
185
|
+
orgId: string,
|
|
185
186
|
ctx?: TxContext,
|
|
186
187
|
): Promise<PromotionUsage> {
|
|
187
188
|
const db = this.getDb(ctx);
|
|
@@ -190,10 +191,13 @@ export class PromotionsRepository {
|
|
|
190
191
|
// race conditions between concurrent checkouts using the same coupon.
|
|
191
192
|
// A plain count-then-insert has a TOCTOU gap; this single statement
|
|
192
193
|
// ensures the insert only succeeds if the limit has not been reached.
|
|
194
|
+
// Both reads are scoped by organizationId: these are raw/lock queries that
|
|
195
|
+
// bypass the scoped-db proxy, so a promotionId from another tenant must
|
|
196
|
+
// resolve to no row rather than reading/locking a foreign promotion.
|
|
193
197
|
const promo = await db
|
|
194
198
|
.select({ usageLimitTotal: promotions.usageLimitTotal })
|
|
195
199
|
.from(promotions)
|
|
196
|
-
.where(eq(promotions.id, data.promotionId));
|
|
200
|
+
.where(and(eq(promotions.id, data.promotionId), eq(promotions.organizationId, orgId)));
|
|
197
201
|
|
|
198
202
|
const limit = promo[0]?.usageLimitTotal;
|
|
199
203
|
|
|
@@ -206,7 +210,7 @@ export class PromotionsRepository {
|
|
|
206
210
|
// concurrent callers. If the caller is already inside a transaction
|
|
207
211
|
// (ctx.tx), the lock is held until that transaction commits.
|
|
208
212
|
await db.execute(
|
|
209
|
-
sql`SELECT id FROM promotions WHERE id = ${data.promotionId} FOR UPDATE`,
|
|
213
|
+
sql`SELECT id FROM promotions WHERE id = ${data.promotionId} AND organization_id = ${orgId} FOR UPDATE`,
|
|
210
214
|
);
|
|
211
215
|
|
|
212
216
|
const currentCount = await this.countUsages(data.promotionId, ctx);
|
|
@@ -500,6 +500,7 @@ export class PromotionService {
|
|
|
500
500
|
async recordUsage(
|
|
501
501
|
input: {
|
|
502
502
|
promotions: AppliedPromotion[];
|
|
503
|
+
organizationId: string;
|
|
503
504
|
customerId?: string;
|
|
504
505
|
orderId?: string;
|
|
505
506
|
},
|
|
@@ -513,6 +514,7 @@ export class PromotionService {
|
|
|
513
514
|
customerId: input.customerId ?? null,
|
|
514
515
|
orderId: input.orderId ?? null,
|
|
515
516
|
},
|
|
517
|
+
input.organizationId,
|
|
516
518
|
ctx,
|
|
517
519
|
);
|
|
518
520
|
usages.push(usage);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq, and, lte, isNull, or, desc, sql } from "drizzle-orm";
|
|
1
|
+
import { eq, and, lte, isNull, or, desc, sql, inArray } from "drizzle-orm";
|
|
2
2
|
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
3
|
import type {
|
|
4
4
|
DrizzleDatabase,
|
|
@@ -195,28 +195,30 @@ export class WebhooksRepository {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
async findFailedDeliveries(
|
|
198
|
+
orgId: string,
|
|
198
199
|
endpointId?: string,
|
|
199
200
|
ctx?: TxContext,
|
|
200
201
|
): Promise<WebhookDelivery[]> {
|
|
201
202
|
const db = this.getDb(ctx);
|
|
202
203
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
204
|
+
// webhook_deliveries has no organizationId column — org lives on the parent
|
|
205
|
+
// endpoint. Scope failed deliveries to endpoints owned by orgId so this can
|
|
206
|
+
// never read another tenant's deliveries.
|
|
207
|
+
const orgEndpointIds = db
|
|
208
|
+
.select({ id: webhookEndpoints.id })
|
|
209
|
+
.from(webhookEndpoints)
|
|
210
|
+
.where(eq(webhookEndpoints.organizationId, orgId));
|
|
211
|
+
|
|
212
|
+
const conditions = [
|
|
213
|
+
sql`${webhookDeliveries.failedAt} IS NOT NULL`,
|
|
214
|
+
inArray(webhookDeliveries.endpointId, orgEndpointIds),
|
|
215
|
+
];
|
|
216
|
+
if (endpointId) conditions.push(eq(webhookDeliveries.endpointId, endpointId));
|
|
215
217
|
|
|
216
218
|
return db
|
|
217
219
|
.select()
|
|
218
220
|
.from(webhookDeliveries)
|
|
219
|
-
.where(
|
|
221
|
+
.where(and(...conditions))
|
|
220
222
|
.orderBy(desc(webhookDeliveries.failedAt));
|
|
221
223
|
}
|
|
222
224
|
|
package/src/runtime/kernel.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { withTiming } from "../kernel/service-timing.js";
|
|
|
11
11
|
import { setBootDefaultOrgId } from "../auth/org.js";
|
|
12
12
|
import { setBootStrictOrgResolution } from "../auth/strict-org-resolution.js";
|
|
13
13
|
import { DrizzleJobsAdapter } from "../kernel/jobs/drizzle-adapter.js";
|
|
14
|
+
import type { ExecutionEngine } from "../kernel/jobs/adapter.js";
|
|
14
15
|
import { CompensationFailuresRepository } from "../kernel/compensation/repository.js";
|
|
15
16
|
|
|
16
17
|
import {
|
|
@@ -72,8 +73,21 @@ export function createKernel(config: CommerceConfig): Kernel {
|
|
|
72
73
|
const jobsTaskMap = new Map(
|
|
73
74
|
(config.jobs?.tasks ?? []).map((t) => [t.slug, t]),
|
|
74
75
|
);
|
|
75
|
-
const
|
|
76
|
-
|
|
76
|
+
const jobsEngine: ExecutionEngine =
|
|
77
|
+
config.jobs?.adapter ?? new DrizzleJobsAdapter(db);
|
|
78
|
+
const jobLogger = {
|
|
79
|
+
info: (message: string, data?: unknown) => logger.info(message, data),
|
|
80
|
+
warn: (message: string, data?: unknown) => logger.warn(message, data),
|
|
81
|
+
error: (message: string, data?: unknown) => logger.error(message, data),
|
|
82
|
+
};
|
|
83
|
+
jobsEngine.register({
|
|
84
|
+
tasks: jobsTaskMap,
|
|
85
|
+
context: { logger: jobLogger, db, services: serviceContainer },
|
|
86
|
+
...(config.jobs?.processingOrder !== undefined
|
|
87
|
+
? { processingOrder: config.jobs.processingOrder }
|
|
88
|
+
: {}),
|
|
89
|
+
});
|
|
90
|
+
serviceContainer.jobs = jobsEngine;
|
|
77
91
|
|
|
78
92
|
const topoGraph = kernelModulesForTopoSort();
|
|
79
93
|
const order = topoSortModules(topoGraph);
|
package/src/runtime/server.ts
CHANGED
|
@@ -17,7 +17,6 @@ import { createCustomerPortalRoutes } from "../interfaces/rest/customer-portal.j
|
|
|
17
17
|
import { createKernel } from "./kernel.js";
|
|
18
18
|
import { ensureDefaultOrg } from "../auth/org.js";
|
|
19
19
|
import { createLogger, type Logger } from "./logger.js";
|
|
20
|
-
import type { DrizzleDatabase } from "../kernel/database/drizzle-db.js";
|
|
21
20
|
import { createCommerce, type CommerceInstance } from "./commerce.js";
|
|
22
21
|
import { rewriteCommerceAliasRequest } from "./url-alias-rewrite.js";
|
|
23
22
|
import { mapErrorToResponse } from "../kernel/error-mapper.js";
|
|
@@ -408,48 +407,18 @@ export async function createServer(config: CommerceConfig) {
|
|
|
408
407
|
// 2. GET /api/jobs/run: cron endpoint (serverless — Vercel, Cloudflare)
|
|
409
408
|
// 3. runPendingJobs() export (custom worker process)
|
|
410
409
|
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
await import("../kernel/jobs/reaper.js");
|
|
414
|
-
const taskMap = new Map<string, unknown>();
|
|
415
|
-
for (const task of config.jobs?.tasks ?? []) {
|
|
416
|
-
const t = task as { slug?: string; name?: string };
|
|
417
|
-
taskMap.set(t.slug ?? t.name ?? "", task);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const jobLogger = {
|
|
421
|
-
info: (msg: string, data?: unknown) => logger.info(data != null ? { data } : {}, msg),
|
|
422
|
-
warn: (msg: string, data?: unknown) => logger.warn(data != null ? { data } : {}, msg),
|
|
423
|
-
error: (msg: string, data?: unknown) => logger.error(data != null ? { data } : {}, msg),
|
|
424
|
-
};
|
|
425
|
-
|
|
426
|
-
let lastStaleJobReaperAt = 0;
|
|
410
|
+
const jobsEngine = (kernel.services as Record<string, unknown>)
|
|
411
|
+
.jobs as import("../kernel/jobs/adapter.js").ExecutionEngine;
|
|
427
412
|
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
lastStaleJobReaperAt = now;
|
|
433
|
-
try {
|
|
434
|
-
await runStaleJobReaper(
|
|
435
|
-
kernel.database.db as DrizzleDatabase,
|
|
436
|
-
getJobReapThresholdMs(),
|
|
437
|
-
jobLogger,
|
|
413
|
+
const runJobs = async (queue?: string, limit?: number) => {
|
|
414
|
+
if (jobsEngine.execution.mode !== "pull") {
|
|
415
|
+
throw new Error(
|
|
416
|
+
"The configured push execution engine is driven by its native runtime and cannot be polled with runJobs().",
|
|
438
417
|
);
|
|
439
|
-
} catch (err) {
|
|
440
|
-
logger.error({ err }, "Stale job reaper failed");
|
|
441
418
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const runJobs = async (queue?: string, limit?: number) => {
|
|
445
|
-
await maybeRunStaleJobReaper();
|
|
446
|
-
return runPendingJobs({
|
|
447
|
-
db: kernel.database.db as DrizzleDatabase,
|
|
448
|
-
tasks: taskMap as Parameters<typeof runPendingJobs>[0]["tasks"],
|
|
419
|
+
return jobsEngine.execution.run({
|
|
449
420
|
queue: queue ?? "default",
|
|
450
421
|
limit: limit ?? 10,
|
|
451
|
-
logger: jobLogger,
|
|
452
|
-
services: kernel.services as Parameters<typeof runPendingJobs>[0]["services"],
|
|
453
422
|
});
|
|
454
423
|
};
|
|
455
424
|
|
|
@@ -478,6 +447,11 @@ export async function createServer(config: CommerceConfig) {
|
|
|
478
447
|
// Strategy 2: In-process polling for long-running servers (ECS, Cloud Run, Docker).
|
|
479
448
|
// Enable via config.jobs.autorun.enabled = true
|
|
480
449
|
if (config.jobs?.autorun?.enabled) {
|
|
450
|
+
if (jobsEngine.execution.mode !== "pull") {
|
|
451
|
+
throw new Error(
|
|
452
|
+
"jobs.autorun is only valid for pull execution engines. Serve the configured push engine through its native runtime.",
|
|
453
|
+
);
|
|
454
|
+
}
|
|
481
455
|
const intervalMs = config.jobs.autorun.intervalMs ?? 10_000;
|
|
482
456
|
|
|
483
457
|
const jobInterval = setInterval(async () => {
|
|
@@ -494,10 +468,16 @@ export async function createServer(config: CommerceConfig) {
|
|
|
494
468
|
|
|
495
469
|
logger.info({ intervalMs }, "Job queue autorun started (in-process polling)");
|
|
496
470
|
} else {
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
471
|
+
if (jobsEngine.execution.mode === "push") {
|
|
472
|
+
logger.info(
|
|
473
|
+
"Push job engine registered. Serve it through its native runtime.",
|
|
474
|
+
);
|
|
475
|
+
} else {
|
|
476
|
+
logger.info(
|
|
477
|
+
"Job queue autorun disabled. Use GET /api/jobs/run for serverless cron, " +
|
|
478
|
+
"or set config.jobs.autorun.enabled = true for in-process polling.",
|
|
479
|
+
);
|
|
480
|
+
}
|
|
501
481
|
}
|
|
502
482
|
|
|
503
483
|
const dispatchFetch = app.fetch.bind(app);
|