@porulle/core 0.54.0 → 0.55.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.
Files changed (32) hide show
  1. package/dist/index.d.ts +2 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/modules/catalog/brand-service.d.ts +2 -2
  5. package/dist/modules/catalog/brand-service.d.ts.map +1 -1
  6. package/dist/modules/catalog/brand-service.js +3 -3
  7. package/dist/modules/catalog/category-service.d.ts +2 -2
  8. package/dist/modules/catalog/category-service.d.ts.map +1 -1
  9. package/dist/modules/catalog/category-service.js +3 -3
  10. package/dist/modules/catalog/entity-links.d.ts +63 -0
  11. package/dist/modules/catalog/entity-links.d.ts.map +1 -0
  12. package/dist/modules/catalog/entity-links.js +69 -0
  13. package/dist/modules/catalog/import-service.d.ts.map +1 -1
  14. package/dist/modules/catalog/import-service.js +13 -17
  15. package/dist/modules/catalog/repository/index.d.ts +4 -2
  16. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  17. package/dist/modules/catalog/repository/index.js +10 -4
  18. package/dist/modules/catalog/service.d.ts +6 -0
  19. package/dist/modules/catalog/service.d.ts.map +1 -1
  20. package/dist/modules/catalog/service.js +35 -12
  21. package/dist/modules/media/service.d.ts.map +1 -1
  22. package/dist/modules/media/service.js +3 -0
  23. package/package.json +1 -1
  24. package/src/index.ts +2 -0
  25. package/src/modules/catalog/brand-service.ts +5 -5
  26. package/src/modules/catalog/category-service.ts +5 -5
  27. package/src/modules/catalog/entity-links.ts +114 -0
  28. package/src/modules/catalog/import-service.ts +12 -17
  29. package/src/modules/catalog/repository/index.ts +12 -6
  30. package/src/modules/catalog/service.ts +43 -8
  31. package/src/modules/media/service.ts +4 -0
  32. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -204,6 +204,9 @@ export class CatalogServiceImpl {
204
204
  requestId: ctx.requestId,
205
205
  }, ctx);
206
206
  }
207
+ notifyEntityChanged(entityId, changedFieldPaths, actor, ctx) {
208
+ return this.entities.notifyEntityUpdated(entityId, changedFieldPaths, actor, ctx);
209
+ }
207
210
  async recordEntityRevision(entityId, actor, reason = "update", ctx) {
208
211
  try {
209
212
  return await this.withMutation(actor, ctx, async (txCtx) => {
@@ -567,17 +570,27 @@ export class CatalogServiceImpl {
567
570
  addToCategory(entityId, categoryId, actor, ctx) {
568
571
  return this.withMutationResult(actor, ctx, async (txCtx) => {
569
572
  const result = await this.categories.addToCategory(entityId, categoryId, actor, txCtx);
570
- if (result.ok)
571
- await this.captureRevision(entityId, actor, "update", txCtx);
572
- return result;
573
+ if (!result.ok)
574
+ return result;
575
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
576
+ // is already linked moves nothing.
577
+ if (result.value)
578
+ await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
579
+ await this.captureRevision(entityId, actor, "update", txCtx);
580
+ return Ok(undefined);
573
581
  });
574
582
  }
575
583
  removeFromCategory(entityId, categoryId, actor, ctx) {
576
584
  return this.withMutationResult(actor, ctx, async (txCtx) => {
577
585
  const result = await this.categories.removeFromCategory(entityId, categoryId, actor, txCtx);
578
- if (result.ok)
579
- await this.captureRevision(entityId, actor, "update", txCtx);
580
- return result;
586
+ if (!result.ok)
587
+ return result;
588
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
589
+ // is already linked moves nothing.
590
+ if (result.value)
591
+ await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
592
+ await this.captureRevision(entityId, actor, "update", txCtx);
593
+ return Ok(undefined);
581
594
  });
582
595
  }
583
596
  listBrands(ctx) {
@@ -595,17 +608,27 @@ export class CatalogServiceImpl {
595
608
  addToBrand(entityId, brandId, actor, ctx) {
596
609
  return this.withMutationResult(actor, ctx, async (txCtx) => {
597
610
  const result = await this.brands.addToBrand(entityId, brandId, actor, txCtx);
598
- if (result.ok)
599
- await this.captureRevision(entityId, actor, "update", txCtx);
600
- return result;
611
+ if (!result.ok)
612
+ return result;
613
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
614
+ // is already linked moves nothing.
615
+ if (result.value)
616
+ await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
617
+ await this.captureRevision(entityId, actor, "update", txCtx);
618
+ return Ok(undefined);
601
619
  });
602
620
  }
603
621
  removeFromBrand(entityId, brandId, actor, ctx) {
604
622
  return this.withMutationResult(actor, ctx, async (txCtx) => {
605
623
  const result = await this.brands.removeFromBrand(entityId, brandId, actor, txCtx);
606
- if (result.ok)
607
- await this.captureRevision(entityId, actor, "update", txCtx);
608
- return result;
624
+ if (!result.ok)
625
+ return result;
626
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
627
+ // is already linked moves nothing.
628
+ if (result.value)
629
+ await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
630
+ await this.captureRevision(entityId, actor, "update", txCtx);
631
+ return Ok(undefined);
609
632
  });
610
633
  }
611
634
  createOptionType(input, actor, ctx) {
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/modules/media/service.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../../src/modules/media/service.ts"],"names":[],"mappings":"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"}
@@ -181,6 +181,9 @@ export class MediaService {
181
181
  throw new Error("Catalog service is required for media revision capture.");
182
182
  }
183
183
  const catalogService = catalog;
184
+ // A new attachment versions the entity: the hero drives the image embedding, so a consumer
185
+ // that versions on `updated_at` must see it.
186
+ await catalogService.notifyEntityChanged(input.entityId, [`media.${input.role}`], actor ?? txCtx.actor, txCtx);
184
187
  const revision = await catalogService.recordEntityRevision(input.entityId, actor ?? txCtx.actor, "update", txCtx);
185
188
  if (!revision.ok)
186
189
  throw revision.error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@porulle/core",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.ts CHANGED
@@ -227,6 +227,8 @@ export type {
227
227
  ImportProductsReport,
228
228
  ImportRowFailureCode,
229
229
  } from "./modules/catalog/import-service.js";
230
+ export { writeEntityLinks, linkFieldPaths } from "./modules/catalog/entity-links.js";
231
+ export type { EntityLinkRows, EntityMediaRole, WrittenEntityLinks } from "./modules/catalog/entity-links.js";
230
232
  export type {
231
233
  TxContext,
232
234
  WithTransactionOptions,
@@ -70,7 +70,7 @@ export class BrandService {
70
70
  return Ok(undefined);
71
71
  }
72
72
 
73
- async addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
73
+ async addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
74
74
  try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
75
75
  const entity = await this.deps.repository.findEntityById(entityId, ctx);
76
76
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
@@ -82,11 +82,11 @@ export class BrandService {
82
82
  if (!brand) {
83
83
  brand = await this.repo.createBrand({ organizationId: addBrandOrgId, slug: brandId, displayName: brandId, metadata: {} }, ctx);
84
84
  }
85
- await this.repo.addEntityToBrand(entityId, brand.id, 0, ctx);
86
- return Ok(undefined);
85
+ const added = await this.repo.addEntityToBrand(entityId, brand.id, 0, ctx);
86
+ return Ok(added);
87
87
  }
88
88
 
89
- async removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
89
+ async removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
90
90
  try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
91
91
  const entity = await this.deps.repository.findEntityById(entityId, ctx);
92
92
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
@@ -96,6 +96,6 @@ export class BrandService {
96
96
  if (!brand) brand = await this.repo.findBrandBySlug(resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config), brandId, ctx);
97
97
  const removed = await this.repo.removeEntityFromBrand(entityId, brand?.id ?? brandId, ctx);
98
98
  if (!removed) return Err(new CommerceNotFoundError("Brand assignment not found."));
99
- return Ok(undefined);
99
+ return Ok(true);
100
100
  }
101
101
  }
@@ -93,7 +93,7 @@ export class CategoryService {
93
93
  return Ok(undefined);
94
94
  }
95
95
 
96
- async addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
96
+ async addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
97
97
  try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
98
98
  const entity = await this.deps.repository.findEntityById(entityId, ctx);
99
99
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
@@ -105,11 +105,11 @@ export class CategoryService {
105
105
  if (!category) {
106
106
  category = await this.repo.createCategory({ organizationId: addCatOrgId, slug: categoryId, sortOrder: 0, metadata: {} }, ctx);
107
107
  }
108
- await this.repo.addEntityToCategory(entityId, category.id, 0, ctx);
109
- return Ok(undefined);
108
+ const added = await this.repo.addEntityToCategory(entityId, category.id, 0, ctx);
109
+ return Ok(added);
110
110
  }
111
111
 
112
- async removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
112
+ async removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<boolean>> {
113
113
  try { assertPermission(actor, "catalog:update"); } catch (error) { return Err(toCommerceError(error)); }
114
114
  const entity = await this.deps.repository.findEntityById(entityId, ctx);
115
115
  if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
@@ -119,6 +119,6 @@ export class CategoryService {
119
119
  if (!category) category = await this.repo.findCategoryBySlug(resolveOrgIdForCommerce(actor ?? ctx?.actor ?? null, this.deps.config), categoryId, ctx);
120
120
  const removed = await this.repo.removeEntityFromCategory(entityId, category?.id ?? categoryId, ctx);
121
121
  if (!removed) return Err(new CommerceNotFoundError("Category assignment not found."));
122
- return Ok(undefined);
122
+ return Ok(true);
123
123
  }
124
124
  }
@@ -0,0 +1,114 @@
1
+ import { and, eq, isNull, ne, or, sql, type SQL } from "drizzle-orm";
2
+ import type { PluginDb } from "../../kernel/database/plugin-types.js";
3
+ import { mediaAssets, entityMedia } from "../media/schema.js";
4
+ import { brands, categories, entityBrands, entityCategories, entityTags, sellableEntities, tags } from "./schema.js";
5
+
6
+ /**
7
+ * THE writer of an entity's link rows — categories, brand, tags, media — for the bulk paths: the
8
+ * page import (`importProducts`) and the channel converge, both of which write links directly
9
+ * rather than one service call per link.
10
+ *
11
+ * - One statement per link class, however many entities the rows span.
12
+ * - Org scope is enforced IN the statement: a row whose entity, or whose category / brand / tag /
13
+ * media asset, belongs to another organization matches nothing and is not written. Callers
14
+ * resolve ids within their org already; this makes a slip a no-write instead of a cross-org link.
15
+ * - Idempotent: a link that already exists is not rewritten and is not returned. What comes back
16
+ * is exactly what changed, which is what a caller versions the entity from (`linkFieldPaths`).
17
+ */
18
+ export type EntityMediaRole = (typeof entityMedia.$inferInsert)["role"];
19
+
20
+ export interface EntityLinkRows {
21
+ categories?: ReadonlyArray<{ entityId: string; categoryId: string; sortOrder: number }>;
22
+ brands?: ReadonlyArray<{ entityId: string; brandId: string; sortOrder: number }>;
23
+ tags?: ReadonlyArray<{ entityId: string; tagId: string }>;
24
+ /** New media links. */
25
+ media?: ReadonlyArray<{ entityId: string; variantId: string | null; mediaAssetId: string; role: EntityMediaRole; sortOrder: number }>;
26
+ /** Existing media links whose role or sort order should become these; unchanged ones write nothing. */
27
+ mediaPlacements?: ReadonlyArray<{ entityId: string; variantId: string | null; mediaAssetId: string; role: EntityMediaRole; sortOrder: number }>;
28
+ }
29
+
30
+ export interface WrittenEntityLinks {
31
+ categories: Array<typeof entityCategories.$inferSelect>;
32
+ brands: Array<typeof entityBrands.$inferSelect>;
33
+ tags: Array<typeof entityTags.$inferSelect>;
34
+ media: Array<typeof entityMedia.$inferSelect>;
35
+ placed: Array<typeof entityMedia.$inferSelect>;
36
+ }
37
+
38
+ const valuesOf = (rows: ReadonlyArray<readonly SQL[]>): SQL =>
39
+ sql.join(rows.map((row) => sql`(${sql.join([...row], sql`, `)})`), sql`, `);
40
+
41
+ const entityInOrg = (orgId: string): SQL =>
42
+ sql`join ${sellableEntities} on ${sellableEntities.id} = v.entity_id and ${sellableEntities.organizationId} = ${orgId}`;
43
+
44
+ export async function writeEntityLinks(
45
+ db: Pick<PluginDb, "insert" | "update">,
46
+ orgId: string,
47
+ rows: EntityLinkRows,
48
+ ): Promise<WrittenEntityLinks> {
49
+ const written: WrittenEntityLinks = { categories: [], brands: [], tags: [], media: [], placed: [] };
50
+ if (rows.categories?.length) {
51
+ written.categories = await db.insert(entityCategories).select(sql`
52
+ select v.entity_id, v.category_id, v.sort_order
53
+ from (values ${valuesOf(rows.categories.map((row) => [sql`${row.entityId}::uuid`, sql`${row.categoryId}::uuid`, sql`${row.sortOrder}::integer`]))})
54
+ as v(entity_id, category_id, sort_order)
55
+ join ${categories} on ${categories.id} = v.category_id and ${categories.organizationId} = ${orgId}
56
+ ${entityInOrg(orgId)}`).onConflictDoNothing().returning();
57
+ }
58
+ if (rows.brands?.length) {
59
+ written.brands = await db.insert(entityBrands).select(sql`
60
+ select v.entity_id, v.brand_id, v.sort_order
61
+ from (values ${valuesOf(rows.brands.map((row) => [sql`${row.entityId}::uuid`, sql`${row.brandId}::uuid`, sql`${row.sortOrder}::integer`]))})
62
+ as v(entity_id, brand_id, sort_order)
63
+ join ${brands} on ${brands.id} = v.brand_id and ${brands.organizationId} = ${orgId}
64
+ ${entityInOrg(orgId)}`).onConflictDoNothing().returning();
65
+ }
66
+ if (rows.tags?.length) {
67
+ written.tags = await db.insert(entityTags).select(sql`
68
+ select v.entity_id, v.tag_id
69
+ from (values ${valuesOf(rows.tags.map((row) => [sql`${row.entityId}::uuid`, sql`${row.tagId}::uuid`]))})
70
+ as v(entity_id, tag_id)
71
+ join ${tags} on ${tags.id} = v.tag_id and ${tags.organizationId} = ${orgId}
72
+ ${entityInOrg(orgId)}`).onConflictDoNothing().returning();
73
+ }
74
+ if (rows.media?.length) {
75
+ written.media = await db.insert(entityMedia).select(sql`
76
+ select v.entity_id, v.variant_id, v.media_asset_id, v.role, v.sort_order, now()
77
+ from (values ${valuesOf(rows.media.map((row) => [
78
+ sql`${row.entityId}::uuid`, sql`${row.variantId}::uuid`, sql`${row.mediaAssetId}::uuid`, sql`${row.role}::text`, sql`${row.sortOrder}::integer`,
79
+ ]))}) as v(entity_id, variant_id, media_asset_id, role, sort_order)
80
+ join ${mediaAssets} on ${mediaAssets.id} = v.media_asset_id and ${mediaAssets.organizationId} = ${orgId}
81
+ ${entityInOrg(orgId)}`).onConflictDoNothing().returning();
82
+ }
83
+ // ponytail: one UPDATE per re-placed link; a role/sort change upstream is rare, batch it if a
84
+ // store ever reshuffles whole galleries on every sync.
85
+ for (const row of rows.mediaPlacements ?? []) {
86
+ written.placed.push(...await db.update(entityMedia).set({ role: row.role, sortOrder: row.sortOrder }).where(and(
87
+ eq(entityMedia.entityId, row.entityId),
88
+ eq(entityMedia.mediaAssetId, row.mediaAssetId),
89
+ row.variantId === null ? isNull(entityMedia.variantId) : eq(entityMedia.variantId, row.variantId),
90
+ or(ne(entityMedia.role, row.role), ne(entityMedia.sortOrder, row.sortOrder)),
91
+ sql`exists (select 1 from ${sellableEntities} where ${sellableEntities.id} = ${entityMedia.entityId} and ${sellableEntities.organizationId} = ${orgId})`,
92
+ )).returning());
93
+ }
94
+ return written;
95
+ }
96
+
97
+ /**
98
+ * The field paths each entity's written links changed, sorted — the names the catalog services
99
+ * version links under (`categories`, `brand`, `tags`, `media.<role>`), so a consumer sees the same
100
+ * paths whichever path wrote the link.
101
+ */
102
+ export function linkFieldPaths(written: WrittenEntityLinks): Map<string, string[]> {
103
+ const byEntity = new Map<string, Set<string>>();
104
+ const add = (entityId: string, path: string) => {
105
+ const paths = byEntity.get(entityId) ?? new Set<string>();
106
+ paths.add(path);
107
+ byEntity.set(entityId, paths);
108
+ };
109
+ for (const row of written.categories) add(row.entityId, "categories");
110
+ for (const row of written.brands) add(row.entityId, "brand");
111
+ for (const row of written.tags) add(row.entityId, "tags");
112
+ for (const row of [...written.media, ...written.placed]) add(row.entityId, `media.${row.role}`);
113
+ return new Map([...byEntity].map(([entityId, paths]) => [entityId, [...paths].sort()]));
114
+ }
@@ -9,13 +9,11 @@ import { createTxContext, isWriteContextTransactional, resolveWriteContextHookCo
9
9
  import { runAfterHooks } from "../../kernel/hooks/executor.js";
10
10
  import type { AfterHook } from "../../kernel/hooks/types.js";
11
11
  import { isValidFieldPath, type FieldPath } from "./ownership.js";
12
+ import { writeEntityLinks } from "./entity-links.js";
12
13
  import {
13
14
  brands,
14
15
  categories,
15
16
  catalogFieldOwnership,
16
- entityBrands,
17
- entityCategories,
18
- entityTags,
19
17
  optionTypes,
20
18
  optionValues,
21
19
  sellableAttributes,
@@ -137,7 +135,7 @@ export interface ImportProductsReport extends Record<string, unknown> {
137
135
  rows: ImportProductRowResult[];
138
136
  }
139
137
 
140
- type Writer = Pick<PluginDb, "insert" | "select">;
138
+ type Writer = Pick<PluginDb, "insert" | "select" | "update">;
141
139
 
142
140
  /** The drizzle handle a transaction context carries (typed `unknown` on the context). */
143
141
  const writerOf = (txCtx: TxContext): PluginDb => txCtx.tx as PluginDb;
@@ -391,7 +389,6 @@ async function writeItem(
391
389
  if (priceRows.length > 0) await sp.insert(prices).values(priceRows);
392
390
  }
393
391
 
394
- const categoryLinks: Row[] = [];
395
392
  const categoryInputs = [...new Set(item.categories ?? [])].flatMap((slug, index) => {
396
393
  const category = taxonomy.categories.get(slug);
397
394
  if (!category) throw new Error(`Category "${slug}" was not resolved for the page.`);
@@ -401,20 +398,18 @@ async function writeItem(
401
398
  }
402
399
  return [{ entityId, categoryId: category.id, sortOrder: index }];
403
400
  });
404
- if (categoryInputs.length > 0) categoryLinks.push(...asRows(await sp.insert(entityCategories).values(categoryInputs).returning()));
405
- const brandLinks: Row[] = [];
406
- if (item.brand) {
407
- const brandId = taxonomy.brands.get(item.brand);
408
- if (brandId === undefined) throw new Error(`Brand "${item.brand}" was not resolved for the page.`);
409
- brandLinks.push(...asRows(await sp.insert(entityBrands).values([{ entityId, brandId, sortOrder: 0 }]).returning()));
410
- }
411
- const tagLinks: Row[] = [];
401
+ const brandId = item.brand ? taxonomy.brands.get(item.brand) : undefined;
402
+ if (item.brand && brandId === undefined) throw new Error(`Brand "${item.brand}" was not resolved for the page.`);
412
403
  const tagInputs = [...new Set(item.tags ?? [])].map((slug) => {
413
404
  const tagId = taxonomy.tags.get(slug);
414
405
  if (tagId === undefined) throw new Error(`Tag "${slug}" was not resolved for the page.`);
415
406
  return { entityId, tagId };
416
407
  });
417
- if (tagInputs.length > 0) tagLinks.push(...asRows(await sp.insert(entityTags).values(tagInputs).returning()));
408
+ const links = await writeEntityLinks(sp, orgId, {
409
+ categories: categoryInputs,
410
+ brands: brandId === undefined ? [] : [{ entityId, brandId, sortOrder: 0 }],
411
+ tags: tagInputs,
412
+ });
418
413
 
419
414
  const ownedPaths = [...new Set(item.ownedFieldPaths ?? [])] as FieldPath[];
420
415
  if (ownedPaths.length > 0) {
@@ -434,9 +429,9 @@ async function writeItem(
434
429
  attributes: asRows(attributeRows).sort((a, b) => String(a.locale).localeCompare(String(b.locale)) || String(a.id).localeCompare(String(b.id))),
435
430
  customFields: [],
436
431
  media: [],
437
- categories: categoryLinks.sort(byKey("categoryId")),
438
- brands: brandLinks.sort(byKey("brandId")),
439
- tags: tagLinks.sort(byKey("tagId")),
432
+ categories: asRows(links.categories).sort(byKey("categoryId")),
433
+ brands: asRows(links.brands).sort(byKey("brandId")),
434
+ tags: asRows(links.tags).sort(byKey("tagId")),
440
435
  };
441
436
  return { entityId, variantIds, warnings, snapshot };
442
437
  }
@@ -923,17 +923,20 @@ export class CatalogRepository {
923
923
  return rows.map((r) => r.entityId);
924
924
  }
925
925
 
926
+ /** True when the link was inserted; false when it already existed. */
926
927
  async addEntityToCategory(
927
928
  entityId: string,
928
929
  categoryId: string,
929
930
  sortOrder = 0,
930
931
  ctx?: TxContext,
931
- ): Promise<void> {
932
+ ): Promise<boolean> {
932
933
  const db = this.getDb(ctx);
933
- await db
934
+ const inserted = await db
934
935
  .insert(entityCategories)
935
936
  .values({ entityId, categoryId, sortOrder })
936
- .onConflictDoNothing();
937
+ .onConflictDoNothing()
938
+ .returning({ entityId: entityCategories.entityId });
939
+ return inserted.length > 0;
937
940
  }
938
941
 
939
942
  async removeEntityFromCategory(
@@ -1079,17 +1082,20 @@ export class CatalogRepository {
1079
1082
  await db.delete(entityTags).where(eq(entityTags.entityId, entityId));
1080
1083
  }
1081
1084
 
1085
+ /** True when the link was inserted; false when it already existed. */
1082
1086
  async addEntityToBrand(
1083
1087
  entityId: string,
1084
1088
  brandId: string,
1085
1089
  sortOrder = 0,
1086
1090
  ctx?: TxContext,
1087
- ): Promise<void> {
1091
+ ): Promise<boolean> {
1088
1092
  const db = this.getDb(ctx);
1089
- await db
1093
+ const inserted = await db
1090
1094
  .insert(entityBrands)
1091
1095
  .values({ entityId, brandId, sortOrder })
1092
- .onConflictDoNothing();
1096
+ .onConflictDoNothing()
1097
+ .returning({ entityId: entityBrands.entityId });
1098
+ return inserted.length > 0;
1093
1099
  }
1094
1100
 
1095
1101
  async removeEntityFromBrand(
@@ -379,6 +379,16 @@ export interface CatalogService {
379
379
  actor: Actor | null,
380
380
  ctx?: TxContext,
381
381
  ): Promise<Result<{ created: Variant[]; skipped: number }>>;
382
+ /**
383
+ * Move the entity's `updated_at` and fire `catalog.afterUpdate` for a change to rows RELATED to
384
+ * it (links, media) — the versioning contract consumers rely on to re-read a product.
385
+ */
386
+ notifyEntityChanged(
387
+ entityId: string,
388
+ changedFieldPaths: readonly string[],
389
+ actor: Actor | null,
390
+ ctx?: TxContext,
391
+ ): Promise<void>;
382
392
  recordEntityRevision(
383
393
  entityId: string,
384
394
  actor: Actor | null,
@@ -678,6 +688,15 @@ export class CatalogServiceImpl implements CatalogService {
678
688
  }, ctx);
679
689
  }
680
690
 
691
+ notifyEntityChanged(
692
+ entityId: string,
693
+ changedFieldPaths: readonly string[],
694
+ actor: Actor | null,
695
+ ctx?: TxContext,
696
+ ): Promise<void> {
697
+ return this.entities.notifyEntityUpdated(entityId, changedFieldPaths, actor, ctx);
698
+ }
699
+
681
700
  async recordEntityRevision(
682
701
  entityId: string,
683
702
  actor: Actor | null,
@@ -1134,16 +1153,24 @@ export class CatalogServiceImpl implements CatalogService {
1134
1153
  addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
1135
1154
  return this.withMutationResult(actor, ctx, async (txCtx) => {
1136
1155
  const result = await this.categories.addToCategory(entityId, categoryId, actor, txCtx);
1137
- if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1138
- return result;
1156
+ if (!result.ok) return result;
1157
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
1158
+ // is already linked moves nothing.
1159
+ if (result.value) await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
1160
+ await this.captureRevision(entityId, actor, "update", txCtx);
1161
+ return Ok(undefined);
1139
1162
  });
1140
1163
  }
1141
1164
 
1142
1165
  removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
1143
1166
  return this.withMutationResult(actor, ctx, async (txCtx) => {
1144
1167
  const result = await this.categories.removeFromCategory(entityId, categoryId, actor, txCtx);
1145
- if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1146
- return result;
1168
+ if (!result.ok) return result;
1169
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
1170
+ // is already linked moves nothing.
1171
+ if (result.value) await this.entities.notifyEntityUpdated(entityId, ["categories"], actor, txCtx);
1172
+ await this.captureRevision(entityId, actor, "update", txCtx);
1173
+ return Ok(undefined);
1147
1174
  });
1148
1175
  }
1149
1176
 
@@ -1166,16 +1193,24 @@ export class CatalogServiceImpl implements CatalogService {
1166
1193
  addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
1167
1194
  return this.withMutationResult(actor, ctx, async (txCtx) => {
1168
1195
  const result = await this.brands.addToBrand(entityId, brandId, actor, txCtx);
1169
- if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1170
- return result;
1196
+ if (!result.ok) return result;
1197
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
1198
+ // is already linked moves nothing.
1199
+ if (result.value) await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
1200
+ await this.captureRevision(entityId, actor, "update", txCtx);
1201
+ return Ok(undefined);
1171
1202
  });
1172
1203
  }
1173
1204
 
1174
1205
  removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
1175
1206
  return this.withMutationResult(actor, ctx, async (txCtx) => {
1176
1207
  const result = await this.brands.removeFromBrand(entityId, brandId, actor, txCtx);
1177
- if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1178
- return result;
1208
+ if (!result.ok) return result;
1209
+ // A link that actually changed versions the entity (an indexed facet moved); re-linking what
1210
+ // is already linked moves nothing.
1211
+ if (result.value) await this.entities.notifyEntityUpdated(entityId, ["brand"], actor, txCtx);
1212
+ await this.captureRevision(entityId, actor, "update", txCtx);
1213
+ return Ok(undefined);
1179
1214
  });
1180
1215
  }
1181
1216
 
@@ -287,6 +287,7 @@ export class MediaService {
287
287
  throw new Error("Catalog service is required for media revision capture.");
288
288
  }
289
289
  const catalogService = catalog as {
290
+ notifyEntityChanged: (entityId: string, changedFieldPaths: readonly string[], actor: Actor | null, ctx?: TxContext) => Promise<void>;
290
291
  recordEntityRevision: (
291
292
  entityId: string,
292
293
  actor: Actor | null,
@@ -294,6 +295,9 @@ export class MediaService {
294
295
  ctx?: TxContext,
295
296
  ) => Promise<Result<unknown>>;
296
297
  };
298
+ // A new attachment versions the entity: the hero drives the image embedding, so a consumer
299
+ // that versions on `updated_at` must see it.
300
+ await catalogService.notifyEntityChanged(input.entityId, [`media.${input.role}`], actor ?? txCtx.actor, txCtx);
297
301
  const revision = await catalogService.recordEntityRevision(input.entityId, actor ?? txCtx.actor, "update", txCtx);
298
302
  if (!revision.ok) throw revision.error;
299
303
  return Ok(undefined);