@rudra-js/core 0.7.4 → 0.7.6

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 (55) hide show
  1. package/README.md +14 -4
  2. package/dist/claim-screening.d.ts +3 -3
  3. package/dist/claim-screening.d.ts.map +1 -1
  4. package/dist/claim-screening.js +30 -93
  5. package/dist/claim-screening.js.map +1 -1
  6. package/dist/component-generator.d.ts.map +1 -1
  7. package/dist/component-generator.js +161 -176
  8. package/dist/component-generator.js.map +1 -1
  9. package/dist/component-spec.d.ts +1 -0
  10. package/dist/component-spec.d.ts.map +1 -1
  11. package/dist/component-spec.js +3 -0
  12. package/dist/component-spec.js.map +1 -1
  13. package/dist/fallback-component.d.ts +1 -1
  14. package/dist/fallback-component.d.ts.map +1 -1
  15. package/dist/fallback-component.js +9 -17
  16. package/dist/fallback-component.js.map +1 -1
  17. package/dist/fit-to-shopper.d.ts.map +1 -1
  18. package/dist/fit-to-shopper.js +19 -19
  19. package/dist/fit-to-shopper.js.map +1 -1
  20. package/dist/model-prompt.d.ts.map +1 -1
  21. package/dist/model-prompt.js +56 -39
  22. package/dist/model-prompt.js.map +1 -1
  23. package/dist/product-selection.d.ts.map +1 -1
  24. package/dist/product-selection.js +15 -31
  25. package/dist/product-selection.js.map +1 -1
  26. package/dist/provider.d.ts.map +1 -1
  27. package/dist/provider.js.map +1 -1
  28. package/dist/reconciliation.d.ts +3 -3
  29. package/dist/reconciliation.d.ts.map +1 -1
  30. package/dist/reconciliation.js +133 -171
  31. package/dist/reconciliation.js.map +1 -1
  32. package/dist/signal-digest.d.ts +1 -1
  33. package/dist/signal-digest.d.ts.map +1 -1
  34. package/dist/signal-digest.js +51 -52
  35. package/dist/signal-digest.js.map +1 -1
  36. package/dist/spec-cache.d.ts +0 -4
  37. package/dist/spec-cache.d.ts.map +1 -1
  38. package/dist/spec-cache.js +15 -35
  39. package/dist/spec-cache.js.map +1 -1
  40. package/dist/tracking-input.d.ts.map +1 -1
  41. package/dist/tracking-input.js +16 -19
  42. package/dist/tracking-input.js.map +1 -1
  43. package/package.json +8 -6
  44. package/src/claim-screening.ts +34 -114
  45. package/src/component-generator.ts +223 -220
  46. package/src/component-spec.ts +4 -15
  47. package/src/fallback-component.ts +11 -20
  48. package/src/fit-to-shopper.ts +19 -21
  49. package/src/model-prompt.ts +55 -54
  50. package/src/product-selection.ts +21 -50
  51. package/src/provider.ts +0 -4
  52. package/src/reconciliation.ts +159 -268
  53. package/src/signal-digest.ts +50 -79
  54. package/src/spec-cache.ts +15 -48
  55. package/src/tracking-input.ts +21 -36
@@ -8,7 +8,6 @@ import type {
8
8
 
9
9
  export interface CategoryAffinity {
10
10
  category: string;
11
-
12
11
  score: number;
13
12
  }
14
13
 
@@ -62,27 +61,29 @@ export const DIGEST_LIMITS = {
62
61
  const SIGNAL_WEIGHTS = {
63
62
  purchase: 5,
64
63
  like: 4,
64
+ browsing: 4,
65
65
  cart: 3,
66
66
  view: 1,
67
67
  dislike: -6,
68
68
  } as const;
69
69
 
70
- const effectiveWeight = (signal: { weight?: number | undefined }): number => signal.weight ?? 1;
70
+ interface MergedView extends ViewedProduct {
71
+ category?: string;
72
+ weight: number;
73
+ }
71
74
 
72
- function byMostRecent(
73
- left: { at?: number | undefined },
74
- right: { at?: number | undefined },
75
- ): number {
76
- return (right.at ?? 0) - (left.at ?? 0);
75
+ function weightOf(signal: SkuSignal): number {
76
+ return signal.weight ?? 1;
77
77
  }
78
78
 
79
79
  function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
80
+ const newestFirst = signals.toSorted((left, right) => (right.at ?? 0) - (left.at ?? 0));
80
81
  const skus: string[] = [];
81
- const alreadySeen = new Set<string>();
82
+ const seen = new Set<string>();
82
83
 
83
- for (const signal of signals.toSorted(byMostRecent)) {
84
- if (alreadySeen.has(signal.sku)) continue;
85
- alreadySeen.add(signal.sku);
84
+ for (const signal of newestFirst) {
85
+ if (seen.has(signal.sku)) continue;
86
+ seen.add(signal.sku);
86
87
  skus.push(signal.sku);
87
88
  if (skus.length >= limit) break;
88
89
  }
@@ -90,54 +91,34 @@ function recentUniqueSkus(signals: SkuSignal[], limit: number): string[] {
90
91
  return skus;
91
92
  }
92
93
 
93
- function categoryOf(
94
- signal: { sku: string; category?: string | undefined },
95
- candidatesBySku: Map<string, Product>,
96
- ): string | undefined {
94
+ function categoryOf(signal: SkuSignal, candidatesBySku: Map<string, Product>): string | undefined {
97
95
  return signal.category ?? candidatesBySku.get(signal.sku)?.category;
98
96
  }
99
97
 
100
- function computeCategoryAffinity(
101
- input: TrackingInput,
102
- candidatesBySku: Map<string, Product>,
103
- ): CategoryAffinity[] {
104
- const scoreByCategory = new Map<string, number>();
98
+ function scoreCategories(input: TrackingInput): CategoryAffinity[] {
99
+ const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
100
+ const scores = new Map<string, number>();
105
101
 
106
- const addScore = (category: string | undefined, score: number): void => {
102
+ const add = (category: string | undefined, score: number): void => {
107
103
  if (!category) return;
108
- scoreByCategory.set(category, (scoreByCategory.get(category) ?? 0) + score);
104
+ scores.set(category, (scores.get(category) ?? 0) + score);
105
+ };
106
+ const addSignal = (signal: SkuSignal, base: number): void => {
107
+ add(categoryOf(signal, candidatesBySku), base * weightOf(signal));
109
108
  };
110
109
 
111
110
  const { signals } = input;
112
-
113
- for (const purchase of signals.lastPurchased) {
114
- addScore(
115
- categoryOf(purchase, candidatesBySku),
116
- SIGNAL_WEIGHTS.purchase * effectiveWeight(purchase),
117
- );
118
- }
119
- for (const like of signals.likes) {
120
- addScore(categoryOf(like, candidatesBySku), SIGNAL_WEIGHTS.like * effectiveWeight(like));
121
- }
122
- for (const inCart of signals.cart) {
123
- addScore(categoryOf(inCart, candidatesBySku), SIGNAL_WEIGHTS.cart * effectiveWeight(inCart));
124
- }
125
-
111
+ for (const purchase of signals.lastPurchased) addSignal(purchase, SIGNAL_WEIGHTS.purchase);
112
+ for (const like of signals.likes) addSignal(like, SIGNAL_WEIGHTS.like);
113
+ for (const inCart of signals.cart) addSignal(inCart, SIGNAL_WEIGHTS.cart);
126
114
  for (const view of mergeViewsBySku(signals.mostViewed)) {
127
- const scaledViews = Math.log2(1 + view.views);
128
- addScore(categoryOf(view, candidatesBySku), SIGNAL_WEIGHTS.view * scaledViews * view.weight);
115
+ addSignal(view, SIGNAL_WEIGHTS.view * Math.log2(1 + view.views));
129
116
  }
130
- for (const dislike of signals.dislikes) {
131
- addScore(
132
- categoryOf(dislike, candidatesBySku),
133
- SIGNAL_WEIGHTS.dislike * effectiveWeight(dislike),
134
- );
135
- }
136
-
137
- addScore(input.context.currentCategory, SIGNAL_WEIGHTS.like);
117
+ for (const dislike of signals.dislikes) addSignal(dislike, SIGNAL_WEIGHTS.dislike);
118
+ add(input.context.currentCategory, SIGNAL_WEIGHTS.browsing);
138
119
 
139
120
  const affinities: CategoryAffinity[] = [];
140
- for (const [category, score] of scoreByCategory) {
121
+ for (const [category, score] of scores) {
141
122
  const rounded = Math.round(score * 100) / 100;
142
123
  if (rounded > 0) affinities.push({ category, score: rounded });
143
124
  }
@@ -147,7 +128,7 @@ function computeCategoryAffinity(
147
128
  .slice(0, DIGEST_LIMITS.affinity);
148
129
  }
149
130
 
150
- export function engagedCategories(input: TrackingInput): Set<string> {
131
+ export function categoriesTouchedBySignals(input: TrackingInput): Set<string> {
151
132
  const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
152
133
  const { signals } = input;
153
134
  const categories = new Set<string>();
@@ -165,53 +146,44 @@ export function engagedCategories(input: TrackingInput): Set<string> {
165
146
  return categories;
166
147
  }
167
148
 
168
- interface MergedView extends ViewedProduct {
169
- category?: string;
170
- weight: number;
171
- }
172
-
173
149
  function mergeViewsBySku(views: ViewSignal[]): MergedView[] {
174
- const totalsBySku = new Map<string, MergedView>();
150
+ const totals = new Map<string, MergedView>();
175
151
 
176
152
  for (const view of views) {
177
- const running = totalsBySku.get(view.sku);
178
- if (running) {
179
- running.views += view.views;
180
- if (view.dwellMs !== undefined) running.dwellMs = (running.dwellMs ?? 0) + view.dwellMs;
181
- if (view.category !== undefined) running.category ??= view.category;
182
- running.weight = Math.max(running.weight, effectiveWeight(view));
153
+ const total = totals.get(view.sku);
154
+ if (!total) {
155
+ const first: MergedView = { sku: view.sku, views: view.views, weight: weightOf(view) };
156
+ if (view.dwellMs !== undefined) first.dwellMs = view.dwellMs;
157
+ if (view.category !== undefined) first.category = view.category;
158
+ totals.set(view.sku, first);
183
159
  continue;
184
160
  }
185
- totalsBySku.set(view.sku, {
186
- sku: view.sku,
187
- views: view.views,
188
- ...(view.dwellMs !== undefined ? { dwellMs: view.dwellMs } : {}),
189
- ...(view.category !== undefined ? { category: view.category } : {}),
190
- weight: effectiveWeight(view),
191
- });
161
+
162
+ total.views += view.views;
163
+ if (view.dwellMs !== undefined) total.dwellMs = (total.dwellMs ?? 0) + view.dwellMs;
164
+ if (view.category !== undefined) total.category ??= view.category;
165
+ total.weight = Math.max(total.weight, weightOf(view));
192
166
  }
193
167
 
194
- return [...totalsBySku.values()];
168
+ return [...totals.values()];
195
169
  }
196
170
 
197
- function mostViewedProducts(views: ViewSignal[]): ViewedProduct[] {
198
- const top = mergeViewsBySku(views)
171
+ function mostViewedProducts(viewSignals: ViewSignal[]): ViewedProduct[] {
172
+ const top = mergeViewsBySku(viewSignals)
199
173
  .toSorted((left, right) => right.views - left.views)
200
174
  .slice(0, DIGEST_LIMITS.viewed);
201
175
 
202
- return top.map(({ sku, views: count, dwellMs }) =>
203
- dwellMs === undefined ? { sku, views: count } : { sku, views: count, dwellMs },
176
+ return top.map(({ sku, views, dwellMs }) =>
177
+ dwellMs === undefined ? { sku, views } : { sku, views, dwellMs },
204
178
  );
205
179
  }
206
180
 
207
181
  function countByInteractionType(interactions: Interaction[]): InteractionCount[] {
208
182
  const countByType = new Map<string, number>();
183
+ for (const { type } of interactions) countByType.set(type, (countByType.get(type) ?? 0) + 1);
209
184
 
210
- for (const interaction of interactions) {
211
- countByType.set(interaction.type, (countByType.get(interaction.type) ?? 0) + 1);
212
- }
213
-
214
- const counts = [...countByType.entries()].map(([type, count]) => ({ type, count }));
185
+ const counts: InteractionCount[] = [];
186
+ for (const [type, count] of countByType) counts.push({ type, count });
215
187
 
216
188
  return counts
217
189
  .toSorted((left, right) => right.count - left.count)
@@ -219,7 +191,6 @@ function countByInteractionType(interactions: Interaction[]): InteractionCount[]
219
191
  }
220
192
 
221
193
  export function buildDigest(input: TrackingInput): SignalDigest {
222
- const candidatesBySku = new Map(input.candidates.map((product) => [product.sku, product]));
223
194
  const { signals, context, user } = input;
224
195
 
225
196
  const likedSkus = recentUniqueSkus(signals.likes, DIGEST_LIMITS.liked);
@@ -254,7 +225,7 @@ export function buildDigest(input: TrackingInput): SignalDigest {
254
225
  cartSkus,
255
226
  topViewed,
256
227
  recentSearches: signals.recentSearches.slice(0, DIGEST_LIMITS.searches),
257
- categoryAffinity: computeCategoryAffinity(input, candidatesBySku),
228
+ categoryAffinity: scoreCategories(input),
258
229
  interactionCounts: countByInteractionType(signals.interactions),
259
230
 
260
231
  isColdStart: evidenceCount === 0,
package/src/spec-cache.ts CHANGED
@@ -5,7 +5,6 @@ import type { SignalDigest } from './signal-digest.js';
5
5
 
6
6
  export interface CachedSpec {
7
7
  spec: GeneratedSpec;
8
-
9
8
  generatedAt: number;
10
9
  }
11
10
 
@@ -17,9 +16,7 @@ export interface SpecCache {
17
16
 
18
17
  export interface MemorySpecCacheOptions {
19
18
  ttlMs?: number;
20
-
21
19
  maxEntries?: number;
22
-
23
20
  now?: () => number;
24
21
  }
25
22
 
@@ -55,12 +52,9 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
55
52
  const entry = entries.get(key);
56
53
  if (!entry) return undefined;
57
54
 
58
- if (entry.expiresAt <= now()) {
59
- entries.delete(key);
60
- return undefined;
61
- }
62
-
55
+ const isExpired = entry.expiresAt <= now();
63
56
  entries.delete(key);
57
+ if (isExpired) return undefined;
64
58
  entries.set(key, entry);
65
59
  return entry.cached;
66
60
  },
@@ -69,10 +63,9 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
69
63
  entries.delete(key);
70
64
  entries.set(key, { cached, expiresAt: now() + ttlMs });
71
65
 
72
- while (entries.size > maxEntries) {
73
- const oldest = entries.keys().next();
74
- if (oldest.done) break;
75
- entries.delete(oldest.value);
66
+ for (const oldest of entries.keys()) {
67
+ if (entries.size <= maxEntries) break;
68
+ entries.delete(oldest);
76
69
  }
77
70
  },
78
71
 
@@ -83,23 +76,22 @@ export function createMemorySpecCache(options: MemorySpecCacheOptions = {}): Spe
83
76
  }
84
77
 
85
78
  export function createNullSpecCache(): SpecCache {
86
- return {
87
- async get() {
88
- return undefined;
89
- },
90
- async set() {},
91
- };
79
+ return { get: async () => undefined, set: async () => {} };
92
80
  }
93
81
 
94
82
  function canonicalise(value: unknown): string {
95
83
  if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
96
84
  if (Array.isArray(value)) return `[${value.map(canonicalise).join(',')}]`;
97
85
 
98
- const entries = Object.entries(value as Record<string, unknown>)
99
- .filter(([, fieldValue]) => fieldValue !== undefined)
100
- .toSorted(([left], [right]) => (left < right ? -1 : 1));
101
-
102
- return `{${entries.map(([name, fieldValue]) => `${JSON.stringify(name)}:${canonicalise(fieldValue)}`).join(',')}}`;
86
+ const sorted = Object.entries(value as Record<string, unknown>).toSorted(([left], [right]) =>
87
+ left < right ? -1 : 1,
88
+ );
89
+ const fields: string[] = [];
90
+ for (const [name, fieldValue] of sorted) {
91
+ if (fieldValue === undefined) continue;
92
+ fields.push(`${JSON.stringify(name)}:${canonicalise(fieldValue)}`);
93
+ }
94
+ return `{${fields.join(',')}}`;
103
95
  }
104
96
 
105
97
  const PROMPT_FINGERPRINT = createHash('sha256').update(SYSTEM_PROMPT).digest('hex').slice(0, 16);
@@ -119,28 +111,3 @@ export function specCacheKey(
119
111
 
120
112
  return createHash('sha256').update(material).digest('hex').slice(0, 32);
121
113
  }
122
-
123
- export function cohortCacheKey(
124
- digest: SignalDigest,
125
- candidateSkus: readonly string[],
126
- provider: { name: string; model: string },
127
- ): string {
128
- const material = canonicalise({
129
- specVersion: SPEC_VERSION,
130
- prompt: PROMPT_FINGERPRINT,
131
- provider,
132
- segment: digest.segment ?? null,
133
- surface: digest.surface,
134
- slot: digest.slot,
135
- locale: digest.locale,
136
- maxItems: digest.maxItems,
137
- isColdStart: digest.isColdStart,
138
-
139
- currentCategory: digest.currentCategory ?? null,
140
- topCategory: digest.categoryAffinity[0]?.category ?? null,
141
-
142
- candidates: candidateSkus.toSorted(),
143
- });
144
-
145
- return createHash('sha256').update(material).digest('hex').slice(0, 32);
146
- }
@@ -13,7 +13,6 @@ export const FIELD_LIMITS = {
13
13
  bundles: 20,
14
14
  localeTag: 35,
15
15
  maxItems: 12,
16
-
17
16
  reason: 120,
18
17
  } as const;
19
18
 
@@ -22,13 +21,21 @@ const RESERVED_META_KEY = '__proto__';
22
21
  const MAX_EPOCH_MS = Date.UTC(2100, 0, 1);
23
22
 
24
23
  const identifier = () => z.string().min(1).max(FIELD_LIMITS.identifier);
25
- const optionalIdentifier = () => z.string().min(1).max(FIELD_LIMITS.identifier).optional();
24
+ const optionalIdentifier = () => identifier().optional();
25
+
26
+ const currencyCode = () =>
27
+ z
28
+ .string()
29
+ .regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
30
+ .default('USD');
31
+
32
+ const isUnique = (values: string[]) => new Set(values).size === values.length;
26
33
 
27
34
  const epochMs = () => z.number().int().min(0).max(MAX_EPOCH_MS).optional();
28
35
 
29
36
  const isRootRelative = (value: string) => {
30
- const asParsed = value.replace(/[\t\n\r]/g, '');
31
- return value.startsWith('/') && /^\/(?![/\\])/.test(asParsed);
37
+ const asBrowserReadsIt = value.replace(/[\t\n\r]/g, '');
38
+ return value.startsWith('/') && /^\/(?![/\\])/.test(asBrowserReadsIt);
32
39
  };
33
40
 
34
41
  const imageReference = () =>
@@ -36,8 +43,7 @@ const imageReference = () =>
36
43
  .string()
37
44
  .max(FIELD_LIMITS.shortText)
38
45
  .refine(
39
- (value) =>
40
- isRootRelative(value) ? true : z.url({ protocol: /^https?$/ }).safeParse(value).success,
46
+ (value) => isRootRelative(value) || z.url({ protocol: /^https?$/ }).safeParse(value).success,
41
47
  { message: 'expected an http(s) URL or a root-relative path' },
42
48
  );
43
49
 
@@ -46,14 +52,9 @@ export const productSchema = z.strictObject({
46
52
  title: z.string().min(1).max(FIELD_LIMITS.shortText),
47
53
  category: identifier(),
48
54
  price: z.number().nonnegative(),
49
- currency: z
50
- .string()
51
- .regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
52
- .default('USD'),
53
-
55
+ currency: currencyCode(),
54
56
  imageUrl: imageReference().optional(),
55
57
  rating: z.number().min(0).max(5).optional(),
56
-
57
58
  reason: z.string().min(1).max(FIELD_LIMITS.reason).optional(),
58
59
  isInStock: z.boolean().default(true),
59
60
  tags: z
@@ -67,7 +68,6 @@ export const skuSignalSchema = z.strictObject({
67
68
  sku: identifier(),
68
69
  category: optionalIdentifier(),
69
70
  at: epochMs(),
70
-
71
71
  weight: z.number().min(0).max(1).optional(),
72
72
  });
73
73
  export type SkuSignal = z.infer<typeof skuSignalSchema>;
@@ -93,7 +93,7 @@ const metaKeysAreSafe = z.custom<Record<string, string | number | boolean>>(
93
93
  const metaSchema = metaKeysAreSafe.pipe(
94
94
  z
95
95
  .record(
96
- z.string().min(1).max(FIELD_LIMITS.identifier),
96
+ identifier(),
97
97
  z.union([z.string().max(FIELD_LIMITS.shortText), z.number(), z.boolean()]),
98
98
  )
99
99
  .refine((entries) => Object.keys(entries).length <= FIELD_LIMITS.metaEntries, {
@@ -113,18 +113,15 @@ export type Interaction = z.infer<typeof interactionSchema>;
113
113
 
114
114
  export const renderContextSchema = z.strictObject({
115
115
  surface: identifier(),
116
-
117
- slot: z.string().min(1).max(FIELD_LIMITS.identifier).default('recommendations'),
116
+ slot: identifier().default('recommendations'),
118
117
  currentSku: optionalIdentifier(),
119
118
  currentCategory: optionalIdentifier(),
120
119
  searchQuery: z.string().max(FIELD_LIMITS.searchQuery).optional(),
121
-
122
120
  locale: z
123
121
  .string()
124
122
  .max(FIELD_LIMITS.localeTag)
125
123
  .regex(/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/, 'expected one language tag, such as en-US')
126
124
  .default('en-US'),
127
-
128
125
  maxItems: z.number().int().min(1).max(FIELD_LIMITS.maxItems).default(4),
129
126
  });
130
127
  export type RenderContext = z.infer<typeof renderContextSchema>;
@@ -149,15 +146,9 @@ export const bundleSchema = z.strictObject({
149
146
  .array(identifier())
150
147
  .min(2)
151
148
  .max(FIELD_LIMITS.productsPerBundle)
152
- .refine((skus) => new Set(skus).size === skus.length, {
153
- message: 'a bundle must not list the same product twice',
154
- }),
155
-
149
+ .refine(isUnique, { message: 'a bundle must not list the same product twice' }),
156
150
  price: z.number().nonnegative(),
157
- currency: z
158
- .string()
159
- .regex(/^[A-Z]{3}$/, 'expected a three-letter ISO 4217 code')
160
- .default('USD'),
151
+ currency: currencyCode(),
161
152
  label: z.string().min(1).max(FIELD_LIMITS.shortText).optional(),
162
153
  });
163
154
  export type Bundle = z.infer<typeof bundleSchema>;
@@ -171,24 +162,18 @@ export const trackingInputSchema = z
171
162
  isReturning: z.boolean().optional(),
172
163
  }),
173
164
  context: renderContextSchema,
174
-
175
165
  signals: trackingSignalsSchema.prefault({}),
176
-
177
166
  candidates: z
178
167
  .array(productSchema)
179
168
  .min(1)
180
169
  .max(FIELD_LIMITS.candidates)
181
- .refine(
182
- (products) => new Set(products.map((product) => product.sku)).size === products.length,
183
- {
184
- message: 'candidates must have unique SKUs',
185
- },
186
- ),
187
-
170
+ .refine((products) => isUnique(products.map((product) => product.sku)), {
171
+ message: 'candidates must have unique SKUs',
172
+ }),
188
173
  bundles: z
189
174
  .array(bundleSchema)
190
175
  .max(FIELD_LIMITS.bundles)
191
- .refine((bundles) => new Set(bundles.map((bundle) => bundle.id)).size === bundles.length, {
176
+ .refine((bundles) => isUnique(bundles.map((bundle) => bundle.id)), {
192
177
  message: 'bundles must have unique ids',
193
178
  })
194
179
  .default([]),