@purveyors/cli 0.25.0 → 0.27.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 (64) hide show
  1. package/README.md +19 -11
  2. package/dist/commands/catalog.d.ts.map +1 -1
  3. package/dist/commands/catalog.js +11 -47
  4. package/dist/commands/catalog.js.map +1 -1
  5. package/dist/commands/inventory.d.ts.map +1 -1
  6. package/dist/commands/inventory.js +17 -44
  7. package/dist/commands/inventory.js.map +1 -1
  8. package/dist/commands/market.d.ts.map +1 -1
  9. package/dist/commands/market.js +4 -2
  10. package/dist/commands/market.js.map +1 -1
  11. package/dist/commands/roast.d.ts +3 -0
  12. package/dist/commands/roast.d.ts.map +1 -1
  13. package/dist/commands/roast.js +15 -11
  14. package/dist/commands/roast.js.map +1 -1
  15. package/dist/commands/sales.d.ts.map +1 -1
  16. package/dist/commands/sales.js +15 -11
  17. package/dist/commands/sales.js.map +1 -1
  18. package/dist/commands/tasting.d.ts.map +1 -1
  19. package/dist/commands/tasting.js +1 -2
  20. package/dist/commands/tasting.js.map +1 -1
  21. package/dist/lib/auth-guard.d.ts.map +1 -1
  22. package/dist/lib/auth-guard.js +14 -6
  23. package/dist/lib/auth-guard.js.map +1 -1
  24. package/dist/lib/catalog.d.ts +12 -14
  25. package/dist/lib/catalog.d.ts.map +1 -1
  26. package/dist/lib/catalog.js +177 -520
  27. package/dist/lib/catalog.js.map +1 -1
  28. package/dist/lib/interactive/forms.d.ts +1 -1
  29. package/dist/lib/interactive/forms.d.ts.map +1 -1
  30. package/dist/lib/interactive/forms.js +3 -10
  31. package/dist/lib/interactive/forms.js.map +1 -1
  32. package/dist/lib/inventory.d.ts +8 -16
  33. package/dist/lib/inventory.d.ts.map +1 -1
  34. package/dist/lib/inventory.js +44 -227
  35. package/dist/lib/inventory.js.map +1 -1
  36. package/dist/lib/manifest.d.ts.map +1 -1
  37. package/dist/lib/manifest.js +7 -15
  38. package/dist/lib/manifest.js.map +1 -1
  39. package/dist/lib/market.d.ts +1 -1
  40. package/dist/lib/market.d.ts.map +1 -1
  41. package/dist/lib/parchment-base.d.ts +3 -0
  42. package/dist/lib/parchment-base.d.ts.map +1 -0
  43. package/dist/lib/parchment-base.js +9 -0
  44. package/dist/lib/parchment-base.js.map +1 -0
  45. package/dist/lib/parchment.d.ts +1 -1
  46. package/dist/lib/parchment.d.ts.map +1 -1
  47. package/dist/lib/parchment.js +5 -7
  48. package/dist/lib/parchment.js.map +1 -1
  49. package/dist/lib/roast.d.ts +16 -24
  50. package/dist/lib/roast.d.ts.map +1 -1
  51. package/dist/lib/roast.js +33 -224
  52. package/dist/lib/roast.js.map +1 -1
  53. package/dist/lib/sales.d.ts +12 -6
  54. package/dist/lib/sales.d.ts.map +1 -1
  55. package/dist/lib/sales.js +13 -27
  56. package/dist/lib/sales.js.map +1 -1
  57. package/dist/lib/supabase.d.ts.map +1 -1
  58. package/dist/lib/supabase.js +14 -9
  59. package/dist/lib/supabase.js.map +1 -1
  60. package/dist/lib/tasting.d.ts +1 -1
  61. package/dist/lib/tasting.d.ts.map +1 -1
  62. package/dist/lib/tasting.js +5 -53
  63. package/dist/lib/tasting.js.map +1 -1
  64. package/package.json +2 -2
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { AuthError, PrvrsError } from './errors.js';
3
+ import { createParchmentClient, getParchmentBaseUrl, resolveParchmentToken, unwrapParchment, } from './parchment.js';
3
4
  // ─── Zod schemas ──────────────────────────────────────────────────────────────
4
5
  export const catalogSortFields = ['price', 'price-desc', 'name', 'origin', 'newest'];
5
6
  export const searchCatalogSchema = z.object({
@@ -32,7 +33,6 @@ export const getCatalogSchema = z.object({
32
33
  export const getCatalogStatsSchema = z.object({});
33
34
  const CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE = 5000;
34
35
  const CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE = 250;
35
- const SUPABASE_DATA_API_MAX_PAGE_SIZE = 1000;
36
36
  const SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE = CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE;
37
37
  export const catalogRankPremiumSchema = z.object({
38
38
  origin: z.string().optional(),
@@ -80,13 +80,6 @@ export const catalogFacetsSchema = z.object({
80
80
  field: z.enum(catalogFacetFields),
81
81
  stockedOnly: z.boolean().default(true).optional(),
82
82
  limit: z.number().int().min(1).max(100).default(60).optional(),
83
- sampleSize: z
84
- .number()
85
- .int()
86
- .min(1)
87
- .max(CATALOG_INTELLIGENCE_MAX_SAMPLE_SIZE)
88
- .default(SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE)
89
- .optional(),
90
83
  });
91
84
  export const catalogRankObjectives = ['premium', 'value', 'fresh_arrival', 'rare_origin'];
92
85
  export const catalogRankSchema = z.object({
@@ -108,6 +101,9 @@ export const catalogRankSchema = z.object({
108
101
  .optional(),
109
102
  });
110
103
  export const catalogSimilarityModes = ['all', 'likely_same', 'similar_profile'];
104
+ const CATALOG_SIMILARITY_MIN_THRESHOLD = 0.5;
105
+ const CATALOG_SIMILARITY_MAX_THRESHOLD = 0.99;
106
+ const CATALOG_SIMILARITY_MAX_RESULTS = 25;
111
107
  export const getCatalogSimilaritySchema = z.object({
112
108
  coffee_id: z
113
109
  .number()
@@ -116,8 +112,8 @@ export const getCatalogSimilaritySchema = z.object({
116
112
  .describe('The coffee_catalog ID to request canonical similarity for'),
117
113
  threshold: z
118
114
  .number()
119
- .min(0.5)
120
- .max(0.99)
115
+ .min(CATALOG_SIMILARITY_MIN_THRESHOLD)
116
+ .max(CATALOG_SIMILARITY_MAX_THRESHOLD)
121
117
  .default(0.7)
122
118
  .optional()
123
119
  .describe('Minimum canonical similarity threshold (0.5-0.99)'),
@@ -125,7 +121,7 @@ export const getCatalogSimilaritySchema = z.object({
125
121
  .number()
126
122
  .int()
127
123
  .min(1)
128
- .max(25)
124
+ .max(CATALOG_SIMILARITY_MAX_RESULTS)
129
125
  .default(10)
130
126
  .optional()
131
127
  .describe('Maximum canonical similarity results to request'),
@@ -144,19 +140,19 @@ export const findSimilarBeansSchema = z.object({
144
140
  .describe('The coffee_catalog ID to find similar beans for'),
145
141
  threshold: z
146
142
  .number()
147
- .min(0)
148
- .max(1)
143
+ .min(CATALOG_SIMILARITY_MIN_THRESHOLD)
144
+ .max(CATALOG_SIMILARITY_MAX_THRESHOLD)
149
145
  .default(0.7)
150
146
  .optional()
151
- .describe('Minimum similarity score (0-1)'),
147
+ .describe('Minimum canonical similarity threshold (0.5-0.99)'),
152
148
  limit: z
153
149
  .number()
154
150
  .int()
155
151
  .min(1)
156
- .max(50)
152
+ .max(CATALOG_SIMILARITY_MAX_RESULTS)
157
153
  .default(10)
158
154
  .optional()
159
- .describe('Maximum results to return'),
155
+ .describe('Maximum canonical similarity results to return'),
160
156
  });
161
157
  // ─── Helpers ──────────────────────────────────────────────────────────────────
162
158
  /**
@@ -369,22 +365,8 @@ const CATALOG_API_SORT_MAP = {
369
365
  'price-desc': { field: 'price_per_lb', direction: 'desc' },
370
366
  name: { field: 'name', direction: 'asc' },
371
367
  origin: { field: 'country', direction: 'asc' },
372
- newest: { field: 'stocked_date', direction: 'desc' },
368
+ newest: { field: 'last_updated', direction: 'desc' },
373
369
  };
374
- function getCatalogApiBaseUrl() {
375
- return (process.env.PURVEYORS_BASE_URL ?? 'https://www.purveyors.io').replace(/\/+$/, '');
376
- }
377
- async function getCatalogApiAuthHeader(supabase) {
378
- const apiKey = process.env.PARCHMENT_API_KEY ?? process.env.PURVEYORS_API_KEY;
379
- if (apiKey) {
380
- return `Bearer ${apiKey}`;
381
- }
382
- const { data: { session }, } = await supabase.auth.getSession();
383
- if (!session?.access_token) {
384
- 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.');
385
- }
386
- return `Bearer ${session.access_token}`;
387
- }
388
370
  function appendSearchParam(params, name, value) {
389
371
  if (value === undefined)
390
372
  return;
@@ -415,7 +397,7 @@ function assertCatalogApiCompatibleSearch(parsed) {
415
397
  }
416
398
  function buildCatalogApiUrl(parsed) {
417
399
  assertCatalogApiCompatibleSearch(parsed);
418
- const url = new URL('/v1/catalog', getCatalogApiBaseUrl());
400
+ const url = new URL('/v1/catalog', getParchmentBaseUrl());
419
401
  const params = url.searchParams;
420
402
  params.set('include', 'proof');
421
403
  params.set('stocked', parsed.stocked ? 'true' : 'all');
@@ -481,7 +463,7 @@ async function parseCatalogApiError(response, context = 'include=proof') {
481
463
  return new PrvrsError('GENERAL_ERROR', `Catalog API request failed (${response.status}): ${serverMessage}`, details);
482
464
  }
483
465
  function buildCatalogSimilarityApiUrl(parsed) {
484
- const url = new URL(`/v1/catalog/${parsed.coffee_id}/similar`, getCatalogApiBaseUrl());
466
+ const url = new URL(`/v1/catalog/${parsed.coffee_id}/similar`, getParchmentBaseUrl());
485
467
  const params = url.searchParams;
486
468
  params.set('threshold', String(parsed.threshold ?? 0.7));
487
469
  params.set('limit', String(parsed.limit ?? 10));
@@ -508,12 +490,12 @@ function isCatalogSimilarityResponse(value) {
508
490
  typeof meta.classification_version === 'string' &&
509
491
  typeof meta.query_strategy === 'string');
510
492
  }
511
- async function fetchCatalogSimilarityApi(supabase, parsed) {
493
+ async function fetchCatalogSimilarityApi(parsed) {
512
494
  const url = buildCatalogSimilarityApiUrl(parsed);
513
495
  const response = await fetch(url, {
514
496
  headers: {
515
497
  Accept: 'application/json',
516
- Authorization: await getCatalogApiAuthHeader(supabase),
498
+ Authorization: `Bearer ${await resolveParchmentToken('member')}`,
517
499
  },
518
500
  });
519
501
  if (!response.ok) {
@@ -525,12 +507,18 @@ async function fetchCatalogSimilarityApi(supabase, parsed) {
525
507
  }
526
508
  return envelope;
527
509
  }
528
- async function fetchCatalogApiItems(supabase, parsed) {
510
+ async function fetchCatalogApiItems(parsed) {
529
511
  const url = buildCatalogApiUrl(parsed);
530
512
  const response = await fetch(url, {
531
513
  headers: {
532
514
  Accept: 'application/json',
533
- Authorization: await getCatalogApiAuthHeader(supabase),
515
+ Authorization: `Bearer ${await resolveParchmentToken(parsed.processingBaseMethod ||
516
+ parsed.fermentationType ||
517
+ parsed.processAdditive ||
518
+ parsed.processingDisclosureLevel ||
519
+ parsed.processingConfidenceMin !== undefined
520
+ ? 'member'
521
+ : 'viewer')}`,
534
522
  },
535
523
  });
536
524
  if (!response.ok) {
@@ -567,121 +555,59 @@ export function computeCatalogStats(items) {
567
555
  /**
568
556
  * Search the coffee catalog with optional filters.
569
557
  */
570
- export async function searchCatalog(supabase, opts) {
558
+ export async function searchCatalog(opts) {
571
559
  const parsed = searchCatalogSchema.parse(opts);
572
560
  if (parsed.includeProof) {
573
- return fetchCatalogApiItems(supabase, parsed);
574
- }
575
- let query = supabase.from('coffee_catalog').select('*');
576
- if (parsed.origin) {
577
- const o = sanitizeFilterValue(parsed.origin);
578
- query = query.or(`country.ilike.%${o}%,continent.ilike.%${o}%,region.ilike.%${o}%`);
579
- }
580
- if (parsed.process) {
581
- const p = sanitizeFilterValue(parsed.process);
582
- query = query.ilike('processing', `%${p}%`);
583
- }
584
- if (parsed.processingBaseMethod) {
585
- query = query.eq('processing_base_method', sanitizeFilterValue(parsed.processingBaseMethod));
586
- }
587
- if (parsed.fermentationType) {
588
- query = query.eq('fermentation_type', sanitizeFilterValue(parsed.fermentationType));
589
- }
590
- if (parsed.processAdditive) {
591
- query = query.contains('process_additives', [sanitizeFilterValue(parsed.processAdditive)]);
592
- }
593
- if (parsed.processingDisclosureLevel) {
594
- query = query.eq('processing_disclosure_level', sanitizeFilterValue(parsed.processingDisclosureLevel));
595
- }
596
- if (parsed.processingConfidenceMin !== undefined) {
597
- query = query.gte('processing_confidence', parsed.processingConfidenceMin);
598
- }
599
- if (parsed.priceMin !== undefined) {
600
- query = query.gte('price_per_lb', parsed.priceMin);
601
- }
602
- if (parsed.priceMax !== undefined) {
603
- query = query.lte('price_per_lb', parsed.priceMax);
604
- }
605
- if (parsed.flavor) {
606
- const keywords = parsed.flavor
607
- .split(',')
608
- .map((k) => sanitizeFilterValue(k.trim()))
609
- .filter(Boolean);
610
- const flavorFilters = keywords
611
- .flatMap((kw) => [
612
- `description_short.ilike.%${kw}%`,
613
- `description_long.ilike.%${kw}%`,
614
- `cupping_notes.ilike.%${kw}%`,
615
- `farm_notes.ilike.%${kw}%`,
616
- ])
617
- .join(',');
618
- query = query.or(flavorFilters);
619
- }
620
- if (parsed.name) {
621
- const n = sanitizeFilterValue(parsed.name);
622
- query = query.ilike('name', `%${n}%`);
623
- }
624
- if (parsed.supplier) {
625
- const s = sanitizeFilterValue(parsed.supplier);
626
- query = query.ilike('source', `%${s}%`);
627
- }
628
- if (parsed.ids && parsed.ids.length > 0) {
629
- // coffee_catalog PK is `id`; `catalog_id` is the FK name on other tables
630
- query = query.in('id', parsed.ids);
631
- }
632
- if (parsed.variety) {
633
- const v = sanitizeFilterValue(parsed.variety);
634
- query = query.ilike('cultivar_detail', `%${v}%`);
635
- }
636
- if (parsed.dryingMethod) {
637
- const d = sanitizeFilterValue(parsed.dryingMethod);
638
- query = query.ilike('drying_method', `%${d}%`);
639
- }
640
- if (parsed.stockedDays) {
641
- const cutoff = new Date();
642
- cutoff.setDate(cutoff.getDate() - parsed.stockedDays);
643
- query = query.gte('stocked_date', cutoff.toISOString().slice(0, 10));
561
+ return fetchCatalogApiItems(parsed);
644
562
  }
645
- if (parsed.stocked) {
646
- query = query.eq('stocked', true);
647
- }
648
- // Apply sort order
649
- if (parsed.sort) {
650
- switch (parsed.sort) {
651
- case 'price':
652
- query = query.order('price_per_lb', { ascending: true, nullsFirst: false });
653
- break;
654
- case 'price-desc':
655
- query = query.order('price_per_lb', { ascending: false, nullsFirst: false });
656
- break;
657
- case 'name':
658
- query = query.order('name', { ascending: true, nullsFirst: false });
659
- break;
660
- case 'origin':
661
- query = query.order('country', { ascending: true, nullsFirst: false });
662
- break;
663
- case 'newest':
664
- query = query.order('last_updated', { ascending: false, nullsFirst: false });
665
- break;
666
- }
667
- }
668
- // Apply offset/limit for pagination (skip when fetching specific IDs)
669
- if (!parsed.ids || parsed.ids.length === 0) {
670
- const offset = parsed.offset ?? 0;
671
- query = query.range(offset, offset + parsed.limit - 1);
563
+ const offset = parsed.offset ?? 0;
564
+ if (!hasCatalogIdFilter(parsed) && offset % parsed.limit !== 0) {
565
+ throw new PrvrsError('INVALID_ARGUMENT', `Canonical catalog pagination requires --offset to be a multiple of --limit. Received offset=${offset}, limit=${parsed.limit}.`);
672
566
  }
673
- const { data, error } = await query;
674
- if (error)
675
- throw error;
676
- return (data ?? []);
567
+ const sort = parsed.sort ? CATALOG_API_SORT_MAP[parsed.sort] : undefined;
568
+ const client = await createParchmentClient(parsed.processingBaseMethod ||
569
+ parsed.fermentationType ||
570
+ parsed.processAdditive ||
571
+ parsed.processingDisclosureLevel ||
572
+ parsed.processingConfidenceMin !== undefined
573
+ ? 'member'
574
+ : 'viewer');
575
+ const result = await client.catalog.list({
576
+ origin: parsed.origin,
577
+ processing: parsed.process,
578
+ processing_base_method: parsed.processingBaseMethod,
579
+ fermentation_type: parsed.fermentationType,
580
+ process_additive: parsed.processAdditive,
581
+ processing_disclosure_level: parsed.processingDisclosureLevel,
582
+ processing_confidence_min: parsed.processingConfidenceMin,
583
+ pricePerLbMin: parsed.priceMin,
584
+ pricePerLbMax: parsed.priceMax,
585
+ flavorKeywords: parsed.flavor
586
+ ?.split(',')
587
+ .map((value) => value.trim())
588
+ .filter(Boolean),
589
+ name: parsed.name,
590
+ supplier: parsed.supplier,
591
+ coffeeIds: parsed.ids?.join(','),
592
+ variety: parsed.variety,
593
+ dryingMethod: parsed.dryingMethod,
594
+ stockedDays: parsed.stockedDays,
595
+ stocked: parsed.stocked ? 'true' : 'all',
596
+ sort: sort?.field,
597
+ order: sort?.direction,
598
+ limit: hasCatalogIdFilter(parsed) ? Math.min(parsed.ids?.length ?? 1, 100) : parsed.limit,
599
+ page: hasCatalogIdFilter(parsed) ? 1 : Math.floor(offset / parsed.limit) + 1,
600
+ });
601
+ const envelope = unwrapParchment(result, 'Catalog search');
602
+ return envelope.data;
677
603
  }
678
604
  /**
679
605
  * Fetch a single catalog item by ID.
680
606
  */
681
- export async function getCatalog(supabase, id, opts = {}) {
607
+ export async function getCatalog(id, opts = {}) {
682
608
  const parsed = getCatalogSchema.parse({ id, includeProof: opts.includeProof });
683
609
  if (parsed.includeProof) {
684
- const rows = await fetchCatalogApiItems(supabase, {
610
+ const rows = await fetchCatalogApiItems({
685
611
  ids: [parsed.id],
686
612
  limit: 1,
687
613
  includeProof: true,
@@ -692,120 +618,21 @@ export async function getCatalog(supabase, id, opts = {}) {
692
618
  }
693
619
  return item;
694
620
  }
695
- const { data, error } = await supabase.from('coffee_catalog').select('*').eq('id', id).single();
696
- if (error)
697
- throw error;
698
- return data;
621
+ const rows = await searchCatalog({ ids: [parsed.id], limit: 1 });
622
+ const item = rows[0];
623
+ if (!item) {
624
+ throw new PrvrsError('NOT_FOUND', `Coffee ID ${parsed.id} not found in catalog.`);
625
+ }
626
+ return item;
699
627
  }
700
628
  /**
701
629
  * Fetch aggregate statistics for the coffee catalog.
702
630
  */
703
- export async function getCatalogStats(supabase) {
704
- const { data, error } = await supabase
705
- .from('coffee_catalog')
706
- .select('id, country, continent, price_per_lb, price_tiers, cost_lb, stocked');
707
- if (error)
708
- throw error;
709
- return computeCatalogStats((data ?? []));
631
+ export async function getCatalogStats() {
632
+ const client = await createParchmentClient('viewer');
633
+ const envelope = unwrapParchment(await client.catalog.stats({ stocked: 'all' }), 'Catalog stats');
634
+ return envelope.stats;
710
635
  }
711
- function buildCatalogIntelligenceQuery(supabase, parsed, options = {}) {
712
- let query = supabase.from('coffee_catalog').select('*');
713
- if (parsed.origin) {
714
- const origin = sanitizeFilterValue(parsed.origin);
715
- query = query.or(`country.ilike.%${origin}%,continent.ilike.%${origin}%,region.ilike.%${origin}%`);
716
- }
717
- if (parsed.process) {
718
- const process = sanitizeFilterValue(parsed.process);
719
- query = query.or(`processing.ilike.%${process}%,processing_base_method.ilike.%${process}%`);
720
- }
721
- if (parsed.supplier) {
722
- query = query.ilike('source', `%${sanitizeFilterValue(parsed.supplier)}%`);
723
- }
724
- if (parsed.country) {
725
- query = query.ilike('country', `%${sanitizeFilterValue(parsed.country)}%`);
726
- }
727
- if (parsed.stocked !== undefined) {
728
- query = query.eq('stocked', parsed.stocked);
729
- }
730
- if (parsed.priceMax !== undefined) {
731
- query = query.lte('price_per_lb', parsed.priceMax);
732
- }
733
- if (parsed.minScore !== undefined) {
734
- query = query.gte('purveyor_score', parsed.minScore);
735
- }
736
- if (parsed.nonWholesaleOnly) {
737
- query = query.or('wholesale.is.null,wholesale.eq.false');
738
- }
739
- if (parsed.orderByScore) {
740
- query = query.order('purveyor_score', { ascending: false, nullsFirst: false });
741
- }
742
- else {
743
- query = query.order('source', { ascending: true, nullsFirst: false });
744
- }
745
- if (options.applyRange ?? true) {
746
- const sampleSize = parsed.sampleSize ?? CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE;
747
- query = query.range(0, sampleSize - 1);
748
- }
749
- return query;
750
- }
751
- async function fetchSupplierAggregateRows(supabase, parsed) {
752
- const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
753
- const pageSize = Math.min(sampleSize, SUPABASE_DATA_API_MAX_PAGE_SIZE);
754
- const rows = [];
755
- for (let offset = 0;; offset += pageSize) {
756
- const { data, error } = await buildCatalogIntelligenceQuery(supabase, {
757
- supplier: parsed.supplier,
758
- country: parsed.country,
759
- stocked: parsed.stocked,
760
- nonWholesaleOnly: parsed.nonWholesaleOnly,
761
- }, { applyRange: false })
762
- .order('id', { ascending: true })
763
- .range(offset, offset + pageSize - 1);
764
- if (error)
765
- throw error;
766
- const page = (data ?? []);
767
- rows.push(...page);
768
- if (page.length < pageSize)
769
- break;
770
- }
771
- return rows;
772
- }
773
- async function fetchCatalogPremiumSampleRows(supabase, parsed, sampleSize) {
774
- const targetRows = sampleSize + 1;
775
- const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
776
- const rows = [];
777
- for (let offset = 0; rows.length < targetRows; offset += pageSize) {
778
- const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
779
- const { data, error } = await buildCatalogIntelligenceQuery(supabase, {
780
- ...parsed,
781
- orderByScore: true,
782
- }, { applyRange: false })
783
- .order('id', { ascending: true })
784
- .range(offset, pageEnd);
785
- if (error)
786
- throw error;
787
- const page = (data ?? []);
788
- rows.push(...page);
789
- if (page.length < pageEnd - offset + 1)
790
- break;
791
- }
792
- return {
793
- sampledRows: rows.slice(0, sampleSize),
794
- truncated: rows.length > sampleSize,
795
- };
796
- }
797
- const catalogIntelligenceCaveats = [
798
- '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.',
799
- 'Ranking is catalog-only and does not account for a roaster’s owned inventory, roast history, or target menu fit.',
800
- ];
801
- const catalogPremiumRankingCaveats = [
802
- ...catalogIntelligenceCaveats,
803
- '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.',
804
- ];
805
- const supplierAggregateCaveats = [
806
- ...catalogIntelligenceCaveats,
807
- '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.',
808
- ];
809
636
  const catalogFacetColumns = {
810
637
  supplier: 'source',
811
638
  country: 'country',
@@ -818,324 +645,154 @@ const catalogFacetColumns = {
818
645
  const catalogFacetCaveats = [
819
646
  'Facet counts are computed from catalog rows visible to the current client and are intended for value discovery, not inventory guarantees.',
820
647
  ];
821
- const catalogRankingCaveats = [
822
- ...catalogIntelligenceCaveats,
823
- 'Generic catalog ranking samples catalog rows ordered by id ascending before applying deterministic objective-specific ranking; meta.truncated indicates more rows matched than the requested sample_size.',
824
- ];
825
- function asPositiveNumber(value) {
826
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
827
- }
828
- function scoreBasis(item) {
829
- const score = summarizePurveyorScore(item);
830
- if (score.value === null)
831
- return 'no Purveyor Score yet';
832
- const tier = score.tier ? `, ${score.tier}` : '';
833
- return `Purveyor Score ${score.value}${tier}`;
834
- }
835
- async function fetchCatalogFacetRows(supabase, parsed) {
836
- const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
837
- const targetRows = sampleSize + 1;
838
- const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
839
- const rows = [];
840
- const column = catalogFacetColumns[parsed.field];
841
- for (let offset = 0; rows.length < targetRows; offset += pageSize) {
842
- const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
843
- let query = supabase.from('coffee_catalog').select(`id, ${String(column)}`);
844
- if (parsed.stockedOnly ?? true)
845
- query = query.eq('stocked', true);
846
- const { data, error } = await query.order('id', { ascending: true }).range(offset, pageEnd);
847
- if (error)
848
- throw error;
849
- const page = (data ?? []);
850
- rows.push(...page);
851
- if (page.length < pageEnd - offset + 1)
852
- break;
853
- }
854
- return { rows: rows.slice(0, sampleSize), truncated: rows.length > sampleSize };
855
- }
856
- async function fetchCatalogRankRows(supabase, parsed) {
857
- const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
858
- const targetRows = sampleSize + 1;
859
- const pageSize = Math.min(targetRows, SUPABASE_DATA_API_MAX_PAGE_SIZE);
860
- const rows = [];
861
- for (let offset = 0; rows.length < targetRows; offset += pageSize) {
862
- const pageEnd = Math.min(offset + pageSize - 1, targetRows - 1);
863
- let query = supabase.from('coffee_catalog').select('*');
864
- if (parsed.stockedOnly ?? true)
865
- query = query.eq('stocked', true);
866
- if (parsed.supplier)
867
- query = query.ilike('source', `%${sanitizeFilterValue(parsed.supplier)}%`);
868
- if (parsed.country)
869
- query = query.ilike('country', `%${sanitizeFilterValue(parsed.country)}%`);
870
- if (parsed.process) {
871
- const process = sanitizeFilterValue(parsed.process);
872
- query = query.or(`processing.ilike.%${process}%,processing_base_method.ilike.%${process}%`);
873
- }
874
- if (parsed.priceMax !== undefined)
875
- query = query.lte('price_per_lb', parsed.priceMax);
876
- if (parsed.minScore !== undefined)
877
- query = query.gte('purveyor_score', parsed.minScore);
878
- if (parsed.nonWholesaleOnly)
879
- query = query.or('wholesale.is.null,wholesale.eq.false');
880
- const { data, error } = await query.order('id', { ascending: true }).range(offset, pageEnd);
881
- if (error)
882
- throw error;
883
- const page = (data ?? []);
884
- rows.push(...page);
885
- if (page.length < pageEnd - offset + 1)
886
- break;
887
- }
888
- return { rows: rows.slice(0, sampleSize), truncated: rows.length > sampleSize };
889
- }
890
- function rankCatalogRows(rows, objective, limit) {
891
- const byScoreDesc = (a, b) => (getPurveyorScoreValue(b) ?? -Infinity) - (getPurveyorScoreValue(a) ?? -Infinity) ||
892
- String(b.stocked_date ?? '').localeCompare(String(a.stocked_date ?? '')) ||
893
- a.id - b.id;
894
- let ranked;
895
- switch (objective) {
896
- case 'premium':
897
- ranked = [...rows].sort(byScoreDesc).map((row) => ({ row, basis: scoreBasis(row) }));
898
- break;
899
- case 'value': {
900
- ranked = rows
901
- .map((row) => {
902
- const score = getPurveyorScoreValue(row);
903
- const price = asPositiveNumber(getPerLbPrice(row));
904
- if (score === null || price === null)
905
- return null;
906
- return { row, ratio: score / price };
907
- })
908
- .filter((entry) => entry !== null)
909
- .sort((a, b) => b.ratio - a.ratio || byScoreDesc(a.row, b.row))
910
- .map(({ row, ratio }) => ({
911
- row,
912
- basis: `${scoreBasis(row)} at $${getPerLbPrice(row)}/lb (${round(ratio, 1)} score points per dollar)`,
913
- }));
914
- break;
915
- }
916
- case 'fresh_arrival':
917
- ranked = [...rows]
918
- .sort((a, b) => String(b.stocked_date ?? '').localeCompare(String(a.stocked_date ?? '')) ||
919
- String(b.arrival_date ?? '').localeCompare(String(a.arrival_date ?? '')) ||
920
- byScoreDesc(a, b))
921
- .map((row) => ({
922
- row,
923
- basis: `stocked ${row.stocked_date ?? 'date unknown'}; ${scoreBasis(row)}`,
924
- }));
925
- break;
926
- case 'rare_origin': {
927
- const originCounts = new Map();
928
- for (const row of rows) {
929
- const country = row.country?.trim();
930
- if (country)
931
- originCounts.set(country, (originCounts.get(country) ?? 0) + 1);
932
- }
933
- const rarity = (row) => {
934
- const country = row.country?.trim();
935
- return country
936
- ? (originCounts.get(country) ?? Number.MAX_SAFE_INTEGER)
937
- : Number.MAX_SAFE_INTEGER;
938
- };
939
- ranked = [...rows]
940
- .filter((row) => Boolean(row.country?.trim()))
941
- .sort((a, b) => rarity(a) - rarity(b) || byScoreDesc(a, b))
942
- .map((row) => ({
943
- row,
944
- basis: `${row.country} has ${rarity(row)} matching listing(s); ${scoreBasis(row)}`,
945
- }));
946
- break;
947
- }
948
- }
949
- return ranked.slice(0, limit).map(({ row, basis }, index) => ({
950
- ...row,
951
- rank: index + 1,
952
- rank_basis: basis,
953
- }));
954
- }
955
648
  /** List distinct catalog facet values with counts for agent/client filter discovery. */
956
- export async function listCatalogFacets(supabase, input) {
649
+ export async function listCatalogFacets(input) {
957
650
  const parsed = catalogFacetsSchema.parse(input);
958
651
  const limit = parsed.limit ?? 60;
959
- const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
960
- const { rows, truncated } = await fetchCatalogFacetRows(supabase, parsed);
961
652
  const column = catalogFacetColumns[parsed.field];
962
- const counts = new Map();
963
- for (const row of rows) {
964
- const raw = row[column];
965
- const value = typeof raw === 'boolean' ? String(raw) : typeof raw === 'string' ? raw.trim() : '';
966
- if (!value)
967
- continue;
968
- counts.set(value, (counts.get(value) ?? 0) + 1);
969
- }
970
- const values = [...counts.entries()]
971
- .map(([value, count]) => ({ value, count }))
972
- .sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
653
+ const facetKey = {
654
+ supplier: 'sources',
655
+ country: 'countries',
656
+ processing_base_method: 'processing_base_method',
657
+ fermentation_type: 'fermentation_type',
658
+ drying_method: 'drying_method',
659
+ grade: 'grade',
660
+ wholesale: 'wholesale',
661
+ }[parsed.field];
662
+ const client = await createParchmentClient('viewer');
663
+ const envelope = unwrapParchment(await client.catalog.facets({ stocked: parsed.stockedOnly === false ? 'all' : 'true' }), 'Catalog facets');
664
+ const facets = envelope.facets;
665
+ const values = (facets[facetKey] ?? []).slice(0, limit);
666
+ const rowsExamined = (facets[facetKey] ?? []).reduce((total, facet) => total + facet.count, 0);
973
667
  return {
974
- data: values.slice(0, limit),
668
+ data: values,
975
669
  meta: {
976
670
  resource: 'catalog-facets',
977
671
  field: parsed.field,
978
672
  source_column: String(column),
979
673
  stocked_only: parsed.stockedOnly ?? true,
980
674
  scope: (parsed.stockedOnly ?? true) ? 'stocked_only' : 'all_visible',
981
- sample_size: sampleSize,
982
- sample_limited: true,
675
+ sample_size: rowsExamined,
676
+ sample_limited: false,
983
677
  sample_order: 'id_asc',
984
- truncated: truncated || values.length > limit,
985
- rows_examined: rows.length,
986
- distinct_values: values.length,
987
- returned: Math.min(values.length, limit),
678
+ truncated: (facets[facetKey]?.length ?? 0) > limit,
679
+ rows_examined: rowsExamined,
680
+ distinct_values: facets[facetKey]?.length ?? 0,
681
+ returned: values.length,
988
682
  caveats: catalogFacetCaveats,
989
683
  },
990
684
  };
991
685
  }
992
686
  /** Rank catalog candidates by deterministic product-intelligence objectives. */
993
- export async function rankCatalog(supabase, input = {}) {
687
+ export async function rankCatalog(input = {}) {
994
688
  const parsed = catalogRankSchema.parse(input);
995
- const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
996
- const objective = parsed.objective ?? 'premium';
997
- const limit = parsed.limit ?? 10;
998
- const { rows, truncated } = await fetchCatalogRankRows(supabase, parsed);
999
- const data = rankCatalogRows(rows, objective, limit);
1000
- const caveats = [...catalogRankingCaveats];
1001
- if (objective === 'value') {
1002
- caveats.push('Value objective ranks by Purveyor Score per dollar; rows without score or price are excluded.');
1003
- }
1004
- if (objective === 'rare_origin' && parsed.country) {
1005
- caveats.push('rare_origin combined with a country filter measures rarity only within that country filter.');
1006
- }
1007
- return {
1008
- data,
1009
- meta: {
1010
- resource: 'catalog-ranking',
1011
- objective,
1012
- scoring_source: 'coffee_catalog.purveyor_score',
1013
- sample_size: sampleSize,
1014
- sample_limited: true,
1015
- sample_order: 'id_asc',
1016
- truncated,
1017
- candidates_considered: rows.length,
1018
- returned: data.length,
1019
- filters: {
1020
- supplier: parsed.supplier,
1021
- country: parsed.country,
1022
- process: parsed.process,
1023
- stocked_only: parsed.stockedOnly ?? true,
1024
- scope: (parsed.stockedOnly ?? true) ? 'stocked_only' : 'all_visible',
1025
- priceMax: parsed.priceMax,
1026
- minScore: parsed.minScore,
1027
- nonWholesaleOnly: parsed.nonWholesaleOnly ?? false,
1028
- },
1029
- caveats,
1030
- },
1031
- };
689
+ const client = await createParchmentClient('viewer');
690
+ const envelope = unwrapParchment(await client.catalog.rank({
691
+ objective: parsed.objective,
692
+ stockedOnly: parsed.stockedOnly === false ? 'false' : 'true',
693
+ supplier: parsed.supplier,
694
+ country: parsed.country,
695
+ process: parsed.process,
696
+ priceMax: parsed.priceMax,
697
+ minScore: parsed.minScore,
698
+ nonWholesaleOnly: parsed.nonWholesaleOnly ? 'true' : 'false',
699
+ limit: parsed.limit,
700
+ sampleSize: parsed.sampleSize,
701
+ }), 'Catalog ranking');
702
+ return envelope;
1032
703
  }
1033
704
  /**
1034
705
  * Rank premium catalog candidates by Purveyor Score, with pricing and sourcing signals.
1035
706
  */
1036
- export async function catalogRankPremium(supabase, input = {}) {
707
+ export async function catalogRankPremium(input = {}) {
1037
708
  const parsed = catalogRankPremiumSchema.parse(input);
1038
- const sampleSize = parsed.sampleSize ?? CATALOG_PREMIUM_DEFAULT_SAMPLE_SIZE;
1039
- const { sampledRows, truncated } = await fetchCatalogPremiumSampleRows(supabase, parsed, sampleSize);
1040
- const ranking = computeCatalogPremiumRanking(sampledRows, {
1041
- limit: parsed.limit ?? 10,
1042
- includeUnscored: parsed.includeUnscored ?? false,
709
+ const client = await createParchmentClient('viewer');
710
+ const envelope = unwrapParchment(await client.catalog.rankPremium({
711
+ origin: parsed.origin,
712
+ process: parsed.process,
713
+ supplier: parsed.supplier,
714
+ stocked: parsed.stocked === undefined ? undefined : parsed.stocked ? 'true' : 'false',
715
+ priceMax: parsed.priceMax,
1043
716
  minScore: parsed.minScore,
1044
- });
1045
- return {
1046
- data: ranking,
1047
- meta: {
1048
- resource: 'catalog-premium-ranking',
1049
- scoring_source: 'coffee_catalog.purveyor_score',
1050
- sample_size: sampleSize,
1051
- sample_limited: true,
1052
- sample_order: 'purveyor_score_desc_nulls_last',
1053
- truncated,
1054
- returned: ranking.length,
1055
- filters: {
1056
- origin: parsed.origin,
1057
- process: parsed.process,
1058
- supplier: parsed.supplier,
1059
- stocked: parsed.stocked,
1060
- priceMax: parsed.priceMax,
1061
- minScore: parsed.minScore,
1062
- includeUnscored: parsed.includeUnscored ?? false,
1063
- },
1064
- caveats: catalogPremiumRankingCaveats,
1065
- },
1066
- };
717
+ includeUnscored: parsed.includeUnscored ? 'true' : 'false',
718
+ limit: parsed.limit,
719
+ sampleSize: parsed.sampleSize,
720
+ }), 'Catalog premium ranking');
721
+ return envelope;
1067
722
  }
1068
- async function getSupplierAggregates(supabase, input, resource) {
1069
- const parsed = supplierAggregateSchema.parse(input);
1070
- const sampleSize = parsed.sampleSize ?? SUPPLIER_AGGREGATE_DEFAULT_SAMPLE_SIZE;
1071
- const rows = await fetchSupplierAggregateRows(supabase, {
723
+ function toSupplierQuery(parsed) {
724
+ return {
1072
725
  supplier: parsed.supplier,
1073
726
  country: parsed.country,
1074
- stocked: parsed.stocked,
1075
- nonWholesaleOnly: parsed.nonWholesaleOnly,
1076
- sampleSize,
1077
- });
1078
- const aggregates = computeSupplierAggregates(rows, {
1079
- topCoffees: parsed.topCoffees ?? 5,
1080
- minCoffees: parsed.minCoffees ?? 1,
1081
- }).slice(0, parsed.limit ?? 25);
1082
- return {
1083
- data: aggregates,
1084
- meta: {
1085
- resource,
1086
- sample_size: sampleSize,
1087
- sample_limited: false,
1088
- sample_order: 'source_asc_nulls_last',
1089
- truncated: false,
1090
- rows_examined: rows.length,
1091
- returned: aggregates.length,
1092
- filters: {
1093
- supplier: parsed.supplier,
1094
- country: parsed.country,
1095
- stocked: parsed.stocked,
1096
- minCoffees: parsed.minCoffees ?? 1,
1097
- nonWholesaleOnly: parsed.nonWholesaleOnly ?? false,
1098
- },
1099
- caveats: supplierAggregateCaveats,
1100
- },
727
+ stocked: parsed.stocked === undefined
728
+ ? undefined
729
+ : parsed.stocked
730
+ ? 'true'
731
+ : 'false',
732
+ nonWholesaleOnly: parsed.nonWholesaleOnly ? 'true' : 'false',
733
+ minCoffees: parsed.minCoffees,
734
+ topCoffees: parsed.topCoffees,
735
+ limit: parsed.limit,
736
+ sampleSize: parsed.sampleSize,
1101
737
  };
1102
738
  }
1103
739
  /** List supplier aggregates from catalog rows. */
1104
- export async function supplierList(supabase, input = {}) {
1105
- return getSupplierAggregates(supabase, input, 'supplier-list');
740
+ export async function supplierList(input = {}) {
741
+ const parsed = supplierAggregateSchema.parse(input);
742
+ const client = await createParchmentClient('viewer');
743
+ return unwrapParchment(await client.catalog.suppliers(toSupplierQuery(parsed)), 'Catalog suppliers');
1106
744
  }
1107
745
  /** Return aggregate detail for a supplier query. */
1108
- export async function supplierDetail(supabase, input) {
746
+ export async function supplierDetail(input) {
1109
747
  const parsed = supplierAggregateSchema.parse(input);
1110
748
  if (!parsed.supplier?.trim()) {
1111
749
  throw new PrvrsError('INVALID_ARGUMENT', 'supplierDetail requires a non-empty supplier name.');
1112
750
  }
1113
- return getSupplierAggregates(supabase, { ...parsed, limit: parsed.limit ?? 10 }, 'supplier-detail');
751
+ const client = await createParchmentClient('viewer');
752
+ return unwrapParchment(await client.catalog.supplierDetail({
753
+ ...toSupplierQuery({ ...parsed, limit: parsed.limit ?? 10 }),
754
+ supplier: parsed.supplier,
755
+ }), 'Catalog supplier detail');
1114
756
  }
1115
757
  /** Rank suppliers by average Purveyor Score, then currently stocked coverage. */
1116
- export async function supplierRank(supabase, input = {}) {
1117
- return getSupplierAggregates(supabase, input, 'supplier-rank');
758
+ export async function supplierRank(input = {}) {
759
+ const parsed = supplierAggregateSchema.parse(input);
760
+ const client = await createParchmentClient('viewer');
761
+ return unwrapParchment(await client.catalog.supplierRank(toSupplierQuery(parsed)), 'Catalog supplier ranking');
1118
762
  }
1119
763
  /**
1120
764
  * Fetch canonical catalog similarity groups from the beta /v1/catalog/{id}/similar API.
1121
765
  */
1122
- export async function getCatalogSimilarity(supabase, input) {
766
+ export async function getCatalogSimilarity(input) {
1123
767
  const parsed = getCatalogSimilaritySchema.parse(input);
1124
- return fetchCatalogSimilarityApi(supabase, parsed);
768
+ return fetchCatalogSimilarityApi(parsed);
1125
769
  }
1126
770
  /**
1127
771
  * Find beans similar to a target coffee using pgvector embedding similarity.
1128
772
  * Calls the `find_similar_beans_aggregated` RPC and returns ranked matches.
1129
773
  */
1130
- export async function findSimilarBeans(supabase, input) {
774
+ export async function findSimilarBeans(input) {
1131
775
  const parsed = findSimilarBeansSchema.parse(input);
1132
- const { data, error } = await supabase.rpc('find_similar_beans_aggregated', {
1133
- target_coffee_id: parsed.coffee_id,
1134
- match_threshold: parsed.threshold ?? 0.7,
1135
- match_count: parsed.limit ?? 10,
776
+ const response = await getCatalogSimilarity({
777
+ coffee_id: parsed.coffee_id,
778
+ threshold: parsed.threshold,
779
+ limit: parsed.limit,
1136
780
  });
1137
- if (error)
1138
- throw new Error(`RPC error: ${error.message}`);
1139
- return (data ?? []);
781
+ const matches = response.data.matches ?? [
782
+ ...response.data.groups.canonical_candidates,
783
+ ...response.data.groups.similar_recommendations,
784
+ ];
785
+ return matches.map((match) => ({
786
+ coffee_id: match.coffee.id,
787
+ coffee_name: match.coffee.name,
788
+ source: match.coffee.source ?? '',
789
+ origin: match.coffee.origin,
790
+ processing: match.coffee.processing,
791
+ cost_lb: match.compatibility.cost_lb,
792
+ price_per_lb: match.pricing.price_per_lb,
793
+ stocked: match.coffee.stocked ?? false,
794
+ avg_similarity: match.score.average,
795
+ chunk_matches: match.score.chunk_matches,
796
+ }));
1140
797
  }
1141
798
  //# sourceMappingURL=catalog.js.map