@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
@@ -10,6 +10,7 @@ export interface ChannelConnectorCapabilities {
10
10
  readonly importCatalog: boolean;
11
11
  readonly importInventory: boolean;
12
12
  readonly pushOrder: boolean;
13
+ readonly pushCatalog?: boolean;
13
14
  readonly receiveWebhooks: boolean;
14
15
  readonly reserve?: boolean;
15
16
  }
@@ -24,11 +25,49 @@ export interface ChannelStore {
24
25
  webhookSecret: string | null;
25
26
  }
26
27
 
28
+ export interface ChannelCatalogLocalizedAttributes {
29
+ locale: string;
30
+ title: string;
31
+ subtitle?: string;
32
+ description?: string;
33
+ richDescription?: unknown;
34
+ seoTitle?: string;
35
+ seoDescription?: string;
36
+ }
37
+
38
+ export interface ChannelCatalogImage {
39
+ externalId?: string;
40
+ url: string;
41
+ alt?: string;
42
+ role: "primary" | "gallery" | "thumbnail" | "video" | "document";
43
+ sortOrder?: number;
44
+ variantExternalIds?: string[];
45
+ }
46
+
47
+ export interface ChannelCatalogOptionType {
48
+ name: string;
49
+ displayName: string;
50
+ sortOrder?: number;
51
+ values: Array<{
52
+ value: string;
53
+ displayValue: string;
54
+ sortOrder?: number;
55
+ }>;
56
+ }
57
+
58
+ export interface ChannelCatalogPrice {
59
+ currency: string;
60
+ amount: number;
61
+ compareAtAmount?: number;
62
+ }
63
+
27
64
  export interface ChannelCatalogVariant {
28
65
  externalId: string;
29
66
  sku?: string;
30
67
  barcode?: string;
31
68
  metadata?: Record<string, unknown>;
69
+ optionValues?: Record<string, string>;
70
+ prices?: ChannelCatalogPrice[];
32
71
  }
33
72
 
34
73
  export interface ChannelCatalogItem {
@@ -38,6 +77,13 @@ export interface ChannelCatalogItem {
38
77
  description?: string;
39
78
  variants: ChannelCatalogVariant[];
40
79
  metadata?: Record<string, unknown>;
80
+ attributes?: ChannelCatalogLocalizedAttributes[];
81
+ images?: ChannelCatalogImage[];
82
+ options?: ChannelCatalogOptionType[];
83
+ tags?: string[];
84
+ brand?: string;
85
+ categories?: string[];
86
+ status?: "draft" | "active" | "archived" | "discontinued";
41
87
  }
42
88
 
43
89
  export interface ChannelCatalogPage {
@@ -76,6 +122,54 @@ export interface ChannelPushOrderResult {
76
122
  remoteUrl?: string;
77
123
  }
78
124
 
125
+ export type ChannelPushCatalogIntent = "filterable" | "display" | "tag";
126
+
127
+ export interface ChannelPushCatalogField {
128
+ fieldPath: string;
129
+ intent: ChannelPushCatalogIntent;
130
+ value: unknown;
131
+ locale?: string;
132
+ remoteKey?: string;
133
+ }
134
+
135
+ export interface ChannelPushCatalogImage {
136
+ externalId?: string;
137
+ url: string;
138
+ alt?: string;
139
+ role: "primary" | "gallery" | "thumbnail" | "video" | "document";
140
+ sortOrder?: number;
141
+ variantExternalIds?: string[];
142
+ }
143
+
144
+ export interface ChannelPushCatalogVariant {
145
+ externalId: string;
146
+ fields: ChannelPushCatalogField[];
147
+ }
148
+
149
+ export interface ChannelPushCatalogItem {
150
+ externalId: string;
151
+ variants?: ChannelPushCatalogVariant[];
152
+ fields: ChannelPushCatalogField[];
153
+ images?: ChannelPushCatalogImage[];
154
+ }
155
+
156
+ export interface ChannelPushCatalogPreviousField {
157
+ fieldPath: string;
158
+ value: unknown;
159
+ }
160
+
161
+ export interface ChannelPushCatalogItemOutcome {
162
+ externalId: string;
163
+ ok: boolean;
164
+ error?: ChannelConnectorError;
165
+ remoteUpdatedAt?: string;
166
+ previousFields?: ChannelPushCatalogPreviousField[];
167
+ }
168
+
169
+ export interface ChannelPushCatalogResult {
170
+ outcomes: ChannelPushCatalogItemOutcome[];
171
+ }
172
+
79
173
  export interface ChannelOrderStatus {
80
174
  status: "pending" | "confirmed" | "failed" | "cancelled" | "fulfilled";
81
175
  }
@@ -113,6 +207,11 @@ export interface ChannelConnector {
113
207
  importCatalog(store: ChannelStore, cursor?: string): Promise<Result<ChannelCatalogPage>>;
114
208
  fetchInventory(store: ChannelStore, ids?: string[]): Promise<Result<ChannelInventoryLevel[]>>;
115
209
  pushOrder(store: ChannelStore, slice: ChannelOrderSlice): Promise<Result<ChannelPushOrderResult, ChannelConnectorError>>;
210
+ pushCatalog?(
211
+ store: ChannelStore,
212
+ items: ChannelPushCatalogItem[],
213
+ opts?: { dryRun?: boolean },
214
+ ): Promise<Result<ChannelPushCatalogResult, ChannelConnectorError>>;
116
215
  fetchOrderStatus(store: ChannelStore, remoteId: string): Promise<Result<ChannelOrderStatus, ChannelConnectorError>>;
117
216
  verifyWebhook(store: ChannelStore, request: Request): Promise<Result<ChannelWebhookEvent>>;
118
217
  verifyAppWebhook?(
@@ -135,5 +234,12 @@ export interface ChannelConnector {
135
234
  }
136
235
 
137
236
  export function defineChannelConnector<T extends ChannelConnector>(connector: T): T {
138
- return connector;
237
+ if (connector.capabilities.pushCatalog !== undefined) return connector;
238
+ return {
239
+ ...connector,
240
+ capabilities: {
241
+ ...connector.capabilities,
242
+ pushCatalog: false,
243
+ },
244
+ } as T;
139
245
  }
@@ -20,6 +20,8 @@ export const mediaModule = defineModule({
20
20
  catalogRepository: new CatalogRepository(db),
21
21
  storage,
22
22
  config: deps.config as CommerceConfig,
23
+ database: deps.db,
24
+ services: deps.services,
23
25
  });
24
26
  },
25
27
  });
@@ -1,7 +1,12 @@
1
- import { index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
1
+ import type { AnyPgColumn } from "drizzle-orm/pg-core";
2
+ import { sql } from "drizzle-orm";
3
+ import { index, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
4
  import { organization } from "../../auth/auth-schema.js";
3
5
  import { sellableEntities, variants } from "../catalog/schema.js";
4
6
 
7
+ export const mediaAssetOrigin = pgEnum("media_asset_origin", ["merchant", "generated", "imported"]);
8
+ export type MediaAssetOrigin = (typeof mediaAssetOrigin.enumValues)[number];
9
+
5
10
  export const mediaAssets = pgTable(
6
11
  "media_assets",
7
12
  {
@@ -17,6 +22,11 @@ export const mediaAssets = pgTable(
17
22
  height: integer("height"),
18
23
  alt: text("alt"),
19
24
  metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
25
+ origin: mediaAssetOrigin("origin").notNull().default("merchant"),
26
+ confidence: numeric("confidence", { precision: 4, scale: 3 }),
27
+ derivedFromAssetId: uuid("derived_from_asset_id").references((): AnyPgColumn => mediaAssets.id, {
28
+ onDelete: "set null",
29
+ }),
20
30
  uploadedAt: timestamp("uploaded_at", { withTimezone: true }).defaultNow().notNull(),
21
31
  },
22
32
  (table) => ({
@@ -38,4 +48,15 @@ export const entityMedia = pgTable("entity_media", {
38
48
  enum: ["primary", "gallery", "thumbnail", "video", "document"],
39
49
  }).notNull(),
40
50
  sortOrder: integer("sort_order").notNull().default(0),
41
- });
51
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
52
+ }, (table) => ({
53
+ // Two partial indexes instead of one NULLS DISTINCT index: Postgres treats
54
+ // null variant ids as distinct, so a single index cannot reject duplicate
55
+ // entity-level rows.
56
+ entityLevelUnique: uniqueIndex("entity_media_entity_level_unique")
57
+ .on(table.entityId, table.mediaAssetId)
58
+ .where(sql`${table.variantId} IS NULL`),
59
+ variantLevelUnique: uniqueIndex("entity_media_variant_level_unique")
60
+ .on(table.entityId, table.variantId, table.mediaAssetId)
61
+ .where(sql`${table.variantId} IS NOT NULL`),
62
+ }));
@@ -1,16 +1,22 @@
1
1
  import { resolveOrgId } from "../../auth/org.js";
2
+ import { assertPermission } from "../../auth/permissions.js";
2
3
  import type { Actor } from "../../auth/types.js";
3
4
  import type { CommerceConfig } from "../../config/types.js";
4
5
  import type { StorageAdapter, StoredFile } from "./adapter.js";
5
6
  import { Err, Ok, type Result } from "../../kernel/result.js";
6
7
  import {
8
+ CommerceConflictError,
7
9
  CommerceNotFoundError,
8
10
  CommerceValidationError,
11
+ toCommerceError,
9
12
  } from "../../kernel/errors.js";
10
- import type { MediaRepository } from "./repository/index.js";
13
+ import type { MediaRepository, MediaAsset } from "./repository/index.js";
11
14
  import type { CatalogRepository } from "../catalog/repository/index.js";
12
15
  import type { TxContext } from "../../kernel/database/tx-context.js";
16
+ import { createTxContext } from "../../kernel/database/tx-context.js";
17
+ import type { DatabaseAdapter } from "../../kernel/database/adapter.js";
13
18
  import { makeId } from "../../utils/id.js";
19
+ import type { MediaAssetOrigin } from "./schema.js";
14
20
 
15
21
  export interface UploadMediaInput {
16
22
  filename: string;
@@ -18,6 +24,7 @@ export interface UploadMediaInput {
18
24
  data: ArrayBuffer;
19
25
  alt?: string;
20
26
  metadata?: Record<string, unknown>;
27
+ origin?: MediaAssetOrigin;
21
28
  }
22
29
 
23
30
  export interface AttachMediaInput {
@@ -43,6 +50,8 @@ interface MediaServiceDeps {
43
50
  catalogRepository: CatalogRepository;
44
51
  storage: StorageAdapter;
45
52
  config: CommerceConfig;
53
+ database?: DatabaseAdapter;
54
+ services?: Record<string, unknown>;
46
55
  }
47
56
 
48
57
  const DEFAULT_ALLOWED_MIME_TYPES = [
@@ -53,6 +62,17 @@ const DEFAULT_ALLOWED_MIME_TYPES = [
53
62
  "application/pdf",
54
63
  ];
55
64
 
65
+ export function assertMediaWritable(asset: Pick<MediaAsset, "origin">, actor: Actor | null): void {
66
+ if (asset.origin === "merchant") assertPermission(actor, "media:write");
67
+ }
68
+
69
+ function isUniqueViolation(error: unknown): boolean {
70
+ if (error == null || typeof error !== "object") return false;
71
+ const value = error as { code?: unknown; cause?: unknown };
72
+ if (value.code === "23505") return true;
73
+ return isUniqueViolation(value.cause);
74
+ }
75
+
56
76
  function isSvgOrXml(buffer: Uint8Array): boolean {
57
77
  const prefix = new TextDecoder()
58
78
  .decode(buffer.slice(0, 256))
@@ -97,6 +117,16 @@ export class MediaService {
97
117
  this.catalogRepo = deps.catalogRepository;
98
118
  }
99
119
 
120
+ private async withTransaction<T>(
121
+ actor: Actor | null,
122
+ ctx: TxContext | undefined,
123
+ fn: (txCtx: TxContext) => Promise<T>,
124
+ ): Promise<T> {
125
+ if (ctx?.tx != null) return fn(ctx);
126
+ if (this.deps.database == null) throw new Error("Database is required for media mutations.");
127
+ return this.deps.database.transaction(async (tx) => fn(createTxContext(tx, { actor })));
128
+ }
129
+
100
130
  async upload(
101
131
  input: UploadMediaInput,
102
132
  actor?: Actor | null,
@@ -148,6 +178,7 @@ export class MediaService {
148
178
  contentType: effectiveMime,
149
179
  size: input.data.byteLength,
150
180
  metadata: input.metadata ?? {},
181
+ origin: input.origin ?? "merchant",
151
182
  uploadedAt: new Date(),
152
183
  ...(input.alt !== undefined ? { alt: input.alt } : {}),
153
184
  },
@@ -199,6 +230,12 @@ export class MediaService {
199
230
  const asset = await this.repo.findAssetById(id, ctx, orgId);
200
231
  if (!asset) return Err(new CommerceNotFoundError("Media asset not found."));
201
232
 
233
+ try {
234
+ assertMediaWritable(asset, actor ?? ctx?.actor ?? null);
235
+ } catch (error) {
236
+ return Err(toCommerceError(error));
237
+ }
238
+
202
239
  const deleted = await this.deps.storage.delete(asset.storageKey);
203
240
  if (!deleted.ok) return deleted;
204
241
 
@@ -218,31 +255,55 @@ export class MediaService {
218
255
  actor?: Actor | null,
219
256
  ctx?: TxContext,
220
257
  ): Promise<Result<void>> {
221
- const orgId = resolveOrgId(actor ?? ctx?.actor ?? null);
222
- const entity = await this.catalogRepo.findEntityById(input.entityId, ctx, orgId);
223
- if (!entity) {
224
- return Err(new CommerceNotFoundError("Entity not found."));
225
- }
258
+ try {
259
+ return await this.withTransaction(actor ?? ctx?.actor ?? null, ctx, async (txCtx) => {
260
+ const orgId = resolveOrgId(actor ?? txCtx.actor);
261
+ const entity = await this.catalogRepo.findEntityById(input.entityId, txCtx, orgId);
262
+ if (!entity) return Err(new CommerceNotFoundError("Entity not found."));
226
263
 
227
- const asset = await this.repo.findAssetById(input.mediaAssetId, ctx, orgId);
228
- if (!asset) {
229
- return Err(new CommerceNotFoundError("Media asset not found."));
230
- }
264
+ const asset = await this.repo.findAssetById(input.mediaAssetId, txCtx, orgId);
265
+ if (!asset) return Err(new CommerceNotFoundError("Media asset not found."));
231
266
 
232
- await this.repo.createEntityMedia(
233
- {
234
- entityId: input.entityId,
235
- mediaAssetId: input.mediaAssetId,
236
- role: input.role,
237
- sortOrder: input.sortOrder ?? 0,
238
- ...(input.variantId !== undefined
239
- ? { variantId: input.variantId }
240
- : {}),
241
- },
242
- ctx,
243
- );
267
+ const existing = await this.repo.findEntityMedia(input.entityId, input.variantId, txCtx);
268
+ if (existing.some((link) => link.mediaAssetId === input.mediaAssetId)) {
269
+ return Err(new CommerceConflictError("Media asset is already attached at this level."));
270
+ }
244
271
 
245
- return Ok(undefined);
272
+ await this.repo.createEntityMedia(
273
+ {
274
+ entityId: input.entityId,
275
+ mediaAssetId: input.mediaAssetId,
276
+ role: input.role,
277
+ sortOrder: input.sortOrder ?? 0,
278
+ ...(input.variantId !== undefined
279
+ ? { variantId: input.variantId }
280
+ : {}),
281
+ },
282
+ txCtx,
283
+ );
284
+
285
+ const catalog = this.deps.services?.catalog;
286
+ if (catalog == null || typeof catalog !== "object") {
287
+ throw new Error("Catalog service is required for media revision capture.");
288
+ }
289
+ const catalogService = catalog as {
290
+ recordEntityRevision: (
291
+ entityId: string,
292
+ actor: Actor | null,
293
+ reason?: "create" | "update" | "import" | "enrichment" | "push" | "restore",
294
+ ctx?: TxContext,
295
+ ) => Promise<Result<unknown>>;
296
+ };
297
+ const revision = await catalogService.recordEntityRevision(input.entityId, actor ?? txCtx.actor, "update", txCtx);
298
+ if (!revision.ok) throw revision.error;
299
+ return Ok(undefined);
300
+ });
301
+ } catch (error) {
302
+ if (isUniqueViolation(error)) {
303
+ return Err(new CommerceConflictError("Media asset is already attached at this level."));
304
+ }
305
+ return Err(toCommerceError(error));
306
+ }
246
307
  }
247
308
 
248
309
  /**
@@ -42,6 +42,8 @@ import {
42
42
  } from "./repository/index.js";
43
43
 
44
44
  export interface CreateOrderInput {
45
+ /** Server-allocated id for trusted pipelines that must reference the order before persistence. */
46
+ id?: string;
45
47
  customerId?: string;
46
48
  /** Client-supplied retry key — a repeat create with the same key returns the original order. */
47
49
  idempotencyKey?: string | undefined;
@@ -379,6 +381,7 @@ export class OrderService {
379
381
  try {
380
382
  order = await this.repo.create(
381
383
  {
384
+ ...(opts?.trustedPricing === true && processed.id != null ? { id: processed.id } : {}),
382
385
  organizationId: orgId,
383
386
  orderNumber,
384
387
  status: "pending",
@@ -15,6 +15,7 @@ export const prices = pgTable(
15
15
  variantId: uuid("variant_id").references(() => variants.id, { onDelete: "cascade" }),
16
16
  currency: text("currency").notNull(),
17
17
  amount: integer("amount").notNull(),
18
+ compareAtAmount: integer("compare_at_amount"),
18
19
  customerGroupId: text("customer_group_id"),
19
20
  minQuantity: integer("min_quantity"),
20
21
  maxQuantity: integer("max_quantity"),
@@ -7,6 +7,7 @@ export const SetBasePriceBodySchema = z.object({
7
7
  variantId: z.string().optional().openapi({ example: "variant-uuid" }),
8
8
  currency: z.string().length(3).openapi({ example: "USD" }),
9
9
  amount: z.number().openapi({ example: 29.99 }),
10
+ compareAtAmount: z.number().nullable().optional(),
10
11
  customerGroupId: z.string().optional(),
11
12
  minQuantity: z.number().int().optional(),
12
13
  maxQuantity: z.number().int().optional(),
@@ -224,6 +224,7 @@ export class PricingService {
224
224
  entityId: input.entityId,
225
225
  currency: normalizeCurrency(input.currency),
226
226
  amount: input.amount,
227
+ ...(input.compareAtAmount !== undefined ? { compareAtAmount: input.compareAtAmount } : {}),
227
228
  metadata: input.metadata ?? {},
228
229
  variantId: input.variantId ?? null,
229
230
  customerGroupId: input.customerGroupId ?? null,
@@ -252,8 +253,12 @@ export class PricingService {
252
253
 
253
254
  const record = existing
254
255
  ? (await this.repo.updatePrice(
255
- existing.id,
256
- { amount: priceData.amount, metadata: priceData.metadata },
256
+ existing.id,
257
+ {
258
+ amount: priceData.amount,
259
+ ...(priceData.compareAtAmount !== undefined ? { compareAtAmount: priceData.compareAtAmount } : {}),
260
+ metadata: priceData.metadata,
261
+ },
257
262
  ctx,
258
263
  )) ?? existing
259
264
  : await this.repo.createPrice(priceData, ctx);
@@ -10,6 +10,7 @@ export interface SearchDocument {
10
10
  categories: string[];
11
11
  brands: string[];
12
12
  text: string;
13
+ attributes?: Record<string, string | string[]>;
13
14
  payload?: Record<string, unknown>;
14
15
  }
15
16
 
@@ -18,6 +19,7 @@ export interface SearchFilters {
18
19
  category?: string;
19
20
  brand?: string;
20
21
  status?: string;
22
+ attributes?: Record<string, string | string[]>;
21
23
  }
22
24
 
23
25
  export interface SearchQueryParams {
@@ -16,6 +16,8 @@ export const searchModule = defineModule<Record<string, never>, SearchService, S
16
16
  const config = deps.config as CommerceConfig;
17
17
  return new SearchService({
18
18
  catalogRepository: deps.services.catalog.repository,
19
+ resolveEntityFieldDefinitions: deps.services.catalog.resolveEntityFieldDefinitions.bind(deps.services.catalog),
20
+ ...(config.entities ? { entities: config.entities } : {}),
19
21
  ...(config.search?.adapter ? { adapter: config.search.adapter } : {}),
20
22
  ...(config.search?.defaultFacets
21
23
  ? { defaultFacets: config.search.defaultFacets }
@@ -1,7 +1,12 @@
1
1
  import { Ok, type Result } from "../../kernel/result.js";
2
2
  import { resolveOrgId } from "../../auth/org.js";
3
3
  import type { TxContext } from "../../kernel/database/tx-context.js";
4
- import type { CatalogRepository, SellableEntity } from "../catalog/repository/index.js";
4
+ import type { CommerceConfig } from "../../config/types.js";
5
+ import type {
6
+ CatalogRepository,
7
+ SellableCustomField,
8
+ SellableEntity,
9
+ } from "../catalog/repository/index.js";
5
10
  import type {
6
11
  SearchAdapter,
7
12
  SearchDocument,
@@ -10,9 +15,12 @@ import type {
10
15
  SearchQueryResult,
11
16
  SearchSuggestParams,
12
17
  } from "./adapter.js";
18
+ import type { EntityFieldDefinitionResolver } from "../catalog/service.js";
13
19
 
14
20
  interface SearchServiceDeps {
15
21
  catalogRepository: CatalogRepository;
22
+ entities?: CommerceConfig["entities"];
23
+ resolveEntityFieldDefinitions?: EntityFieldDefinitionResolver;
16
24
  adapter?: SearchAdapter;
17
25
  defaultFacets?: string[];
18
26
  }
@@ -39,6 +47,19 @@ function includesAllTokens(haystack: string, tokens: string[]): boolean {
39
47
  return tokens.every((token) => lower.includes(token));
40
48
  }
41
49
 
50
+ function customFieldValue(field: SellableCustomField): string | undefined {
51
+ if (field.textValue != null) return field.textValue;
52
+ if (field.numberValue != null) return String(field.numberValue);
53
+ if (field.booleanValue != null) return String(field.booleanValue);
54
+ if (field.dateValue != null) return field.dateValue.toISOString();
55
+ return undefined;
56
+ }
57
+
58
+ function attributeValues(value: string | string[] | undefined): string[] {
59
+ if (value == null) return [];
60
+ return Array.isArray(value) ? value : [value];
61
+ }
62
+
42
63
  function scoreText(document: SearchDocument, tokens: string[]): number {
43
64
  if (tokens.length === 0) return 1;
44
65
 
@@ -109,16 +130,43 @@ export class SearchService {
109
130
  entity: SellableEntity,
110
131
  ctx?: TxContext,
111
132
  ): Promise<SearchDocument> {
112
- const attributes =
133
+ const localizedAttributes =
113
134
  await this.deps.catalogRepository.findAttributesByEntityId(
114
135
  entity.id,
115
136
  ctx,
116
137
  );
117
- const primary = attributes[0];
138
+ const primary = localizedAttributes[0];
118
139
  const title = primary?.title ?? entity.slug;
119
140
  const description = primary?.description;
120
141
  const categories = await this.entityCategories(entity.id, ctx);
121
142
  const brands = await this.entityBrands(entity.id, ctx);
143
+ const definitions = this.deps.resolveEntityFieldDefinitions
144
+ ? await this.deps.resolveEntityFieldDefinitions(entity.type, entity.organizationId, ctx)
145
+ : (this.deps.entities?.[entity.type]?.fields ?? []);
146
+ const filterableFields = new Set(
147
+ definitions
148
+ .filter((field) => field.filterable === true)
149
+ .map((field) => field.name),
150
+ );
151
+ const customFields = await this.deps.catalogRepository.findCustomFieldsByEntityId(
152
+ entity.id,
153
+ ctx,
154
+ );
155
+ const attributeValuesByName = new Map<string, string[]>();
156
+ for (const field of customFields) {
157
+ if (field.locale !== "en" || !filterableFields.has(field.fieldName)) continue;
158
+ const value = customFieldValue(field);
159
+ if (value == null) continue;
160
+ const values = attributeValuesByName.get(field.fieldName) ?? [];
161
+ if (!values.includes(value)) values.push(value);
162
+ attributeValuesByName.set(field.fieldName, values);
163
+ }
164
+ const attributes = Object.fromEntries(
165
+ [...attributeValuesByName.entries()].map(([name, values]) => [
166
+ name,
167
+ values.length === 1 ? values[0]! : values,
168
+ ]),
169
+ ) as Record<string, string | string[]>;
122
170
 
123
171
  const textParts: string[] = [
124
172
  entity.slug,
@@ -126,8 +174,8 @@ export class SearchService {
126
174
  description ?? "",
127
175
  ...categories,
128
176
  ...brands,
129
- ...attributes.map((attr) => attr.title),
130
- ...attributes.map((attr) => attr.description ?? ""),
177
+ ...localizedAttributes.map((attr) => attr.title),
178
+ ...localizedAttributes.map((attr) => attr.description ?? ""),
131
179
  ];
132
180
 
133
181
  return {
@@ -140,6 +188,7 @@ export class SearchService {
140
188
  categories,
141
189
  brands,
142
190
  text: textParts.join(" ").trim(),
191
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
143
192
  payload: {
144
193
  metadata: entity.metadata ?? undefined,
145
194
  },
@@ -168,6 +217,11 @@ export class SearchService {
168
217
  if (filters.category && !document.categories.includes(filters.category))
169
218
  return false;
170
219
  if (filters.brand && !document.brands.includes(filters.brand)) return false;
220
+ for (const [name, requested] of Object.entries(filters.attributes ?? {})) {
221
+ const actual = attributeValues(document.attributes?.[name]);
222
+ const expected = attributeValues(requested);
223
+ if (!expected.some((value) => actual.includes(value))) return false;
224
+ }
171
225
  return true;
172
226
  }
173
227
 
@@ -214,6 +268,26 @@ export class SearchService {
214
268
  }
215
269
  }
216
270
  }
271
+
272
+ const attributeName = facet.startsWith("attributes.")
273
+ ? facet.slice("attributes.".length)
274
+ : facet;
275
+ if (
276
+ attributeName !== "type" &&
277
+ attributeName !== "status" &&
278
+ attributeName !== "category" &&
279
+ attributeName !== "categories" &&
280
+ attributeName !== "brand" &&
281
+ attributeName !== "brands"
282
+ ) {
283
+ const values: Record<string, number> = {};
284
+ for (const document of documents) {
285
+ for (const value of new Set(attributeValues(document.attributes?.[attributeName]))) {
286
+ values[value] = (values[value] ?? 0) + 1;
287
+ }
288
+ }
289
+ if (Object.keys(values).length > 0) output[attributeName] = values;
290
+ }
217
291
  }
218
292
 
219
293
  return output;
@@ -255,7 +329,14 @@ export class SearchService {
255
329
  // In-memory fallback: require a non-empty query to avoid loading all entities
256
330
  // (allDocuments triggers N+1 DB queries per entity — unsafe at scale)
257
331
  const tokens = tokenize(safeQuery);
258
- if (tokens.length === 0 && !params.filters?.type && !params.filters?.category && !params.filters?.brand && !params.filters?.status) {
332
+ if (
333
+ tokens.length === 0 &&
334
+ !params.filters?.type &&
335
+ !params.filters?.category &&
336
+ !params.filters?.brand &&
337
+ !params.filters?.status &&
338
+ Object.keys(params.filters?.attributes ?? {}).length === 0
339
+ ) {
259
340
  return Ok({ hits: [], total: 0, page, limit, facets: {} });
260
341
  }
261
342
 
package/src/utils/id.ts CHANGED
@@ -1,3 +1,14 @@
1
1
  export function makeId(): string {
2
2
  return crypto.randomUUID();
3
3
  }
4
+
5
+ export async function makeDeterministicId(value: string): Promise<string> {
6
+ const digest = new Uint8Array(
7
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)),
8
+ );
9
+ const bytes = digest.slice(0, 16);
10
+ bytes[6] = (bytes[6]! & 0x0f) | 0x80;
11
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80;
12
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
13
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
14
+ }