@porulle/core 0.50.2 → 0.51.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.50.2",
3
+ "version": "0.51.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/eslint-config": "0.1.0",
66
- "@porulle/typescript-config": "0.1.0"
65
+ "@porulle/typescript-config": "0.1.0",
66
+ "@porulle/eslint-config": "0.1.0"
67
67
  },
68
68
  "publishConfig": {
69
69
  "access": "public"
@@ -139,6 +139,9 @@ export interface ImportProductsReport extends Record<string, unknown> {
139
139
 
140
140
  type Writer = Pick<PluginDb, "insert" | "select">;
141
141
 
142
+ /** The drizzle handle a transaction context carries (typed `unknown` on the context). */
143
+ const writerOf = (txCtx: TxContext): PluginDb => txCtx.tx as PluginDb;
144
+
142
145
  type Failure = { code: ImportRowFailureCode; error: string };
143
146
 
144
147
  function failed(ref: string, code: ImportRowFailureCode, error: string): ImportProductRowResult {
@@ -473,9 +476,18 @@ export class CatalogImportService {
473
476
  if (errorPolicy === "reject-everything" && memoryFailures.size > 0) return Ok(rejectAll());
474
477
 
475
478
  try {
479
+ const candidates = page.map((item, index) => ({ item, index })).filter(({ index }) => !memoryFailures.has(index));
480
+ // The shared vocabulary is resolved in its OWN short transaction, never in the page's, and
481
+ // never in a caller's (no ctx is passed on purpose). Inside the page transaction a newly
482
+ // inserted tag's unique key stayed uncommitted for the whole page, so every other page naming
483
+ // it — another store's, or this store's next one — waited on that index until this page
484
+ // committed: 36.6 s behind one 89 s page on the sim, 2026-09-24. The cost is that a page
485
+ // rejected afterwards can leave new vocabulary behind; it is organization-wide and reusable.
486
+ const taxonomy = await this.withTransaction(actor, undefined, (txCtx) =>
487
+ resolveTaxonomy(writerOf(txCtx), orgId, candidates.map(({ item }) => item)));
488
+
476
489
  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));
490
+ const tx = writerOf(txCtx);
479
491
 
480
492
  // Page-level reads: the slugs already taken, then the shared vocabulary.
481
493
  const slugs = candidates.map(({ item }) => item.slug);
@@ -495,7 +507,6 @@ export class CatalogImportService {
495
507
  if (errorPolicy === "reject-everything" && writable.length < candidates.length) {
496
508
  throw new RejectEverything();
497
509
  }
498
- const taxonomy = await resolveTaxonomy(tx, orgId, writable.map(({ item }) => item));
499
510
 
500
511
  const written: Array<{ index: number; write: ItemWrite }> = [];
501
512
  for (const { item, index } of writable) {
@@ -1,4 +1,4 @@
1
- import { eq, and, asc, count, desc, gt, inArray, isNull, lt, max, ne, or, type SQL } from "drizzle-orm";
1
+ import { eq, and, asc, count, desc, gt, inArray, isNull, lt, max, ne, or, sql, type SQL } from "drizzle-orm";
2
2
  import type { TxContext } from "../../../kernel/database/tx-context.js";
3
3
  import { CommerceNotFoundError } from "../../../kernel/errors.js";
4
4
  import type {
@@ -1290,12 +1290,29 @@ export class CatalogRepository {
1290
1290
  const db = this.getDb(ctx);
1291
1291
  const rows = await db
1292
1292
  .update(variants)
1293
- .set(data)
1293
+ .set({ ...data, updatedAt: sql`now()` })
1294
1294
  .where(eq(variants.id, id))
1295
1295
  .returning();
1296
1296
  return rows[0];
1297
1297
  }
1298
1298
 
1299
+ /**
1300
+ * Run a write to variants' option values and bump those variants' `updated_at` in ONE
1301
+ * transaction (a savepoint when the caller already holds one). A bump that could commit without
1302
+ * its write, or a write without its bump, is exactly the version drift the column exists to end.
1303
+ */
1304
+ private async touchingVariants(
1305
+ variantIds: readonly string[],
1306
+ ctx: TxContext | undefined,
1307
+ write: (db: DbOrTx) => Promise<void>,
1308
+ ): Promise<void> {
1309
+ if (variantIds.length === 0) return;
1310
+ await this.getDb(ctx).transaction(async (tx) => {
1311
+ await write(tx);
1312
+ await tx.update(variants).set({ updatedAt: sql`now()` }).where(inArray(variants.id, [...new Set(variantIds)]));
1313
+ });
1314
+ }
1315
+
1299
1316
  async deleteVariantsByEntityId(
1300
1317
  entityId: string,
1301
1318
  ctx?: TxContext,
@@ -1324,32 +1341,33 @@ export class CatalogRepository {
1324
1341
  ctx?: TxContext,
1325
1342
  ): Promise<void> {
1326
1343
  if (data.length === 0) return;
1327
- const db = this.getDb(ctx);
1328
- await db.insert(variantOptionValues).values(data).onConflictDoNothing();
1344
+ await this.touchingVariants(data.map((row) => row.variantId), ctx, async (db) => {
1345
+ await db.insert(variantOptionValues).values(data).onConflictDoNothing();
1346
+ });
1329
1347
  }
1330
1348
 
1331
1349
  async deleteVariantOptionValuesByVariantId(
1332
1350
  variantId: string,
1333
1351
  ctx?: TxContext,
1334
1352
  ): Promise<void> {
1335
- const db = this.getDb(ctx);
1336
- await db
1337
- .delete(variantOptionValues)
1338
- .where(eq(variantOptionValues.variantId, variantId));
1353
+ await this.touchingVariants([variantId], ctx, async (db) => {
1354
+ await db
1355
+ .delete(variantOptionValues)
1356
+ .where(eq(variantOptionValues.variantId, variantId));
1357
+ });
1339
1358
  }
1340
1359
 
1341
1360
  async deleteVariantOptionValuesByEntityId(
1342
1361
  entityId: string,
1343
1362
  ctx?: TxContext,
1344
1363
  ): Promise<void> {
1345
- const db = this.getDb(ctx);
1346
1364
  // Get all variant IDs for this entity first
1347
1365
  const entityVariants = await this.findVariantsByEntityId(entityId, ctx);
1348
1366
  const variantIds = entityVariants.map((v) => v.id);
1349
- if (variantIds.length > 0) {
1367
+ await this.touchingVariants(variantIds, ctx, async (db) => {
1350
1368
  await db
1351
1369
  .delete(variantOptionValues)
1352
1370
  .where(inArray(variantOptionValues.variantId, variantIds));
1353
- }
1371
+ });
1354
1372
  }
1355
1373
  }
@@ -337,6 +337,10 @@ export const variants = pgTable(
337
337
  .default("active"),
338
338
  sortOrder: integer("sort_order").notNull().default(0),
339
339
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
340
+ // Bumped on EVERY write to the variant or its option values, so a variant-only change is
341
+ // visible to anything that versions a product from its timestamps (the entity row is not
342
+ // touched by a variant write).
343
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
340
344
  },
341
345
  (table) => ({
342
346
  barcodeIdx: index("idx_variants_barcode").on(table.barcode),