Skip to content
Open
11 changes: 6 additions & 5 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,11 @@ Two background concerns keep that store in sync with the world:
## Local setup

1. `asdf install` — picks up Node from `.tool-versions`
2. Clone the data repo as a sibling: `git clone git@github.com:CodeForPhilly/codeforphilly-data.git ../codeforphilly-data` (checkout `fixture` for a small seed, or `published` for the full laddr import)
3. `cp .env.example .env` and edit — point `CFP_DATA_REPO_PATH` at your sibling clone (absolute path recommended; relative paths resolve from `apps/api/`, not repo root)
4. `npm install`
5. `npm run dev` — api + web concurrently
2. **Bare-clone** the data repo as a sibling: `git clone --bare git@github.com:CodeForPhilly/codeforphilly-data.git ../codeforphilly-data` (use `--branch fixture` for a small seed, or `--branch published` for the full laddr import). The app reads via gitsheets' tree-object interface and *requires* bare — see [specs/behaviors/storage.md](../specs/behaviors/storage.md) → "The data clone is bare."
3. *(optional)* If you want a working tree to browse/edit records by hand, clone *from* your bare into a second directory: `git clone ../codeforphilly-data ../codeforphilly-data-wt`. Push/pull between them; the app doesn't care.
4. `cp .env.example .env` and edit — point `CFP_DATA_REPO_PATH` at your bare sibling clone (absolute path recommended; relative paths resolve from `apps/api/`, not repo root)
5. `npm install`
6. `npm run dev` — api + web concurrently

```bash
npm install # install all workspaces
Expand All @@ -124,7 +125,7 @@ Typical change flow:
1. **Merge to `main`** — CI builds + tests; nothing deploys yet.
2. **Publish image** (currently manual) — `docker build --platform=linux/amd64 -t ghcr.io/codeforphilly/codeforphilly-ng:sandbox . && docker push …`. Apple-silicon dev machines must set the platform flag — cluster nodes are amd64.
3. **GitOps pickup** — `cfp-sandbox-cluster` projects from our `deploy/kustomize/`; on its own merge, applies via `kubectl apply -k`.
4. **Pod boot** — single replica, `Recreate` strategy. Container entrypoint clones the data repo on first boot (PVC persists across pods). Node boots: env → store load → **reconcile** (ff/rebase/escape-hatch against `origin/<CFP_DATA_BRANCH>`) → **push daemon** → routes → SPA. `/api/health/ready` returns 200 once stores are loaded *and* reconciled.
4. **Pod boot** — single replica, `Recreate` strategy. Container entrypoint bare-clones the data repo on every pod start (the data volume is `emptyDir`, so first boot = every fresh pod). Node boots: env → store load → **reconcile** (ff/replay/escape-hatch against `origin/<CFP_DATA_BRANCH>`) → **push daemon** → routes → SPA. `/api/health/ready` returns 200 once stores are loaded *and* reconciled.
5. **Live data updates** — independent of app deploy. Pushes to `published` trigger the [hot-reload webhook](../docs/operations/runbook.md#hot-reload-webhook); the pod rebuilds in-memory state in place, no restart.

Constraints worth knowing before touching anything deploy-shaped:
Expand Down
48 changes: 29 additions & 19 deletions apps/api/scripts/import-laddr/importer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,34 +557,44 @@ async function ensureGitRepo(repo: string): Promise<void> {
}

/**
* Check out the target branch in the working tree. On first run, create it
* from `origin/<branch>` if available, falling back to `initialParent`
* (typically `origin/empty`).
* Point HEAD at the target branch in the bare data repo. On first run,
* the branch ref is created from `origin/<branch>` if available, falling
* back to `initialParent` (typically `origin/empty`).
*
* Bare-friendly — no working tree to check out into. The branch ref is
* created/updated via `git update-ref`, and HEAD becomes a symbolic-ref
* pointing at it so subsequent transacts commit onto the right branch.
*/
async function ensureBranchCheckedOut(
repo: string,
branch: string,
initialParent: string,
): Promise<void> {
// Existing local branch: just switch.
try {
await exec('git', ['rev-parse', '--verify', `refs/heads/${branch}`], { cwd: repo });
await exec('git', ['checkout', branch], { cwd: repo });
return;
} catch {
// No local branch — fall through.
}
// No local branch yet. Try origin/<branch>, fall back to initialParent.
let parent: string;
// Resolve the parent commit hash: either the existing local branch (use
// it as-is), origin/<branch> if it exists, or the initialParent fallback.
let parentCommit: string;
try {
await exec('git', ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], {
cwd: repo,
});
parent = `origin/${branch}`;
const result = await exec('git', ['rev-parse', '--verify', `refs/heads/${branch}`], { cwd: repo });
parentCommit = result.stdout.trim();
} catch {
parent = initialParent;
// No local branch — try origin/<branch>, fall back to initialParent.
let parentRef: string;
try {
await exec('git', ['rev-parse', '--verify', `refs/remotes/origin/${branch}`], {
cwd: repo,
});
parentRef = `refs/remotes/origin/${branch}`;
} catch {
parentRef = initialParent;
}
const result = await exec('git', ['rev-parse', '--verify', parentRef], { cwd: repo });
parentCommit = result.stdout.trim();
await exec('git', ['update-ref', `refs/heads/${branch}`, parentCommit], { cwd: repo });
}
await exec('git', ['checkout', '-b', branch, parent], { cwd: repo });

// Point HEAD at the (now-existing) branch so subsequent gitsheets
// transacts commit onto it.
await exec('git', ['symbolic-ref', 'HEAD', `refs/heads/${branch}`], { cwd: repo });
}

// ---------------------------------------------------------------------------
Expand Down
26 changes: 17 additions & 9 deletions apps/api/scripts/scrub-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ export async function scrubRepo(opts: ScrubOptions): Promise<ScrubResult> {
// -------------------------------------------------------------------------
// 1. Open source repo
// -------------------------------------------------------------------------
const sourceRepo = await openRepo({ workTree: source, gitDir: join(source, '.git') });
const sourceRepo = await openRepo({ gitDir: source });
const sourceHeadHash = await sourceRepo.resolveRef('HEAD');

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -585,16 +585,24 @@ export async function scrubRepo(opts: ScrubOptions): Promise<ScrubResult> {
await writeFile(fullPath, content, 'utf-8');
}

// Copy .gitsheets config from source
const sourceGitsheetsDir = join(source, '.gitsheets');
// Copy .gitsheets configs from source's HEAD tree (source is a bare clone,
// per specs/behaviors/storage.md → "The data clone is bare", so the configs
// live as git blobs rather than working-tree files).
const targetGitsheetsDir = join(target, '.gitsheets');
await mkdir(targetGitsheetsDir, { recursive: true });
const configFiles = await readdir(sourceGitsheetsDir, { withFileTypes: true }).catch(() => []);
for (const entry of configFiles) {
if (entry.isFile() && entry.name.endsWith('.toml')) {
const configContent = await readFile(join(sourceGitsheetsDir, entry.name), 'utf-8');
await writeFile(join(targetGitsheetsDir, entry.name), configContent, 'utf-8');
}
const lsTreeResult = await exec('git', [
'--git-dir', source,
'ls-tree', '--name-only', 'HEAD', '.gitsheets/',
]);
const configFiles = lsTreeResult.stdout
.split('\n')
.filter((name) => name.endsWith('.toml'));
for (const path of configFiles) {
const blob = await exec('git', [
'--git-dir', source,
'show', `HEAD:${path}`,
]);
await writeFile(join(target, path), blob.stdout, 'utf-8');
}

// -------------------------------------------------------------------------
Expand Down
29 changes: 24 additions & 5 deletions apps/api/src/store/public.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';

import { openRepo, openStore } from 'gitsheets';
import type { Repository, StandardSchemaV1, Store, StoreTx, ValidatorMap } from 'gitsheets';
import {
Expand Down Expand Up @@ -60,17 +63,33 @@ export type PublicStoreTx = StoreTx<PublicValidators>;
/**
* Open the gitsheets-backed public data store.
*
* Reads `.gitsheets/<sheet>.toml` for each declared sheet in `repoPath`.
* In-memory secondary indices are built by the caller (boot.ts) after this
* `repoPath` is the **bare gitdir** — no `.git` subdirectory. The app
* always operates against a bare clone per
* specs/behaviors/storage.md → "The data clone is bare". A non-bare
* repoPath fails loudly here so misconfiguration surfaces at boot
* rather than as runtime drift between the API's HEAD and a stale
* working tree.
*
* Reads `.gitsheets/<sheet>.toml` for each declared sheet. In-memory
* secondary indices are built by the caller (boot.ts) after this
* returns, since they require iterating over all records.
*
* Returns both the typed store and the underlying Repository handle — the
* latter is needed by the push-daemon plugin to push commits to origin.
* Returns both the typed store and the underlying Repository handle —
* the latter is needed by the push-daemon plugin to push commits to
* origin.
*/
export async function openPublicStore(
repoPath: string,
): Promise<{ store: PublicStore; repo: Repository }> {
const repo = await openRepo({ gitDir: `${repoPath}/.git`, workTree: repoPath });
if (existsSync(join(repoPath, '.git'))) {
throw new Error(
`CFP_DATA_REPO_PATH=${repoPath} looks like a non-bare clone (found .git subdirectory). ` +
`The app requires a bare clone — re-create with: git clone --bare <remote> ${repoPath}. ` +
`See specs/behaviors/storage.md → "The data clone is bare".`,
);
}

const repo = await openRepo({ gitDir: repoPath });
repo.requireExplicitTransactions();

const validators: PublicValidators = {
Expand Down
Loading