scripts/loaders/miami/_lib.ts
Served verbatim from the project repository. Internal working document conventions apply: documents may reference file paths, branch names, and findings-ledger anchors from the repo.
// Miami loader common module. Parallel to scripts/loaders/philly/_lib.ts but
// writes to the miami_* tables. Decisions: docs/cities/miami/00_overview.md.
//
// Provenance records (sha256 + bytes) come from the discovered file in
// data/cities/miami/sources/<source_id>/<date>/provenance.json — written by
// pipeline_miami/discover.py — so loaders re-use them rather than re-hashing.
import "dotenv/config";
import { config as loadEnv } from "dotenv";
import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process";
loadEnv({ path: path.join(process.cwd(), ".env.local") });
// Same Postgres binding as the NYC / Philly loaders — DIRECT_URL bypasses RLS.
const direct = process.env.DIRECT_URL ?? process.env.POSTGRES_URL_NON_POOLING;
if (direct) {
process.env.DATABASE_URL = direct;
}
import { prisma } from "@/prisma/client";
export { prisma };
export const SOURCES_ROOT = path.join(process.cwd(), "data", "cities", "miami", "sources");
export function gitSha(): string | null {
try {
return execSync("git rev-parse HEAD", { stdio: ["ignore", "pipe", "ignore"] })
.toString()
.trim();
} catch {
return null;
}
}
export type Provenance = {
source_id: string;
url: string;
retrieved_at: string;
sha256: string;
bytes: number;
publisher?: string;
format?: string;
fetched_from?: string;
notes?: string;
};
function latestDatedDir(sourceId: string): string | null {
const dir = path.join(SOURCES_ROOT, sourceId);
if (!fs.existsSync(dir)) return null;
const dated = fs
.readdirSync(dir)
.filter((n) => /^\d{4}-\d{2}-\d{2}$/.test(n))
.sort()
.reverse();
for (const d of dated) {
if (fs.existsSync(path.join(dir, d, "provenance.json"))) return path.join(dir, d);
}
return null;
}
/** Latest provenance.json for a source_id, or null if never fetched. */
export function readLatestProvenance(sourceId: string): Provenance | null {
const d = latestDatedDir(sourceId);
if (!d) return null;
return JSON.parse(fs.readFileSync(path.join(d, "provenance.json"), "utf-8")) as Provenance;
}
/** Path to the latest downloaded source file living beside provenance.json. */
export function readLatestSourcePath(sourceId: string): string | null {
const d = latestDatedDir(sourceId);
if (!d) return null;
const entries = fs.readdirSync(d).filter((n) => n !== "provenance.json").sort();
if (entries.length === 0) return null;
return path.join(d, entries[0]);
}
/** Both at once, throwing the standard "run discover.py" message. */
export function requireSource(sourceId: string): { filePath: string; prov: Provenance } {
const filePath = readLatestSourcePath(sourceId);
const prov = readLatestProvenance(sourceId);
if (!filePath || !prov) {
throw new Error(
`No fetched source for ${sourceId}. Run: python -m pipeline_miami.discover --source ${sourceId}`,
);
}
console.log(`[load] file: ${filePath}`);
console.log(`[load] sha256: ${prov.sha256.slice(0, 12)}... (${prov.bytes.toLocaleString()} bytes)`);
return { filePath, prov };
}
// Metadata and fact candidates stay in memory until one atomic replacement commits.
// Non-fact loaders stage their writes with stageLoadOperation in the same transaction.
import { createDataLoader, type LoadOptions } from "./atomic-load";
export type { Fact } from "./atomic-load";
const dataLoader = createDataLoader(prisma);
export const startDataLoad = (opts: Omit<LoadOptions, "gitSha">) => dataLoader.startDataLoad({ ...opts, gitSha: gitSha() });
export const { finishDataLoad, upsertFacts, stageLoadOperation } = dataLoader;
/** Run fn against batches of items with bounded concurrency (same as NYC/Philly). */
export async function batched<T>(
items: T[],
batchSize: number,
fn: (batch: T[], batchIndex: number) => Promise<void>,
concurrency = 4,
): Promise<void> {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += batchSize) {
batches.push(items.slice(i, i + batchSize));
}
let next = 0;
async function worker() {
while (next < batches.length) {
const idx = next++;
await fn(batches[idx], idx);
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
}
// ── Florida-specific normalizers ────────────────────────────────────────────
/**
* FLDOE school numbers are zero-padded text in some files ("0041") and bare
* integers in others (41). Normalize to 4-digit text (findings.md F.1).
* Returns null for blanks / non-numeric tokens (district total rows, "*").
*/
export function padSchoolNumber(v: unknown): string | null {
if (v === null || v === undefined) return null;
const s = String(v).trim();
if (s === "") return null;
const n = s.replace(/\.0+$/, "");
if (!/^\d{1,4}$/.test(n)) return null;
return n.padStart(4, "0");
}
/** "2425" → "2024-25"; "2024-25" passes through; "25" (spring) → "2024-25". */
export function schoolYearFromSuffix(v: string): string {
const s = String(v).trim();
if (/^\d{4}-\d{2}$/.test(s)) return s;
if (/^\d{4}$/.test(s)) {
const a = 2000 + Number(s.slice(0, 2));
return `${a}-${s.slice(2)}`;
}
if (/^\d{2}$/.test(s)) {
const end = 2000 + Number(s);
return `${end - 1}-${s}`;
}
throw new Error(`cannot derive a school year from "${v}"`);
}
/** "2024-25" + (−1) → "2023-24". */
export function shiftSchoolYear(year: string, offset: number): string {
const m = /^(\d{4})-(\d{2})$/.exec(year);
if (!m) throw new Error(`bad school year: ${year}`);
const start = Number(m[1]) + offset;
return `${start}-${String((start + 1) % 100).padStart(2, "0")}`;
}
/**
* FLDOE suppression tokens vary by file and year: "*" (n<10), "**" (older
* membership files), blank (School Grades components), "i.s.", "n/a"
* (findings.md F.2). parseCell returns the number, or null with a
* `suppressed` flag when the cell carried a token rather than being empty.
*/
export const SUPPRESSION_TOKENS = new Set(["*", "**", "***", "i.s.", "n.a.", "n/a", "na", "s", "is", "--", "†", "‡"]);
export function parseCell(v: unknown): { value: number | null; suppressed: boolean; token: string | null } {
if (v === null || v === undefined) return { value: null, suppressed: false, token: null };
const raw = String(v).trim();
if (raw === "") return { value: null, suppressed: false, token: null };
const cleaned = raw.replace(/[,%$]/g, "");
const n = Number.parseFloat(cleaned);
if (Number.isFinite(n) && /^-?\d*\.?\d+$/.test(cleaned)) return { value: n, suppressed: false, token: null };
return { value: null, suppressed: SUPPRESSION_TOKENS.has(raw.toLowerCase()), token: raw };
}
/** Census of non-numeric tokens per column, for the load notes and the QA. */
export class TokenCensus {
private counts = new Map<string, number>();
add(token: string | null, column: string) {
if (token === null) return;
const k = `${column}::${token}`;
this.counts.set(k, (this.counts.get(k) ?? 0) + 1);
}
summary(): Record<string, number> {
return Object.fromEntries([...this.counts.entries()].sort((a, b) => b[1] - a[1]));
}
toString(): string {
const s = this.summary();
const keys = Object.keys(s);
if (keys.length === 0) return "no non-numeric tokens";
return keys.slice(0, 12).map((k) => `${k}=${s[k]}`).join(", ") + (keys.length > 12 ? ` (+${keys.length - 12} more)` : "");
}
}
/** Standard entry point wrapper: run, print errors, disconnect. */
export function runMain(main: () => Promise<void>) {
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());
}
// ── Shared fact writer ──────────────────────────────────────────────────────
/** The set of school numbers in miami_schools (the universe every loader filters to). */
export async function schoolUniverse(): Promise<Set<string>> {
const rows = await prisma.miamiSchool.findMany({ select: { schoolNumber: true } });
return new Set(rows.map((r) => r.schoolNumber));
}
/** Read an Excel sheet as row arrays and locate the header row by a cell value. */
export function sheetRows(filePath: string, sheetName?: string): { rows: unknown[][]; sheetNames: string[] } {
// Lazy import keeps _lib usable from scripts that never touch Excel.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const XLSX = require("xlsx") as typeof import("xlsx");
const wb = XLSX.readFile(filePath, { cellDates: false });
const name = sheetName ?? wb.SheetNames[0];
const sheet = wb.Sheets[name];
if (!sheet) throw new Error(`sheet "${name}" not in ${filePath} (have: ${wb.SheetNames.join(", ")})`);
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: null, raw: true });
return { rows, sheetNames: wb.SheetNames };
}
export function findHeaderRow(rows: unknown[][], firstCell: string, maxScan = 12): number {
for (let i = 0; i < Math.min(maxScan, rows.length); i++) {
const c = rows[i]?.[0];
if (c !== null && c !== undefined && String(c).trim().toLowerCase() === firstCell.toLowerCase()) return i;
}
throw new Error(`header row starting with "${firstCell}" not found in first ${maxScan} rows`);
}