@porulle/core 0.48.1 → 0.49.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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/modules/audit/hooks.d.ts +0 -4
- package/dist/modules/audit/hooks.d.ts.map +1 -1
- package/dist/modules/audit/hooks.js +17 -0
- package/dist/modules/catalog/entity-service.d.ts +2 -0
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +1 -1
- package/dist/modules/catalog/import-service.d.ts +108 -0
- package/dist/modules/catalog/import-service.d.ts.map +1 -0
- package/dist/modules/catalog/import-service.js +423 -0
- package/dist/modules/catalog/service.d.ts +8 -0
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +6 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +3 -0
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/modules/audit/hooks.ts +18 -0
- package/src/modules/catalog/entity-service.ts +1 -1
- package/src/modules/catalog/import-service.ts +563 -0
- package/src/modules/catalog/service.ts +22 -0
- package/src/testing.ts +3 -0
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AfterHook } from "../../kernel/hooks/types.js";
|
|
2
2
|
import type { HookHandler } from "../../kernel/hooks/registry.js";
|
|
3
3
|
import type { AuditService } from "./service.js";
|
|
4
|
+
import type { ImportProductsReport } from "../catalog/import-service.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Creates an after-hook that records an audit entry for the operation.
|
|
@@ -52,10 +53,27 @@ function safePayload(result: unknown): Record<string, unknown> {
|
|
|
52
53
|
* All audit hooks, keyed by hook registration key.
|
|
53
54
|
* Registered in kernel boot via hooks.append().
|
|
54
55
|
*/
|
|
56
|
+
/**
|
|
57
|
+
* One audit row per imported PAGE. The fast path fires no `catalog.afterCreate`, so the per-entity
|
|
58
|
+
* "created" rows do not exist for imported products; the page row names the store and the counts.
|
|
59
|
+
*/
|
|
60
|
+
const catalogImportAuditHook: AfterHook<ImportProductsReport> = async ({ result, context }) => {
|
|
61
|
+
const audit = context.services.audit as AuditService | undefined;
|
|
62
|
+
if (!audit?.record) return;
|
|
63
|
+
await audit.record({
|
|
64
|
+
entityType: "catalog_import",
|
|
65
|
+
entityId: result.sourceStoreId,
|
|
66
|
+
event: "imported",
|
|
67
|
+
payload: { created: result.created, failed: result.failed },
|
|
68
|
+
ctx: context,
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
55
72
|
export const auditHooks: Record<string, HookHandler> = {
|
|
56
73
|
// Catalog
|
|
57
74
|
"catalog.afterCreate": createAuditAfterHook("catalog_entity", "created") as HookHandler,
|
|
58
75
|
"catalog.afterUpdate": createAuditAfterHook("catalog_entity", "updated") as HookHandler,
|
|
76
|
+
"catalog.afterImport": catalogImportAuditHook as HookHandler,
|
|
59
77
|
|
|
60
78
|
// Orders
|
|
61
79
|
"orders.afterCreate": createAuditAfterHook("order", "created") as HookHandler,
|
|
@@ -58,7 +58,7 @@ function hookDatabaseArg(database: CatalogServiceDeps["database"]): { database:
|
|
|
58
58
|
|
|
59
59
|
const attributeFields = ["title", "subtitle", "description", "richDescription", "seoTitle", "seoDescription"] as const;
|
|
60
60
|
|
|
61
|
-
function catalogHookContext(
|
|
61
|
+
export function catalogHookContext(
|
|
62
62
|
deps: CatalogServiceDeps,
|
|
63
63
|
actor: Actor | null,
|
|
64
64
|
ctx: CatalogWriteContext | undefined,
|
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
2
|
+
import type { Actor } from "../../auth/types.js";
|
|
3
|
+
import { resolveOrgIdForCommerce } from "../../auth/org.js";
|
|
4
|
+
import { assertPermission } from "../../auth/permissions.js";
|
|
5
|
+
import { Err, Ok, type Result } from "../../kernel/result.js";
|
|
6
|
+
import { toCommerceError } from "../../kernel/errors.js";
|
|
7
|
+
import type { PluginDb } from "../../kernel/database/plugin-types.js";
|
|
8
|
+
import { createTxContext, isWriteContextTransactional, resolveWriteContextHookContext, type CatalogWriteContext, type TxContext } from "../../kernel/database/tx-context.js";
|
|
9
|
+
import { runAfterHooks } from "../../kernel/hooks/executor.js";
|
|
10
|
+
import type { AfterHook } from "../../kernel/hooks/types.js";
|
|
11
|
+
import { isValidFieldPath, type FieldPath } from "./ownership.js";
|
|
12
|
+
import {
|
|
13
|
+
brands,
|
|
14
|
+
categories,
|
|
15
|
+
catalogFieldOwnership,
|
|
16
|
+
entityBrands,
|
|
17
|
+
entityCategories,
|
|
18
|
+
entityTags,
|
|
19
|
+
optionTypes,
|
|
20
|
+
optionValues,
|
|
21
|
+
sellableAttributes,
|
|
22
|
+
sellableEntities,
|
|
23
|
+
sellableEntityRevisions,
|
|
24
|
+
tags,
|
|
25
|
+
variantOptionValues,
|
|
26
|
+
variants,
|
|
27
|
+
type SellableEntityRevisionReason,
|
|
28
|
+
type SellableEntityRevisionSnapshot,
|
|
29
|
+
} from "./schema.js";
|
|
30
|
+
import { prices } from "../pricing/schema.js";
|
|
31
|
+
import { catalogHookContext } from "./entity-service.js";
|
|
32
|
+
import type { CatalogServiceDeps } from "./service.js";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The import fast path: a PAGE of new products lands in one transaction.
|
|
36
|
+
*
|
|
37
|
+
* `create` + `createVariant` + `setAttributes` + … cost 564 statements for one 12-variant product
|
|
38
|
+
* (measured 2026-09-22) because every call re-reads the entity, records a revision and writes one
|
|
39
|
+
* row per statement. This path reads the page's state once, writes multi-row, isolates each item
|
|
40
|
+
* behind a savepoint, records one revision per item and one audit row per page, and fires no
|
|
41
|
+
* per-entity hook — the same split Vendure (`FastImporterService`), Saleor (`productBulkCreate`)
|
|
42
|
+
* and Medusa (`createProducts([])`) make between the editor path and the importer path.
|
|
43
|
+
*
|
|
44
|
+
* It creates; it never updates. A caller that finds an item already present routes it through the
|
|
45
|
+
* editor path, which is where ownership, conflict detection and revision diffing live.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface ImportProductPrice {
|
|
49
|
+
currency: string;
|
|
50
|
+
amount: number;
|
|
51
|
+
compareAtAmount?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ImportProductVariant {
|
|
55
|
+
/** The caller's key for this variant; echoed in the row result so ids can be mapped back. */
|
|
56
|
+
ref: string;
|
|
57
|
+
sku?: string;
|
|
58
|
+
barcode?: string;
|
|
59
|
+
/** option name → option value, both as declared in `ImportProduct.options`. */
|
|
60
|
+
options?: Record<string, string>;
|
|
61
|
+
prices?: ImportProductPrice[];
|
|
62
|
+
metadata?: Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ImportProductOption {
|
|
66
|
+
name: string;
|
|
67
|
+
displayName?: string;
|
|
68
|
+
sortOrder?: number;
|
|
69
|
+
values: Array<{ value: string; displayValue?: string; sortOrder?: number }>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ImportProductAttributes {
|
|
73
|
+
locale: string;
|
|
74
|
+
title: string;
|
|
75
|
+
subtitle?: string;
|
|
76
|
+
description?: string;
|
|
77
|
+
richDescription?: unknown;
|
|
78
|
+
seoTitle?: string;
|
|
79
|
+
seoDescription?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ImportProduct {
|
|
83
|
+
/** The caller's key for this item; echoed in the row result. Unique within the page. */
|
|
84
|
+
ref: string;
|
|
85
|
+
type?: string;
|
|
86
|
+
slug: string;
|
|
87
|
+
status?: "draft" | "active" | "archived" | "discontinued";
|
|
88
|
+
isVisible?: boolean;
|
|
89
|
+
taxClass?: string;
|
|
90
|
+
metadata?: Record<string, unknown>;
|
|
91
|
+
attributes: ImportProductAttributes[];
|
|
92
|
+
options?: ImportProductOption[];
|
|
93
|
+
variants: ImportProductVariant[];
|
|
94
|
+
tags?: string[];
|
|
95
|
+
brand?: string;
|
|
96
|
+
categories?: string[];
|
|
97
|
+
/** Field paths seeded as owned by the source store; an invalid path fails the row. */
|
|
98
|
+
ownedFieldPaths?: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Saleor's `errorPolicy`, minus IGNORE_FAILED (a silently shrinking page is the failure mode a
|
|
103
|
+
* page-level report exists to prevent).
|
|
104
|
+
*/
|
|
105
|
+
export type ImportErrorPolicy = "reject-failed-rows" | "reject-everything";
|
|
106
|
+
|
|
107
|
+
export interface ImportProductsOptions {
|
|
108
|
+
sourceStoreId: string;
|
|
109
|
+
errorPolicy?: ImportErrorPolicy;
|
|
110
|
+
reason?: Extract<SellableEntityRevisionReason, "import" | "create">;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type ImportRowFailureCode =
|
|
114
|
+
| "duplicate-in-page"
|
|
115
|
+
| "invalid"
|
|
116
|
+
| "slug-conflict"
|
|
117
|
+
| "conflict"
|
|
118
|
+
| "write-failed"
|
|
119
|
+
| "rejected-by-policy";
|
|
120
|
+
|
|
121
|
+
export type ImportProductRowResult =
|
|
122
|
+
| {
|
|
123
|
+
ref: string;
|
|
124
|
+
status: "created";
|
|
125
|
+
entityId: string;
|
|
126
|
+
/** variant ref → variant id. */
|
|
127
|
+
variantIds: Record<string, string>;
|
|
128
|
+
warnings: string[];
|
|
129
|
+
}
|
|
130
|
+
| { ref: string; status: "failed"; code: ImportRowFailureCode; error: string };
|
|
131
|
+
|
|
132
|
+
export interface ImportProductsReport extends Record<string, unknown> {
|
|
133
|
+
sourceStoreId: string;
|
|
134
|
+
created: number;
|
|
135
|
+
failed: number;
|
|
136
|
+
/** One per input item, in input order. */
|
|
137
|
+
rows: ImportProductRowResult[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type Writer = Pick<PluginDb, "insert" | "select">;
|
|
141
|
+
|
|
142
|
+
type Failure = { code: ImportRowFailureCode; error: string };
|
|
143
|
+
|
|
144
|
+
function failed(ref: string, code: ImportRowFailureCode, error: string): ImportProductRowResult {
|
|
145
|
+
return { ref, status: "failed", code, error };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isUniqueViolation(error: unknown): boolean {
|
|
149
|
+
if (typeof error !== "object" || error === null) return false;
|
|
150
|
+
const value = error as { code?: unknown; cause?: unknown };
|
|
151
|
+
if (value.code === "23505") return true;
|
|
152
|
+
return isUniqueViolation(value.cause);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A row's failure names the constraint, not the statement: Drizzle's message carries the whole
|
|
157
|
+
* query with its parameters, which is merchant data in a report that travels.
|
|
158
|
+
*/
|
|
159
|
+
function describeWriteError(error: unknown): string {
|
|
160
|
+
let cursor: unknown = error;
|
|
161
|
+
for (let depth = 0; depth < 5 && typeof cursor === "object" && cursor !== null; depth += 1) {
|
|
162
|
+
const value = cursor as { detail?: unknown; constraint?: unknown; cause?: unknown };
|
|
163
|
+
if (typeof value.detail === "string") return typeof value.constraint === "string" ? `${value.constraint}: ${value.detail}` : value.detail;
|
|
164
|
+
cursor = value.cause;
|
|
165
|
+
}
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
return message.split("\n", 1)[0] ?? message;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normaliseCurrency(currency: string): string {
|
|
171
|
+
return currency.trim().toUpperCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Everything that can be known wrong without a database. */
|
|
175
|
+
function validateInMemory(page: ImportProduct[]): Map<number, Failure> {
|
|
176
|
+
const failures = new Map<number, Failure>();
|
|
177
|
+
const seenRefs = new Set<string>();
|
|
178
|
+
const seenSlugs = new Set<string>();
|
|
179
|
+
page.forEach((item, index) => {
|
|
180
|
+
const fail = (error: string, code: ImportRowFailureCode = "invalid"): void => {
|
|
181
|
+
if (!failures.has(index)) failures.set(index, { code, error });
|
|
182
|
+
};
|
|
183
|
+
if (seenRefs.has(item.ref)) fail(`Item ref "${item.ref}" appears twice in the page.`, "duplicate-in-page");
|
|
184
|
+
seenRefs.add(item.ref);
|
|
185
|
+
if (seenSlugs.has(item.slug)) fail(`Slug "${item.slug}" appears twice in the page.`, "duplicate-in-page");
|
|
186
|
+
seenSlugs.add(item.slug);
|
|
187
|
+
if (item.slug.trim().length === 0) fail("Slug is required.");
|
|
188
|
+
if (item.attributes.length === 0) fail("At least one attributes row is required.");
|
|
189
|
+
const locales = new Set<string>();
|
|
190
|
+
for (const attributes of item.attributes) {
|
|
191
|
+
if (attributes.title.trim().length === 0) fail(`Attributes for "${attributes.locale}" need a title.`);
|
|
192
|
+
if (locales.has(attributes.locale)) fail(`Attributes for "${attributes.locale}" appear twice.`);
|
|
193
|
+
locales.add(attributes.locale);
|
|
194
|
+
}
|
|
195
|
+
const declared = new Map<string, Set<string>>();
|
|
196
|
+
for (const option of item.options ?? []) {
|
|
197
|
+
if (declared.has(option.name)) fail(`Option "${option.name}" is declared twice.`);
|
|
198
|
+
const values = new Set<string>();
|
|
199
|
+
for (const value of option.values) {
|
|
200
|
+
if (values.has(value.value)) fail(`Option "${option.name}" declares value "${value.value}" twice.`);
|
|
201
|
+
values.add(value.value);
|
|
202
|
+
}
|
|
203
|
+
declared.set(option.name, values);
|
|
204
|
+
}
|
|
205
|
+
const variantRefs = new Set<string>();
|
|
206
|
+
for (const variant of item.variants) {
|
|
207
|
+
if (variantRefs.has(variant.ref)) fail(`Variant ref "${variant.ref}" appears twice.`);
|
|
208
|
+
variantRefs.add(variant.ref);
|
|
209
|
+
for (const [name, value] of Object.entries(variant.options ?? {})) {
|
|
210
|
+
const values = declared.get(name);
|
|
211
|
+
if (!values) fail(`Variant "${variant.ref}" references undeclared option "${name}".`);
|
|
212
|
+
else if (!values.has(value)) fail(`Variant "${variant.ref}" references undeclared value "${value}" for option "${name}".`);
|
|
213
|
+
}
|
|
214
|
+
for (const price of variant.prices ?? []) {
|
|
215
|
+
if (!Number.isInteger(price.amount) || price.amount < 0) fail(`Variant "${variant.ref}" has a non-integer or negative amount for ${price.currency}.`);
|
|
216
|
+
if (normaliseCurrency(price.currency).length === 0) fail(`Variant "${variant.ref}" has a price with no currency.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
for (const path of item.ownedFieldPaths ?? []) {
|
|
220
|
+
if (!isValidFieldPath(path)) fail(`Owned field path "${path}" is not a valid field path.`);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
return failures;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
interface Taxonomy {
|
|
227
|
+
categories: Map<string, { id: string; status: string }>;
|
|
228
|
+
brands: Map<string, string>;
|
|
229
|
+
tags: Map<string, string>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Page-level: read what exists, create what is missing (racing a peer safely), read back. */
|
|
233
|
+
async function resolveTaxonomy(tx: Writer, orgId: string, page: ImportProduct[]): Promise<Taxonomy> {
|
|
234
|
+
const categorySlugs = [...new Set(page.flatMap((item) => item.categories ?? []))];
|
|
235
|
+
const brandSlugs = [...new Set(page.flatMap((item) => (item.brand ? [item.brand] : [])))];
|
|
236
|
+
const tagSlugs = [...new Set(page.flatMap((item) => item.tags ?? []))];
|
|
237
|
+
|
|
238
|
+
const readCategories = async (): Promise<Map<string, { id: string; status: string }>> => {
|
|
239
|
+
if (categorySlugs.length === 0) return new Map();
|
|
240
|
+
const rows = await tx.select({ id: categories.id, slug: categories.slug, status: categories.status }).from(categories)
|
|
241
|
+
.where(and(eq(categories.organizationId, orgId), inArray(categories.slug, categorySlugs)));
|
|
242
|
+
return new Map(rows.map((row) => [row.slug, { id: row.id, status: row.status }]));
|
|
243
|
+
};
|
|
244
|
+
const readBrands = async (): Promise<Map<string, string>> => {
|
|
245
|
+
if (brandSlugs.length === 0) return new Map();
|
|
246
|
+
const rows = await tx.select({ id: brands.id, slug: brands.slug }).from(brands)
|
|
247
|
+
.where(and(eq(brands.organizationId, orgId), inArray(brands.slug, brandSlugs)));
|
|
248
|
+
return new Map(rows.map((row) => [row.slug, row.id]));
|
|
249
|
+
};
|
|
250
|
+
const readTags = async (): Promise<Map<string, string>> => {
|
|
251
|
+
if (tagSlugs.length === 0) return new Map();
|
|
252
|
+
const rows = await tx.select({ id: tags.id, slug: tags.slug }).from(tags)
|
|
253
|
+
.where(and(eq(tags.organizationId, orgId), inArray(tags.slug, tagSlugs)));
|
|
254
|
+
return new Map(rows.map((row) => [row.slug, row.id]));
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
let categoryMap = await readCategories();
|
|
258
|
+
const missingCategories = categorySlugs.filter((slug) => !categoryMap.has(slug));
|
|
259
|
+
if (missingCategories.length > 0) {
|
|
260
|
+
// ON CONFLICT DO NOTHING, then re-read: two consumers landing pages that share a vocabulary
|
|
261
|
+
// both succeed, and whichever lost the race reads the winner's row. The find-then-insert the
|
|
262
|
+
// editor path does raises 23505 for the loser.
|
|
263
|
+
await tx.insert(categories).values(missingCategories.map((slug) => ({ organizationId: orgId, slug }))).onConflictDoNothing();
|
|
264
|
+
categoryMap = await readCategories();
|
|
265
|
+
}
|
|
266
|
+
let brandMap = await readBrands();
|
|
267
|
+
const missingBrands = brandSlugs.filter((slug) => !brandMap.has(slug));
|
|
268
|
+
if (missingBrands.length > 0) {
|
|
269
|
+
await tx.insert(brands).values(missingBrands.map((slug) => ({ organizationId: orgId, slug, displayName: slug }))).onConflictDoNothing();
|
|
270
|
+
brandMap = await readBrands();
|
|
271
|
+
}
|
|
272
|
+
let tagMap = await readTags();
|
|
273
|
+
const missingTags = tagSlugs.filter((slug) => !tagMap.has(slug));
|
|
274
|
+
if (missingTags.length > 0) {
|
|
275
|
+
await tx.insert(tags).values(missingTags.map((slug) => ({ organizationId: orgId, slug, displayName: slug }))).onConflictDoNothing();
|
|
276
|
+
tagMap = await readTags();
|
|
277
|
+
}
|
|
278
|
+
return { categories: categoryMap, brands: brandMap, tags: tagMap };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
type Snapshot = SellableEntityRevisionSnapshot;
|
|
282
|
+
type Row = Record<string, unknown>;
|
|
283
|
+
const asRows = (rows: object[]): Row[] => rows.map((row) => ({ ...row }));
|
|
284
|
+
const byKey = (key: string) => (a: Row, b: Row): number => String(a[key]).localeCompare(String(b[key]));
|
|
285
|
+
|
|
286
|
+
interface ItemWrite {
|
|
287
|
+
entityId: string;
|
|
288
|
+
variantIds: Record<string, string>;
|
|
289
|
+
warnings: string[];
|
|
290
|
+
snapshot: Snapshot;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** One item, inside its own savepoint: eleven multi-row statements at most. */
|
|
294
|
+
async function writeItem(
|
|
295
|
+
sp: Writer,
|
|
296
|
+
orgId: string,
|
|
297
|
+
sourceStoreId: string,
|
|
298
|
+
item: ImportProduct,
|
|
299
|
+
taxonomy: Taxonomy,
|
|
300
|
+
): Promise<ItemWrite> {
|
|
301
|
+
const warnings: string[] = [];
|
|
302
|
+
const [entity] = await sp.insert(sellableEntities).values({
|
|
303
|
+
organizationId: orgId,
|
|
304
|
+
sourceStoreId,
|
|
305
|
+
type: item.type ?? "product",
|
|
306
|
+
slug: item.slug,
|
|
307
|
+
metadata: item.metadata ?? {},
|
|
308
|
+
...(item.status !== undefined ? { status: item.status } : {}),
|
|
309
|
+
...(item.isVisible !== undefined ? { isVisible: item.isVisible } : item.status !== undefined ? { isVisible: item.status === "active" } : {}),
|
|
310
|
+
...(item.taxClass !== undefined ? { taxClass: item.taxClass } : {}),
|
|
311
|
+
}).returning();
|
|
312
|
+
if (!entity) throw new Error("Entity insert returned no row.");
|
|
313
|
+
const entityId = entity.id;
|
|
314
|
+
|
|
315
|
+
const attributeRows = await sp.insert(sellableAttributes).values(item.attributes.map((attributes) => ({
|
|
316
|
+
entityId,
|
|
317
|
+
locale: attributes.locale,
|
|
318
|
+
title: attributes.title,
|
|
319
|
+
...(attributes.subtitle !== undefined ? { subtitle: attributes.subtitle } : {}),
|
|
320
|
+
...(attributes.description !== undefined ? { description: attributes.description } : {}),
|
|
321
|
+
...(attributes.richDescription !== undefined ? { richDescription: attributes.richDescription } : {}),
|
|
322
|
+
...(attributes.seoTitle !== undefined ? { seoTitle: attributes.seoTitle } : {}),
|
|
323
|
+
...(attributes.seoDescription !== undefined ? { seoDescription: attributes.seoDescription } : {}),
|
|
324
|
+
}))).returning();
|
|
325
|
+
|
|
326
|
+
// Option types and values: two statements for the whole axis set, mapped back by name / value
|
|
327
|
+
// rather than by row order, so the mapping does not depend on RETURNING preserving VALUES order.
|
|
328
|
+
const optionTypeIds = new Map<string, string>();
|
|
329
|
+
const optionValueIds = new Map<string, string>();
|
|
330
|
+
const options = item.options ?? [];
|
|
331
|
+
if (options.length > 0) {
|
|
332
|
+
const typeRows = await sp.insert(optionTypes).values(options.map((option, index) => ({
|
|
333
|
+
entityId,
|
|
334
|
+
name: option.name,
|
|
335
|
+
displayName: option.displayName ?? option.name,
|
|
336
|
+
sortOrder: option.sortOrder ?? index,
|
|
337
|
+
}))).returning({ id: optionTypes.id, name: optionTypes.name });
|
|
338
|
+
for (const row of typeRows) optionTypeIds.set(row.name, row.id);
|
|
339
|
+
const valueInputs = options.flatMap((option) => option.values.map((value, index) => ({
|
|
340
|
+
optionTypeId: optionTypeIds.get(option.name) ?? "",
|
|
341
|
+
value: value.value,
|
|
342
|
+
displayValue: value.displayValue ?? value.value,
|
|
343
|
+
sortOrder: value.sortOrder ?? index,
|
|
344
|
+
})));
|
|
345
|
+
if (valueInputs.length > 0) {
|
|
346
|
+
const valueRows = await sp.insert(optionValues).values(valueInputs).returning({ id: optionValues.id, optionTypeId: optionValues.optionTypeId, value: optionValues.value });
|
|
347
|
+
for (const row of valueRows) optionValueIds.set(`${row.optionTypeId}\u0000${row.value}`, row.id);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const variantIds: Record<string, string> = {};
|
|
352
|
+
if (item.variants.length > 0) {
|
|
353
|
+
// `sortOrder` is the input index, which is also how the returned rows map back to their refs.
|
|
354
|
+
const variantRows = await sp.insert(variants).values(item.variants.map((variant, index) => ({
|
|
355
|
+
entityId,
|
|
356
|
+
organizationId: orgId,
|
|
357
|
+
sourceStoreId,
|
|
358
|
+
sortOrder: index,
|
|
359
|
+
metadata: variant.metadata ?? {},
|
|
360
|
+
...(variant.sku !== undefined ? { sku: variant.sku } : {}),
|
|
361
|
+
...(variant.barcode !== undefined ? { barcode: variant.barcode } : {}),
|
|
362
|
+
}))).returning({ id: variants.id, sortOrder: variants.sortOrder });
|
|
363
|
+
for (const row of variantRows) {
|
|
364
|
+
const ref = item.variants[row.sortOrder]?.ref;
|
|
365
|
+
if (ref === undefined) throw new Error(`Variant row ${row.sortOrder} has no input.`);
|
|
366
|
+
variantIds[ref] = row.id;
|
|
367
|
+
}
|
|
368
|
+
const linkRows = item.variants.flatMap((variant) => Object.entries(variant.options ?? {}).map(([name, value]) => {
|
|
369
|
+
const optionTypeId = optionTypeIds.get(name);
|
|
370
|
+
const optionValueId = optionTypeId === undefined ? undefined : optionValueIds.get(`${optionTypeId}\u0000${value}`);
|
|
371
|
+
const variantId = variantIds[variant.ref];
|
|
372
|
+
if (optionValueId === undefined || variantId === undefined) throw new Error(`Variant "${variant.ref}" option "${name}=${value}" did not resolve after insert.`);
|
|
373
|
+
return { variantId, optionValueId };
|
|
374
|
+
}));
|
|
375
|
+
if (linkRows.length > 0) await sp.insert(variantOptionValues).values(linkRows);
|
|
376
|
+
const priceRows = item.variants.flatMap((variant) => (variant.prices ?? []).map((price) => {
|
|
377
|
+
const variantId = variantIds[variant.ref];
|
|
378
|
+
if (variantId === undefined) throw new Error(`Variant "${variant.ref}" did not resolve after insert.`);
|
|
379
|
+
return {
|
|
380
|
+
organizationId: orgId,
|
|
381
|
+
entityId,
|
|
382
|
+
variantId,
|
|
383
|
+
currency: normaliseCurrency(price.currency),
|
|
384
|
+
amount: price.amount,
|
|
385
|
+
...(price.compareAtAmount !== undefined ? { compareAtAmount: price.compareAtAmount } : {}),
|
|
386
|
+
};
|
|
387
|
+
}));
|
|
388
|
+
if (priceRows.length > 0) await sp.insert(prices).values(priceRows);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const categoryLinks: Row[] = [];
|
|
392
|
+
const categoryInputs = [...new Set(item.categories ?? [])].flatMap((slug, index) => {
|
|
393
|
+
const category = taxonomy.categories.get(slug);
|
|
394
|
+
if (!category) throw new Error(`Category "${slug}" was not resolved for the page.`);
|
|
395
|
+
if (category.status === "archived") {
|
|
396
|
+
warnings.push(`Skipped archived category "${slug}".`);
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
return [{ entityId, categoryId: category.id, sortOrder: index }];
|
|
400
|
+
});
|
|
401
|
+
if (categoryInputs.length > 0) categoryLinks.push(...asRows(await sp.insert(entityCategories).values(categoryInputs).returning()));
|
|
402
|
+
const brandLinks: Row[] = [];
|
|
403
|
+
if (item.brand) {
|
|
404
|
+
const brandId = taxonomy.brands.get(item.brand);
|
|
405
|
+
if (brandId === undefined) throw new Error(`Brand "${item.brand}" was not resolved for the page.`);
|
|
406
|
+
brandLinks.push(...asRows(await sp.insert(entityBrands).values([{ entityId, brandId, sortOrder: 0 }]).returning()));
|
|
407
|
+
}
|
|
408
|
+
const tagLinks: Row[] = [];
|
|
409
|
+
const tagInputs = [...new Set(item.tags ?? [])].map((slug) => {
|
|
410
|
+
const tagId = taxonomy.tags.get(slug);
|
|
411
|
+
if (tagId === undefined) throw new Error(`Tag "${slug}" was not resolved for the page.`);
|
|
412
|
+
return { entityId, tagId };
|
|
413
|
+
});
|
|
414
|
+
if (tagInputs.length > 0) tagLinks.push(...asRows(await sp.insert(entityTags).values(tagInputs).returning()));
|
|
415
|
+
|
|
416
|
+
const ownedPaths = [...new Set(item.ownedFieldPaths ?? [])] as FieldPath[];
|
|
417
|
+
if (ownedPaths.length > 0) {
|
|
418
|
+
await sp.insert(catalogFieldOwnership).values(ownedPaths.map((fieldPath) => ({
|
|
419
|
+
organizationId: orgId,
|
|
420
|
+
entityId,
|
|
421
|
+
storeId: sourceStoreId,
|
|
422
|
+
fieldPath,
|
|
423
|
+
owner: "store" as const,
|
|
424
|
+
}))).onConflictDoNothing();
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// The same shape `CatalogRepository.snapshotEntity` reads back with seven selects, built from the
|
|
428
|
+
// rows this item just wrote. Media is empty by construction: the fast path attaches none.
|
|
429
|
+
const snapshot: Snapshot = {
|
|
430
|
+
entity: { ...entity },
|
|
431
|
+
attributes: asRows(attributeRows).sort((a, b) => String(a.locale).localeCompare(String(b.locale)) || String(a.id).localeCompare(String(b.id))),
|
|
432
|
+
customFields: [],
|
|
433
|
+
media: [],
|
|
434
|
+
categories: categoryLinks.sort(byKey("categoryId")),
|
|
435
|
+
brands: brandLinks.sort(byKey("brandId")),
|
|
436
|
+
tags: tagLinks.sort(byKey("tagId")),
|
|
437
|
+
};
|
|
438
|
+
return { entityId, variantIds, warnings, snapshot };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export class CatalogImportService {
|
|
442
|
+
constructor(private readonly deps: CatalogServiceDeps) {}
|
|
443
|
+
|
|
444
|
+
async importProducts(
|
|
445
|
+
page: ImportProduct[],
|
|
446
|
+
options: ImportProductsOptions,
|
|
447
|
+
actor: Actor | null,
|
|
448
|
+
ctx?: CatalogWriteContext,
|
|
449
|
+
): Promise<Result<ImportProductsReport>> {
|
|
450
|
+
const errorPolicy = options.errorPolicy ?? "reject-failed-rows";
|
|
451
|
+
const reason = options.reason ?? "import";
|
|
452
|
+
let orgId: string;
|
|
453
|
+
try {
|
|
454
|
+
assertPermission(actor, "catalog:create");
|
|
455
|
+
assertPermission(actor, "catalog:sync");
|
|
456
|
+
orgId = resolveOrgIdForCommerce(actor, this.deps.config);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return Err(toCommerceError(error));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const rows: ImportProductRowResult[] = page.map((item) => failed(item.ref, "write-failed", "Not attempted."));
|
|
462
|
+
const memoryFailures = validateInMemory(page);
|
|
463
|
+
for (const [index, failure] of memoryFailures) {
|
|
464
|
+
const item = page[index];
|
|
465
|
+
if (item) rows[index] = failed(item.ref, failure.code, failure.error);
|
|
466
|
+
}
|
|
467
|
+
const rejectAll = (): ImportProductsReport => ({
|
|
468
|
+
sourceStoreId: options.sourceStoreId,
|
|
469
|
+
created: 0,
|
|
470
|
+
failed: page.length,
|
|
471
|
+
rows: rows.map((row) => (row.status === "failed" && row.code !== "write-failed" ? row : failed(row.ref, "rejected-by-policy", "Rejected: another row in the page failed."))),
|
|
472
|
+
});
|
|
473
|
+
if (errorPolicy === "reject-everything" && memoryFailures.size > 0) return Ok(rejectAll());
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const report = await this.withTransaction(actor, ctx, async (txCtx): Promise<ImportProductsReport> => {
|
|
477
|
+
const tx = txCtx.tx as PluginDb;
|
|
478
|
+
const candidates = page.map((item, index) => ({ item, index })).filter(({ index }) => !memoryFailures.has(index));
|
|
479
|
+
|
|
480
|
+
// Page-level reads: the slugs already taken, then the shared vocabulary.
|
|
481
|
+
const slugs = candidates.map(({ item }) => item.slug);
|
|
482
|
+
const taken = slugs.length === 0
|
|
483
|
+
? []
|
|
484
|
+
: await tx.select({ slug: sellableEntities.slug }).from(sellableEntities)
|
|
485
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, slugs)));
|
|
486
|
+
const takenSlugs = new Set(taken.map((row) => row.slug));
|
|
487
|
+
const writable: Array<{ item: ImportProduct; index: number }> = [];
|
|
488
|
+
for (const candidate of candidates) {
|
|
489
|
+
if (takenSlugs.has(candidate.item.slug)) {
|
|
490
|
+
rows[candidate.index] = failed(candidate.item.ref, "slug-conflict", `Slug "${candidate.item.slug}" already exists in this organization.`);
|
|
491
|
+
} else {
|
|
492
|
+
writable.push(candidate);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (errorPolicy === "reject-everything" && writable.length < candidates.length) {
|
|
496
|
+
throw new RejectEverything();
|
|
497
|
+
}
|
|
498
|
+
const taxonomy = await resolveTaxonomy(tx, orgId, writable.map(({ item }) => item));
|
|
499
|
+
|
|
500
|
+
const written: Array<{ index: number; write: ItemWrite }> = [];
|
|
501
|
+
for (const { item, index } of writable) {
|
|
502
|
+
try {
|
|
503
|
+
// Drizzle nests a transaction as a SAVEPOINT; a throw inside rolls back to it and the
|
|
504
|
+
// page continues. That is what makes one poisoned item cost one item.
|
|
505
|
+
const write = await tx.transaction((sp) => writeItem(sp, orgId, options.sourceStoreId, item, taxonomy));
|
|
506
|
+
written.push({ index, write });
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (errorPolicy === "reject-everything") throw new RejectEverything();
|
|
509
|
+
rows[index] = failed(item.ref, isUniqueViolation(error) ? "conflict" : "write-failed", describeWriteError(error));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (written.length > 0) {
|
|
514
|
+
await tx.insert(sellableEntityRevisions).values(written.map(({ write }) => ({
|
|
515
|
+
organizationId: orgId,
|
|
516
|
+
entityId: write.entityId,
|
|
517
|
+
revision: 1,
|
|
518
|
+
pinned: true,
|
|
519
|
+
snapshot: write.snapshot,
|
|
520
|
+
reason,
|
|
521
|
+
actorId: actor?.userId ?? null,
|
|
522
|
+
actorType: actor?.type ?? null,
|
|
523
|
+
requestId: txCtx.requestId,
|
|
524
|
+
})));
|
|
525
|
+
}
|
|
526
|
+
for (const { index, write } of written) {
|
|
527
|
+
const item = page[index];
|
|
528
|
+
if (item) rows[index] = { ref: item.ref, status: "created", entityId: write.entityId, variantIds: write.variantIds, warnings: write.warnings };
|
|
529
|
+
}
|
|
530
|
+
const createdCount = written.length;
|
|
531
|
+
return { sourceStoreId: options.sourceStoreId, created: createdCount, failed: page.length - createdCount, rows };
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const afterHooks = this.deps.hooks.resolve("catalog.afterImport") as AfterHook<ImportProductsReport>[];
|
|
535
|
+
if (afterHooks.length > 0) {
|
|
536
|
+
const context = catalogHookContext(this.deps, actor, ctx, "import");
|
|
537
|
+
await runAfterHooks(afterHooks, null, report, "custom", context, (hook) => this.deps.hooks.runsInTransaction(hook));
|
|
538
|
+
}
|
|
539
|
+
return Ok(report);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
if (error instanceof RejectEverything) return Ok(rejectAll());
|
|
542
|
+
return Err(toCommerceError(error));
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
private async withTransaction<T>(
|
|
547
|
+
actor: Actor | null,
|
|
548
|
+
ctx: CatalogWriteContext | undefined,
|
|
549
|
+
fn: (txCtx: TxContext) => Promise<T>,
|
|
550
|
+
): Promise<T> {
|
|
551
|
+
if (isWriteContextTransactional(ctx)) return fn(ctx);
|
|
552
|
+
const hookContext = resolveWriteContextHookContext(ctx);
|
|
553
|
+
return this.deps.database.transaction(async (tx) => fn(createTxContext(tx, { actor, ...(hookContext ? { hookContext } : {}) })));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Thrown inside the transaction to roll the whole page back under `reject-everything`. */
|
|
558
|
+
class RejectEverything extends Error {
|
|
559
|
+
constructor() {
|
|
560
|
+
super("Page rejected.");
|
|
561
|
+
this.name = "RejectEverything";
|
|
562
|
+
}
|
|
563
|
+
}
|
|
@@ -59,6 +59,7 @@ import type {
|
|
|
59
59
|
import { EntityService } from "./entity-service.js";
|
|
60
60
|
import { CategoryService } from "./category-service.js";
|
|
61
61
|
import { BrandService } from "./brand-service.js";
|
|
62
|
+
import { CatalogImportService, type ImportProduct, type ImportProductsOptions, type ImportProductsReport } from "./import-service.js";
|
|
62
63
|
|
|
63
64
|
// ─── Hand-written types (not derivable from a single z.infer) ───────────────
|
|
64
65
|
|
|
@@ -232,6 +233,16 @@ export interface CatalogService {
|
|
|
232
233
|
actor: Actor | null,
|
|
233
234
|
ctx?: TxContext,
|
|
234
235
|
): Promise<Result<CatalogEntityHydrated>>;
|
|
236
|
+
/**
|
|
237
|
+
* The import fast path: a page of NEW products in one transaction, per-item savepoints, one
|
|
238
|
+
* revision per item, one `catalog.afterImport` hook per page. See `import-service.ts`.
|
|
239
|
+
*/
|
|
240
|
+
importProducts(
|
|
241
|
+
page: ImportProduct[],
|
|
242
|
+
options: ImportProductsOptions,
|
|
243
|
+
actor: Actor | null,
|
|
244
|
+
ctx?: CatalogWriteContext,
|
|
245
|
+
): Promise<Result<ImportProductsReport>>;
|
|
235
246
|
discontinue(
|
|
236
247
|
id: string,
|
|
237
248
|
actor: Actor | null,
|
|
@@ -445,6 +456,7 @@ export class CatalogServiceImpl implements CatalogService {
|
|
|
445
456
|
private readonly entities: EntityService;
|
|
446
457
|
private readonly categories: CategoryService;
|
|
447
458
|
private readonly brands: BrandService;
|
|
459
|
+
private readonly importer: CatalogImportService;
|
|
448
460
|
|
|
449
461
|
constructor(deps: CatalogServiceDeps) {
|
|
450
462
|
this.repository = deps.repository;
|
|
@@ -453,6 +465,7 @@ export class CatalogServiceImpl implements CatalogService {
|
|
|
453
465
|
this.entities = new EntityService(deps, this.resolveEntityFieldDefinitions.bind(this));
|
|
454
466
|
this.categories = new CategoryService(deps);
|
|
455
467
|
this.brands = new BrandService(deps);
|
|
468
|
+
this.importer = new CatalogImportService(deps);
|
|
456
469
|
}
|
|
457
470
|
|
|
458
471
|
private codeFieldDefinition(entityType: string, name: string): EntityFieldDefinition | undefined {
|
|
@@ -990,6 +1003,15 @@ export class CatalogServiceImpl implements CatalogService {
|
|
|
990
1003
|
});
|
|
991
1004
|
}
|
|
992
1005
|
|
|
1006
|
+
importProducts(
|
|
1007
|
+
page: ImportProduct[],
|
|
1008
|
+
options: ImportProductsOptions,
|
|
1009
|
+
actor: Actor | null,
|
|
1010
|
+
ctx?: CatalogWriteContext,
|
|
1011
|
+
): Promise<Result<ImportProductsReport>> {
|
|
1012
|
+
return this.importer.importProducts(page, options, actor, ctx);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
993
1015
|
archive(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CatalogEntityHydrated>> {
|
|
994
1016
|
return this.withMutationResult(actor, ctx, async (txCtx) => {
|
|
995
1017
|
const result = await this.entities.archive(id, actor, txCtx);
|
package/src/testing.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
export { createTestKernel } from "./test-utils/create-test-kernel.js";
|
|
10
|
+
// The query log is the only honest statement counter: the PGlite logger sees every statement core
|
|
11
|
+
// issues, which a proxy around a plugin's own handle cannot.
|
|
12
|
+
export { createPGliteTestAdapter, type PGliteTestAdapter, type QueryLog } from "./test-utils/create-pglite-adapter.js";
|
|
10
13
|
export { createTestPluginContext } from "./test-utils/create-test-plugin-context.js";
|
|
11
14
|
export { createRepositoryTestHarness } from "./test-utils/create-repository-test-harness.js";
|
|
12
15
|
export { createPluginTestApp, type PluginTestApp, type TestAppEnv } from "./test-utils/create-plugin-test-app.js";
|