@porulle/core 0.59.0 → 0.61.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.
@@ -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":"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"}
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 report = await this.withTransaction(actor, ctx, async (txCtx) => {
346
- const tx = writerOf(txCtx);
347
- // Page-level reads: the slugs already taken, then the shared vocabulary.
348
- const slugs = candidates.map(({ item }) => item.slug);
349
- const taken = slugs.length === 0
350
- ? []
351
- : await tx.select({ slug: sellableEntities.slug }).from(sellableEntities)
352
- .where(and(eq(sellableEntities.organizationId, orgId), inArray(sellableEntities.slug, slugs)));
353
- const takenSlugs = new Set(taken.map((row) => row.slug));
354
- const writable = [];
355
- for (const candidate of candidates) {
356
- if (takenSlugs.has(candidate.item.slug)) {
357
- rows[candidate.index] = failed(candidate.item.ref, "slug-conflict", `Slug "${candidate.item.slug}" already exists in this organization.`);
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
- else {
360
- writable.push(candidate);
382
+ if (errorPolicy === "reject-everything" && writable.length < candidates.length) {
383
+ throw new RejectEverything();
361
384
  }
362
- }
363
- if (errorPolicy === "reject-everything" && writable.length < candidates.length) {
364
- throw new RejectEverything();
365
- }
366
- const written = [];
367
- for (const { item, index } of writable) {
368
- try {
369
- // Drizzle nests a transaction as a SAVEPOINT; a throw inside rolls back to it and the
370
- // page continues. That is what makes one poisoned item cost one item.
371
- const write = await tx.transaction((sp) => writeItem(sp, orgId, options.sourceStoreId, item, taxonomy));
372
- written.push({ index, write });
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
- catch (error) {
375
- if (errorPolicy === "reject-everything")
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
- if (written.length > 0) {
381
- await tx.insert(sellableEntityRevisions).values(written.map(({ write }) => ({
382
- organizationId: orgId,
383
- entityId: write.entityId,
384
- revision: 1,
385
- pinned: true,
386
- snapshot: write.snapshot,
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.59.0",
3
+ "version": "0.61.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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,