Skip to content

Commit 7a76f11

Browse files
feat(laddr-import): orchestrator, CLI, and end-to-end tests
The orchestrator (importer.ts) drives FK-respecting passes — tags → people → projects → memberships → updates → buzz → tag-assignments — emitting one gitsheets commit per entity type (7 total). Each commit uses the pseudonymous "Code for Philly API" identity per specs/behaviors/storage.md, with Action/Source-Dump/Run-At trailers per specs/behaviors/legacy-id-mapping.md. Idempotence: before any pass, walks the data-repo's git tree to collect existing legacyId → (id, slug) maps for the five sheets with legacyId, plus composite-path sets for memberships + tag-assignments (which have no legacyId field). Re-running against the same dump on an already-imported repo produces zero new files and zero commits. Private store: PrivateProfile records flow through PrivateStoreTx; LegacyPasswordCredential records (one-shot migration data, never written by the runtime API) are seeded via direct flush onto BasePrivateStore's internal legacyPasswords map — keeping the runtime interface narrow. CLI (`npm run -w apps/api script:import-laddr`) accepts --sql, --data-repo, --private-store, --dry-run, --verbose, --limit. Tests cover: dry-run report shape, full-import end-to-end with PII audit (no email patterns or bcrypt hashes anywhere in the public tree), tag namespace splitting, stage normalization, composite-path shapes, per-project ProjectUpdate.number sequencing, second-run no-op, and --limit truncation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d9977ae commit 7a76f11

4 files changed

Lines changed: 1105 additions & 1 deletion

File tree

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"type-check": "tsc -p tsconfig.json --noEmit",
1212
"test": "vitest run",
1313
"script:scrub-data": "tsx scripts/scrub-data.ts",
14-
"script:setup-dev-data": "tsx scripts/setup-dev-data.ts"
14+
"script:setup-dev-data": "tsx scripts/setup-dev-data.ts",
15+
"script:import-laddr": "tsx scripts/import-laddr.ts"
1516
},
1617
"dependencies": {
1718
"@aws-sdk/client-s3": "^3.1048.0",

apps/api/scripts/import-laddr.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
/**
2+
* import-laddr.ts — One-shot migration from a laddr mysqldump
3+
*
4+
* Reads a mysqldump (`--sql`), translates each row to the v1 data model
5+
* (Zod-validated against `@cfp/shared/schemas`), and writes records into:
6+
*
7+
* - the public gitsheets data repo (`--data-repo`)
8+
* - the private filesystem store (`--private-store`)
9+
*
10+
* Idempotent on `legacyId`: re-running against the same dump + target
11+
* skips rows already present. See specs/behaviors/legacy-id-mapping.md.
12+
*
13+
* Usage:
14+
* npm run -w apps/api script:import-laddr -- \
15+
* --sql=./scratch/laddr.sql \
16+
* --data-repo=./codeforphilly-data \
17+
* --private-store=./scratch/private-storage \
18+
* [--dry-run] [--verbose] [--limit=N]
19+
*/
20+
import { resolve } from 'node:path';
21+
22+
import { FilesystemPrivateStore } from '../src/store/private/filesystem.js';
23+
import { importLaddr, type ImportReport } from './import-laddr/importer.js';
24+
25+
interface CliArgs {
26+
readonly sql: string;
27+
readonly dataRepo: string;
28+
readonly privateStore: string;
29+
readonly dryRun: boolean;
30+
readonly verbose: boolean;
31+
readonly limit: number | undefined;
32+
}
33+
34+
function parseArgs(argv: readonly string[]): CliArgs {
35+
const opts: Record<string, string | true> = {};
36+
for (const a of argv) {
37+
if (!a.startsWith('--')) continue;
38+
const eq = a.indexOf('=');
39+
if (eq === -1) opts[a.slice(2)] = true;
40+
else opts[a.slice(2, eq)] = a.slice(eq + 1);
41+
}
42+
const need = (k: string): string => {
43+
const v = opts[k];
44+
if (typeof v !== 'string' || !v) {
45+
process.stderr.write(`missing --${k}=<path>\n`);
46+
process.exit(2);
47+
}
48+
return v;
49+
};
50+
const limitRaw = opts['limit'];
51+
const limit =
52+
typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : undefined;
53+
54+
return {
55+
sql: resolve(need('sql')),
56+
dataRepo: resolve(need('data-repo')),
57+
privateStore: resolve(need('private-store')),
58+
dryRun: opts['dry-run'] === true,
59+
verbose: opts['verbose'] === true,
60+
limit: Number.isFinite(limit ?? NaN) ? limit : undefined,
61+
};
62+
}
63+
64+
async function main(): Promise<void> {
65+
const args = parseArgs(process.argv.slice(2));
66+
67+
const privateStore = new FilesystemPrivateStore({
68+
CFP_PRIVATE_STORAGE_PATH: args.privateStore,
69+
});
70+
await privateStore.load();
71+
72+
console.log(`[import-laddr] sql=${args.sql}`);
73+
console.log(`[import-laddr] data-repo=${args.dataRepo}`);
74+
console.log(`[import-laddr] private-store=${args.privateStore}`);
75+
console.log(`[import-laddr] dry-run=${args.dryRun} limit=${args.limit ?? 'none'}`);
76+
77+
const report = await importLaddr({
78+
sql: args.sql,
79+
dataRepo: args.dataRepo,
80+
privateStore,
81+
dryRun: args.dryRun,
82+
verbose: args.verbose,
83+
limit: args.limit,
84+
});
85+
86+
printReport(report, args.dryRun);
87+
}
88+
89+
function printReport(report: ImportReport, dryRun: boolean): void {
90+
const lines: string[] = [];
91+
lines.push(`\n=== import-laddr report ===`);
92+
lines.push(`runAt: ${report.runAt}`);
93+
lines.push(`sourceSha256: ${report.sourceSha256}`);
94+
for (const [sheet, r] of Object.entries(report.entities)) {
95+
lines.push(
96+
` ${sheet.padEnd(22)} input=${r.input} imported=${r.imported} skipped=${r.skipped} errors=${r.errors}`,
97+
);
98+
}
99+
lines.push(`warnings: ${report.warnings.length}`);
100+
for (const w of report.warnings.slice(0, 25)) lines.push(` ${w}`);
101+
if (report.warnings.length > 25) {
102+
lines.push(` ... (${report.warnings.length - 25} more)`);
103+
}
104+
if (dryRun) {
105+
lines.push(`(dry-run: no writes performed)`);
106+
} else {
107+
lines.push(`commits: ${report.commits.length}`);
108+
for (const c of report.commits) lines.push(` ${c}`);
109+
}
110+
console.log(lines.join('\n'));
111+
112+
process.stdout.write(`\n${JSON.stringify(reportToJson(report), null, 2)}\n`);
113+
}
114+
115+
function reportToJson(report: ImportReport): unknown {
116+
return {
117+
runAt: report.runAt,
118+
sourceSha256: report.sourceSha256,
119+
entities: report.entities,
120+
warnings: report.warnings,
121+
commits: report.commits,
122+
};
123+
}
124+
125+
const isMain =
126+
process.argv[1] !== undefined &&
127+
import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'));
128+
129+
if (isMain) {
130+
main().catch((err: unknown) => {
131+
console.error('[import-laddr] failed:', err);
132+
process.exit(1);
133+
});
134+
}

0 commit comments

Comments
 (0)