@videntia/review-stack
v0.1.5
Published
Multi-slot Docker dev stack (api/web/db) with per-PR port isolation, hot reload, idempotent up/down/migrate/seed. Runtime-agnostic (bun, pnpm, npm).
Maintainers
Readme
@videntia/review-stack
Multi-slot Docker dev stack — bring up an isolated web + api + db per PR with hot reload, deterministic port allocation, and one-command teardown.
Built on top of docker compose. Runtime-agnostic — works under bun, pnpm, npm.
Why
Reviewing PRs locally means spinning up a fresh DB + API + web instance per branch without colliding ports. review-stack does port math + project naming + lifecycle commands so you don't.
Install
# bun
bun add -d @videntia/review-stack
# pnpm
pnpm add -D @videntia/review-stack
# npm
npm i -D @videntia/review-stackOr one-shot:
bunx @videntia/review-stack up
pnpm dlx @videntia/review-stack up
npx @videntia/review-stack upQuickstart
# 1. scaffold config + the docker-compose stack (base + infra + review + shared)
# existing files are kept; only missing ones are written
review-stack init --prefix myapp-review
# 2. start (uses current PR number from `gh pr view`, else fallback)
review-stack up
# 3. iterate — rebuild only what changed since the last `up` (fast loop)
review-stack rebuild # diff-scoped: touches just api / web / … as needed
review-stack rebuild --all # ignore the diff and refresh every app service
# 4. inspect
review-stack ps
review-stack logs --service api --follow
# 5. teardown
review-stack down # drop this slot's volumes + shared db (default)
review-stack down --keep # stop but preserve volumes + shared db
review-stack reset # nuke + restartPort allocation
web = base + (slot % modulus) * stride
api = web + 1
db = web + 2Defaults: base=10000, stride=10, modulus=5554. Slot = PR number, or fallbacks.main on main, fallbacks.noPr otherwise.
| PR | web | api | db | |-------|-------|-------|-------| | 7 | 10070 | 10071 | 10072 | | 230 | 12300 | 12301 | 12302 | | 1234 | 22340 | 22341 | 22342 |
Config (review.config.json)
{
"projectPrefix": "myapp-review",
"composeFile": "docker-compose.review.yml",
"services": { "web": "web", "api": "api", "db": "db" },
"ports": { "base": 10000, "stride": 10, "modulus": 5554 },
"fallbacks": { "main": 80, "noPr": 90 },
"hooks": {
"migrate": "pnpm --filter @workspace/api db:migrate",
"seed": "pnpm --filter @workspace/api db:seed"
}
}Env vars exposed to your compose file and hooks:
REVIEW_SLOT— PR/slot numberREVIEW_WEB_PORT,REVIEW_API_PORT,REVIEW_DB_PORT— computed host portsCOMPOSE_PROJECT_NAME—<prefix>-<slot>(set automatically)
Shared backing services (opt-in)
By default every slot runs a full copy of every service — including heavy or
stateful ones (Postgres, a virus scanner, an image proxy…). Running N copies of
those is wasteful when they can be shared. Add a shared block to run them
once for all slots, while keeping each slot's web+api isolated:
{
"projectPrefix": "myapp-review",
"composeFile": "docker-compose.review.yml",
"shared": {
"composeFile": "docker-compose.shared.yml",
"project": "myapp-review-shared",
"network": "myapp-review-shared",
"db": {
"service": "db",
"user": "app",
"nameTemplate": "myapp_review_{slot}",
"hostPort": 15500
}
}
}How it works:
One singleton stack (
shared.composeFile, projectshared.project) holds the shared services.upstarts it automatically (idempotent) before the slot; manage it directly withreview shared up|down|ps.Slot isolation stays at the database layer: each slot gets its own database (
nameTemplate,{slot}substituted) inside the one Postgres.up/migratecreate it;down/resetdrop it. The shared stack keeps running.Wiring: the shared services and every slot's
web+apijoin the externalshared.network, so a slot's api reaches them by service name. Compose files declare that network (and any shared volume, e.g. uploads) asexternal.External volumes are auto-provisioned:
upcreates any volume the compose file declaresexternal: true(idempotent) before starting, so caches shared across slots — e.g. a commonnode_modules/ package-manager cache — can be markedexternalto survive a per-slotdownand be reused by the next slot.Extra env exposed in shared mode:
REVIEW_DB_NAME(per-slot database),REVIEW_SHARED_PROJECT,REVIEW_SHARED_NETWORK.REVIEW_DB_PORTpoints atshared.db.hostPortsohooks.migrate/hooks.seedconnect to the shared PG:"hooks": { "migrate": "DATABASE_URL=postgres://app:[email protected]:${REVIEW_DB_PORT}/${REVIEW_DB_NAME} pnpm db:migrate" }
Trade-off: shared stateless services (scanner/imgproxy) and any shared volume (e.g. uploads) are common to all slots — fine for ephemeral review, not for isolation-critical data. Databases remain per-slot.
Commands
| Command | Purpose |
|---------|---------|
| init [--prefix] [--force] | Scaffold review.config.json + the compose stack (docker-compose.yml, .infra.yml, .review.yml, .shared.yml); existing files are kept unless --force |
| up [--slot N] [--pull] | (shared: ensure shared stack + slot db) build + start + hooks.migrate + hooks.seed |
| rebuild [--slot N] [--all] [--migrate] [--seed] | Rebuild/restart only the services affected by changes since the last up — fast iteration (see below) |
| down [--keep] | Stop the slot and drop its volumes + shared database; --keep preserves them (external shared volumes are never dropped) |
| reset | down -v (+ recreate slot db) then up |
| ps | docker compose ps for the active stack |
| logs [--service api] [--follow] | Tail logs |
| migrate | Run hooks.migrate |
| seed | Run hooks.seed |
| shared up\|down\|ps [--volumes] | Manage the shared singleton stack (all slots) |
Slot resolution order: --slot → REVIEW_SLOT env → gh pr view → fallback.
Incremental rebuild (rebuild)
up builds and starts everything. Once a slot is running, rebuild is the fast
loop: it looks at what changed since the last up and touches only the
services that actually depend on those changes.
Change detection. up (and reset) record HEAD as a per-slot baseline
under .git/review-stack/<slot>.baseline. rebuild diffs baseline..HEAD
(committed work) unioned with the working tree (staged, unstaged, and untracked
files), then updates the baseline to the new HEAD on success.
Service routing. A changed file maps to a service one of two ways:
- Inferred (default) — from the compose config: a service's
build.contextand any host bind-mount sources inside the repo. Buildable services (build:) are rebuilt (build+up -d); bind-mount-only services are force-recreated so the process re-reads code that a macOS bind-mount watcher won't hot-reload. - Explicit — set
rebuild.servicesin the config to route paths precisely. This is authoritative: only listed services are considered. Recommended for monorepos where every service shares a root-level context/mount (.), which otherwise makes any change touch every app service.
"rebuild": {
"services": {
"api": ["apps/api", "packages/db", "packages/shared"],
"web": ["apps/web", "packages/ui", "packages/shared"]
}
}Backing services (db, etc.) are never bounced — rebuild always runs --no-deps.
The migrate hook runs automatically when the api service is affected (force it
with --migrate); seed only runs with --seed. A change to the compose file
itself, a missing baseline, or --all triggers a full refresh of every app
service. When nothing relevant changed, rebuild is a no-op.
License
MIT
