Skip to content

Commit 96e987b

Browse files
feat(importer): replace mysqldump-based importer with JSON snapshot importer
Each run fetches the public laddr dataset from `codeforphilly.org`'s `?format=json` endpoints (tags, people, projects, project-updates, project-buzz) and writes a full-tree snapshot commit on the `legacy-import` branch in the public data repo. Consecutive runs diff cleanly to show what changed upstream. Differences from the prior mysqldump implementation: - Reads JSON from the live site, not a SQL dump file. No fixture SQL or mysqldump parser needed. - Memberships and tag-assignments arrive via `?include=Tags,Memberships` on the projects list (and `?include=Tags` on people) — no separate `/project-memberships` or `/tag-assignments` list endpoints exist. - Files on `legacy-import` are keyed by laddr's auto-increment ID (`<sheet>/<legacyId>.toml`, composite for memberships and tag-assignments) so re-runs overwrite stable paths. - Full-tree replace per run, not per-entity upserts. The wipe + write pattern is bare-git, not gitsheets transact, because the path templates we want for diff-ability differ from the runtime spec's slug-based paths. The legacy-import branch is parallel history — runtime data lives on `main`, which the operator merges into separately. - UUIDs are read-forward from the previous snapshot when a path already exists, so idempotence holds without depending on `now`. - Pseudonymous author identity on every commit (Code for Philly API <api@users.noreply.codeforphilly.org>). Translator robustness improvements drawn from the live data: - Tag handles with the dot stripped by laddr's JSON renderer (`topicparking`) are recovered from the Title field (`topic.Parking`) when present. - Tag slug components with underscores are coerced to hyphens. - Bios over 10k chars (spam accounts) are truncated with a warning. - Full names over 120 chars are truncated. - ChatChannel is coerced through the v1 regex (lowercase, strip leading `#`, replace non-allowed chars with `-`). CLI surface: npm run -w apps/api script:import-laddr -- \ --source-host=codeforphilly.org \ --data-repo=$CFP_DATA_REPO_PATH \ --branch=legacy-import \ [--dry-run] [--no-commit] [--limit=N] [--verbose] \ [--page-size=N] [--delay-ms=N] Private-store import (emails, password hashes, newsletter prefs) is out of scope — the JSON endpoints expose public fields only. That will be covered by a separate plan (per laddr-import-via-json.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5fdfd25 commit 96e987b

7 files changed

Lines changed: 2017 additions & 1427 deletions

File tree

apps/api/scripts/fixtures/laddr-fixture.sql

Lines changed: 0 additions & 111 deletions
This file was deleted.

apps/api/scripts/import-laddr.ts

Lines changed: 81 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,40 @@
11
/**
2-
* import-laddr.ts — One-shot migration from a laddr mysqldump
2+
* import-laddr.ts — Re-runnable import from the live laddr site at
3+
* codeforphilly.org into the public `codeforphilly-data` repo.
34
*
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.
5+
* Each run produces one new commit on the `legacy-import` branch whose tree
6+
* is a complete replacement of the previous snapshot. Consecutive commits
7+
* diff cleanly to show what changed upstream between runs.
128
*
139
* Usage:
1410
* 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]
11+
* --source-host=codeforphilly.org \
12+
* --data-repo=/path/to/codeforphilly-data \
13+
* --branch=legacy-import \
14+
* [--dry-run] [--no-commit] [--limit=N] [--verbose] [--page-size=N] [--delay-ms=N]
15+
*
16+
* Defaults:
17+
* --source-host codeforphilly.org
18+
* --data-repo $CFP_DATA_REPO_PATH (required if flag not given)
19+
* --branch legacy-import
20+
*
21+
* See plans/laddr-import-via-json.md for the design and
22+
* specs/behaviors/legacy-id-mapping.md for the contract.
1923
*/
2024
import { resolve } from 'node:path';
2125

22-
import { FilesystemPrivateStore } from '../src/store/private/filesystem.js';
23-
import { importLaddr, type ImportReport } from './import-laddr/importer.js';
26+
import { importLaddrFromJson, type ImportReport } from './import-laddr/importer.js';
2427

2528
interface CliArgs {
26-
readonly sql: string;
29+
readonly sourceHost: string;
2730
readonly dataRepo: string;
28-
readonly privateStore: string;
31+
readonly branch: string;
2932
readonly dryRun: boolean;
30-
readonly verbose: boolean;
33+
readonly noCommit: boolean;
3134
readonly limit: number | undefined;
35+
readonly verbose: boolean;
36+
readonly pageSize: number | undefined;
37+
readonly delayMs: number | undefined;
3238
}
3339

3440
function parseArgs(argv: readonly string[]): CliArgs {
@@ -39,87 +45,96 @@ function parseArgs(argv: readonly string[]): CliArgs {
3945
if (eq === -1) opts[a.slice(2)] = true;
4046
else opts[a.slice(2, eq)] = a.slice(eq + 1);
4147
}
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-
};
48+
49+
const envRepo = process.env['CFP_DATA_REPO_PATH'];
50+
const dataRepoRaw =
51+
typeof opts['data-repo'] === 'string' && opts['data-repo'] !== ''
52+
? (opts['data-repo'] as string)
53+
: envRepo;
54+
if (!dataRepoRaw) {
55+
process.stderr.write(
56+
'missing --data-repo=<path> (or set CFP_DATA_REPO_PATH)\n',
57+
);
58+
process.exit(2);
59+
}
60+
5061
const limitRaw = opts['limit'];
51-
const limit =
52-
typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : undefined;
62+
const limit = typeof limitRaw === 'string' ? Number.parseInt(limitRaw, 10) : undefined;
63+
const pageSizeRaw = opts['page-size'];
64+
const pageSize = typeof pageSizeRaw === 'string' ? Number.parseInt(pageSizeRaw, 10) : undefined;
65+
const delayMsRaw = opts['delay-ms'];
66+
const delayMs = typeof delayMsRaw === 'string' ? Number.parseInt(delayMsRaw, 10) : undefined;
5367

5468
return {
55-
sql: resolve(need('sql')),
56-
dataRepo: resolve(need('data-repo')),
57-
privateStore: resolve(need('private-store')),
69+
sourceHost:
70+
typeof opts['source-host'] === 'string' && opts['source-host'] !== ''
71+
? (opts['source-host'] as string)
72+
: 'codeforphilly.org',
73+
dataRepo: resolve(dataRepoRaw),
74+
branch:
75+
typeof opts['branch'] === 'string' && opts['branch'] !== ''
76+
? (opts['branch'] as string)
77+
: 'legacy-import',
5878
dryRun: opts['dry-run'] === true,
79+
noCommit: opts['no-commit'] === true,
80+
limit: typeof limit === 'number' && Number.isFinite(limit) ? limit : undefined,
5981
verbose: opts['verbose'] === true,
60-
limit: Number.isFinite(limit ?? NaN) ? limit : undefined,
82+
pageSize: typeof pageSize === 'number' && Number.isFinite(pageSize) ? pageSize : undefined,
83+
delayMs: typeof delayMs === 'number' && Number.isFinite(delayMs) ? delayMs : undefined,
6184
};
6285
}
6386

6487
async function main(): Promise<void> {
6588
const args = parseArgs(process.argv.slice(2));
6689

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}`);
90+
console.log(`[import-laddr] source-host=${args.sourceHost}`);
7391
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'}`);
92+
console.log(`[import-laddr] branch=${args.branch}`);
93+
console.log(
94+
`[import-laddr] dry-run=${args.dryRun} no-commit=${args.noCommit} limit=${args.limit ?? 'none'}`,
95+
);
7696

77-
const report = await importLaddr({
78-
sql: args.sql,
97+
const report = await importLaddrFromJson({
98+
sourceHost: args.sourceHost,
7999
dataRepo: args.dataRepo,
80-
privateStore,
100+
branch: args.branch,
81101
dryRun: args.dryRun,
82-
verbose: args.verbose,
102+
noCommit: args.noCommit,
83103
limit: args.limit,
104+
verbose: args.verbose,
105+
pageSize: args.pageSize,
106+
delayMs: args.delayMs,
84107
});
85108

86-
printReport(report, args.dryRun);
109+
printReport(report, args);
87110
}
88111

89-
function printReport(report: ImportReport, dryRun: boolean): void {
112+
function printReport(report: ImportReport, args: CliArgs): void {
90113
const lines: string[] = [];
91114
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)) {
115+
lines.push(`runAt: ${report.runAt}`);
116+
lines.push(`sourceHost: ${report.sourceHost}`);
117+
lines.push(`branch: ${report.branch}`);
118+
for (const [sheet, c] of Object.entries(report.counts)) {
95119
lines.push(
96-
` ${sheet.padEnd(22)} input=${r.input} imported=${r.imported} skipped=${r.skipped} errors=${r.errors}`,
120+
` ${sheet.padEnd(22)} imported=${c.imported} skipped=${c.skipped} errors=${c.errors}`,
97121
);
98122
}
99123
lines.push(`warnings: ${report.warnings.length}`);
100124
for (const w of report.warnings.slice(0, 25)) lines.push(` ${w}`);
101125
if (report.warnings.length > 25) {
102126
lines.push(` ... (${report.warnings.length - 25} more)`);
103127
}
104-
if (dryRun) {
128+
if (args.dryRun) {
105129
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}`);
130+
} else if (args.noCommit) {
131+
lines.push(`(no-commit: files staged, no commit made)`);
132+
} else if (report.noChanges) {
133+
lines.push(`(no changes from parent commit — branch unchanged)`);
134+
} else if (report.commitHash) {
135+
lines.push(`commit: ${report.commitHash} on ${report.branch}`);
109136
}
110137
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-
};
123138
}
124139

125140
const isMain =

0 commit comments

Comments
 (0)