Skip to content

feat!: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B) - #44

Merged
dopry merged 9 commits into
nextfrom
claude/pecans-phase-7b-release-service
Jul 13, 2026
Merged

feat!: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B)#44
dopry merged 9 commits into
nextfrom
claude/pecans-phase-7b-release-service

Conversation

@dopry

@dopry dopry commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Summary

Second of the three Phase 7 PRs (backend abstraction ✅ → resolution pipeline → HTTP split). This dissolves the dual composite-Platform/discrete-{os, arch, pkg} split: every route handler resolves releases and assets through one pipeline, and the legacy composite ids exist only at the HTTP edge. HTTP behavior is unchanged — the Phase 1 contract suite passes untouched. The Node API changes (below) are deliberate breaking changes within the 2.0 major.

ReleaseService (src/service.ts)

  • filterReleases / resolveRelease carry the download semantics Versions.filter/resolve previously implemented: channel matching, platform availability (a RELEASES manifest never counts as a downloadable asset), semver ranges with "latest" collapsing to the newest match, NotFoundError when nothing matches.
  • queryReleases keeps the strict structural query (/dl, /dl/:filename, /notes surfaces) exactly as before.
  • resolveAssetForRelease is the single home of the prefer-universal policy and extension-preference ranking, previously duplicated between versions.ts and resolveForVersion.ts. It returns the original asset object (typed honestly as | undefined) and preserves the legacy specificity ordering verbatim.

The package-format model (clarified in review)

An asset's pkg is undefined for the platform's default package (tarball, dmg, setup.exe) and set for alternate formats (deb, rpm). Structural queries take a plain PackageFormat (satisfiesPkg is two-state). The download-resolution filters additionally accept pkg: "default" (PackageFormatFilter) to select only default-package assets — which is what an os+arch composite id always meant under prefix matching (linux_deb_64 never matched linux_64). platformToQuery applies that rule uniformly for every os (an os+arch id has no pkg segment by grammar), not just linux.

Breaking Node API changes (2.0)

  • Pecans.versions removed — route handlers resolve through the (protected) ReleaseService.
  • Versions, VersionFilterOpts, PlatformQuery, resolveReleaseAssetForVersion removed — the deprecation shims this PR would otherwise have introduced are dropped rather than carried to 3.0. Consumers use ReleaseService / resolveAssetForRelease with discrete filters (platformToQuery translates composite ids).
  • Pre-existing deprecations (GitHubBackend, PecansSettings.timeout, PecansReleaseDTO.channel) keep their 3.0 schedule.

The table-driven specs that pinned legacy composite-id resolution were migrated onto the pipeline (via platformToQuery), so the behavioral pins survive the adapter removal.

Module hygiene

All modules import specific files rather than the package barrels; npx madge --circular reports zero cycles across src/ (the pre-existing pecans.ts ↔ versions.ts coupling is gone with versions.ts itself).

Preserved quirk (flagged, not changed)

The update endpoints and /api/versions never honored opts.preferUniversal — they always widened osx queries to universal builds. Only /download/* honors the option. Preserved with explicit preferUniversal: true + comments; happy to wire them to the option later if you'd rather.

Verification

  • npm test: 31 files / 718 passed (service semantics unit-pinned: RELEASES exclusion, latest collapse, universal widening on/off, pkg: "default", extension ranking, migrated composite-id resolution tables)
  • lint, typecheck, format, build green; packed tarball smoke-tested from CJS and ESM (new exports present, removed exports confirmed absent)

🤖 Generated with Claude Code

https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

…pkg (Phase 7B)

Second of the three Phase 7 PRs. All route handlers now resolve
releases and assets through one pipeline (src/service.ts) speaking the
discrete {os, arch, pkg, extensions} model:

- ReleaseService.filterReleases/resolveRelease carry the download
  semantics (channel match, platform availability excluding the
  RELEASES manifest, semver ranges with 'latest' collapse) that
  Versions.filter/resolve previously implemented; queryReleases keeps
  the strict structural query used by /dl and /notes.
- resolveAssetForRelease is the single home of the prefer-universal
  policy and the extension-preference ranking, previously duplicated
  between versions.ts and resolveForVersion.ts.
- Legacy composite platform ids translate to the discrete model at the
  HTTP edge via platformToQuery, which encodes the old prefix-matching
  semantics exactly (notably: 'linux_64' matches only unpackaged
  assets, so it maps to pkg: null - PecansAssetQuery.pkg now accepts
  null meaning 'no package format').
- Versions and resolveReleaseAssetForVersion become @deprecated thin
  adapters over the service (removed in 3.0); all package exports keep
  working and the pecans.ts <-> versions.ts coupling is gone.

HTTP behavior is unchanged - the Phase 1 contract suite passes
untouched; 19 new unit tests pin the service semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a unified ReleaseService-based release/asset resolution pipeline built on discrete {os, arch, pkg} queries, while keeping legacy composite Platform IDs only at the HTTP boundary to preserve existing endpoint behavior.

Changes:

  • Add src/service.ts (ReleaseService, filterReleases/resolveRelease, resolveAssetForRelease, assetMatchesPlatform) and export it publicly.
  • Introduce parsePlatform / platformToQuery and extend pkg query semantics to support null meaning “only assets without a package format”.
  • Convert Versions and resolveReleaseAssetForVersion into thin deprecated adapters over the new service, and update unit tests accordingly.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
test/unit/service.spec.ts Adds unit coverage for new discrete platform translation and service resolution semantics.
test/unit/pecans.spec.ts Updates tests to spy on the new ReleaseService pipeline instead of Versions.
src/versions.ts Replaces in-class filtering logic with a deprecated adapter over ReleaseService.
src/utils/resolveForVersion.ts Replaces duplicate asset resolution logic with a deprecated adapter to resolveAssetForRelease.
src/utils/platforms.ts Adds composite→discrete parsing and legacy prefix-matching translation (platformToQuery).
src/service.ts Introduces the unified resolution pipeline and exported helper functions.
src/pecans.ts Switches route handlers to use ReleaseService and translates composite platform IDs at the HTTP edge.
src/models/PecansAssetQuery.ts Extends pkg query type to allow null to mean “only assets without pkg”.
src/models/PecansAsset.ts Updates package matching to honor pkg: null semantics.
src/index.ts Exports the new service module from the public API surface.

Comment thread src/service.ts
Comment thread src/service.ts
…kg filters without os

- resolveAsset now applies the service default via ?? so an explicit
  preferUniversal: undefined no longer disables universal widening,
  matching filterReleases (Copilot review).
- filterReleases enforces platform availability whenever any of
  os/arch/pkg is constrained, instead of silently ignoring arch/pkg
  passed without os (Copilot review). Legacy callers always set os via
  platformToQuery, so route behavior is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Comment thread src/service.ts
An arch-only filter (no os) must not be satisfied by osx universal
builds; the widening condition now checks filter.os instead of the
asset os (which the earlier os check already guarantees when set).
Legacy callers always set os via platformToQuery, so route behavior is
unchanged (Copilot review).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

…tead of null

An asset without an alternate package format (deb/rpm) IS the
platform's default package - tarball, dmg, setup.exe - so the query
side now names that concept: pkg accepts 'default' to select only
default-package assets, undefined stays unconstrained, and the
null/undefined tri-state is gone. platformToQuery('linux_64') maps to
pkg: 'default', matching the legacy semantics where an os+arch id
always meant the default package.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment thread src/service.ts Outdated
Comment thread src/models/PecansAssetQuery.ts Outdated
- The 'default' package-format query only exists on the download
  resolution path (ReleaseService filters / assetMatchesPlatform);
  nothing that calls the structural PecansAssetQuery ever passes it, so
  satisfiesPkg returns to plain 'pkg == undefined || this.pkg == pkg'
  and PackageFormatQuery moves next to platformToQuery in
  utils/platforms.
- All modules now import specific util/model modules instead of the
  barrels, dissolving every import cycle (the barrels re-enter service
  through the deprecated resolveForVersion adapter); madge --circular
  reports none (Copilot review x2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment thread test/unit/service.spec.ts Outdated
Comment thread src/utils/platforms.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

claude added 2 commits July 13, 2026 15:13
The name dated from when it typed PecansAssetQuery.pkg; the structural
query now takes a plain PackageFormat, and the 'default' union belongs
to the download-resolution filter family (ReleaseFilter, AssetFilter,
DiscretePlatformQuery) where it actually applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
Route handlers resolve through ReleaseService; hosts that reached into
pecans.versions can construct new Versions(backend) themselves (the
class remains exported as a deprecated adapter for now). Also make the
platformToQuery default-package rule uniform across operating systems -
an os+arch composite id has no pkg segment by grammar, so it means the
default package for every os, not just linux (behavior-identical today
since only linux has alternate formats; faithful if that ever changes).

BREAKING CHANGE: Pecans no longer exposes a versions property.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Comment thread src/pecans.ts
Comment thread src/utils/resolveForVersion.ts Outdated
The deprecation shims this PR introduced are dropped instead of carried
to 3.0: route handlers and consumers resolve through ReleaseService /
resolveAssetForRelease directly. The table-driven specs that pinned the
legacy composite-id resolution semantics are migrated onto the pipeline
(via platformToQuery) so the behavioral pins survive the adapter
removal; unique Versions coverage moved into service.spec.

Pre-existing deprecations (GitHubBackend, PecansSettings.timeout,
PecansReleaseDTO.channel) keep their 3.0 schedule.

BREAKING CHANGE: Versions, VersionFilterOpts, PlatformQuery, and
resolveReleaseAssetForVersion are no longer exported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
@dopry dopry changed the title feat: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B) feat!: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B) Jul 13, 2026
@dopry
dopry requested a review from Copilot July 13, 2026 15:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated no new comments.

@dopry
dopry merged commit 8a2f06a into next Jul 13, 2026
3 checks passed
@dopry
dopry deleted the claude/pecans-phase-7b-release-service branch July 13, 2026 15:49
github-actions Bot pushed a commit that referenced this pull request Jul 21, 2026
# [2.0.0-next.18](v2.0.0-next.17...v2.0.0-next.18) (2026-07-21)

* feat!: unified ReleaseService resolution pipeline on discrete os/arch/pkg (Phase 7 PR B) ([#44](#44)) ([8a2f06a](8a2f06a))

### Bug Fixes

* **github:** default octokit to native fetch to prevent empty release lists ([#28](#28)) ([0f6eac6](0f6eac6))
* honor route params in downloads, dead code removal, small fixes (Phase 2) ([#31](#31)) ([9fd8f79](9fd8f79))
* semantic-release trusted publishing ([4f1ee88](4f1ee88))
* semantic-release trusted publishing ([#53](#53)) ([3d4d4ce](3d4d4ce))
* working generic refresh webhook; document raw as the backend-private asset slot (Phase 7 PR A) ([#43](#43)) ([23d1d9f](23d1d9f))

### chore

* esm only ([#24](#24)) ([1b8a546](1b8a546))

### Features

* dependency modernization — Express 5, octokit 22, remove UA autodetection (Phase 5) ([#41](#41)) ([8ea329a](8ea329a))
* modernize packaging and dev tooling (Phase 3) ([#32](#32)) ([3345ed7](3345ed7))
* recognize .msix / .msixbundle assets and 'msix' package format ([#46](#46)) ([4f848b9](4f848b9)), closes [#26](#26)
* typed HTTP errors with router-scoped error handling (Phase 6) ([#42](#42)) ([e3b7b54](e3b7b54))

### BREAKING CHANGES

* @dopry/pecans is now ESM-only. require('@dopry/pecans')
is no longer supported; use import (Node >= 22.12).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zXcPSv6FFTPgD59T4KudM

* refactor: import model types with import type in runtime modules

Follows up on Copilot review: PecansRelease/PecansReleases (and other
names used only in type positions) are classes, so tsc accepts plain
imports, but with verbatimModuleSyntax they would stay in the emitted
JS as runtime imports. Convert the type-only usages to import type to
keep the runtime module graph minimal and cycle-free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zXcPSv6FFTPgD59T4KudM
* Pecans no longer exposes a versions property.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* feat!: remove the Versions and resolveReleaseAssetForVersion adapters

The deprecation shims this PR introduced are dropped instead of carried
to 3.0: route handlers and consumers resolve through ReleaseService /
resolveAssetForRelease directly. The table-driven specs that pinned the
legacy composite-id resolution semantics are migrated onto the pipeline
(via platformToQuery) so the behavioral pins survive the adapter
removal; unique Versions coverage moved into service.spec.

Pre-existing deprecations (GitHubBackend, PecansSettings.timeout,
PecansReleaseDTO.channel) keep their 3.0 schedule.
* Versions, VersionFilterOpts, PlatformQuery, and
resolveReleaseAssetForVersion are no longer exported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
* getArchFromUserAgent, getOsFromUserAgent, and
getPlatformFromUserAgent now take the internal UserAgentDetails type
instead of express-useragent's Details, and getArchFromUserAgent
defaults Windows and Linux to '64' (32-bit desktops are effectively
extinct; the function is not used internally).

pecans consumed exactly four booleans from the unmaintained
express-useragent package; src/utils/userAgent.ts derives them from the
User-Agent header directly, with mobile exclusions the old library
handled via separate flags (iOS UAs contain 'like Mac OS X', Android
UAs contain 'Linux'). The middleware attaches the same req.useragent
shape. Unit specs cover the parser; the Phase 1 UA-driven download
contract tests pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: validate update-route params through getStringParam consistently

Review feedback: handleUpdateOSX truthiness-checked req.params directly
but read values through getStringParam, and handleUpdateWin had no
version guard at all; a missing tag would have produced a '>=undefined'
range. Validate once through the helper and reuse the validated values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: exclude Macintosh+Mobile webview UAs from macOS detection

Review feedback (partial): a Mobile token alongside Macintosh indicates
an iPad-class webview masquerading as a Mac; genuine macOS browsers
never send it. Fixture + test added. Note true iPadOS desktop-mode UAs
are byte-identical to Mac Safari and undetectable by any parser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: short-circuit dlfilename when the filename param is absent

Review feedback: an undefined filename passed into queryReleases matches
every release (the predicate treats undefined as no-filter), which would
serve an arbitrary asset instead of a 404. Unreachable via the current
route but guarded for consistency with the update handlers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* feat: remove user-agent platform autodetection
* selecting a platform is now the client's
responsibility. GET / is no longer a download route, and the platform
segment is required on /download, /download/version/:tag, and
/download/channel/:channel (a missing platform returns 400). The
user-agent parser, its middleware, and the getPlatformFromUserAgent /
getArchFromUserAgent / getOsFromUserAgent helpers are removed.

Autodetection only ever served bare browser links - Squirrel update
clients and /dl/* always send explicit platforms - and reliable device
detection is better handled client-side where UA Client Hints are
available. Reverting this commit restores the feature wholesale if
anyone misses it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: remove imports orphaned by the autodetection removal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: validate tag ranges early in validateReqQueryTag

Review feedback: validRange's result was discarded, so invalid tags only
failed deep in release matching with a generic 'Invalid Range Specified'
error. Invalid ranges now throw UnsupportedTagError at the parameter
boundary ('latest' stays allowed), and the error message no longer says
'channel' for tags (copy-paste from UnsupportedChannelError).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
* build output moves from dist/cjs + dist/mjs to a tsup
bundle (dist/index.js CJS, dist/index.mjs ESM). Deep imports into dist
paths no longer resolve; all models (PecansRelease, PecansAsset,
PecansReleases, ...) are now exported from the package root instead.

- replace the dual-tsc + fixup.sh build with tsup (CJS + ESM + d.ts +
  sourcemaps, node22 target); consolidate four tsconfigs into one
  typecheck-only tsconfig.json
- add a proper exports map with types for both module systems; verified
  with publint and arethetypeswrong (all green: node10/node16/bundler)
- declare debug and qs as real dependencies - both are imported directly
  but were only present transitively via express, which broke the ESM
  bundle (inlined CJS require calls)
- guard the run-directly check with typeof require so the ESM build is
  importable; node dist/index.js still starts the server
- ts-node out of runtime dependencies; dev now runs tsx watch; start
  runs the compiled dist; drop nodemon
- ESLint 9 flat config + prettier (replaces the stale mocha-era
  .eslintrc); fix the 19 findings it surfaced (unused imports/vars,
  case-block declarations, error causes, no-useless-assignment)
- add explicit @types/node; add lint/format/typecheck scripts
- package.json: type commonjs, sideEffects false, canonical repo url

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: rank .tar.gz assets by their full extension in resolveForVersion

path.extname reports '.gz' for .tar.gz filenames, so the sort fallback
ranked them at prefs.indexOf(-1) - ahead of every genuine preference -
whenever .tgz and .tar.gz assets coexisted. Use getSupportedExt, which
handles the double extension, matching the Phase 2 fix to
PecansAsset.satisfiesExtensions. Regression test added.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: throw Error from configure(), correct Listening typo

Review feedback: the default switch case in configure() threw a raw
string (no stack trace); the startup log said 'Lisening'. The test that
pinned the typo is updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM

* fix: throw on invalid os in getDownloadExtensionsByOs

Review feedback: the switch had no default, so an invalid OperatingSystem
cast in at runtime silently returned undefined against the declared
SupportedFileExtension[] return type. Fail loudly instead; edge-case test
updated to pin the throw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zUyj4PpSog9RkrYxzFRhM
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.

3 participants