teqo-backup/scripts/lib/worktree-env.mjs

144 lines
5.4 KiB
JavaScript

/**
* Pure helpers for the per-worktree environment provisioning in
* `scripts/worktree.mjs` — deterministic port + database names derived from
* the branch, so parallel agents (each in their own worktree) never fight
* over port 3000, the shared `teqo_test` database, or a second Postgres
* container on 5432.
*
* Contract: `slot` is the single collision domain. Port and BOTH database
* names derive from the final slot, so a bumped slot frees all three at
* once. Deterministic for a given (branch, code, live taken-slots set) —
* the same "depends on live state" determinism the claim-queue branch picker
* already has.
*/
/** Dev-server ports start here: slot 15 → http://localhost:3115. */
export const DEV_PORT_BASE = 3100
/** Marker line identifying a generated env file (never overwrite manual ones). */
export const GENERATED_ENV_MARKER = '# teqo worktree env (generated by pnpm worktree next)'
/** Slot cap — ports above 3100 + 999 would leave the usual ephemeral range. */
const MAX_SLOT = 999
/** First run of digits in the issue code (`C15` → 15, `B164` → 164); null when none. */
export const numericSlotOfCode = (code) => {
const match = /([0-9]+)/.exec(code ?? '')
return match ? Number(match[1]) : null
}
/** Stable 32-bit FNV-1a hash → slot in [0, MAX_SLOT] for codes without digits. */
export const hashSlotOf = (value) => {
let hash = 0x811c9dc5
for (const byte of Buffer.from(String(value), 'utf8')) {
hash ^= byte
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash % (MAX_SLOT + 1)
}
/** The slot a branch prefers before collision resolution. */
const preferredSlotForBranch = (branch, code) => {
const numeric = numericSlotOfCode(code)
return numeric !== null && numeric <= MAX_SLOT ? numeric : hashSlotOf(branch)
}
export const devPortForSlot = (slot) => DEV_PORT_BASE + slot
export const devDatabaseForSlot = (slot) => `teqo_wt${slot}`
export const testDatabaseForSlot = (slot) => `teqo_wt${slot}_test`
/** DB name charset guard — derived names are fed into `CREATE DATABASE` SQL. */
export const isGeneratedDatabaseName = (name) => /^teqo_wt[0-9]+(_test)?$/.test(name ?? '')
/**
* Media storage (OPS52) — the four required `S3_*` keys, mirrored from
* `resolveS3StorageEnv` in `src/utilities/mediaStorage.ts` (scripts cannot
* import the TS module). `S3_REGION` is optional (default `garage`).
*/
const S3_WORKTREE_REQUIRED_KEYS = [
'S3_BUCKET',
'S3_ENDPOINT',
'S3_ACCESS_KEY_ID',
'S3_SECRET_ACCESS_KEY',
]
/**
* `S3_*` lines to copy into a worktree env — ALL or NOTHING: a partial set
* would abort the worktree's boot (fail-closed `resolveS3StorageEnv`), so a
* partial main-env copies nothing and the worktree falls back to local disk
* storage. The dev is expected to keep the main env's bucket out of
* production (`.env.example` contract).
*
* @param {Record<string, string | undefined>} env
* @returns {string[]}
*/
export const s3EnvCopiedLines = (env) =>
S3_WORKTREE_REQUIRED_KEYS.every((key) => env[key])
? [...S3_WORKTREE_REQUIRED_KEYS, 'S3_REGION'].map((key) => `${key}=${env[key]}`)
: []
/**
* Final environment for a branch. `takenSlots` = slots already claimed by
* OTHER live worktrees; bumps +1 until free (bounded — live slot sets are
* tiny). Every value a consumer needs derives from the one final slot.
*/
export const worktreeEnvironment = ({ branch, code, takenSlots = new Set() }) => {
let slot = preferredSlotForBranch(branch, code)
while (takenSlots.has(slot)) slot += 1
return {
slot,
devPort: devPortForSlot(slot),
devDatabase: devDatabaseForSlot(slot),
testDatabase: testDatabaseForSlot(slot),
}
}
/**
* Line arrays for `.env.local` (dev) and `.env.test.local` (test) of a
* provisioned worktree. `NEXT_PUBLIC_SITE_URL` and `PLAYWRIGHT_BASE_URL` must
* point BOTH files at the SAME slot port — the parity is what keeps a parallel
* e2e run in another worktree from falling back to port 3000 (OPS20: they had
* drifted — the dev file carried PLAYWRIGHT_BASE_URL, the test file didn't, and
* playwright.config.ts only reads `.env.test.local`). Building both from one
* `url` makes the drift structurally impossible.
*
* @param {{ branch: string, issueLabel?: string, generatedBy: string, env: { slot: number, devPort: number, devDatabase: string, testDatabase: string }, payloadSecret: string, copiedLines?: string[] }} options
* @returns {{ dev: string[], test: string[] }}
*/
export const worktreeEnvFileContents = ({
branch,
issueLabel = '',
generatedBy,
env,
payloadSecret,
copiedLines = [],
}) => {
// Both DATABASE_URLs derive from the same slot as the port (module contract).
const devUrl = `postgresql://teqo:teqo@localhost:5432/${env.devDatabase}`
const testUrl = `postgresql://teqo:teqo@localhost:5432/${env.testDatabase}`
const url = `http://localhost:${env.devPort}`
const header = [
GENERATED_ENV_MARKER,
`# branch ${branch}${issueLabel} · slot ${env.slot} — ${generatedBy}`,
]
return {
dev: [
...header,
`DATABASE_URL=${devUrl}`,
`PORT=${env.devPort}`,
`NEXT_PUBLIC_SITE_URL=${url}`,
`PLAYWRIGHT_BASE_URL=${url}`,
`PAYLOAD_SECRET=${payloadSecret}`,
...copiedLines,
'',
],
test: [
...header,
'# Lido DEPOIS de .env.test (vitest.setup.ts / playwright.config.ts) — suites isoladas.',
`DATABASE_URL=${testUrl}`,
`NEXT_PUBLIC_SITE_URL=${url}`,
`PLAYWRIGHT_BASE_URL=${url}`,
'',
],
}
}