@porulle/core 0.58.0 → 0.60.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/auth/auth-failure.d.ts +6 -1
- package/dist/auth/auth-failure.d.ts.map +1 -1
- package/dist/auth/auth-failure.js +4 -0
- package/dist/auth/middleware.d.ts.map +1 -1
- package/dist/auth/middleware.js +17 -3
- package/dist/modules/catalog/import-service.d.ts +1 -0
- package/dist/modules/catalog/import-service.d.ts.map +1 -1
- package/dist/modules/catalog/import-service.js +95 -52
- package/package.json +3 -3
- package/src/auth/auth-failure.ts +6 -1
- package/src/auth/middleware.ts +18 -3
- package/src/modules/catalog/import-service.ts +69 -12
|
@@ -11,5 +11,10 @@
|
|
|
11
11
|
* better-call's `APIError` carries `name === "APIError"` and a numeric
|
|
12
12
|
* `statusCode`; its `status` is a string such as `"UNAUTHORIZED"`.
|
|
13
13
|
*/
|
|
14
|
-
export declare function isCredentialRejection(err: unknown):
|
|
14
|
+
export declare function isCredentialRejection(err: unknown): err is {
|
|
15
|
+
name: "APIError";
|
|
16
|
+
statusCode: number;
|
|
17
|
+
};
|
|
18
|
+
/** The HTTP status better-auth attached to a credential rejection, or null for anything else. */
|
|
19
|
+
export declare function credentialRejectionStatus(err: unknown): number | null;
|
|
15
20
|
//# sourceMappingURL=auth-failure.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth-failure.d.ts","sourceRoot":"","sources":["../../src/auth/auth-failure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,
|
|
1
|
+
{"version":3,"file":"auth-failure.d.ts","sourceRoot":"","sources":["../../src/auth/auth-failure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAInG;AAED,iGAAiG;AACjG,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAErE"}
|
|
@@ -17,3 +17,7 @@ export function isCredentialRejection(err) {
|
|
|
17
17
|
const { name, statusCode } = err;
|
|
18
18
|
return name === "APIError" && typeof statusCode === "number";
|
|
19
19
|
}
|
|
20
|
+
/** The HTTP status better-auth attached to a credential rejection, or null for anything else. */
|
|
21
|
+
export function credentialRejectionStatus(err) {
|
|
22
|
+
return isCredentialRejection(err) ? err.statusCode : null;
|
|
23
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/auth/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAc/C,OAAO,EAAE,4BAA4B,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,yBAAuB,CAAC;AAE3D;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAInE;AAKD,wBAAgB,cAAc,CAC5B,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,cAAc,GACrB,iBAAiB,
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../../src/auth/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEzD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAc/C,OAAO,EAAE,4BAA4B,EAAE,MAAM,YAAY,CAAC;AAE1D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,sBAAsB,yBAAuB,CAAC;AAE3D;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,QAAQ,GAAG,IAAI,CAInE;AAKD,wBAAgB,cAAc,CAC5B,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,cAAc,GACrB,iBAAiB,CAsQnB"}
|
package/dist/auth/middleware.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getCustomerPermissions, resolveActor } from "./actor.js";
|
|
2
2
|
import { DEFAULT_ORG_ID } from "./org.js";
|
|
3
|
-
import { isCredentialRejection } from "./auth-failure.js";
|
|
3
|
+
import { credentialRejectionStatus, isCredentialRejection } from "./auth-failure.js";
|
|
4
4
|
import { isStrictOrgResolution } from "./strict-org-resolution.js";
|
|
5
5
|
import { isIdentityFreeRoute } from "./identity-free-routes.js";
|
|
6
6
|
function emptyToNull(value) {
|
|
@@ -182,14 +182,28 @@ export function authMiddleware(auth, config) {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
catch (err) {
|
|
185
|
-
//
|
|
186
|
-
//
|
|
185
|
+
// A rejection better-auth raised was an evaluated credential; anything else means the key
|
|
186
|
+
// was never checked, and is a fault. A rate-limited key is not a bad one: say so.
|
|
187
187
|
if (!isCredentialRejection(err)) {
|
|
188
188
|
reportAuthCheckFault(err, "api_key");
|
|
189
189
|
throw err;
|
|
190
190
|
}
|
|
191
|
+
if (credentialRejectionStatus(err) === 429) {
|
|
192
|
+
return c.json({ error: { code: "RATE_LIMITED", message: "Too many requests for this credential." } }, 429);
|
|
193
|
+
}
|
|
191
194
|
}
|
|
192
195
|
}
|
|
196
|
+
// A credential the caller PRESENTED and that did not verify is refused, not served as a guest.
|
|
197
|
+
// Falling through to anonymous gave a shopper whose token had expired a fresh guest cart in
|
|
198
|
+
// place of theirs, and told a broken client nothing. Absent credentials stay anonymous (guest
|
|
199
|
+
// checkout depends on it), and a stale session COOKIE is not "presented": browsers carry one on
|
|
200
|
+
// every public page, and refusing it would 401 logged-out browsing.
|
|
201
|
+
const presented = (c.req.header("x-api-key") ?? "").trim() !== "" || (c.req.header("authorization") ?? "").trim() !== "";
|
|
202
|
+
if (presented) {
|
|
203
|
+
const refused = c.json({ error: { code: "UNAUTHORIZED", message: "The presented credential could not be verified." } }, 401);
|
|
204
|
+
applyAuthenticateChallenge(refused);
|
|
205
|
+
return refused;
|
|
206
|
+
}
|
|
193
207
|
if (!c.get("actor")) {
|
|
194
208
|
// For anonymous requests in multi-store deployments, resolve the
|
|
195
209
|
// store so catalog/search queries return the right store's data.
|
|
@@ -103,6 +103,7 @@ export declare class CatalogImportService {
|
|
|
103
103
|
private readonly deps;
|
|
104
104
|
constructor(deps: CatalogServiceDeps);
|
|
105
105
|
importProducts(page: ImportProduct[], options: ImportProductsOptions, actor: Actor | null, ctx?: CatalogWriteContext): Promise<Result<ImportProductsReport>>;
|
|
106
|
+
private importItemByItem;
|
|
106
107
|
private withTransaction;
|
|
107
108
|
}
|
|
108
109
|
//# sourceMappingURL=import-service.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"import-service.d.ts","sourceRoot":"","sources":["../../../src/modules/catalog/import-service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"import-service.d.ts","sourceRoot":"","sources":["../../../src/modules/catalog/import-service.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { and, eq, inArray } from "drizzle-orm";
|
|
2
3
|
import { resolveOrgIdForCommerce } from "../../auth/org.js";
|
|
3
4
|
import { assertPermission } from "../../auth/permissions.js";
|
|
@@ -342,62 +343,70 @@ export class CatalogImportService {
|
|
|
342
343
|
// committed: 36.6 s behind one 89 s page on the sim, 2026-09-24. The cost is that a page
|
|
343
344
|
// rejected afterwards can leave new vocabulary behind; it is organization-wide and reusable.
|
|
344
345
|
const taxonomy = await this.withTransaction(actor, undefined, (txCtx) => resolveTaxonomy(writerOf(txCtx), orgId, candidates.map(({ item }) => item)));
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
346
|
+
const revisionRow = (write, requestId) => ({
|
|
347
|
+
organizationId: orgId,
|
|
348
|
+
entityId: write.entityId,
|
|
349
|
+
revision: 1,
|
|
350
|
+
pinned: true,
|
|
351
|
+
snapshot: write.snapshot,
|
|
352
|
+
reason,
|
|
353
|
+
actorId: actor?.userId ?? null,
|
|
354
|
+
actorType: actor?.type ?? null,
|
|
355
|
+
requestId,
|
|
356
|
+
});
|
|
357
|
+
// Each item in its OWN transaction, so no item's rows outlive that item. As savepoints of one
|
|
358
|
+
// page transaction, an item's rows stayed uncommitted until the page's LAST item committed,
|
|
359
|
+
// and another store's page naming the same product slug (an organization-wide unique key)
|
|
360
|
+
// waited on it for the rest of the page. `reject-everything` keeps the page transaction (it
|
|
361
|
+
// must roll the page back as one), and so does a caller's transaction, which this cannot split.
|
|
362
|
+
const report = errorPolicy === "reject-failed-rows" && !isWriteContextTransactional(ctx)
|
|
363
|
+
? await this.importItemByItem(actor, ctx, orgId, options.sourceStoreId, candidates, rows, taxonomy, revisionRow, page.length)
|
|
364
|
+
: await this.withTransaction(actor, ctx, async (txCtx) => {
|
|
365
|
+
const tx = writerOf(txCtx);
|
|
366
|
+
// Page-level reads: the slugs already taken, then the shared vocabulary.
|
|
367
|
+
const slugs = candidates.map(({ item }) => item.slug);
|
|
368
|
+
const taken = slugs.length === 0
|
|
369
|
+
? []
|
|
370
|
+
: await tx.select({ slug: sellableEntities.slug }).from(sellableEntities)
|
|
371
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, slugs)));
|
|
372
|
+
const takenSlugs = new Set(taken.map((row) => row.slug));
|
|
373
|
+
const writable = [];
|
|
374
|
+
for (const candidate of candidates) {
|
|
375
|
+
if (takenSlugs.has(candidate.item.slug)) {
|
|
376
|
+
rows[candidate.index] = failed(candidate.item.ref, "slug-conflict", `Slug "${candidate.item.slug}" already exists in this organization.`);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
writable.push(candidate);
|
|
380
|
+
}
|
|
358
381
|
}
|
|
359
|
-
|
|
360
|
-
|
|
382
|
+
if (errorPolicy === "reject-everything" && writable.length < candidates.length) {
|
|
383
|
+
throw new RejectEverything();
|
|
361
384
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
385
|
+
const written = [];
|
|
386
|
+
for (const { item, index } of writable) {
|
|
387
|
+
try {
|
|
388
|
+
// Drizzle nests a transaction as a SAVEPOINT; a throw inside rolls back to it and the
|
|
389
|
+
// page continues. That is what makes one poisoned item cost one item.
|
|
390
|
+
const write = await tx.transaction((sp) => writeItem(sp, orgId, options.sourceStoreId, item, taxonomy));
|
|
391
|
+
written.push({ index, write });
|
|
392
|
+
}
|
|
393
|
+
catch (error) {
|
|
394
|
+
if (errorPolicy === "reject-everything")
|
|
395
|
+
throw new RejectEverything();
|
|
396
|
+
rows[index] = failed(item.ref, isUniqueViolation(error) ? "conflict" : "write-failed", describeWriteError(error));
|
|
397
|
+
}
|
|
373
398
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
throw new RejectEverything();
|
|
377
|
-
rows[index] = failed(item.ref, isUniqueViolation(error) ? "conflict" : "write-failed", describeWriteError(error));
|
|
399
|
+
if (written.length > 0) {
|
|
400
|
+
await tx.insert(sellableEntityRevisions).values(written.map(({ write }) => revisionRow(write, txCtx.requestId)));
|
|
378
401
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
reason,
|
|
388
|
-
actorId: actor?.userId ?? null,
|
|
389
|
-
actorType: actor?.type ?? null,
|
|
390
|
-
requestId: txCtx.requestId,
|
|
391
|
-
})));
|
|
392
|
-
}
|
|
393
|
-
for (const { index, write } of written) {
|
|
394
|
-
const item = page[index];
|
|
395
|
-
if (item)
|
|
396
|
-
rows[index] = { ref: item.ref, status: "created", entityId: write.entityId, variantIds: write.variantIds, warnings: write.warnings };
|
|
397
|
-
}
|
|
398
|
-
const createdCount = written.length;
|
|
399
|
-
return { sourceStoreId: options.sourceStoreId, created: createdCount, failed: page.length - createdCount, rows };
|
|
400
|
-
});
|
|
402
|
+
for (const { index, write } of written) {
|
|
403
|
+
const item = page[index];
|
|
404
|
+
if (item)
|
|
405
|
+
rows[index] = { ref: item.ref, status: "created", entityId: write.entityId, variantIds: write.variantIds, warnings: write.warnings };
|
|
406
|
+
}
|
|
407
|
+
const createdCount = written.length;
|
|
408
|
+
return { sourceStoreId: options.sourceStoreId, created: createdCount, failed: page.length - createdCount, rows };
|
|
409
|
+
});
|
|
401
410
|
const afterHooks = this.deps.hooks.resolve("catalog.afterImport");
|
|
402
411
|
if (afterHooks.length > 0) {
|
|
403
412
|
const context = catalogHookContext(this.deps, actor, ctx, "import");
|
|
@@ -411,6 +420,40 @@ export class CatalogImportService {
|
|
|
411
420
|
return Err(toCommerceError(error));
|
|
412
421
|
}
|
|
413
422
|
}
|
|
423
|
+
async importItemByItem(actor, ctx, orgId, sourceStoreId, candidates, rows, taxonomy, revisionRow, pageLength) {
|
|
424
|
+
// One request id for the page, so the revisions it writes still group as one import.
|
|
425
|
+
const requestId = randomUUID();
|
|
426
|
+
const hookContext = resolveWriteContextHookContext(ctx);
|
|
427
|
+
const inItemTransaction = (fn) => this.deps.database.transaction(async (tx) => fn(writerOf(createTxContext(tx, { actor, requestId, ...(hookContext ? { hookContext } : {}) }))));
|
|
428
|
+
const slugs = candidates.map(({ item }) => item.slug);
|
|
429
|
+
const taken = slugs.length === 0
|
|
430
|
+
? []
|
|
431
|
+
: await inItemTransaction((tx) => tx.select({ slug: sellableEntities.slug }).from(sellableEntities)
|
|
432
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, slugs))));
|
|
433
|
+
const takenSlugs = new Set(taken.map((row) => row.slug));
|
|
434
|
+
const written = [];
|
|
435
|
+
for (const { item, index } of candidates) {
|
|
436
|
+
if (takenSlugs.has(item.slug)) {
|
|
437
|
+
rows[index] = failed(item.ref, "slug-conflict", `Slug "${item.slug}" already exists in this organization.`);
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
const write = await inItemTransaction((tx) => writeItem(tx, orgId, sourceStoreId, item, taxonomy));
|
|
442
|
+
rows[index] = { ref: item.ref, status: "created", entityId: write.entityId, variantIds: write.variantIds, warnings: write.warnings };
|
|
443
|
+
written.push(write);
|
|
444
|
+
}
|
|
445
|
+
catch (error) {
|
|
446
|
+
rows[index] = failed(item.ref, isUniqueViolation(error) ? "conflict" : "write-failed", describeWriteError(error));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// The page's revisions in ONE statement, as the page transaction wrote them: a revision per item
|
|
450
|
+
// would put a statement per product back on the import's critical path (links-provenance-budget).
|
|
451
|
+
// Revision rows carry no unique key another store's page could wait on.
|
|
452
|
+
if (written.length > 0) {
|
|
453
|
+
await inItemTransaction((tx) => tx.insert(sellableEntityRevisions).values(written.map((write) => revisionRow(write, requestId))));
|
|
454
|
+
}
|
|
455
|
+
return { sourceStoreId, created: written.length, failed: pageLength - written.length, rows };
|
|
456
|
+
}
|
|
414
457
|
async withTransaction(actor, ctx, fn) {
|
|
415
458
|
if (isWriteContextTransactional(ctx))
|
|
416
459
|
return fn(ctx);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@porulle/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.60.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"eslint": "^9.39.1",
|
|
63
63
|
"typescript": "5.9.2",
|
|
64
64
|
"vitest": "^3.2.4",
|
|
65
|
-
"@porulle/
|
|
66
|
-
"@porulle/
|
|
65
|
+
"@porulle/typescript-config": "0.1.0",
|
|
66
|
+
"@porulle/eslint-config": "0.1.0"
|
|
67
67
|
},
|
|
68
68
|
"publishConfig": {
|
|
69
69
|
"access": "public"
|
package/src/auth/auth-failure.ts
CHANGED
|
@@ -11,8 +11,13 @@
|
|
|
11
11
|
* better-call's `APIError` carries `name === "APIError"` and a numeric
|
|
12
12
|
* `statusCode`; its `status` is a string such as `"UNAUTHORIZED"`.
|
|
13
13
|
*/
|
|
14
|
-
export function isCredentialRejection(err: unknown):
|
|
14
|
+
export function isCredentialRejection(err: unknown): err is { name: "APIError"; statusCode: number } {
|
|
15
15
|
if (typeof err !== "object" || err === null) return false;
|
|
16
16
|
const { name, statusCode } = err as { name?: unknown; statusCode?: unknown };
|
|
17
17
|
return name === "APIError" && typeof statusCode === "number";
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/** The HTTP status better-auth attached to a credential rejection, or null for anything else. */
|
|
21
|
+
export function credentialRejectionStatus(err: unknown): number | null {
|
|
22
|
+
return isCredentialRejection(err) ? err.statusCode : null;
|
|
23
|
+
}
|
package/src/auth/middleware.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { Actor } from "./types.js";
|
|
|
4
4
|
import type { AuthInstance } from "./setup.js";
|
|
5
5
|
import { getCustomerPermissions, resolveActor } from "./actor.js";
|
|
6
6
|
import { DEFAULT_ORG_ID } from "./org.js";
|
|
7
|
-
import { isCredentialRejection } from "./auth-failure.js";
|
|
7
|
+
import { credentialRejectionStatus, isCredentialRejection } from "./auth-failure.js";
|
|
8
8
|
import { isStrictOrgResolution } from "./strict-org-resolution.js";
|
|
9
9
|
import { isIdentityFreeRoute } from "./identity-free-routes.js";
|
|
10
10
|
|
|
@@ -208,15 +208,30 @@ export function authMiddleware(
|
|
|
208
208
|
return;
|
|
209
209
|
}
|
|
210
210
|
} catch (err) {
|
|
211
|
-
//
|
|
212
|
-
//
|
|
211
|
+
// A rejection better-auth raised was an evaluated credential; anything else means the key
|
|
212
|
+
// was never checked, and is a fault. A rate-limited key is not a bad one: say so.
|
|
213
213
|
if (!isCredentialRejection(err)) {
|
|
214
214
|
reportAuthCheckFault(err, "api_key");
|
|
215
215
|
throw err;
|
|
216
216
|
}
|
|
217
|
+
if (credentialRejectionStatus(err) === 429) {
|
|
218
|
+
return c.json({ error: { code: "RATE_LIMITED", message: "Too many requests for this credential." } }, 429);
|
|
219
|
+
}
|
|
217
220
|
}
|
|
218
221
|
}
|
|
219
222
|
|
|
223
|
+
// A credential the caller PRESENTED and that did not verify is refused, not served as a guest.
|
|
224
|
+
// Falling through to anonymous gave a shopper whose token had expired a fresh guest cart in
|
|
225
|
+
// place of theirs, and told a broken client nothing. Absent credentials stay anonymous (guest
|
|
226
|
+
// checkout depends on it), and a stale session COOKIE is not "presented": browsers carry one on
|
|
227
|
+
// every public page, and refusing it would 401 logged-out browsing.
|
|
228
|
+
const presented = (c.req.header("x-api-key") ?? "").trim() !== "" || (c.req.header("authorization") ?? "").trim() !== "";
|
|
229
|
+
if (presented) {
|
|
230
|
+
const refused = c.json({ error: { code: "UNAUTHORIZED", message: "The presented credential could not be verified." } }, 401);
|
|
231
|
+
applyAuthenticateChallenge(refused);
|
|
232
|
+
return refused;
|
|
233
|
+
}
|
|
234
|
+
|
|
220
235
|
if (!c.get("actor")) {
|
|
221
236
|
// For anonymous requests in multi-store deployments, resolve the
|
|
222
237
|
// store so catalog/search queries return the right store's data.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { and, eq, inArray } from "drizzle-orm";
|
|
2
3
|
import type { Actor } from "../../auth/types.js";
|
|
3
4
|
import { resolveOrgIdForCommerce } from "../../auth/org.js";
|
|
@@ -480,8 +481,27 @@ export class CatalogImportService {
|
|
|
480
481
|
// rejected afterwards can leave new vocabulary behind; it is organization-wide and reusable.
|
|
481
482
|
const taxonomy = await this.withTransaction(actor, undefined, (txCtx) =>
|
|
482
483
|
resolveTaxonomy(writerOf(txCtx), orgId, candidates.map(({ item }) => item)));
|
|
484
|
+
const revisionRow = (write: ItemWrite, requestId: string) => ({
|
|
485
|
+
organizationId: orgId,
|
|
486
|
+
entityId: write.entityId,
|
|
487
|
+
revision: 1,
|
|
488
|
+
pinned: true,
|
|
489
|
+
snapshot: write.snapshot,
|
|
490
|
+
reason,
|
|
491
|
+
actorId: actor?.userId ?? null,
|
|
492
|
+
actorType: actor?.type ?? null,
|
|
493
|
+
requestId,
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
// Each item in its OWN transaction, so no item's rows outlive that item. As savepoints of one
|
|
497
|
+
// page transaction, an item's rows stayed uncommitted until the page's LAST item committed,
|
|
498
|
+
// and another store's page naming the same product slug (an organization-wide unique key)
|
|
499
|
+
// waited on it for the rest of the page. `reject-everything` keeps the page transaction (it
|
|
500
|
+
// must roll the page back as one), and so does a caller's transaction, which this cannot split.
|
|
501
|
+
const report = errorPolicy === "reject-failed-rows" && !isWriteContextTransactional(ctx)
|
|
502
|
+
? await this.importItemByItem(actor, ctx, orgId, options.sourceStoreId, candidates, rows, taxonomy, revisionRow, page.length)
|
|
503
|
+
: await this.withTransaction(actor, ctx, async (txCtx): Promise<ImportProductsReport> => {
|
|
483
504
|
|
|
484
|
-
const report = await this.withTransaction(actor, ctx, async (txCtx): Promise<ImportProductsReport> => {
|
|
485
505
|
const tx = writerOf(txCtx);
|
|
486
506
|
|
|
487
507
|
// Page-level reads: the slugs already taken, then the shared vocabulary.
|
|
@@ -517,17 +537,7 @@ export class CatalogImportService {
|
|
|
517
537
|
}
|
|
518
538
|
|
|
519
539
|
if (written.length > 0) {
|
|
520
|
-
await tx.insert(sellableEntityRevisions).values(written.map(({ write }) => (
|
|
521
|
-
organizationId: orgId,
|
|
522
|
-
entityId: write.entityId,
|
|
523
|
-
revision: 1,
|
|
524
|
-
pinned: true,
|
|
525
|
-
snapshot: write.snapshot,
|
|
526
|
-
reason,
|
|
527
|
-
actorId: actor?.userId ?? null,
|
|
528
|
-
actorType: actor?.type ?? null,
|
|
529
|
-
requestId: txCtx.requestId,
|
|
530
|
-
})));
|
|
540
|
+
await tx.insert(sellableEntityRevisions).values(written.map(({ write }) => revisionRow(write, txCtx.requestId)));
|
|
531
541
|
}
|
|
532
542
|
for (const { index, write } of written) {
|
|
533
543
|
const item = page[index];
|
|
@@ -549,6 +559,53 @@ export class CatalogImportService {
|
|
|
549
559
|
}
|
|
550
560
|
}
|
|
551
561
|
|
|
562
|
+
private async importItemByItem(
|
|
563
|
+
actor: Actor | null,
|
|
564
|
+
ctx: CatalogWriteContext | undefined,
|
|
565
|
+
orgId: string,
|
|
566
|
+
sourceStoreId: string,
|
|
567
|
+
candidates: Array<{ item: ImportProduct; index: number }>,
|
|
568
|
+
rows: ImportProductRowResult[],
|
|
569
|
+
taxonomy: Taxonomy,
|
|
570
|
+
revisionRow: (write: ItemWrite, requestId: string) => typeof sellableEntityRevisions.$inferInsert,
|
|
571
|
+
pageLength: number,
|
|
572
|
+
): Promise<ImportProductsReport> {
|
|
573
|
+
// One request id for the page, so the revisions it writes still group as one import.
|
|
574
|
+
const requestId = randomUUID();
|
|
575
|
+
const hookContext = resolveWriteContextHookContext(ctx);
|
|
576
|
+
const inItemTransaction = <T>(fn: (tx: PluginDb) => Promise<T>): Promise<T> =>
|
|
577
|
+
this.deps.database.transaction(async (tx) => fn(writerOf(createTxContext(tx, { actor, requestId, ...(hookContext ? { hookContext } : {}) }))));
|
|
578
|
+
|
|
579
|
+
const slugs = candidates.map(({ item }) => item.slug);
|
|
580
|
+
const taken = slugs.length === 0
|
|
581
|
+
? []
|
|
582
|
+
: await inItemTransaction((tx) => tx.select({ slug: sellableEntities.slug }).from(sellableEntities)
|
|
583
|
+
.where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, slugs))));
|
|
584
|
+
const takenSlugs = new Set(taken.map((row) => row.slug));
|
|
585
|
+
|
|
586
|
+
const written: ItemWrite[] = [];
|
|
587
|
+
for (const { item, index } of candidates) {
|
|
588
|
+
if (takenSlugs.has(item.slug)) {
|
|
589
|
+
rows[index] = failed(item.ref, "slug-conflict", `Slug "${item.slug}" already exists in this organization.`);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
try {
|
|
593
|
+
const write = await inItemTransaction((tx) => writeItem(tx, orgId, sourceStoreId, item, taxonomy));
|
|
594
|
+
rows[index] = { ref: item.ref, status: "created", entityId: write.entityId, variantIds: write.variantIds, warnings: write.warnings };
|
|
595
|
+
written.push(write);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
rows[index] = failed(item.ref, isUniqueViolation(error) ? "conflict" : "write-failed", describeWriteError(error));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// The page's revisions in ONE statement, as the page transaction wrote them: a revision per item
|
|
601
|
+
// would put a statement per product back on the import's critical path (links-provenance-budget).
|
|
602
|
+
// Revision rows carry no unique key another store's page could wait on.
|
|
603
|
+
if (written.length > 0) {
|
|
604
|
+
await inItemTransaction((tx) => tx.insert(sellableEntityRevisions).values(written.map((write) => revisionRow(write, requestId))));
|
|
605
|
+
}
|
|
606
|
+
return { sourceStoreId, created: written.length, failed: pageLength - written.length, rows };
|
|
607
|
+
}
|
|
608
|
+
|
|
552
609
|
private async withTransaction<T>(
|
|
553
610
|
actor: Actor | null,
|
|
554
611
|
ctx: CatalogWriteContext | undefined,
|