| status | done | |
|---|---|---|
| depends | ||
| specs |
|
|
| issues |
|
|
| pr | 70 |
Add POST /api/_internal/reload-data — an authenticated webhook that pulls the latest commit on the configured CFP_DATA_BRANCH and atomically rebuilds the in-memory state, so a push to published propagates to the running pod without a restart.
Triggered by a GitHub Actions workflow living on the codeforphilly-data repo (delivered as PR-body YAML, not committed here).
Out of scope: HMAC payload signing, multi-pod fanout, push-side schema validation in the GH Action.
- specs/behaviors/storage.md — new "Hot reload" subsection covering the endpoint's existence + behavior.
- Env var. Add
CFP_DATA_RELOAD_SECRETtoapps/api/src/env.ts+envJsonSchema(optional, min 32 chars). When unset, the route exists but responds 503. - Helper. New
apps/api/src/store/memory/reload.tsexportsreloadInMemoryStateAndFts(fastify)— builds a freshInMemoryStatefirst, then mutates the live state's Maps in a tight synchronous block. Failure during the build leaves the running state untouched; failure during the mutate block is loud and the pod is in undefined state (caller returns 5xx). Adds areload(state)method toFtsEnginethat drops every FTS5 table's rows and re-inserts. - Route. New
apps/api/src/routes/internal.tsregistersPOST /api/_internal/reload-data:schema: { hide: true }so it's omitted from the public OpenAPI doc.- Bearer-token auth using
crypto.timingSafeEqual(length-checked first); generic 401 message. - 503 if
CFP_DATA_RELOAD_SECRETis unset. - Body
{ branch?: string, commitHash?: string }— both optional, validated via Fastify schema. - Cheap pre-check: if
commitHashgiven andgit merge-base --is-ancestor commitHash HEADexits 0 → 200 noChanges, no lock acquired. - Otherwise calls
fastify.reconcileDataRepo({ branch }). If outcome is'in-sync'→ 200 noChanges with the outcome. Anything else → rebuild via the helper and return 200 withrebuilt: true.
- Wire-up. Register
internalRoutesinapps/api/src/app.tsalongside other routes. - Tests.
apps/api/tests/internal-reload.test.tscovers 401 (missing/wrong token), 503 (unset secret), 200 noChanges via pre-check, 200 in-sync, 200 fast-forward + rebuilt with the new record visible via a service call. - Docs. Add
CFP_DATA_RELOAD_SECRETrow to the deploy.md env table. New "Hot-reload webhook" section in runbook.md. - Workflow YAML. Not committed to this repo; delivered in the PR body for the operator to drop into
codeforphilly-data.
-
npm run -w apps/api type-checkpasses -
npm run -w apps/api test— full suite green, including the newinternal-reload.test.ts -
POST /api/_internal/reload-datawithout Authorization → 401 generic message - Wrong bearer token → 401 (same shape; constant-time comparison verified by code review)
- Unset
CFP_DATA_RELOAD_SECRET→ 503 "hot-reload not configured" - Body
{ commitHash: <ancestor-of-HEAD> }→ 200 noChanges with no rebuild - Empty body, no remote changes → 200 noChanges with
outcome: 'in-sync' - Empty body, remote ahead of local → 200 with
outcome: 'fast-forwarded',rebuilt: true, and a service call sees the new record - Half-built rebuild does not corrupt running state (validated by reading reload.ts — fresh state is built fully before live state mutates)
- In-place mutation of
fastify.inMemoryState— services hold references to the live state object. We must mutate Map contents in place; replacing the object would orphan the services. Mitigation: helper clears + re-populates Maps on the existing object. - FTS reload mid-failure — if the DELETE succeeds but inserts throw, the running FTS index is in a partial state. Mitigation: load fresh state to a local variable first; if FTS reload throws, log loudly and the route returns 500 so the operator can manually restart the pod.
- Push-daemon self-trigger — the API pushes its own commits, the workflow fires, the webhook arrives for a commit the pod already has. The cheap pre-check handles this without a fetch.
- Gitsheets caches dataTree per Sheet at openStore time. A
git merge --ff-onlyupdates the working tree but the already-openStore's Sheet snapshots still bind the pre-merge tree, soqueryAll()keeps returning the old records. The reload helper re-opens the public store and replaces it via a newStore.swapPublic(newPublic)method. Anything reading via the cached Sheets (the revocation sweeper, anything future-facing that reaches forfastify.store.public.<sheet>) now picks up the new tree. Transacts are unaffected —repo.transactbuilds a fresh workspace from the parent commit per call. - In-place Map mutation, not pointer replacement. Services capture the
InMemoryStateobject at boot. Replacing it would orphan them. Helper builds a fresh state to a local var, thenclear()+set()every Map on the live object in a tight synchronous block. IfloadInMemoryStatethrows, the live state is untouched. If the FTS reload (last step) throws, the in-memory state has already been swapped but the FTS index is in undefined territory — the route logs loudly and returns 500 so the operator knows a pod restart is warranted. - 503 vs. 401 ordering chosen for probe resistance. Missing/empty
Authorization→ 401 before checking whether the secret is configured. Unauthenticated probes can't tell whether the env var is set; only callers that present some bearer token get a 503-vs-401 distinction. FtsEngine.reload(state)is a single SQLite transaction. Drops every FTS5 table's rows, re-inserts. If the inserts throw mid-transaction, SQLite rolls back to the prior contents. The handle and prepared statements are preserved so consumers holdingfastify.ftskeep working.- The workflow YAML for the data repo lives in the PR body, not in this repo. Per the cautions in the task, this PR doesn't touch
.github/workflows/here. The operator adds the workflow tocodeforphilly-dataand mirrors the secret value between the sealed Secret in the GitOps repo and the data repo's repository (or environment) secret.
- Tracked as: operator must add
notify-deployments.ymltocodeforphilly-dataand provisionCFP_DATA_RELOAD_SECRETas both a sealed Secret in the GitOps repo and a repo/env secret oncodeforphilly-data. The PR body contains the workflow YAML + step-by-step. - Tracked as: production cluster gets the same wiring once it stands up — the workflow YAML's matrix has a placeholder entry for the prod URL.