Flatten bottlings into concrete Bottles with automatic BottleGroups
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 103
- Forks
- 18
- Avg merge
- 1h 19m
- Merged PRs (30d)
- 555
Description
Summary
Make every concrete marketed release a Bottle, and group related releases through an automatically managed BottleGroup.
This supersedes #481. That proposal made Bottle+Bottling a first-class paired target, but it preserves the underlying ambiguity: users and developers still have to decide whether a product is a Bottle or a Bottling, and an existing Bottle may need to be split when another release is discovered.
Problem
Today the same physical product can be represented as either:
Bottle containing edition/year/ABV
or
Bottle
└── Bottling containing edition/year/ABV
This creates recurring failure modes:
- “Oops, I created a Bottle when this should have been a Bottling.”
- “This Bottle was valid until another release appeared; now it must be split.”
- Every downstream workflow carries and validates a potentially inconsistent
(bottleId, releaseId)pair. - Add Bottle and Add Bottling compete as separate creation paths.
- Search, aliases, prices, reviews, tastings, collections, and flights can lose exact identity.
The model is asking creators to make a catalog-structure decision that often cannot be known until later.
Target model
BottleGroup (same marketed expression)
├── Bottle (one concrete batch/year/edition)
└── Bottle (another concrete batch/year/edition)
Bottle
A Bottle is the only concrete catalog entity. It owns all exact release facts:
- edition or batch
- release and vintage year
- release-specific age
- ABV
- single-cask and cask-strength flags
- cask traits
- exact images, aliases, prices, reviews, and statistics
There is one Add Bottle workflow. “Add another release” creates another Bottle in the same group; it does not create a different entity type.
BottleGroup
A BottleGroup is automatic grouping infrastructure for releases of the same marketed expression. It owns stable group display identity, aliases, representative Bottle, and aggregate statistics. It never impersonates an exact Bottle or owns exact release fields.
- Every Bottle belongs to exactly one group.
- Independent Bottle creation atomically creates a singleton group.
- “Add another release” reuses the source Bottle's group.
- Existing parent+Bottling relationships migrate into one group.
- Independently created groups are not silently fuzzy-merged.
- Name similarity and shared Series membership may produce suggestions, not automatic semantic merges.
BottleGroup means same expression across releases. BottleSeries remains a broader merchandising relationship across potentially distinct expressions.
Catalog target
Downstream activity should reference one internal targetId, not (bottleId, releaseId):
- exact release known → exact Bottle target
- expression known but exact release unknown → BottleGroup target
This preserves casual “I drank this expression but do not know the batch” behavior without creating a fake generic Bottle. Database constraints must enforce one group target per group, one exact target per Bottle, and consistent group membership.
Grouping behavior
Automatic group creation is reliable; automatically inferring that two independent Bottles belong together is not.
Trusted automatic grouping contexts:
- creating “another release” from an existing Bottle
- migrating an existing Bottle and its Bottlings
- an exact curated group/alias relationship
- a moderator-approved group merge
Group merge and split operations must preserve exact Bottle ids. Generic group activity remains generic unless a moderator explicitly reassigns it.
Migration rules
The migration must be additive, resumable, and audited:
- Add BottleGroup, Bottle membership, CatalogTarget, release-promotion mappings, and nullable target references.
- Create one group from every legacy parent Bottle.
- A parent with no releases remains the concrete Bottle and preserves its id.
- Promote every
bottle_releaseinto a new Bottle in its parent's group. - Map non-null legacy
releaseIdreferences to the promoted Bottle target. - Map parent-only references under a parent with releases to the group target.
- Map parent-only references under a parent without releases to the retained Bottle target.
- Preserve old nested Bottling URLs and API references through permanent mappings and redirects.
- Dual-read and verify parity before removing any legacy columns or routes.
- Remove
bottle_releaseonly after mappings are complete and legacy write traffic reaches zero.
Ambiguous parent release fields, incompatible ages, name/alias collisions, or invalid references must block destructive cleanup rather than be guessed away.
Implementation phases
- Inventory every
releaseId/bottle_releasereader and writer; add a production dry-run audit. - Add BottleGroup, CatalogTarget, mappings, Bottle membership, and nullable consumer targets through generated Drizzle migrations.
- Implement atomic singleton creation, “another release,” target loading, group merge/split, and exact/group aggregation.
- Cut all new writes—web, API, classifier, proposals, importers, workers, and CLI—over to concrete Bottles.
- Promote legacy releases and backfill consumers in resumable batches.
- Dual-read and prove target/aggregate/search parity.
- Replace Add Bottle/Add Bottling with one Bottle form and add related-release/group UI.
- Redirect legacy routes, enforce non-null constraints, and disable legacy writes.
- Remove BottleRelease storage and compatibility code in a separate cleanup release.
- Rewrite the whisky identity and schema documentation around Bottle, BottleGroup, BottleSeries, CatalogTarget, and Observation.
Acceptance criteria
- Every concrete catalog product has one
bottleId; no new BottleRelease records are created. - Add Bottle accepts all exact release fields and never asks Bottle-versus-Bottling.
- Adding another release creates a Bottle in the existing automatic group.
- Independent creation safely creates a singleton group without fuzzy merging.
- Search, prices, reviews, collections, flights, and exact tastings reference the concrete Bottle directly.
- Unknown-release activity references the BottleGroup without selecting a representative Bottle as its target.
- Group aggregation includes member Bottle activity plus generic group activity exactly once.
- Moderator group merge/split preserves exact Bottle ids and has an audit trail.
- Every legacy release has a promoted-Bottle mapping and old URLs remain reachable.
- Cleanup cannot run while mappings, parity checks, or legacy traffic gates fail.
Non-goals
- Treating BottleSeries as the expression group.
- Automatically grouping from fuzzy names, prefix matching, or Series membership alone.
- Modeling an individual physical bottle owned by a user.
- Removing legacy storage in the same deployment that introduces the new model.
Related
- Supersedes #481
- #210
- #246
docs/architecture/whisky-identity-model.mddocs/architecture/bottle-normalization-contract.md
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with docs/architecture/whisky-identity-model.md and docs/architecture/bottle-normalization-contract.md, then inventory every releaseId and bottle_release reader and writer. Review the generated Drizzle migration approach before changing consumers across web, API, workers, importers, and CLI. Done requires audited resumable migration, parity verification, preserved mappings and routes, and no legacy writes.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design, databases, full-stack
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 20/100