@porulle/core 0.9.0 → 0.10.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/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/adapter.d.ts +8 -0
- package/dist/kernel/database/adapter.d.ts.map +1 -1
- package/dist/kernel/database/migrate.d.ts +1 -1
- package/dist/kernel/database/migrate.d.ts.map +1 -1
- package/dist/kernel/database/migrate.js +5 -2
- 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/commerce.d.ts.map +1 -1
- package/dist/runtime/commerce.js +9 -0
- 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 +3 -3
- 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/adapter.ts +8 -0
- package/src/kernel/database/migrate.ts +8 -2
- 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/commerce.ts +10 -0
- package/src/runtime/kernel.ts +16 -2
- package/src/runtime/server.ts +22 -42
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
CommercePlugin,
|
|
15
15
|
PluginPermission,
|
|
16
16
|
} from "../../config/types.js";
|
|
17
|
+
import type { TaskDefinition } from "../jobs/types.js";
|
|
17
18
|
|
|
18
19
|
// ─── Plugin Logger ────────────────────────────────────────────────────
|
|
19
20
|
|
|
@@ -147,6 +148,7 @@ export interface CommercePluginManifest {
|
|
|
147
148
|
hooks?: () => PluginHookRegistration[];
|
|
148
149
|
routes?: (ctx: PluginContext) => PluginRouteRegistration[];
|
|
149
150
|
analyticsModels?: () => unknown[];
|
|
151
|
+
jobs?: () => TaskDefinition[];
|
|
150
152
|
/**
|
|
151
153
|
* Named API-key scopes this plugin mints credentials under (issue #51),
|
|
152
154
|
* merged into `config.auth.apiKeyScopes`. User-defined scopes with the
|
|
@@ -344,6 +346,16 @@ export function defineCommercePlugin(
|
|
|
344
346
|
};
|
|
345
347
|
}
|
|
346
348
|
|
|
349
|
+
if (manifest.jobs) {
|
|
350
|
+
result = {
|
|
351
|
+
...result,
|
|
352
|
+
jobs: {
|
|
353
|
+
...(result.jobs ?? {}),
|
|
354
|
+
tasks: [...(result.jobs?.tasks ?? []), ...manifest.jobs()],
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
347
359
|
return result;
|
|
348
360
|
};
|
|
349
361
|
}
|
|
@@ -98,6 +98,10 @@ export class EntityService {
|
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
private assertEntityWritable(entity: SellableEntity, actor: Actor | null): void {
|
|
102
|
+
if (entity.sourceStoreId != null) assertPermission(actor, "catalog:sync");
|
|
103
|
+
}
|
|
104
|
+
|
|
101
105
|
private async validateAndCreateCustomFields(entityId: string, entityType: string, customFields: Record<string, unknown> | undefined, ctx?: TxContext): Promise<Result<void>> {
|
|
102
106
|
if (!customFields) return Ok(undefined);
|
|
103
107
|
const entityConfig = this.deps.config.entities?.[entityType];
|
|
@@ -212,7 +216,8 @@ export class EntityService {
|
|
|
212
216
|
const afterHooks = this.deps.hooks.resolve("catalog.afterCreate") as CatalogCreateAfterHook[];
|
|
213
217
|
const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.create"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
|
|
214
218
|
const processedInput = await runBeforeHooks(beforeHooks, input, "create", context);
|
|
215
|
-
|
|
219
|
+
if (processedInput.sourceStoreId != null) assertPermission(actor, "catalog:sync");
|
|
220
|
+
const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status: "draft", isVisible: false, ...(processedInput.sourceStoreId !== undefined ? { sourceStoreId: processedInput.sourceStoreId } : {}), ...(processedInput.taxClass !== undefined ? { taxClass: processedInput.taxClass } : {}), metadata: processedInput.metadata ?? {} }, ctx);
|
|
216
221
|
if (processedInput.attributes) {
|
|
217
222
|
await this.repo.createAttribute({ entityId: entity.id, locale: processedInput.attributes.locale ?? "en", title: processedInput.attributes.title, subtitle: processedInput.attributes.subtitle, description: processedInput.attributes.description, richDescription: processedInput.attributes.richDescription, seoTitle: processedInput.attributes.seoTitle, seoDescription: processedInput.attributes.seoDescription }, ctx);
|
|
218
223
|
}
|
|
@@ -228,6 +233,7 @@ export class EntityService {
|
|
|
228
233
|
const existing = await this.repo.findEntityById(id, ctx);
|
|
229
234
|
if (!existing) return Err(new CommerceNotFoundError("Entity not found."));
|
|
230
235
|
this.assertSameOrg(existing, actor);
|
|
236
|
+
this.assertEntityWritable(existing, actor);
|
|
231
237
|
const beforeHooks = this.deps.hooks.resolve("catalog.beforeUpdate") as CatalogUpdateBeforeHook[];
|
|
232
238
|
const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
|
|
233
239
|
const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.update"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
|
|
@@ -244,6 +250,7 @@ export class EntityService {
|
|
|
244
250
|
const existing = await this.repo.findEntityById(id, ctx);
|
|
245
251
|
if (!existing) return Err(new CommerceNotFoundError("Entity not found."));
|
|
246
252
|
this.assertSameOrg(existing, actor);
|
|
253
|
+
this.assertEntityWritable(existing, actor);
|
|
247
254
|
await this.repo.deleteAttributesByEntityId(id, ctx);
|
|
248
255
|
await this.repo.deleteCustomFieldsByEntityId(id, ctx);
|
|
249
256
|
await this.repo.deleteEntityCategoriesByEntityId(id, ctx);
|
|
@@ -382,6 +389,7 @@ export class EntityService {
|
|
|
382
389
|
const entity = await this.repo.findEntityById(id, ctx);
|
|
383
390
|
if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
|
|
384
391
|
try { this.assertSameOrg(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
|
|
392
|
+
try { this.assertEntityWritable(entity, actor); } catch (error) { return Err(toCommerceError(error)); }
|
|
385
393
|
const updateData: Partial<SellableEntity> = { status };
|
|
386
394
|
if (status === "active") { updateData.publishedAt = new Date(); updateData.isVisible = true; }
|
|
387
395
|
const updated = await this.repo.updateEntity(id, updateData, ctx);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { eq, and, inArray, type SQL } from "drizzle-orm";
|
|
2
2
|
import type { TxContext } from "../../../kernel/database/tx-context.js";
|
|
3
|
+
import { CommerceNotFoundError } from "../../../kernel/errors.js";
|
|
3
4
|
import type {
|
|
4
5
|
DrizzleDatabase,
|
|
5
6
|
DbOrTx,
|
|
@@ -698,15 +699,24 @@ export class CatalogRepository {
|
|
|
698
699
|
return rows[0];
|
|
699
700
|
}
|
|
700
701
|
|
|
701
|
-
async createVariant(
|
|
702
|
+
async createVariant(
|
|
703
|
+
data: Omit<VariantInsert, "organizationId" | "sourceStoreId">,
|
|
704
|
+
ctx?: TxContext,
|
|
705
|
+
): Promise<Variant> {
|
|
702
706
|
const db = this.getDb(ctx);
|
|
703
|
-
const
|
|
707
|
+
const entity = await this.findEntityById(data.entityId, ctx);
|
|
708
|
+
if (!entity) throw new CommerceNotFoundError("Entity not found.");
|
|
709
|
+
const rows = await db.insert(variants).values({
|
|
710
|
+
...data,
|
|
711
|
+
organizationId: entity.organizationId,
|
|
712
|
+
sourceStoreId: entity.sourceStoreId,
|
|
713
|
+
}).returning();
|
|
704
714
|
return rows[0]!;
|
|
705
715
|
}
|
|
706
716
|
|
|
707
717
|
async updateVariant(
|
|
708
718
|
id: string,
|
|
709
|
-
data: Partial<Omit<VariantInsert, "id">>,
|
|
719
|
+
data: Partial<Omit<VariantInsert, "id" | "organizationId" | "sourceStoreId">>,
|
|
710
720
|
ctx?: TxContext,
|
|
711
721
|
): Promise<Variant | undefined> {
|
|
712
722
|
const db = this.getDb(ctx);
|
|
@@ -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/commerce.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Actor } from "../auth/types.js";
|
|
|
2
2
|
import type { CommerceConfig } from "../config/types.js";
|
|
3
3
|
import type { Kernel } from "./kernel.js";
|
|
4
4
|
import { createKernel } from "./kernel.js";
|
|
5
|
+
import { pushSchema } from "../kernel/database/migrate.js";
|
|
5
6
|
import { ensureDefaultOrg, setBootDefaultOrgId } from "../auth/org.js";
|
|
6
7
|
import { setBootStrictOrgResolution } from "../auth/strict-org-resolution.js";
|
|
7
8
|
import { createAuth, type AuthInstance } from "../auth/setup.js";
|
|
@@ -122,6 +123,15 @@ export async function createCommerce(
|
|
|
122
123
|
): Promise<CommerceInstance> {
|
|
123
124
|
const kernel = createKernel(config);
|
|
124
125
|
|
|
126
|
+
// Zero-migration adapters (PGlite) push core schema at construction, but a
|
|
127
|
+
// plugin's own tables live in `config.customSchemas`, which only exist after
|
|
128
|
+
// plugins run in `defineConfig` — i.e. after the adapter was constructed.
|
|
129
|
+
// Create them now so plugin routes have their tables. Guarded on both signals
|
|
130
|
+
// so plugin-less stores and migration-managed adapters (Postgres) are untouched.
|
|
131
|
+
if (config.databaseAdapter?.autoMigrate && config.customSchemas?.length) {
|
|
132
|
+
await pushSchema(kernel.database.db, config);
|
|
133
|
+
}
|
|
134
|
+
|
|
125
135
|
setBootStrictOrgResolution(config.auth?.strictOrgResolution === true);
|
|
126
136
|
|
|
127
137
|
// Register the config-driven org ID so resolveOrgId() can use it
|