Skip to content

feat: multi-product registry — ProductRegistry schema, parser, and /api/products routes#11

Merged
lhpaul merged 9 commits into
developfrom
feature/session-7-multi-product
May 18, 2026
Merged

feat: multi-product registry — ProductRegistry schema, parser, and /api/products routes#11
lhpaul merged 9 commits into
developfrom
feature/session-7-multi-product

Conversation

@lhpaul
Copy link
Copy Markdown
Owner

@lhpaul lhpaul commented May 17, 2026

Summary

  • @helm/shared: ProductRegistry Zod schema and loadProductRegistry() parser. Reads .helm/products.yaml (list of knowledge repo paths, relative to sibling layout or absolute). parseProductRegistryYaml() validates without I/O.
  • @helm/api: getProductRegistry() singleton — loads all products from the registry, falls back to [getProductConfig()] when products.yaml is absent (backward compat). Single-flight + cache.
  • New endpoints:
    • GET /api/products — all registered Products
    • GET /api/products/:slug — one Product (404 if unknown)
    • GET /api/products/:slug/items — items filtered by productSlug (404 if unknown product)
  • Backward compat: existing /api/product and /api/items unchanged — all 15 existing tests pass without modification.

Test plan

  • pnpm --filter @helm/shared test — 24 tests pass (12 new registry tests)
  • pnpm --filter @helm/api test — 58 tests pass (11 new products route tests, 15 existing items tests unchanged)
  • pnpm --filter @helm/api build — clean

Known limitations documented in ADR-004 (helm-knowledge PR)

  • Sibling layout assumption: repos must share a parent directory
  • externalId collision across products is possible when two products have items with the same ID (filesystem uses flat data/items/{externalId}.json). Deferred to v1 with data/items/{productSlug}/{externalId}.json namespacing.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Multi-product registry plus API endpoints to list products, fetch a product, and list a product's items.
  • Bug Fixes

    • Slug validation with proper 400/404/500 responses and clearer error handling; fallback to legacy single-product mode when registry is absent.
  • Exports

    • Product-registry schemas and registry parsing/loading utilities added to shared config.
  • Tests

    • New test suites covering product endpoints and registry parsing/loading.

Review Change Stack

lhpaul and others added 2 commits May 17, 2026 13:15
ProductRegistrySchema validates .helm/products.yaml — a list of knowledge
repo paths (relative to a base dir, or absolute). loadProductRegistry()
resolves each path, loads its .helm/product.yaml, and returns Product[].
parseProductRegistryYaml() validates raw YAML without I/O.

Backward-compat: no changes to existing exports. The registry is an
opt-in layer on top of the existing single-product flow.

12 tests covering: single/multi entry, dot-path resolution, missing
registry file (ENOENT), missing product.yaml (ENOENT), malformed
product.yaml, invalid registry YAML, empty products array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…outes

getProductRegistry() reads $HELM_KNOWLEDGE_REPO_PATH/.helm/products.yaml
and resolves each listed path (relative to dirname(HELM_KNOWLEDGE_REPO_PATH))
to a Product. ENOENT on products.yaml → falls back to [getProductConfig()]
for single-product backward compat. Single-flight + cache.

New endpoints:
  GET /api/products             → all registered Products
  GET /api/products/:slug       → one Product by slug (404 if unknown)
  GET /api/products/:slug/items → items filtered by productSlug (404 if unknown product)

Existing /api/product and /api/items are unchanged — 15 existing tests
pass without modification. _resetForTests() extended with registry state.
11 new route tests covering happy paths, cross-product isolation, empty
results, 404, and 500 paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b4f5558c-c5de-49ba-a9b9-164f990dbc87

📥 Commits

Reviewing files that changed from the base of the PR and between 8cc9fc6 and a0310bc.

📒 Files selected for processing (2)
  • apps/api/src/services/index.ts
  • apps/api/src/services/product-registry.test.ts

📝 Walkthrough

Walkthrough

Adds product-registry schemas and parser/loader, exposes a cached getProductRegistry() in services with single-flight and legacy fallback, implements products API routes with tests, re-exports registry utilities, and mounts the new router in the app.

Changes

Multi-product registry and API routes

Layer / File(s) Summary
Product registry schema validation
packages/shared/src/config/product-registry-schema.ts
Zod schemas and inferred types validate products.yaml entries (non-empty path) and require at least one product.
Product registry parsing and loading
packages/shared/src/config/product-registry-parser.ts
parseProductRegistryYaml validates YAML and loadProductRegistry reads the registry file, resolves entry paths relative to a base directory, loads each product config, and returns Product[].
Registry parser tests
packages/shared/src/config/product-registry-parser.test.ts
Fixtures and tests cover single/multi-entry parsing, . resolution, missing files, malformed YAML, and error propagation (ProductConfigError).
Shared config re-exports
packages/shared/src/config/index.ts
Re-exports registry schemas, types, and parser/loader utilities for consumers.
Service-layer product registry singleton
apps/api/src/services/index.ts
Adds getProductRegistry() with single-flight Promise caching, computes registry path from HELM_KNOWLEDGE_REPO_PATH, performs a targeted ENOENT-only fallback to the single-product loader, returns defensively cloned Product[] and clears the in-flight promise; extends _resetForTests() to clear cache.
Service registry tests
apps/api/src/services/product-registry.test.ts
Tests fallback behavior, error propagation, and path-resolution semantics for getProductRegistry() using temporary knowledge repo fixtures.
Product API endpoints and route tests
apps/api/src/routes/products.ts, apps/api/src/routes/products.test.ts
productsRouter exposes GET /products, GET /products/:slug, and GET /products/:slug/items with slug validation, 404 handling, filtering of items by productSlug, error handling (500) and route tests that mock getProductRegistry() and getItemStore().
App router registration
apps/api/src/app.ts
Imports and mounts productsRouter at /api, replacing the previous products router registration.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Route as productsRouter
  participant Service as getProductRegistry
  participant Cache as Cached Registry
  participant Loader as loadProductRegistry
  participant Disk as Filesystem
  Client->>Route: GET /products or /products/:slug or /products/:slug/items
  Route->>Service: getProductRegistry()
  Service->>Cache: check cache
  alt registry cached
    Cache-->>Service: return cached Promise
  else cache miss
    Service->>Loader: loadProductRegistry(registryPath, baseDir)
    Loader->>Disk: read products.yaml
    Loader->>Disk: resolve entry paths and read .helm/product.yaml
    Loader-->>Service: Product[]
  end
  Service-->>Route: Product[]
  Route->>Route: validate slug / lookup / filter items
  alt match found
    Route-->>Client: HTTP 200 + JSON
  else not found
    Route-->>Client: HTTP 404
  end
  alt service error
    Route-->>Client: HTTP 500 + error message
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • lhpaul/helm#1: Introduced single-product parsing utilities (parseProductConfigFromFile, ProductConfigError) reused by the registry loader.
  • lhpaul/helm#3: Prior router wiring that mounted a single-product productRouter; this PR replaces that wiring with productsRouter.
  • lhpaul/helm#6: Related changes to app router wiring and _resetForTests() logic for product-related services.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main objective of the PR: introducing a multi-product registry feature with the schema, parser, and new API routes.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/session-7-multi-product

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/routes/products.ts`:
- Around line 20-21: The route handler using
productsRouter.get('/products/:slug') reads c.req.param('slug') without
validation; create a strict Zod params schema (e.g. const paramsSchema =
z.object({ slug: z.string().min(1).regex(/^[a-z0-9-]+$/) }).strict()) and
validate the incoming params before using the slug (use paramsSchema.parse or
safeParse on { slug: c.req.param('slug') }); if validation fails return a 400
response and do not proceed to lookup/filtering. Apply the same pattern to the
other route reading params at the other occurrence (lines 37-38) so all external
route params are validated with Zod.strict().

In `@apps/api/src/services/index.ts`:
- Around line 121-122: getProductRegistry currently returns the cached
_productRegistry or the pending _productRegistryPromise directly, exposing
mutable internal state; change getProductRegistry to return defensive copies
instead: when returning _productRegistry return a shallow-copied array (e.g.
[..._productRegistry]) and ensure each product object is cloned (e.g.
_productRegistry.map(p => ({...p})); when resolving or returning
_productRegistryPromise ensure the promised value is similarly cloned before
resolving/returning so callers never receive references to the internal
_productRegistry; apply the same defensive-copy approach to the related
getter/return logic around the code at lines 143-149 to avoid leaking mutable
cache objects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a7a2d300-81bd-4546-9e79-531be204d3b8

📥 Commits

Reviewing files that changed from the base of the PR and between bea19f2 and 833b643.

📒 Files selected for processing (8)
  • apps/api/src/app.ts
  • apps/api/src/routes/products.test.ts
  • apps/api/src/routes/products.ts
  • apps/api/src/services/index.ts
  • packages/shared/src/config/index.ts
  • packages/shared/src/config/product-registry-parser.test.ts
  • packages/shared/src/config/product-registry-parser.ts
  • packages/shared/src/config/product-registry-schema.ts

Comment thread apps/api/src/routes/products.ts Outdated
Comment thread apps/api/src/services/index.ts Outdated
…pies

1. products.ts: validate slug route param against /^[a-z0-9-]+$/ via
   SlugParamsSchema before lookup — returns 400 on invalid format.
   Adds 2 new tests (400 for :slug and :slug/items).
2. services/index.ts: getProductRegistry() now returns spread copies
   {...p} instead of direct cache references to prevent callers from
   mutating singleton state.

Addresses CodeRabbit review comments on PR #11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/api/src/services/index.ts (1)

121-122: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Shallow cloning still allows nested cache mutation.

{ ...p } only clones the top level. Nested objects in Product remain shared, so callers can still mutate singleton-backed state through nested fields.

As per coding guidelines, "**/*.{ts,tsx}: Never return mutable internal state directly; spread-copy arrays/objects before returning".

Also applies to: 143-145, 148-149

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/services/index.ts` around lines 121 - 122, The current returns
clone only the top-level with ({ ...p }) allowing nested objects in the Product
entries to be mutated; replace the shallow spread with a true deep-clone when
returning cached products (e.g., use structuredClone(product) or a project
deepClone utility) so callers receive fully independent copies; update both the
synchronous branch that returns _productRegistry (map over _productRegistry and
return structuredClone(p) for each p) and the promise branch that returns
_productRegistryPromise (chain .then(results => results.map(r =>
structuredClone(r)))) and apply the same change to the other similar return
sites referenced (the occurrences around lines 143-145 and 148-149).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/services/index.ts`:
- Around line 132-139: The ENOENT fallback is too broad; when
loadProductRegistry(registryFilePath, baseDir) throws ENOENT we should only
treat it as "registry file missing" if the missing path is the registryFilePath
(products.yaml). Update the catch to detect err.code === 'ENOENT' && (err.path
=== registryFilePath || err.message?.includes(registryFilePath)) and only then
set products = [await getProductConfig()]; otherwise rethrow the error so
missing referenced product files or other ENOENTs aren’t silently converted to
single-product mode.

---

Duplicate comments:
In `@apps/api/src/services/index.ts`:
- Around line 121-122: The current returns clone only the top-level with ({ ...p
}) allowing nested objects in the Product entries to be mutated; replace the
shallow spread with a true deep-clone when returning cached products (e.g., use
structuredClone(product) or a project deepClone utility) so callers receive
fully independent copies; update both the synchronous branch that returns
_productRegistry (map over _productRegistry and return structuredClone(p) for
each p) and the promise branch that returns _productRegistryPromise (chain
.then(results => results.map(r => structuredClone(r)))) and apply the same
change to the other similar return sites referenced (the occurrences around
lines 143-145 and 148-149).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dfb0e006-acfc-4f78-94b3-032ed4e852e9

📥 Commits

Reviewing files that changed from the base of the PR and between 833b643 and 85d9b33.

📒 Files selected for processing (3)
  • apps/api/src/routes/products.test.ts
  • apps/api/src/routes/products.ts
  • apps/api/src/services/index.ts

Comment thread apps/api/src/services/index.ts
@lhpaul
Copy link
Copy Markdown
Owner Author

lhpaul commented May 17, 2026

@CodeRabbit are we clean?

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me check the current state of all review comments on this PR.


🏁 Script executed:

#!/bin/bash
# Get all review comments on PR `#11`
gh pr view 11 --repo lhpaul/helm --json reviews,comments

# Get all review threads (including resolved status)
gh api repos/lhpaul/helm/pulls/11/comments --jq '.[] | {id: .id, body: .body, resolved: .resolved, user: .user.login, path: .path, line: .line}'

Length of output: 37070


@lhpaul: Not quite — two items are still open:

  1. apps/api/src/services/index.ts — ENOENT fallback too broad (line 139, unresolved)
    The catch block currently treats any ENOENT as "registry file missing," which means a missing referenced product file would silently downgrade to single-product mode instead of surfacing the error. The suggestion is to check err.path === registryFilePath before falling back.

  2. apps/api/src/services/index.ts — Shallow clone still leaks nested state (lines 121-122, flagged in the latest review pass)
    The { ...p } spread only clones the top level of each Product object. If Product has any nested objects (arrays, sub-objects), callers can still mutate the singleton through those references. The suggestion is to replace the spread with structuredClone(p):

    // instead of _productRegistry.map(p => ({ ...p }))
    _productRegistry.map(p => structuredClone(p))

What's resolved ✅: slug validation with Zod (commit 85d9b33) and the initial defensive-copy concern (same commit).

So there are two actionable items left before merging. Want me to open issues or help plan those fixes?

lhpaul and others added 2 commits May 17, 2026 17:30
…ProductRegistry

Previously any ENOENT from loadProductRegistry triggered the single-product
fallback, silently hiding broken registry entries (e.g., a referenced
product.yaml that is missing). Now the fallback only fires when the error
path matches registryFilePath — any other ENOENT propagates so the operator
can diagnose the broken entry.

Addresses CodeRabbit review comment on PR #11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… structuredClone

{ ...p } only copies top-level fields; nested objects like workflow,
issue_tracker, and code_repos remained shared references to the singleton
cache. Replaced with structuredClone(p) at all four return sites so
callers cannot mutate cached state through nested properties.

Addresses CodeRabbit review comment on PR #11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/services/index.ts`:
- Line 122: The cached promise _productRegistryPromise is returned directly
causing concurrent callers to share the same mutable array; change the
early-return to wrap the cached promise so callers receive a defensive copy,
e.g. return _productRegistryPromise.then(products => [...products]) instead of
returning _productRegistryPromise directly, and do the same wherever
_productRegistryPromise is returned so every caller gets a shallow-copied array
(reference symbols: _productRegistryPromise and the function that returns the
registry).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f0c42a82-cbca-4df6-a075-7b7ed307e484

📥 Commits

Reviewing files that changed from the base of the PR and between 85d9b33 and 46e69d8.

📒 Files selected for processing (1)
  • apps/api/src/services/index.ts

Comment thread apps/api/src/services/index.ts Outdated
…ry promise

Concurrent callers that arrived during initialization all awaited the same
_productRegistryPromise, receiving the identical resolved array reference.
Wrapping the in-flight promise with .then(products => products.map(structuredClone))
ensures every caller gets its own independent copy, consistent with the
cache-hit and post-init paths.

Addresses CodeRabbit review comment on PR #11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/services/index.ts`:
- Around line 127-131: The code reads HELM_KNOWLEDGE_REPO_PATH into
knowledgePath and immediately uses it to build registryFilePath and baseDir
without validating the value; before constructing paths (before
join(dirname...)), validate knowledgePath against your whitelist regex (e.g.,
EXTERNAL_ID_REGEX or a path-safe regex) and reject or throw an Error if it
doesn't match; ensure you still trim first, perform the regex test on the
trimmed value, and only then compute registryFilePath and baseDir using
join(dirname(knowledgePath)).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 64d287f9-ff51-4a7d-a101-096c18341d7a

📥 Commits

Reviewing files that changed from the base of the PR and between 665c0f2 and f55fd78.

📒 Files selected for processing (1)
  • apps/api/src/services/index.ts

Comment thread apps/api/src/services/index.ts Outdated
…path whitelist

HELM_KNOWLEDGE_REPO_PATH is external input (env var) used directly to
construct filesystem paths. Added SAFE_FS_PATH_REGEX whitelist check
before path construction to prevent path traversal attacks.

Addresses CodeRabbit review comment on PR #11.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@lhpaul
Copy link
Copy Markdown
Owner Author

lhpaul commented May 18, 2026

@CodeRabbit review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

lhpaul and others added 2 commits May 18, 2026 08:57
…se.path

ProductConfigError exposes .code='ENOENT' but .path lives on .cause
(the original NodeJS.ErrnoException), not directly on the error object.
The previous check ('path' in err) never matched, so the fallback to
single-product mode never fired when products.yaml was absent —
getProductConfig() wasn't called and ENOENT propagated to the route.

Fix: use instanceof ProductConfigError + err.cause?.path === registryFilePath.
Adds 3 regression tests using a real tmpdir:
  - absent products.yaml → falls back to [getProductConfig()]
  - present products.yaml → loads correctly
  - present but referencing missing product.yaml → error propagates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ePath itself

dirname(knowledgePath) was one level too high: path "." resolved to the parent
of the knowledge repo instead of the repo itself, causing ENOENT on
{parent}/.helm/product.yaml. Fix: baseDir = knowledgePath so that
  - path "."     → the knowledge repo itself  ✓
  - path "../X"  → a sibling of the knowledge repo  ✓
Remove now-unused dirname import.

Adds 2 regression tests verifying path semantics:
  - path "." resolves to knowledgePath
  - path "../sibling" resolves to a real sibling tmpdir

Updates existing products.yaml test to use path "." (was using basename workaround).
ENOENT fallback unaffected: registryFilePath = join(knowledgePath, '.helm/products.yaml')
unchanged, so err.cause?.path === registryFilePath still holds.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@lhpaul
Copy link
Copy Markdown
Owner Author

lhpaul commented May 18, 2026

@CodeRabbit resume

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

✅ Actions performed

Reviews resumed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant