@porulle/core 0.10.6 → 0.11.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 (136) hide show
  1. package/dist/config/types.d.ts +3 -0
  2. package/dist/config/types.d.ts.map +1 -1
  3. package/dist/hooks/checkout.d.ts +2 -0
  4. package/dist/hooks/checkout.d.ts.map +1 -1
  5. package/dist/hooks/checkout.js +2 -0
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/interfaces/rest/index.d.ts.map +1 -1
  10. package/dist/interfaces/rest/index.js +4 -0
  11. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts +5 -0
  12. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts.map +1 -0
  13. package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +22 -0
  14. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts +5 -0
  15. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts.map +1 -0
  16. package/dist/interfaces/rest/routes/admin/entity-field-definitions.js +39 -0
  17. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  18. package/dist/interfaces/rest/routes/catalog.js +37 -2
  19. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  20. package/dist/interfaces/rest/routes/checkout.js +7 -1
  21. package/dist/interfaces/rest/routes/payments.d.ts.map +1 -1
  22. package/dist/interfaces/rest/routes/payments.js +48 -30
  23. package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
  24. package/dist/interfaces/rest/routes/search.js +12 -0
  25. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts +134 -0
  26. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts.map +1 -0
  27. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.js +26 -0
  28. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts +592 -0
  29. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts.map +1 -0
  30. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.js +109 -0
  31. package/dist/interfaces/rest/schemas/catalog.d.ts +1364 -401
  32. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  33. package/dist/interfaces/rest/schemas/catalog.js +86 -0
  34. package/dist/interfaces/rest/schemas/pricing.d.ts +1 -0
  35. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  36. package/dist/interfaces/rest/schemas/search.d.ts +1 -0
  37. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  38. package/dist/interfaces/rest/schemas/search.js +1 -0
  39. package/dist/kernel/error-mapper.d.ts.map +1 -1
  40. package/dist/kernel/error-mapper.js +3 -0
  41. package/dist/modules/catalog/entity-service.d.ts +5 -3
  42. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  43. package/dist/modules/catalog/entity-service.js +73 -22
  44. package/dist/modules/catalog/module.d.ts +6 -1
  45. package/dist/modules/catalog/module.d.ts.map +1 -1
  46. package/dist/modules/catalog/module.js +6 -1
  47. package/dist/modules/catalog/ownership.d.ts +5 -0
  48. package/dist/modules/catalog/ownership.d.ts.map +1 -0
  49. package/dist/modules/catalog/ownership.js +11 -0
  50. package/dist/modules/catalog/repository/index.d.ts +52 -1
  51. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  52. package/dist/modules/catalog/repository/index.js +321 -2
  53. package/dist/modules/catalog/revision.d.ts +4 -0
  54. package/dist/modules/catalog/revision.d.ts.map +1 -0
  55. package/dist/modules/catalog/revision.js +33 -0
  56. package/dist/modules/catalog/schema.d.ts +979 -110
  57. package/dist/modules/catalog/schema.d.ts.map +1 -1
  58. package/dist/modules/catalog/schema.js +112 -2
  59. package/dist/modules/catalog/schemas.d.ts +2 -0
  60. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  61. package/dist/modules/catalog/schemas.js +2 -0
  62. package/dist/modules/catalog/service.d.ts +66 -3
  63. package/dist/modules/catalog/service.d.ts.map +1 -1
  64. package/dist/modules/catalog/service.js +512 -11
  65. package/dist/modules/channels/adapter.d.ts +85 -0
  66. package/dist/modules/channels/adapter.d.ts.map +1 -1
  67. package/dist/modules/channels/adapter.js +9 -1
  68. package/dist/modules/media/module.d.ts +68 -0
  69. package/dist/modules/media/module.d.ts.map +1 -1
  70. package/dist/modules/media/module.js +2 -0
  71. package/dist/modules/media/schema.d.ts +70 -0
  72. package/dist/modules/media/schema.d.ts.map +1 -1
  73. package/dist/modules/media/schema.js +20 -2
  74. package/dist/modules/media/service.d.ts +8 -1
  75. package/dist/modules/media/service.d.ts.map +1 -1
  76. package/dist/modules/media/service.js +66 -18
  77. package/dist/modules/orders/service.d.ts +2 -0
  78. package/dist/modules/orders/service.d.ts.map +1 -1
  79. package/dist/modules/orders/service.js +1 -0
  80. package/dist/modules/pricing/schema.d.ts +17 -0
  81. package/dist/modules/pricing/schema.d.ts.map +1 -1
  82. package/dist/modules/pricing/schema.js +1 -0
  83. package/dist/modules/pricing/schemas.d.ts +1 -0
  84. package/dist/modules/pricing/schemas.d.ts.map +1 -1
  85. package/dist/modules/pricing/schemas.js +1 -0
  86. package/dist/modules/pricing/service.d.ts.map +1 -1
  87. package/dist/modules/pricing/service.js +6 -1
  88. package/dist/modules/search/adapter.d.ts +2 -0
  89. package/dist/modules/search/adapter.d.ts.map +1 -1
  90. package/dist/modules/search/module.d.ts.map +1 -1
  91. package/dist/modules/search/module.js +2 -0
  92. package/dist/modules/search/service.d.ts +4 -0
  93. package/dist/modules/search/service.d.ts.map +1 -1
  94. package/dist/modules/search/service.js +74 -5
  95. package/dist/runtime/kernel-modules.d.ts +73 -0
  96. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  97. package/dist/utils/id.d.ts +1 -0
  98. package/dist/utils/id.d.ts.map +1 -1
  99. package/dist/utils/id.js +8 -0
  100. package/package.json +1 -1
  101. package/src/config/types.ts +3 -0
  102. package/src/hooks/checkout.ts +4 -0
  103. package/src/index.ts +14 -0
  104. package/src/interfaces/rest/index.ts +4 -0
  105. package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +37 -0
  106. package/src/interfaces/rest/routes/admin/entity-field-definitions.ts +89 -0
  107. package/src/interfaces/rest/routes/catalog.ts +85 -0
  108. package/src/interfaces/rest/routes/checkout.ts +7 -1
  109. package/src/interfaces/rest/routes/payments.ts +55 -35
  110. package/src/interfaces/rest/routes/search.ts +16 -0
  111. package/src/interfaces/rest/schemas/admin-custom-field-proposals.ts +29 -0
  112. package/src/interfaces/rest/schemas/admin-entity-field-definitions.ts +120 -0
  113. package/src/interfaces/rest/schemas/catalog.ts +92 -0
  114. package/src/interfaces/rest/schemas/search.ts +2 -0
  115. package/src/kernel/error-mapper.ts +3 -0
  116. package/src/modules/catalog/entity-service.ts +73 -20
  117. package/src/modules/catalog/module.ts +15 -0
  118. package/src/modules/catalog/ownership.ts +17 -0
  119. package/src/modules/catalog/repository/index.ts +547 -1
  120. package/src/modules/catalog/revision.ts +35 -0
  121. package/src/modules/catalog/schema.ts +169 -1
  122. package/src/modules/catalog/schemas.ts +2 -0
  123. package/src/modules/catalog/service.ts +738 -13
  124. package/src/modules/channels/adapter.ts +107 -1
  125. package/src/modules/media/module.ts +2 -0
  126. package/src/modules/media/schema.ts +23 -2
  127. package/src/modules/media/service.ts +84 -23
  128. package/src/modules/orders/service.ts +3 -0
  129. package/src/modules/pricing/schema.ts +1 -0
  130. package/src/modules/pricing/schemas.ts +1 -0
  131. package/src/modules/pricing/service.ts +7 -2
  132. package/src/modules/search/adapter.ts +2 -0
  133. package/src/modules/search/module.ts +2 -0
  134. package/src/modules/search/service.ts +87 -6
  135. package/src/utils/id.ts +11 -0
  136. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,10 +1,14 @@
1
1
  import type { Actor } from "../../auth/types.js";
2
+ import { resolveOrgId } from "../../auth/org.js";
3
+ import { assertPermission } from "../../auth/permissions.js";
2
4
  import type { CommerceConfig } from "../../config/types.js";
5
+ import type { EntityFieldDefinition, FieldType } from "../../config/types.js";
3
6
  import type { HookRegistry } from "../../kernel/hooks/registry.js";
4
- import type { Result } from "../../kernel/result.js";
7
+ import { Err, Ok, type Result } from "../../kernel/result.js";
8
+ import { CommerceConflictError, CommerceNotFoundError, CommerceValidationError, toCommerceError } from "../../kernel/errors.js";
5
9
  import type { Pagination } from "../../utils/pagination.js";
6
10
  import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
7
- import type { TxContext } from "../../kernel/database/tx-context.js";
11
+ import { createTxContext, type TxContext } from "../../kernel/database/tx-context.js";
8
12
  import {
9
13
  CatalogRepository,
10
14
  type SellableEntity,
@@ -15,7 +19,17 @@ import {
15
19
  type OptionType,
16
20
  type OptionValue,
17
21
  type Variant,
22
+ type SellableEntityRevision,
23
+ type SellableEntityInsert,
24
+ type SellableAttributeInsert,
25
+ type SellableCustomField,
26
+ type SellableCustomFieldInsert,
27
+ type EntityFieldDefinitionRecord,
28
+ type CatalogFieldOwnership,
18
29
  } from "./repository/index.js";
30
+ import type { SellableEntityRevisionReason, SellableEntityRevisionSnapshot } from "./schema.js";
31
+ import { isValidFieldPath, validateFieldPath, type FieldOwner, type FieldPath } from "./ownership.js";
32
+ import { comparableSerialize } from "./revision.js";
19
33
 
20
34
  // ─── Re-exported schema-derived types ────────────────────────────────────────
21
35
  export type {
@@ -87,6 +101,7 @@ export interface GetOptions {
87
101
  includeMedia?: boolean;
88
102
  includeCategories?: boolean;
89
103
  includeBrands?: boolean;
104
+ includeCustomFields?: boolean;
90
105
  }
91
106
 
92
107
  export interface VariantMatrixRule {
@@ -99,8 +114,32 @@ export type VariantGenerationStrategy =
99
114
  | { mode: "manual"; combinations: string[][] }
100
115
  | { mode: "matrix"; matrix: VariantMatrixRule };
101
116
 
117
+ export type EntityFieldDefinitionResolver = (
118
+ entityType: string,
119
+ actorOrOrg?: Actor | string | null,
120
+ ctx?: TxContext,
121
+ ) => Promise<EntityFieldDefinition[]>;
122
+
123
+ export type CreateEntityFieldDefinitionInput = {
124
+ entityType: string;
125
+ name: string;
126
+ type: FieldType;
127
+ unit?: string | null;
128
+ options?: string[] | null;
129
+ target?: string | null;
130
+ filterable?: boolean;
131
+ localized?: boolean;
132
+ sortOrder?: number;
133
+ };
134
+
135
+ export type UpdateEntityFieldDefinitionInput = Partial<Omit<CreateEntityFieldDefinitionInput, "entityType" | "name">> & {
136
+ entityType?: string;
137
+ name?: string;
138
+ };
139
+
102
140
  export interface CatalogEntityHydrated extends SellableEntity {
103
141
  attributes?: SellableAttribute[];
142
+ customFields?: SellableCustomField[];
104
143
  variants?: Array<Variant & { optionValueIds: string[] }>;
105
144
  optionTypes?: Array<OptionType & { values: OptionValue[] }>;
106
145
  categories?: EntityCategory[];
@@ -140,6 +179,41 @@ export interface CatalogService {
140
179
  ctx?: TxContext,
141
180
  ): Promise<Result<CatalogEntityHydrated>>;
142
181
  list(params: ListParams, actor?: Actor | null, ctx?: TxContext): Promise<Result<CatalogListResult>>;
182
+ /** Returns undefined when no concrete ownership row applies to the field. */
183
+ resolveFieldOwner(
184
+ entityId: string,
185
+ fieldPath: FieldPath,
186
+ storeId: string,
187
+ ctx?: TxContext,
188
+ variantId?: string | null,
189
+ ): Promise<FieldOwner | undefined>;
190
+ /** Entity-level bulk resolution: variant-scoped ownership rows are not consulted. */
191
+ resolveFieldOwners(
192
+ entityId: string,
193
+ storeId: string,
194
+ ctx?: TxContext,
195
+ ): Promise<Map<FieldPath, FieldOwner>>;
196
+ setFieldOwner(
197
+ entityId: string,
198
+ fieldPath: FieldPath,
199
+ storeId: string | null,
200
+ owner: FieldOwner,
201
+ actor: Actor | null,
202
+ ctx?: TxContext,
203
+ variantId?: string | null,
204
+ ): Promise<Result<void>>;
205
+ listFieldOwnership(
206
+ entityId: string,
207
+ actor: Actor | null,
208
+ ctx?: TxContext,
209
+ storeId?: string,
210
+ ): Promise<Result<CatalogFieldOwnership[]>>;
211
+ seedImportedFieldOwnership(
212
+ entityId: string,
213
+ storeId: string,
214
+ fieldPaths: FieldPath[],
215
+ ctx?: TxContext,
216
+ ): Promise<Result<void>>;
143
217
  publish(
144
218
  id: string,
145
219
  actor: Actor | null,
@@ -162,6 +236,20 @@ export interface CatalogService {
162
236
  actor: Actor | null,
163
237
  ctx?: TxContext,
164
238
  ): Promise<Result<void>>;
239
+ approveCustomField(
240
+ entityId: string,
241
+ fieldName: string,
242
+ locale: string,
243
+ actor: Actor | null,
244
+ ctx?: TxContext,
245
+ ): Promise<Result<SellableCustomField>>;
246
+ rejectCustomField(
247
+ entityId: string,
248
+ fieldName: string,
249
+ locale: string,
250
+ actor: Actor | null,
251
+ ctx?: TxContext,
252
+ ): Promise<Result<SellableCustomField>>;
165
253
  getAttributes(
166
254
  entityId: string,
167
255
  locale: string,
@@ -271,6 +359,45 @@ export interface CatalogService {
271
359
  actor: Actor | null,
272
360
  ctx?: TxContext,
273
361
  ): Promise<Result<{ created: Variant[]; skipped: number }>>;
362
+ recordEntityRevision(
363
+ entityId: string,
364
+ actor: Actor | null,
365
+ reason?: SellableEntityRevisionReason,
366
+ ctx?: TxContext,
367
+ ): Promise<Result<SellableEntityRevision>>;
368
+ restoreEntityRevision(
369
+ entityId: string,
370
+ revisionId: string,
371
+ actor: Actor | null,
372
+ ctx?: TxContext,
373
+ ): Promise<Result<CatalogEntityHydrated>>;
374
+ trimEntityRevisions(
375
+ actor: Actor | null,
376
+ olderThanDays?: number,
377
+ ctx?: TxContext,
378
+ ): Promise<Result<number>>;
379
+ listEntityFieldDefinitions(
380
+ actor?: Actor | null,
381
+ entityType?: string,
382
+ ctx?: TxContext,
383
+ ): Promise<Result<EntityFieldDefinitionRecord[]>>;
384
+ createEntityFieldDefinition(
385
+ input: CreateEntityFieldDefinitionInput,
386
+ actor: Actor | null,
387
+ ctx?: TxContext,
388
+ ): Promise<Result<EntityFieldDefinitionRecord>>;
389
+ updateEntityFieldDefinition(
390
+ id: string,
391
+ input: UpdateEntityFieldDefinitionInput,
392
+ actor: Actor | null,
393
+ ctx?: TxContext,
394
+ ): Promise<Result<EntityFieldDefinitionRecord>>;
395
+ archiveEntityFieldDefinition(
396
+ id: string,
397
+ actor: Actor | null,
398
+ ctx?: TxContext,
399
+ ): Promise<Result<EntityFieldDefinitionRecord>>;
400
+ resolveEntityFieldDefinitions: EntityFieldDefinitionResolver;
274
401
  }
275
402
 
276
403
  export interface CatalogServiceDeps {
@@ -295,25 +422,421 @@ export type CategorySummary = {
295
422
  metadata: Record<string, unknown>;
296
423
  };
297
424
 
425
+ function isUniqueViolation(error: unknown): boolean {
426
+ if (error == null || typeof error !== "object") return false;
427
+ const value = error as { code?: unknown; cause?: unknown };
428
+ if (value.code === "23505") return true;
429
+ return isUniqueViolation(value.cause);
430
+ }
431
+
298
432
  export class CatalogServiceImpl implements CatalogService {
299
433
  readonly repository: CatalogRepository;
434
+ private readonly database: DatabaseAdapter;
435
+ private readonly config: CommerceConfig;
300
436
  private readonly entities: EntityService;
301
437
  private readonly categories: CategoryService;
302
438
  private readonly brands: BrandService;
303
439
 
304
440
  constructor(deps: CatalogServiceDeps) {
305
441
  this.repository = deps.repository;
306
- this.entities = new EntityService(deps);
442
+ this.database = deps.database;
443
+ this.config = deps.config;
444
+ this.entities = new EntityService(deps, this.resolveEntityFieldDefinitions.bind(this));
307
445
  this.categories = new CategoryService(deps);
308
446
  this.brands = new BrandService(deps);
309
447
  }
310
448
 
449
+ private codeFieldDefinition(entityType: string, name: string): EntityFieldDefinition | undefined {
450
+ return this.config.entities?.[entityType]?.fields.find((field) => field.name === name);
451
+ }
452
+
453
+ private runtimeFieldDefinition(row: EntityFieldDefinitionRecord): EntityFieldDefinition {
454
+ return {
455
+ name: row.name,
456
+ type: row.type,
457
+ ...(row.unit != null ? { unit: row.unit } : {}),
458
+ ...(row.options != null ? { options: row.options } : {}),
459
+ ...(row.target != null ? { target: row.target } : {}),
460
+ filterable: row.filterable,
461
+ localized: row.localized,
462
+ sortOrder: row.sortOrder,
463
+ };
464
+ }
465
+
466
+ async resolveEntityFieldDefinitions(
467
+ entityType: string,
468
+ actorOrOrg?: Actor | string | null,
469
+ ctx?: TxContext,
470
+ ): Promise<EntityFieldDefinition[]> {
471
+ const orgId = typeof actorOrOrg === "string"
472
+ ? actorOrOrg
473
+ : resolveOrgId(actorOrOrg ?? ctx?.actor ?? null);
474
+ const codeFields = this.config.entities?.[entityType]?.fields ?? [];
475
+ const merged = new Map(codeFields.map((field) => [field.name, { ...field }]));
476
+ const runtimeFields = await this.repository.findActiveEntityFieldDefinitions(orgId, entityType, ctx);
477
+
478
+ for (const row of runtimeFields) {
479
+ const codeField = merged.get(row.name);
480
+ if (!codeField) {
481
+ merged.set(row.name, this.runtimeFieldDefinition(row));
482
+ continue;
483
+ }
484
+ merged.set(row.name, {
485
+ ...codeField,
486
+ ...(row.options != null ? { options: row.options } : {}),
487
+ filterable: row.filterable,
488
+ localized: row.localized,
489
+ sortOrder: row.sortOrder,
490
+ });
491
+ }
492
+
493
+ return [...merged.values()].sort((first, second) =>
494
+ (first.sortOrder ?? 0) - (second.sortOrder ?? 0),
495
+ );
496
+ }
497
+
498
+ listEntityFieldDefinitions(
499
+ actor?: Actor | null,
500
+ entityType?: string,
501
+ ctx?: TxContext,
502
+ ): Promise<Result<EntityFieldDefinitionRecord[]>> {
503
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
504
+ return this.withMutationResult(actor ?? ctx?.actor ?? null, ctx, async (txCtx) =>
505
+ Ok(await this.repository.findEntityFieldDefinitions(orgId, entityType, txCtx)),
506
+ );
507
+ }
508
+
509
+ createEntityFieldDefinition(
510
+ input: CreateEntityFieldDefinitionInput,
511
+ actor: Actor | null,
512
+ ctx?: TxContext,
513
+ ): Promise<Result<EntityFieldDefinitionRecord>> {
514
+ if (input.entityType.length === 0 || input.name.length === 0) {
515
+ return Promise.resolve(Err(new CommerceValidationError("Entity type and field name are required.")));
516
+ }
517
+ assertPermission(actor, "catalog:update");
518
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
519
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
520
+ const codeField = this.codeFieldDefinition(input.entityType, input.name);
521
+ if (codeField && input.type !== codeField.type) {
522
+ return Err(new CommerceValidationError("Code-defined fields only allow options, filterable, localized, and sortOrder overrides."));
523
+ }
524
+ return Ok(await this.repository.createEntityFieldDefinition({
525
+ organizationId: orgId,
526
+ entityType: input.entityType,
527
+ name: input.name,
528
+ type: input.type,
529
+ // A shadow row over a code field inherits the code values for anything
530
+ // omitted, so creating one never silently flips a code default.
531
+ ...(codeField?.unit !== undefined || input.unit !== undefined
532
+ ? { unit: codeField ? codeField.unit ?? null : input.unit }
533
+ : {}),
534
+ ...(codeField?.target !== undefined || input.target !== undefined
535
+ ? { target: codeField ? codeField.target ?? null : input.target }
536
+ : {}),
537
+ ...(input.options !== undefined
538
+ ? { options: input.options }
539
+ : codeField?.options !== undefined ? { options: codeField.options } : {}),
540
+ ...(input.filterable !== undefined
541
+ ? { filterable: input.filterable }
542
+ : codeField?.filterable !== undefined ? { filterable: codeField.filterable } : {}),
543
+ ...(input.localized !== undefined ? { localized: input.localized } : {}),
544
+ ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
545
+ }, txCtx));
546
+ });
547
+ }
548
+
549
+ updateEntityFieldDefinition(
550
+ id: string,
551
+ input: UpdateEntityFieldDefinitionInput,
552
+ actor: Actor | null,
553
+ ctx?: TxContext,
554
+ ): Promise<Result<EntityFieldDefinitionRecord>> {
555
+ assertPermission(actor, "catalog:update");
556
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
557
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
558
+ const existing = await this.repository.findEntityFieldDefinitionById(orgId, id, txCtx);
559
+ if (!existing) return Err(new CommerceNotFoundError("Entity field definition not found."));
560
+ const codeField = this.codeFieldDefinition(existing.entityType, existing.name);
561
+ if (codeField && (input.entityType !== undefined || input.name !== undefined || input.type !== undefined || input.unit !== undefined || input.target !== undefined)) {
562
+ return Err(new CommerceValidationError("Code-defined fields only allow options, filterable, localized, and sortOrder updates."));
563
+ }
564
+ const data = {
565
+ ...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
566
+ ...(input.name !== undefined ? { name: input.name } : {}),
567
+ ...(input.type !== undefined ? { type: input.type } : {}),
568
+ ...(input.unit !== undefined ? { unit: input.unit } : {}),
569
+ ...(input.options !== undefined ? { options: input.options } : {}),
570
+ ...(input.target !== undefined ? { target: input.target } : {}),
571
+ ...(input.filterable !== undefined ? { filterable: input.filterable } : {}),
572
+ ...(input.localized !== undefined ? { localized: input.localized } : {}),
573
+ ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
574
+ };
575
+ const updated = await this.repository.updateEntityFieldDefinition(orgId, id, data, txCtx);
576
+ if (!updated) return Err(new CommerceNotFoundError("Entity field definition not found."));
577
+ return Ok(updated);
578
+ });
579
+ }
580
+
581
+ archiveEntityFieldDefinition(
582
+ id: string,
583
+ actor: Actor | null,
584
+ ctx?: TxContext,
585
+ ): Promise<Result<EntityFieldDefinitionRecord>> {
586
+ assertPermission(actor, "catalog:update");
587
+ const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
588
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
589
+ const existing = await this.repository.findEntityFieldDefinitionById(orgId, id, txCtx);
590
+ if (!existing) return Err(new CommerceNotFoundError("Entity field definition not found."));
591
+ if (this.codeFieldDefinition(existing.entityType, existing.name)) {
592
+ return Err(new CommerceValidationError("Code-defined fields cannot be archived."));
593
+ }
594
+ if (existing.status === "archived") return Ok(existing);
595
+ const updated = await this.repository.updateEntityFieldDefinition(orgId, id, { status: "archived" }, txCtx);
596
+ if (!updated) return Err(new CommerceNotFoundError("Entity field definition not found."));
597
+ return Ok(updated);
598
+ });
599
+ }
600
+
601
+ private async withMutation<T>(
602
+ actor: Actor | null,
603
+ ctx: TxContext | undefined,
604
+ fn: (txCtx: TxContext) => Promise<T>,
605
+ ): Promise<T> {
606
+ if (ctx?.tx != null) return fn(ctx);
607
+ for (let attempt = 0; attempt < 3; attempt += 1) {
608
+ try {
609
+ return await this.database.transaction(async (tx) => fn(createTxContext(tx, { actor })));
610
+ } catch (error) {
611
+ if (!isUniqueViolation(error) || attempt === 2) throw error;
612
+ }
613
+ }
614
+ throw new Error("Catalog mutation retry limit reached.");
615
+ }
616
+
617
+ private async withMutationResult<T>(
618
+ actor: Actor | null,
619
+ ctx: TxContext | undefined,
620
+ fn: (txCtx: TxContext) => Promise<Result<T>>,
621
+ ): Promise<Result<T>> {
622
+ try {
623
+ return await this.withMutation(actor, ctx, fn);
624
+ } catch (error) {
625
+ if (isUniqueViolation(error)) return Err(new CommerceConflictError("A resource with those values already exists."));
626
+ return Err(toCommerceError(error));
627
+ }
628
+ }
629
+
630
+ private async captureRevision(
631
+ entityId: string,
632
+ actor: Actor | null,
633
+ reason: SellableEntityRevisionReason,
634
+ ctx: TxContext,
635
+ force = false,
636
+ ): Promise<SellableEntityRevision> {
637
+ const snapshot = await this.repository.snapshotEntity(entityId, ctx);
638
+ const latest = await this.repository.findLatestRevision(entityId, ctx);
639
+ if (!(force || reason === "push") && latest && comparableSerialize(latest.snapshot) === comparableSerialize(snapshot)) return latest;
640
+ const revisionActor = actor ?? ctx.actor;
641
+ const organizationId = typeof snapshot.entity.organizationId === "string"
642
+ ? snapshot.entity.organizationId
643
+ : resolveOrgId(revisionActor);
644
+ return this.repository.createRevision({
645
+ organizationId,
646
+ entityId,
647
+ snapshot,
648
+ reason,
649
+ actorId: revisionActor?.userId ?? null,
650
+ actorType: revisionActor?.type ?? null,
651
+ requestId: ctx.requestId,
652
+ }, ctx);
653
+ }
654
+
655
+ async recordEntityRevision(
656
+ entityId: string,
657
+ actor: Actor | null,
658
+ reason: SellableEntityRevisionReason = "update",
659
+ ctx?: TxContext,
660
+ ): Promise<Result<SellableEntityRevision>> {
661
+ try {
662
+ return await this.withMutation(actor, ctx, async (txCtx) => {
663
+ const revisionActor = actor ?? txCtx.actor;
664
+ assertPermission(revisionActor, "catalog:update");
665
+ const entity = await this.repository.findEntityById(entityId, txCtx);
666
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
667
+ if (entity.organizationId !== resolveOrgId(revisionActor)) {
668
+ return Err(new CommerceNotFoundError("Entity not found."));
669
+ }
670
+ return Ok(await this.captureRevision(entityId, revisionActor, reason, txCtx));
671
+ });
672
+ } catch (error) {
673
+ return Err(toCommerceError(error));
674
+ }
675
+ }
676
+
677
+ private restoreDate(value: unknown): Date | null {
678
+ if (value == null) return null;
679
+ return value instanceof Date ? value : new Date(String(value));
680
+ }
681
+
682
+ private async applyRevisionSnapshot(
683
+ entityId: string,
684
+ snapshot: SellableEntityRevisionSnapshot,
685
+ ctx: TxContext,
686
+ ): Promise<void> {
687
+ const entity = snapshot.entity as unknown as SellableEntity;
688
+ const entityData: Partial<SellableEntityInsert> = {
689
+ sourceStoreId: entity.sourceStoreId,
690
+ type: entity.type,
691
+ slug: entity.slug,
692
+ status: entity.status,
693
+ isVisible: entity.isVisible,
694
+ taxClass: entity.taxClass,
695
+ metadata: entity.metadata,
696
+ publishedAt: this.restoreDate((snapshot.entity as Record<string, unknown>).publishedAt),
697
+ };
698
+ await this.repository.updateEntity(entityId, entityData, ctx);
699
+
700
+ await this.repository.deleteAttributesByEntityId(entityId, ctx);
701
+ for (const row of snapshot.attributes as unknown as Array<SellableAttributeInsert & { id: string }>) {
702
+ await this.repository.createAttribute({
703
+ id: row.id,
704
+ entityId,
705
+ locale: row.locale,
706
+ title: row.title,
707
+ subtitle: row.subtitle,
708
+ description: row.description,
709
+ richDescription: row.richDescription,
710
+ seoTitle: row.seoTitle,
711
+ seoDescription: row.seoDescription,
712
+ }, ctx);
713
+ }
714
+
715
+ await this.repository.deleteCustomFieldsByEntityId(entityId, ctx);
716
+ for (const row of snapshot.customFields as unknown as Array<SellableCustomFieldInsert & { id: string }>) {
717
+ await this.repository.createCustomField({
718
+ id: row.id,
719
+ entityId,
720
+ fieldName: row.fieldName,
721
+ fieldType: row.fieldType,
722
+ source: row.source,
723
+ status: row.status,
724
+ confidence: row.confidence,
725
+ evidence: row.evidence,
726
+ locale: row.locale,
727
+ approvedAt: this.restoreDate(row.approvedAt),
728
+ approvedBy: row.approvedBy,
729
+ textValue: row.textValue,
730
+ numberValue: row.numberValue,
731
+ booleanValue: row.booleanValue,
732
+ dateValue: this.restoreDate(row.dateValue),
733
+ jsonValue: row.jsonValue,
734
+ createdAt: this.restoreDate(row.createdAt) ?? new Date(),
735
+ updatedAt: this.restoreDate(row.updatedAt) ?? new Date(),
736
+ }, ctx);
737
+ }
738
+
739
+ await this.repository.deleteEntityCategoriesByEntityId(entityId, ctx);
740
+ for (const row of snapshot.categories as unknown as Array<{ categoryId: string; sortOrder: number }>) {
741
+ await this.repository.addEntityToCategory(entityId, row.categoryId, row.sortOrder, ctx);
742
+ }
743
+
744
+ await this.repository.deleteEntityBrandsByEntityId(entityId, ctx);
745
+ for (const row of snapshot.brands as unknown as Array<{ brandId: string; sortOrder: number }>) {
746
+ await this.repository.addEntityToBrand(entityId, row.brandId, row.sortOrder, ctx);
747
+ }
748
+
749
+ // Snapshots written before tags existed carry no key; restoring one must
750
+ // not delete tag links the snapshot never saw.
751
+ if (snapshot.tags) {
752
+ await this.repository.deleteEntityTagsByEntityId(entityId, ctx);
753
+ for (const row of snapshot.tags as unknown as Array<{ tagId: string }>) {
754
+ await this.repository.addEntityTag(entityId, row.tagId, ctx);
755
+ }
756
+ }
757
+
758
+ await this.repository.deleteEntityMediaByEntityId(entityId, ctx);
759
+ for (const row of snapshot.media as unknown as Array<{
760
+ mediaAssetId: string;
761
+ variantId: string | null;
762
+ role: "primary" | "gallery" | "thumbnail" | "video" | "document";
763
+ sortOrder: number;
764
+ }>) {
765
+ await this.repository.createEntityMedia({
766
+ entityId,
767
+ mediaAssetId: row.mediaAssetId,
768
+ role: row.role,
769
+ sortOrder: row.sortOrder,
770
+ variantId: row.variantId,
771
+ }, ctx);
772
+ }
773
+ }
774
+
775
+ async restoreEntityRevision(
776
+ entityId: string,
777
+ revisionId: string,
778
+ actor: Actor | null,
779
+ ctx?: TxContext,
780
+ ): Promise<Result<CatalogEntityHydrated>> {
781
+ try {
782
+ return await this.withMutation(actor, ctx, async (txCtx) => {
783
+ const revisionActor = actor ?? txCtx.actor;
784
+ assertPermission(revisionActor, "catalog:update");
785
+ const entity = await this.repository.findEntityById(entityId, txCtx);
786
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
787
+ if (entity.organizationId !== resolveOrgId(revisionActor)) {
788
+ return Err(new CommerceNotFoundError("Entity not found."));
789
+ }
790
+ if (entity.sourceStoreId != null) assertPermission(revisionActor, "catalog:sync");
791
+ const revision = await this.repository.findRevisionById(entityId, revisionId, txCtx);
792
+ if (!revision) return Err(new CommerceNotFoundError("Revision not found."));
793
+ await this.applyRevisionSnapshot(entityId, revision.snapshot, txCtx);
794
+ const restored = await this.entities.getById(
795
+ entityId,
796
+ { includeAttributes: true, includeCategories: true, includeBrands: true, includeMedia: true },
797
+ revisionActor,
798
+ txCtx,
799
+ );
800
+ if (!restored.ok) return restored;
801
+ await this.captureRevision(entityId, revisionActor, "restore", txCtx, true);
802
+ return restored;
803
+ });
804
+ } catch (error) {
805
+ return Err(toCommerceError(error));
806
+ }
807
+ }
808
+
809
+ async trimEntityRevisions(
810
+ actor: Actor | null,
811
+ olderThanDays = 90,
812
+ ctx?: TxContext,
813
+ ): Promise<Result<number>> {
814
+ try {
815
+ return await this.withMutation(actor, ctx, async (txCtx) => {
816
+ const revisionActor = actor ?? txCtx.actor;
817
+ assertPermission(revisionActor, "catalog:update");
818
+ const cutoff = new Date(Date.now() - olderThanDays * 24 * 60 * 60 * 1000);
819
+ return Ok(await this.repository.deleteRevisionsOlderThan(resolveOrgId(revisionActor), cutoff, txCtx));
820
+ });
821
+ } catch (error) {
822
+ return Err(toCommerceError(error));
823
+ }
824
+ }
825
+
311
826
  create(input: CreateEntityInput, actor: Actor | null, ctx?: TxContext): Promise<Result<CatalogEntityHydrated>> {
312
- return this.entities.create(input, actor, ctx);
827
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
828
+ const result = await this.entities.create(input, actor, txCtx);
829
+ if (result.ok) await this.captureRevision(result.value.id, actor, "create", txCtx);
830
+ return result;
831
+ });
313
832
  }
314
833
 
315
834
  update(id: string, input: UpdateEntityInput, actor: Actor | null, ctx?: TxContext): Promise<Result<CatalogEntityHydrated>> {
316
- return this.entities.update(id, input, actor, ctx);
835
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
836
+ const result = await this.entities.update(id, input, actor, txCtx);
837
+ if (result.ok) await this.captureRevision(id, actor, "update", txCtx);
838
+ return result;
839
+ });
317
840
  }
318
841
 
319
842
  delete(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
@@ -332,20 +855,206 @@ export class CatalogServiceImpl implements CatalogService {
332
855
  return this.entities.list(params, actor, ctx);
333
856
  }
334
857
 
858
+ async resolveFieldOwner(
859
+ entityId: string,
860
+ fieldPath: FieldPath,
861
+ storeId: string,
862
+ ctx?: TxContext,
863
+ variantId?: string | null,
864
+ ): Promise<FieldOwner | undefined> {
865
+ if (!isValidFieldPath(fieldPath)) return undefined;
866
+ const rows = await this.repository.findFieldOwnershipForResolution(entityId, storeId, variantId ?? null, ctx);
867
+ let selected: CatalogFieldOwnership | undefined;
868
+ let selectedPriority = -1;
869
+ for (const row of rows) {
870
+ if (row.fieldPath !== fieldPath) continue;
871
+ const priority = (row.variantId === variantId && variantId != null ? 2 : 0)
872
+ + (row.storeId === storeId ? 1 : 0);
873
+ if (priority > selectedPriority) {
874
+ selected = row;
875
+ selectedPriority = priority;
876
+ }
877
+ }
878
+ return selected?.owner;
879
+ }
880
+
881
+ async resolveFieldOwners(
882
+ entityId: string,
883
+ storeId: string,
884
+ ctx?: TxContext,
885
+ ): Promise<Map<FieldPath, FieldOwner>> {
886
+ const rows = await this.repository.findFieldOwnershipForResolution(entityId, storeId, null, ctx);
887
+ const resolved = new Map<FieldPath, { owner: FieldOwner; priority: number }>();
888
+ for (const row of rows) {
889
+ const priority = row.storeId === storeId ? 1 : 0;
890
+ const current = resolved.get(row.fieldPath);
891
+ if (!current || priority > current.priority) {
892
+ resolved.set(row.fieldPath, { owner: row.owner, priority });
893
+ }
894
+ }
895
+ return new Map([...resolved].map(([fieldPath, value]) => [fieldPath, value.owner]));
896
+ }
897
+
898
+ setFieldOwner(
899
+ entityId: string,
900
+ fieldPath: FieldPath,
901
+ storeId: string | null,
902
+ owner: FieldOwner,
903
+ actor: Actor | null,
904
+ ctx?: TxContext,
905
+ variantId?: string | null,
906
+ ): Promise<Result<void>> {
907
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
908
+ const setter = actor ?? txCtx.actor;
909
+ assertPermission(setter, "catalog:update");
910
+ validateFieldPath(fieldPath);
911
+ const entity = await this.repository.findEntityById(entityId, txCtx);
912
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
913
+ if (entity.organizationId !== resolveOrgId(setter)) {
914
+ return Err(new CommerceNotFoundError("Entity not found."));
915
+ }
916
+ if (entity.sourceStoreId != null) assertPermission(setter, "catalog:sync");
917
+ await this.repository.upsertFieldOwnership({
918
+ organizationId: entity.organizationId,
919
+ entityId,
920
+ ...(variantId != null ? { variantId } : {}),
921
+ ...(storeId != null ? { storeId } : {}),
922
+ fieldPath,
923
+ owner,
924
+ }, txCtx);
925
+ return Ok(undefined);
926
+ });
927
+ }
928
+
929
+ listFieldOwnership(
930
+ entityId: string,
931
+ actor: Actor | null,
932
+ ctx?: TxContext,
933
+ storeId?: string,
934
+ ): Promise<Result<CatalogFieldOwnership[]>> {
935
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
936
+ const reader = actor ?? txCtx.actor;
937
+ assertPermission(reader, "catalog:read");
938
+ const entity = await this.repository.findEntityById(entityId, txCtx);
939
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
940
+ if (entity.organizationId !== resolveOrgId(reader)) {
941
+ return Err(new CommerceNotFoundError("Entity not found."));
942
+ }
943
+ return Ok(await this.repository.findFieldOwnership(entityId, txCtx, storeId));
944
+ });
945
+ }
946
+
947
+ seedImportedFieldOwnership(
948
+ entityId: string,
949
+ storeId: string,
950
+ fieldPaths: FieldPath[],
951
+ ctx?: TxContext,
952
+ ): Promise<Result<void>> {
953
+ return this.withMutationResult(null, ctx, async (txCtx) => {
954
+ const entity = await this.repository.findEntityById(entityId, txCtx);
955
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
956
+ const rows = fieldPaths.map((fieldPath) => ({
957
+ organizationId: entity.organizationId,
958
+ entityId,
959
+ storeId,
960
+ fieldPath: validateFieldPath(fieldPath),
961
+ owner: "store" as const,
962
+ }));
963
+ await this.repository.seedFieldOwnership(rows, txCtx);
964
+ return Ok(undefined);
965
+ });
966
+ }
967
+
335
968
  publish(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CatalogEntityHydrated>> {
336
- return this.entities.publish(id, actor, ctx);
969
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
970
+ const result = await this.entities.publish(id, actor, txCtx);
971
+ if (result.ok) await this.captureRevision(id, actor, "update", txCtx);
972
+ return result;
973
+ });
337
974
  }
338
975
 
339
976
  archive(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CatalogEntityHydrated>> {
340
- return this.entities.archive(id, actor, ctx);
977
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
978
+ const result = await this.entities.archive(id, actor, txCtx);
979
+ if (result.ok) await this.captureRevision(id, actor, "update", txCtx);
980
+ return result;
981
+ });
341
982
  }
342
983
 
343
984
  discontinue(id: string, actor: Actor | null, ctx?: TxContext): Promise<Result<CatalogEntityHydrated>> {
344
- return this.entities.discontinue(id, actor, ctx);
985
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
986
+ const result = await this.entities.discontinue(id, actor, txCtx);
987
+ if (result.ok) await this.captureRevision(id, actor, "update", txCtx);
988
+ return result;
989
+ });
345
990
  }
346
991
 
347
992
  setAttributes(entityId: string, locale: string, attrs: SetAttributesInput, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
348
- return this.entities.setAttributes(entityId, locale, attrs, actor, ctx);
993
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
994
+ const result = await this.entities.setAttributes(entityId, locale, attrs, actor, txCtx);
995
+ if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
996
+ return result;
997
+ });
998
+ }
999
+
1000
+ private reviewCustomField(
1001
+ status: "approved" | "rejected",
1002
+ entityId: string,
1003
+ fieldName: string,
1004
+ locale: string,
1005
+ actor: Actor | null,
1006
+ ctx?: TxContext,
1007
+ ): Promise<Result<SellableCustomField>> {
1008
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
1009
+ const reviewer = actor ?? txCtx.actor;
1010
+ assertPermission(reviewer, "catalog:update");
1011
+ const entity = await this.repository.findEntityById(entityId, txCtx);
1012
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
1013
+ if (entity.organizationId !== resolveOrgId(reviewer)) {
1014
+ return Err(new CommerceNotFoundError("Entity not found."));
1015
+ }
1016
+ if (entity.sourceStoreId != null) assertPermission(reviewer, "catalog:sync");
1017
+ const proposal = await this.repository.findProposedCustomField(entityId, fieldName, locale, txCtx);
1018
+ if (!proposal) return Err(new CommerceNotFoundError("Custom field proposal not found."));
1019
+ if (status === "approved") {
1020
+ await this.repository.deleteCustomField(entityId, fieldName, locale, txCtx);
1021
+ }
1022
+ const updated = await this.repository.updateProposedCustomField(proposal.id, {
1023
+ status,
1024
+ ...(status === "approved"
1025
+ ? { approvedAt: new Date(), approvedBy: reviewer?.userId ?? null }
1026
+ : {}),
1027
+ }, txCtx);
1028
+ // Throw, never return Err, past this point: the approved-row delete above
1029
+ // must roll back when the proposal was already resolved by a concurrent
1030
+ // reviewer, or the live value is lost with no replacement.
1031
+ if (!updated) throw new CommerceNotFoundError("Custom field proposal not found.");
1032
+ if (status === "approved") {
1033
+ await this.repository.rejectOtherProposedCustomFields(entityId, fieldName, locale, updated.id, txCtx);
1034
+ }
1035
+ await this.captureRevision(entityId, reviewer, "update", txCtx);
1036
+ return Ok(updated);
1037
+ });
1038
+ }
1039
+
1040
+ approveCustomField(
1041
+ entityId: string,
1042
+ fieldName: string,
1043
+ locale: string,
1044
+ actor: Actor | null,
1045
+ ctx?: TxContext,
1046
+ ): Promise<Result<SellableCustomField>> {
1047
+ return this.reviewCustomField("approved", entityId, fieldName, locale, actor, ctx);
1048
+ }
1049
+
1050
+ rejectCustomField(
1051
+ entityId: string,
1052
+ fieldName: string,
1053
+ locale: string,
1054
+ actor: Actor | null,
1055
+ ctx?: TxContext,
1056
+ ): Promise<Result<SellableCustomField>> {
1057
+ return this.reviewCustomField("rejected", entityId, fieldName, locale, actor, ctx);
349
1058
  }
350
1059
 
351
1060
  getAttributes(entityId: string, locale: string, ctx?: TxContext): Promise<Result<SellableAttribute>> {
@@ -377,11 +1086,19 @@ export class CatalogServiceImpl implements CatalogService {
377
1086
  }
378
1087
 
379
1088
  addToCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
380
- return this.categories.addToCategory(entityId, categoryId, actor, ctx);
1089
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
1090
+ const result = await this.categories.addToCategory(entityId, categoryId, actor, txCtx);
1091
+ if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1092
+ return result;
1093
+ });
381
1094
  }
382
1095
 
383
1096
  removeFromCategory(entityId: string, categoryId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
384
- return this.categories.removeFromCategory(entityId, categoryId, actor, ctx);
1097
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
1098
+ const result = await this.categories.removeFromCategory(entityId, categoryId, actor, txCtx);
1099
+ if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1100
+ return result;
1101
+ });
385
1102
  }
386
1103
 
387
1104
  listBrands(ctx?: TxContext): Promise<Result<Brand[]>> {
@@ -401,11 +1118,19 @@ export class CatalogServiceImpl implements CatalogService {
401
1118
  }
402
1119
 
403
1120
  addToBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
404
- return this.brands.addToBrand(entityId, brandId, actor, ctx);
1121
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
1122
+ const result = await this.brands.addToBrand(entityId, brandId, actor, txCtx);
1123
+ if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1124
+ return result;
1125
+ });
405
1126
  }
406
1127
 
407
1128
  removeFromBrand(entityId: string, brandId: string, actor: Actor | null, ctx?: TxContext): Promise<Result<void>> {
408
- return this.brands.removeFromBrand(entityId, brandId, actor, ctx);
1129
+ return this.withMutationResult(actor, ctx, async (txCtx) => {
1130
+ const result = await this.brands.removeFromBrand(entityId, brandId, actor, txCtx);
1131
+ if (result.ok) await this.captureRevision(entityId, actor, "update", txCtx);
1132
+ return result;
1133
+ });
409
1134
  }
410
1135
 
411
1136
  createOptionType(input: CreateOptionTypeInput, actor: Actor | null, ctx?: TxContext): Promise<Result<OptionType>> {