@purveyors/cli 0.14.0 → 0.19.2

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 (53) hide show
  1. package/README.md +444 -217
  2. package/dist/commands/auth.d.ts +17 -0
  3. package/dist/commands/auth.d.ts.map +1 -1
  4. package/dist/commands/auth.js +160 -62
  5. package/dist/commands/auth.js.map +1 -1
  6. package/dist/commands/catalog.d.ts +3 -1
  7. package/dist/commands/catalog.d.ts.map +1 -1
  8. package/dist/commands/catalog.js +239 -42
  9. package/dist/commands/catalog.js.map +1 -1
  10. package/dist/commands/context.d.ts.map +1 -1
  11. package/dist/commands/context.js +5 -3
  12. package/dist/commands/context.js.map +1 -1
  13. package/dist/commands/manifest.d.ts.map +1 -1
  14. package/dist/commands/manifest.js +5 -1
  15. package/dist/commands/manifest.js.map +1 -1
  16. package/dist/commands/roast.d.ts.map +1 -1
  17. package/dist/commands/roast.js +115 -10
  18. package/dist/commands/roast.js.map +1 -1
  19. package/dist/commands/sales.d.ts.map +1 -1
  20. package/dist/commands/sales.js +87 -40
  21. package/dist/commands/sales.js.map +1 -1
  22. package/dist/lib/catalog.d.ts +332 -1
  23. package/dist/lib/catalog.d.ts.map +1 -1
  24. package/dist/lib/catalog.js +654 -2
  25. package/dist/lib/catalog.js.map +1 -1
  26. package/dist/lib/interactive/forms.d.ts +10 -0
  27. package/dist/lib/interactive/forms.d.ts.map +1 -1
  28. package/dist/lib/interactive/forms.js +10 -8
  29. package/dist/lib/interactive/forms.js.map +1 -1
  30. package/dist/lib/interactive/watch.d.ts +38 -4
  31. package/dist/lib/interactive/watch.d.ts.map +1 -1
  32. package/dist/lib/interactive/watch.js +294 -86
  33. package/dist/lib/interactive/watch.js.map +1 -1
  34. package/dist/lib/manifest.d.ts +7 -0
  35. package/dist/lib/manifest.d.ts.map +1 -1
  36. package/dist/lib/manifest.js +231 -26
  37. package/dist/lib/manifest.js.map +1 -1
  38. package/dist/lib/path-input.d.ts +3 -0
  39. package/dist/lib/path-input.d.ts.map +1 -0
  40. package/dist/lib/path-input.js +13 -0
  41. package/dist/lib/path-input.js.map +1 -0
  42. package/dist/lib/roast.d.ts +2 -0
  43. package/dist/lib/roast.d.ts.map +1 -1
  44. package/dist/lib/roast.js +5 -2
  45. package/dist/lib/roast.js.map +1 -1
  46. package/dist/lib/sales.d.ts +15 -2
  47. package/dist/lib/sales.d.ts.map +1 -1
  48. package/dist/lib/sales.js +77 -15
  49. package/dist/lib/sales.js.map +1 -1
  50. package/dist/program.d.ts.map +1 -1
  51. package/dist/program.js +17 -9
  52. package/dist/program.js.map +1 -1
  53. package/package.json +6 -1
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { AuthError, PrvrsError } from './errors.js';
2
3
  // ─── Zod schemas ──────────────────────────────────────────────────────────────
3
4
  export const catalogSortFields = ['price', 'price-desc', 'name', 'origin', 'newest'];
4
5
  export const searchCatalogSchema = z.object({
@@ -17,11 +18,82 @@ export const searchCatalogSchema = z.object({
17
18
  variety: z.string().optional(),
18
19
  dryingMethod: z.string().optional(),
19
20
  stockedDays: z.number().int().positive().optional(),
21
+ processingBaseMethod: z.string().optional(),
22
+ fermentationType: z.string().optional(),
23
+ processAdditive: z.string().optional(),
24
+ processingDisclosureLevel: z.string().optional(),
25
+ processingConfidenceMin: z.number().min(0).max(1).optional(),
26
+ includeProof: z.boolean().optional(),
20
27
  });
21
28
  export const getCatalogSchema = z.object({
22
29
  id: z.number().int().positive(),
30
+ includeProof: z.boolean().optional(),
23
31
  });
24
32
  export const getCatalogStatsSchema = z.object({});
33
+ const CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE = 5000;
34
+ const CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE = 250;
35
+ const SUPABASE_DATA_API_MAX_PAGE_SIZE = 1000;
36
+ const SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE = CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE;
37
+ export const catalogRankPremiumSchema = z.object({
38
+ origin: z.string().optional(),
39
+ process: z.string().optional(),
40
+ supplier: z.string().optional(),
41
+ stocked: z.boolean().optional(),
42
+ priceMax: z.number().optional(),
43
+ minScore: z.number().optional(),
44
+ includeUnscored: z.boolean().default(false).optional(),
45
+ limit: z.number().int().min(1).max(50).default(10).optional(),
46
+ sampleSize: z
47
+ .number()
48
+ .int()
49
+ .min(1)
50
+ .max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
51
+ .default(CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE)
52
+ .optional(),
53
+ });
54
+ export const supplierAggregateSchema = z.object({
55
+ supplier: z.string().optional(),
56
+ stocked: z.boolean().optional(),
57
+ minCoffees: z.number().int().min(1).default(1).optional(),
58
+ topCoffees: z.number().int().min(1).max(25).default(5).optional(),
59
+ limit: z.number().int().min(1).max(100).default(25).optional(),
60
+ sampleSize: z
61
+ .number()
62
+ .int()
63
+ .min(1)
64
+ .max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
65
+ .default(SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE)
66
+ .optional(),
67
+ });
68
+ export const catalogSimilarityModes = ['all', 'likely_same', 'similar_profile'];
69
+ export const getCatalogSimilaritySchema = z.object({
70
+ coffee_id: z
71
+ .number()
72
+ .int()
73
+ .positive()
74
+ .describe('The coffee_catalog ID to request canonical similarity for'),
75
+ threshold: z
76
+ .number()
77
+ .min(0.5)
78
+ .max(0.99)
79
+ .default(0.7)
80
+ .optional()
81
+ .describe('Minimum canonical similarity threshold (0.5-0.99)'),
82
+ limit: z
83
+ .number()
84
+ .int()
85
+ .min(1)
86
+ .max(25)
87
+ .default(10)
88
+ .optional()
89
+ .describe('Maximum canonical similarity results to request'),
90
+ stockedOnly: z
91
+ .boolean()
92
+ .default(false)
93
+ .optional()
94
+ .describe('Restrict similarity results to currently stocked coffees'),
95
+ mode: z.enum(catalogSimilarityModes).default('all').optional().describe('Canonical group filter'),
96
+ });
25
97
  export const findSimilarBeansSchema = z.object({
26
98
  coffee_id: z
27
99
  .number()
@@ -57,6 +129,377 @@ export function sanitizeFilterValue(value) {
57
129
  function getPerLbPrice(item) {
58
130
  return item.price_tiers?.[0]?.price ?? item.price_per_lb ?? item.cost_lb ?? null;
59
131
  }
132
+ function round(value, places = 2) {
133
+ const multiplier = 10 ** places;
134
+ return Math.round(value * multiplier) / multiplier;
135
+ }
136
+ function average(values) {
137
+ if (values.length === 0)
138
+ return null;
139
+ return round(values.reduce((sum, value) => sum + value, 0) / values.length);
140
+ }
141
+ function uniqueSorted(values, limit = 10) {
142
+ return [
143
+ ...new Set(values.map((value) => value?.trim()).filter((value) => Boolean(value))),
144
+ ]
145
+ .sort((a, b) => a.localeCompare(b))
146
+ .slice(0, limit);
147
+ }
148
+ export function summarizePurveyorScore(scoreValueOrItem) {
149
+ const item = typeof scoreValueOrItem === 'object' && scoreValueOrItem !== null
150
+ ? scoreValueOrItem
151
+ : undefined;
152
+ const value = item
153
+ ? (item.purveyor_score ?? null)
154
+ : typeof scoreValueOrItem === 'number'
155
+ ? scoreValueOrItem
156
+ : null;
157
+ const base = {
158
+ source: 'purveyor_score',
159
+ confidence: item?.purveyor_score_confidence ?? null,
160
+ tier: item?.purveyor_score_tier ?? null,
161
+ factors: item?.purveyor_score_factors ?? null,
162
+ version: item?.purveyor_score_version ?? null,
163
+ updated_at: item?.purveyor_score_updated_at ?? null,
164
+ };
165
+ if (value === null) {
166
+ return {
167
+ value: null,
168
+ band: 'unscored',
169
+ ...base,
170
+ note: 'No Purveyor Score is available on this catalog row.',
171
+ };
172
+ }
173
+ if (value >= 90) {
174
+ return {
175
+ value,
176
+ band: 'premium',
177
+ ...base,
178
+ note: 'High Purveyor Score; inspect confidence and factor breakdown before treating it as a premium catalog candidate.',
179
+ };
180
+ }
181
+ if (value >= 85) {
182
+ return {
183
+ value,
184
+ band: 'strong',
185
+ ...base,
186
+ note: 'Strong Purveyor Score; compare confidence, factor breakdown, price, provenance, and fit before selecting.',
187
+ };
188
+ }
189
+ return {
190
+ value,
191
+ band: 'scored',
192
+ ...base,
193
+ note: 'Purveyor Score is available; ranking still depends on confidence, factor breakdown, and sourcing context.',
194
+ };
195
+ }
196
+ function getPurveyorScoreValue(item) {
197
+ return item.purveyor_score ?? null;
198
+ }
199
+ function buildRankSignals(item) {
200
+ const signals = [];
201
+ const score = summarizePurveyorScore(item);
202
+ if (score.value !== null)
203
+ signals.push(`purveyor_score=${score.value}`);
204
+ if (score.confidence !== null)
205
+ signals.push(`purveyor_score_confidence=${score.confidence}`);
206
+ if (score.tier)
207
+ signals.push(`purveyor_score_tier=${score.tier}`);
208
+ const price = getPerLbPrice(item);
209
+ if (price !== null)
210
+ signals.push(`price_per_lb=${price}`);
211
+ if (item.stocked === true)
212
+ signals.push('currently_stocked');
213
+ if (item.country)
214
+ signals.push(`origin=${item.country}`);
215
+ if (item.processing_base_method ?? item.processing) {
216
+ signals.push(`process=${item.processing_base_method ?? item.processing}`);
217
+ }
218
+ return signals;
219
+ }
220
+ function toPremiumRankedItem(item, rank) {
221
+ return {
222
+ rank,
223
+ id: item.id,
224
+ name: item.name,
225
+ supplier: item.source,
226
+ origin: {
227
+ continent: item.continent,
228
+ country: item.country,
229
+ region: item.region,
230
+ },
231
+ processing: {
232
+ label: item.processing,
233
+ base_method: item.processing_base_method,
234
+ fermentation_type: item.fermentation_type,
235
+ drying_method: item.drying_method,
236
+ },
237
+ purveyor_score: summarizePurveyorScore(item),
238
+ pricing: {
239
+ price_per_lb: item.price_per_lb,
240
+ cost_lb: item.cost_lb,
241
+ price_tiers: item.price_tiers,
242
+ },
243
+ stocked: item.stocked,
244
+ stocked_date: item.stocked_date,
245
+ signals: buildRankSignals(item),
246
+ };
247
+ }
248
+ export function computeCatalogPremiumRanking(items, opts = {}) {
249
+ const limit = opts.limit ?? 10;
250
+ const includeUnscored = opts.includeUnscored ?? false;
251
+ const minScore = opts.minScore;
252
+ return items
253
+ .filter((item) => includeUnscored || getPurveyorScoreValue(item) !== null)
254
+ .filter((item) => minScore === undefined || (getPurveyorScoreValue(item) ?? -Infinity) >= minScore)
255
+ .sort((a, b) => {
256
+ const scoreDelta = (getPurveyorScoreValue(b) ?? -Infinity) - (getPurveyorScoreValue(a) ?? -Infinity);
257
+ if (scoreDelta !== 0)
258
+ return scoreDelta;
259
+ const aPrice = getPerLbPrice(a) ?? Infinity;
260
+ const bPrice = getPerLbPrice(b) ?? Infinity;
261
+ if (aPrice !== bPrice)
262
+ return aPrice - bPrice;
263
+ return (a.name ?? '').localeCompare(b.name ?? '');
264
+ })
265
+ .slice(0, limit)
266
+ .map((item, index) => toPremiumRankedItem(item, index + 1));
267
+ }
268
+ export function computeSupplierAggregates(items, opts = {}) {
269
+ const bySupplier = new Map();
270
+ for (const item of items) {
271
+ const supplier = item.source?.trim() || 'Unknown supplier';
272
+ const group = bySupplier.get(supplier) ?? [];
273
+ group.push(item);
274
+ bySupplier.set(supplier, group);
275
+ }
276
+ const topCoffees = opts.topCoffees ?? 5;
277
+ const minCoffees = opts.minCoffees ?? 1;
278
+ return [...bySupplier.entries()]
279
+ .map(([supplier, supplierItems]) => {
280
+ const scores = supplierItems
281
+ .map((item) => getPurveyorScoreValue(item))
282
+ .filter((score) => score !== null);
283
+ const confidences = supplierItems
284
+ .map((item) => item.purveyor_score_confidence)
285
+ .filter((confidence) => confidence !== null);
286
+ const prices = supplierItems
287
+ .map((item) => getPerLbPrice(item))
288
+ .filter((p) => p !== null);
289
+ return {
290
+ supplier,
291
+ total: supplierItems.length,
292
+ stocked: supplierItems.filter((item) => item.stocked === true).length,
293
+ score: {
294
+ average: average(scores),
295
+ coverage: supplierItems.length === 0 ? 0 : round(scores.length / supplierItems.length, 3),
296
+ scored_count: scores.length,
297
+ top_score: scores.length > 0 ? Math.max(...scores) : null,
298
+ average_confidence: average(confidences),
299
+ confidence_coverage: supplierItems.length === 0 ? 0 : round(confidences.length / supplierItems.length, 3),
300
+ },
301
+ price: {
302
+ average_per_lb: average(prices),
303
+ min_per_lb: prices.length > 0 ? Math.min(...prices) : null,
304
+ max_per_lb: prices.length > 0 ? Math.max(...prices) : null,
305
+ },
306
+ origins: uniqueSorted(supplierItems.flatMap((item) => [item.country, item.continent])),
307
+ processing_methods: uniqueSorted(supplierItems.map((item) => item.processing_base_method ?? item.processing)),
308
+ top_coffees: computeCatalogPremiumRanking(supplierItems, {
309
+ limit: topCoffees,
310
+ includeUnscored: true,
311
+ }),
312
+ };
313
+ })
314
+ .filter((supplier) => supplier.total >= minCoffees)
315
+ .sort((a, b) => {
316
+ const scoreDelta = (b.score.average ?? -Infinity) - (a.score.average ?? -Infinity);
317
+ if (scoreDelta !== 0)
318
+ return scoreDelta;
319
+ const stockedDelta = b.stocked - a.stocked;
320
+ if (stockedDelta !== 0)
321
+ return stockedDelta;
322
+ return a.supplier.localeCompare(b.supplier);
323
+ });
324
+ }
325
+ const CATALOG_API_SORT_MAP = {
326
+ price: { field: 'price_per_lb', direction: 'asc' },
327
+ 'price-desc': { field: 'price_per_lb', direction: 'desc' },
328
+ name: { field: 'name', direction: 'asc' },
329
+ origin: { field: 'country', direction: 'asc' },
330
+ newest: { field: 'stocked_date', direction: 'desc' },
331
+ };
332
+ function getCatalogApiBaseUrl() {
333
+ return (process.env.PURVEYORS_BASE_URL ?? 'https://www.purveyors.io').replace(/\/+$/, '');
334
+ }
335
+ async function getCatalogApiAuthHeader(supabase) {
336
+ const apiKey = process.env.PARCHMENT_API_KEY ?? process.env.PURVEYORS_API_KEY;
337
+ if (apiKey) {
338
+ return `Bearer ${apiKey}`;
339
+ }
340
+ const { data: { session }, } = await supabase.auth.getSession();
341
+ if (!session?.access_token) {
342
+ throw new AuthError('Catalog API reads require a Purveyors session or API key. Run `purvey auth login`, or set PARCHMENT_API_KEY/PURVEYORS_API_KEY for API-backed catalog reads.');
343
+ }
344
+ return `Bearer ${session.access_token}`;
345
+ }
346
+ function appendSearchParam(params, name, value) {
347
+ if (value === undefined)
348
+ return;
349
+ params.append(name, String(value));
350
+ }
351
+ function hasCatalogIdFilter(parsed) {
352
+ return parsed.ids !== undefined && parsed.ids.length > 0;
353
+ }
354
+ function assertCatalogApiCompatibleSearch(parsed) {
355
+ const unsupportedFilters = [];
356
+ if (parsed.flavor)
357
+ unsupportedFilters.push('--flavor');
358
+ if (parsed.supplier)
359
+ unsupportedFilters.push('--supplier');
360
+ if (parsed.dryingMethod)
361
+ unsupportedFilters.push('--drying-method');
362
+ if (parsed.sort === 'newest')
363
+ unsupportedFilters.push('--sort newest');
364
+ if (unsupportedFilters.length > 0) {
365
+ throw new PrvrsError('INVALID_ARGUMENT', `--include-proof uses /v1/catalog and cannot safely preserve ${unsupportedFilters.join(', ')} yet. Omit those filters or run the default catalog search without --include-proof.`);
366
+ }
367
+ if (hasCatalogIdFilter(parsed))
368
+ return;
369
+ const offset = parsed.offset ?? 0;
370
+ if (offset > 0 && offset % parsed.limit !== 0) {
371
+ throw new PrvrsError('INVALID_ARGUMENT', `--include-proof uses /v1/catalog page-based pagination; --offset must be a multiple of --limit. Received offset=${offset}, limit=${parsed.limit}.`);
372
+ }
373
+ }
374
+ function buildCatalogApiUrl(parsed) {
375
+ assertCatalogApiCompatibleSearch(parsed);
376
+ const url = new URL('/v1/catalog', getCatalogApiBaseUrl());
377
+ const params = url.searchParams;
378
+ params.set('include', 'proof');
379
+ params.set('stocked', parsed.stocked ? 'true' : 'all');
380
+ appendSearchParam(params, 'origin', parsed.origin);
381
+ appendSearchParam(params, 'processing', parsed.process);
382
+ appendSearchParam(params, 'processing_base_method', parsed.processingBaseMethod);
383
+ appendSearchParam(params, 'fermentation_type', parsed.fermentationType);
384
+ appendSearchParam(params, 'process_additive', parsed.processAdditive);
385
+ appendSearchParam(params, 'processing_disclosure_level', parsed.processingDisclosureLevel);
386
+ appendSearchParam(params, 'processing_confidence_min', parsed.processingConfidenceMin);
387
+ appendSearchParam(params, 'price_per_lb_min', parsed.priceMin);
388
+ appendSearchParam(params, 'price_per_lb_max', parsed.priceMax);
389
+ appendSearchParam(params, 'name', parsed.name);
390
+ appendSearchParam(params, 'cultivar_detail', parsed.variety);
391
+ appendSearchParam(params, 'stocked_days', parsed.stockedDays);
392
+ for (const id of parsed.ids ?? []) {
393
+ params.append('ids', String(id));
394
+ }
395
+ const sort = parsed.sort ? CATALOG_API_SORT_MAP[parsed.sort] : undefined;
396
+ if (sort) {
397
+ params.set('sortField', sort.field);
398
+ params.set('sortDirection', sort.direction);
399
+ }
400
+ if (!hasCatalogIdFilter(parsed)) {
401
+ const offset = parsed.offset ?? 0;
402
+ const limit = parsed.limit;
403
+ params.set('limit', String(limit));
404
+ if (offset > 0) {
405
+ params.set('page', String(Math.floor(offset / limit) + 1));
406
+ }
407
+ }
408
+ return url;
409
+ }
410
+ async function parseCatalogApiError(response, context = 'include=proof') {
411
+ let body;
412
+ try {
413
+ body = (await response.json());
414
+ }
415
+ catch {
416
+ body = undefined;
417
+ }
418
+ const serverMessage = typeof body?.message === 'string'
419
+ ? body.message
420
+ : typeof body?.error === 'string'
421
+ ? body.error
422
+ : response.statusText;
423
+ const details = { status: response.status, body };
424
+ if (response.status === 401 || response.status === 403) {
425
+ return new AuthError(`Catalog API authentication failed: ${serverMessage}`, details);
426
+ }
427
+ if (response.status === 400) {
428
+ return new PrvrsError('INVALID_ARGUMENT', `Catalog API rejected ${context}: ${serverMessage}. Verify the configured Purveyors API endpoint supports this canonical catalog contract.`, details);
429
+ }
430
+ if (response.status === 404) {
431
+ if (context === '/v1/catalog/{id}/similar') {
432
+ if (/^Catalog coffee \d+ was not found$/i.test(serverMessage)) {
433
+ return new PrvrsError('NOT_FOUND', `Catalog similarity target not found: ${serverMessage}`, details);
434
+ }
435
+ return new PrvrsError('CONFIG_ERROR', `Catalog similarity API endpoint not found. Set PURVEYORS_BASE_URL to a Purveyors deployment that supports ${context}.`, details);
436
+ }
437
+ return new PrvrsError('CONFIG_ERROR', `Catalog API endpoint not found. Set PURVEYORS_BASE_URL to a Purveyors deployment that supports ${context}.`, details);
438
+ }
439
+ return new PrvrsError('GENERAL_ERROR', `Catalog API request failed (${response.status}): ${serverMessage}`, details);
440
+ }
441
+ function buildCatalogSimilarityApiUrl(parsed) {
442
+ const url = new URL(`/v1/catalog/${parsed.coffee_id}/similar`, getCatalogApiBaseUrl());
443
+ const params = url.searchParams;
444
+ params.set('threshold', String(parsed.threshold ?? 0.7));
445
+ params.set('limit', String(parsed.limit ?? 10));
446
+ params.set('stocked_only', String(parsed.stockedOnly ?? false));
447
+ if ((parsed.mode ?? 'all') !== 'all') {
448
+ params.set('mode', parsed.mode ?? 'all');
449
+ }
450
+ return url;
451
+ }
452
+ function isCatalogSimilarityResponse(value) {
453
+ if (!value || typeof value !== 'object')
454
+ return false;
455
+ const record = value;
456
+ const data = record.data;
457
+ const groups = data?.groups;
458
+ const meta = record.meta;
459
+ return Boolean(data &&
460
+ typeof data === 'object' &&
461
+ data.target &&
462
+ groups &&
463
+ Array.isArray(groups.canonical_candidates) &&
464
+ Array.isArray(groups.similar_recommendations) &&
465
+ meta &&
466
+ typeof meta.classification_version === 'string' &&
467
+ typeof meta.query_strategy === 'string');
468
+ }
469
+ async function fetchCatalogSimilarityApi(supabase, parsed) {
470
+ const url = buildCatalogSimilarityApiUrl(parsed);
471
+ const response = await fetch(url, {
472
+ headers: {
473
+ Accept: 'application/json',
474
+ Authorization: await getCatalogApiAuthHeader(supabase),
475
+ },
476
+ });
477
+ if (!response.ok) {
478
+ throw await parseCatalogApiError(response, '/v1/catalog/{id}/similar');
479
+ }
480
+ const envelope = (await response.json());
481
+ if (!isCatalogSimilarityResponse(envelope)) {
482
+ throw new PrvrsError('GENERAL_ERROR', 'Catalog similarity API returned an unexpected response shape; expected canonical { data: { target, groups: { canonical_candidates, similar_recommendations } }, meta }.', { body: envelope });
483
+ }
484
+ return envelope;
485
+ }
486
+ async function fetchCatalogApiItems(supabase, parsed) {
487
+ const url = buildCatalogApiUrl(parsed);
488
+ const response = await fetch(url, {
489
+ headers: {
490
+ Accept: 'application/json',
491
+ Authorization: await getCatalogApiAuthHeader(supabase),
492
+ },
493
+ });
494
+ if (!response.ok) {
495
+ throw await parseCatalogApiError(response);
496
+ }
497
+ const envelope = (await response.json());
498
+ if (!Array.isArray(envelope.data)) {
499
+ throw new PrvrsError('GENERAL_ERROR', 'Catalog API returned an unexpected response shape for include=proof; expected { data: [...] }.', { body: envelope });
500
+ }
501
+ return envelope.data;
502
+ }
60
503
  /**
61
504
  * Aggregate stats from an array of catalog items.
62
505
  * Pure function — no I/O, safe to unit test.
@@ -84,6 +527,9 @@ export function computeCatalogStats(items) {
84
527
  */
85
528
  export async function searchCatalog(supabase, opts) {
86
529
  const parsed = searchCatalogSchema.parse(opts);
530
+ if (parsed.includeProof) {
531
+ return fetchCatalogApiItems(supabase, parsed);
532
+ }
87
533
  let query = supabase.from('coffee_catalog').select('*');
88
534
  if (parsed.origin) {
89
535
  const o = sanitizeFilterValue(parsed.origin);
@@ -93,6 +539,21 @@ export async function searchCatalog(supabase, opts) {
93
539
  const p = sanitizeFilterValue(parsed.process);
94
540
  query = query.ilike('processing', `%${p}%`);
95
541
  }
542
+ if (parsed.processingBaseMethod) {
543
+ query = query.eq('processing_base_method', sanitizeFilterValue(parsed.processingBaseMethod));
544
+ }
545
+ if (parsed.fermentationType) {
546
+ query = query.eq('fermentation_type', sanitizeFilterValue(parsed.fermentationType));
547
+ }
548
+ if (parsed.processAdditive) {
549
+ query = query.contains('process_additives', [sanitizeFilterValue(parsed.processAdditive)]);
550
+ }
551
+ if (parsed.processingDisclosureLevel) {
552
+ query = query.eq('processing_disclosure_level', sanitizeFilterValue(parsed.processingDisclosureLevel));
553
+ }
554
+ if (parsed.processingConfidenceMin !== undefined) {
555
+ query = query.gte('processing_confidence', parsed.processingConfidenceMin);
556
+ }
96
557
  if (parsed.priceMin !== undefined) {
97
558
  query = query.gte('price_per_lb', parsed.priceMin);
98
559
  }
@@ -175,8 +636,20 @@ export async function searchCatalog(supabase, opts) {
175
636
  /**
176
637
  * Fetch a single catalog item by ID.
177
638
  */
178
- export async function getCatalog(supabase, id) {
179
- getCatalogSchema.parse({ id });
639
+ export async function getCatalog(supabase, id, opts = {}) {
640
+ const parsed = getCatalogSchema.parse({ id, includeProof: opts.includeProof });
641
+ if (parsed.includeProof) {
642
+ const rows = await fetchCatalogApiItems(supabase, {
643
+ ids: [parsed.id],
644
+ limit: 1,
645
+ includeProof: true,
646
+ });
647
+ const item = rows[0];
648
+ if (!item) {
649
+ throw new PrvrsError('NOT_FOUND', `Coffee ID ${parsed.id} not found in catalog.`);
650
+ }
651
+ return item;
652
+ }
180
653
  const { data, error } = await supabase.from('coffee_catalog').select('*').eq('id', id).single();
181
654
  if (error)
182
655
  throw error;
@@ -193,6 +666,185 @@ export async function getCatalogStats(supabase) {
193
666
  throw error;
194
667
  return computeCatalogStats((data ?? []));
195
668
  }
669
+ function buildCatalogIntelligenceQuery(supabase, parsed, options = {}) {
670
+ let query = supabase.from('coffee_catalog').select('*');
671
+ if (parsed.origin) {
672
+ const origin = sanitizeFilterValue(parsed.origin);
673
+ query = query.or(`country.ilike.%${origin}%,continent.ilike.%${origin}%,region.ilike.%${origin}%`);
674
+ }
675
+ if (parsed.process) {
676
+ const process = sanitizeFilterValue(parsed.process);
677
+ query = query.or(`processing.ilike.%${process}%,processing_base_method.ilike.%${process}%`);
678
+ }
679
+ if (parsed.supplier) {
680
+ query = query.ilike('source', `%${sanitizeFilterValue(parsed.supplier)}%`);
681
+ }
682
+ if (parsed.stocked !== undefined) {
683
+ query = query.eq('stocked', parsed.stocked);
684
+ }
685
+ if (parsed.priceMax !== undefined) {
686
+ query = query.lte('price_per_lb', parsed.priceMax);
687
+ }
688
+ if (parsed.minScore !== undefined) {
689
+ query = query.gte('purveyor_score', parsed.minScore);
690
+ }
691
+ if (parsed.orderByScore) {
692
+ query = query.order('purveyor_score', { ascending: false, nullsFirst: false });
693
+ }
694
+ else {
695
+ query = query.order('source', { ascending: true, nullsFirst: false });
696
+ }
697
+ if (options.applyRange ?? true) {
698
+ const sampleSize = parsed.sampleSize ?? CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE;
699
+ query = query.range(0, sampleSize - 1);
700
+ }
701
+ return query;
702
+ }
703
+ async function fetchSupplierAggregateRows(supabase, parsed) {
704
+ const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
705
+ const pageSize = Math.min(sampleSize, SUPABASE_DATA_API_MAX_PAGE_SIZE);
706
+ const rows = [];
707
+ for (let offset = 0;; offset += pageSize) {
708
+ const { data, error } = await buildCatalogIntelligenceQuery(supabase, {
709
+ supplier: parsed.supplier,
710
+ stocked: parsed.stocked,
711
+ }, { applyRange: false })
712
+ .order('id', { ascending: true })
713
+ .range(offset, offset + pageSize - 1);
714
+ if (error)
715
+ throw error;
716
+ const page = (data ?? []);
717
+ rows.push(...page);
718
+ if (page.length < pageSize)
719
+ break;
720
+ }
721
+ return rows;
722
+ }
723
+ async function fetchCatalogPremiumSampleRows(supabase, parsed, sampleSize) {
724
+ const targetRows = sampleSize + 1;
725
+ const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
726
+ const rows = [];
727
+ for (let offset = 0; rows.length < targetRows; offset += pageSize) {
728
+ const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
729
+ const { data, error } = await buildCatalogIntelligenceQuery(supabase, {
730
+ ...parsed,
731
+ orderByScore: true,
732
+ }, { applyRange: false })
733
+ .order('id', { ascending: true })
734
+ .range(offset, pageEnd);
735
+ if (error)
736
+ throw error;
737
+ const page = (data ?? []);
738
+ rows.push(...page);
739
+ if (page.length < pageEnd - offset + 1)
740
+ break;
741
+ }
742
+ return {
743
+ sampledRows: rows.slice(0, sampleSize),
744
+ truncated: rows.length > sampleSize,
745
+ };
746
+ }
747
+ const catalogIntelligenceCaveats = [
748
+ 'Purveyor Score is read from coffee_catalog.purveyor_score with confidence, tier, factor breakdown, version, and update metadata; the CLI does not recompute the upstream score model.',
749
+ 'Ranking is catalog-only and does not account for a roaster’s owned inventory, roast history, or target menu fit.',
750
+ ];
751
+ const catalogPremiumRankingCaveats = [
752
+ ...catalogIntelligenceCaveats,
753
+ 'Premium ranking samples catalog rows ordered by purveyor_score descending, then id ascending, before applying agent-facing ranking logic; meta.truncated indicates more rows matched than the requested sample_size.',
754
+ ];
755
+ const supplierAggregateCaveats = [
756
+ ...catalogIntelligenceCaveats,
757
+ 'Supplier aggregates paginate catalog rows ordered by source ascending, then id ascending; meta.sample_size is the requested fetch page size and meta.rows_examined reports the rows included in aggregation.',
758
+ ];
759
+ /**
760
+ * Rank premium catalog candidates by Purveyor Score, with pricing and sourcing signals.
761
+ */
762
+ export async function catalogRankPremium(supabase, input = {}) {
763
+ const parsed = catalogRankPremiumSchema.parse(input);
764
+ const sampleSize = parsed.sampleSize ?? CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE;
765
+ const { sampledRows, truncated } = await fetchCatalogPremiumSampleRows(supabase, parsed, sampleSize);
766
+ const ranking = computeCatalogPremiumRanking(sampledRows, {
767
+ limit: parsed.limit ?? 10,
768
+ includeUnscored: parsed.includeUnscored ?? false,
769
+ minScore: parsed.minScore,
770
+ });
771
+ return {
772
+ data: ranking,
773
+ meta: {
774
+ resource: 'catalog-premium-ranking',
775
+ scoring_source: 'coffee_catalog.purveyor_score',
776
+ sample_size: sampleSize,
777
+ sample_limited: true,
778
+ sample_order: 'purveyor_score_desc_nulls_last',
779
+ truncated,
780
+ returned: ranking.length,
781
+ filters: {
782
+ origin: parsed.origin,
783
+ process: parsed.process,
784
+ supplier: parsed.supplier,
785
+ stocked: parsed.stocked,
786
+ priceMax: parsed.priceMax,
787
+ minScore: parsed.minScore,
788
+ includeUnscored: parsed.includeUnscored ?? false,
789
+ },
790
+ caveats: catalogPremiumRankingCaveats,
791
+ },
792
+ };
793
+ }
794
+ async function getSupplierAggregates(supabase, input, resource) {
795
+ const parsed = supplierAggregateSchema.parse(input);
796
+ const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
797
+ const rows = await fetchSupplierAggregateRows(supabase, {
798
+ supplier: parsed.supplier,
799
+ stocked: parsed.stocked,
800
+ sampleSize,
801
+ });
802
+ const aggregates = computeSupplierAggregates(rows, {
803
+ topCoffees: parsed.topCoffees ?? 5,
804
+ minCoffees: parsed.minCoffees ?? 1,
805
+ }).slice(0, parsed.limit ?? 25);
806
+ return {
807
+ data: aggregates,
808
+ meta: {
809
+ resource,
810
+ sample_size: sampleSize,
811
+ sample_limited: false,
812
+ sample_order: 'source_asc_nulls_last',
813
+ truncated: false,
814
+ rows_examined: rows.length,
815
+ returned: aggregates.length,
816
+ filters: {
817
+ supplier: parsed.supplier,
818
+ stocked: parsed.stocked,
819
+ minCoffees: parsed.minCoffees ?? 1,
820
+ },
821
+ caveats: supplierAggregateCaveats,
822
+ },
823
+ };
824
+ }
825
+ /** List supplier aggregates from catalog rows. */
826
+ export async function supplierList(supabase, input = {}) {
827
+ return getSupplierAggregates(supabase, input, 'supplier-list');
828
+ }
829
+ /** Return aggregate detail for a supplier query. */
830
+ export async function supplierDetail(supabase, input) {
831
+ const parsed = supplierAggregateSchema.parse(input);
832
+ if (!parsed.supplier?.trim()) {
833
+ throw new PrvrsError('INVALID_ARGUMENT', 'supplierDetail requires a non-empty supplier name.');
834
+ }
835
+ return getSupplierAggregates(supabase, { ...parsed, limit: parsed.limit ?? 10 }, 'supplier-detail');
836
+ }
837
+ /** Rank suppliers by average Purveyor Score, then currently stocked coverage. */
838
+ export async function supplierRank(supabase, input = {}) {
839
+ return getSupplierAggregates(supabase, input, 'supplier-rank');
840
+ }
841
+ /**
842
+ * Fetch canonical catalog similarity groups from the beta /v1/catalog/{id}/similar API.
843
+ */
844
+ export async function getCatalogSimilarity(supabase, input) {
845
+ const parsed = getCatalogSimilaritySchema.parse(input);
846
+ return fetchCatalogSimilarityApi(supabase, parsed);
847
+ }
196
848
  /**
197
849
  * Find beans similar to a target coffee using pgvector embedding similarity.
198
850
  * Calls the `find_similar_beans_aggregated` RPC and returns ranked matches.