Source document

scripts/loaders/philly/_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.

// Philly loader common module. Parallel to scripts/loaders/_lib.ts (NYC)
// but writes to the philly_* tables. Decisions: docs/cities/philly/00_overview.md.
//
// Provenance records (sha256 + bytes) come from the discovered file in
// data/cities/philly/sources/<source_id>/<date>/provenance.json — written by
// pipeline_philly/discover.py — so loaders can re-use them rather than
// re-hashing on every load.

import "dotenv/config";
import { config as loadEnv } from "dotenv";
import path from "node:path";
import crypto from "node:crypto";
import fs from "node:fs";
import { execSync } from "node:child_process";

loadEnv({ path: path.join(process.cwd(), ".env.local") });

// Same Postgres binding as NYC 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 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;
};

/**
 * Read the latest provenance.json for a source_id from
 * data/cities/philly/sources/<source_id>/<date>/provenance.json
 * Returns null if no fetch has run yet.
 */
export function readLatestProvenance(sourceId: string): Provenance | null {
  const dir = path.join(process.cwd(), "data", "cities", "philly", "sources", 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) {
    const file = path.join(dir, d, "provenance.json");
    if (fs.existsSync(file)) {
      return JSON.parse(fs.readFileSync(file, "utf-8")) as Provenance;
    }
  }
  return null;
}

/**
 * Path to the latest downloaded source file (the xlsx/csv/zip living alongside
 * provenance.json). The download step keeps the filename as the URL's last
 * segment; the loader needs to know that path.
 */
export function readLatestSourcePath(sourceId: string): string | null {
  const dir = path.join(process.cwd(), "data", "cities", "philly", "sources", 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) {
    const here = path.join(dir, d);
    const entries = fs.readdirSync(here).filter((n) => n !== "provenance.json");
    if (entries.length > 0) return path.join(here, entries[0]);
  }
  return null;
}

export async function startDataLoad(opts: {
  sourceId: string;            // matches docs/cities/philly/sources.yaml source_id
  sourceUrl?: string;
  yearCovered?: string;
  scriptPath: string;
  sha256?: string;
  bytes?: number;
  notes?: string;
}) {
  return prisma.phillyDataLoad.create({
    data: {
      id: crypto.randomUUID(),
      sourceId: opts.sourceId,
      sourceUrl: opts.sourceUrl,
      sha256: opts.sha256,
      bytes: opts.bytes,
      yearCovered: opts.yearCovered,
      scriptPath: opts.scriptPath,
      gitSha: gitSha(),
      notes: opts.notes,
    },
  });
}

export async function finishDataLoad(
  id: string,
  counts: { inserted: number; updated: number; suppressed: number }
) {
  await prisma.phillyDataLoad.update({
    where: { id },
    data: {
      rowsInserted: counts.inserted,
      rowsUpdated: counts.updated,
      rowsSuppressed: counts.suppressed,
    },
  });
}

/**
 * Run an async fn against batches of `items` of size `batchSize`, with bounded
 * concurrency. Identical to the NYC `batched` helper.
 */
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));
}


/**
 * Year semantics for state (Future Ready PA Index) indicators.
 *
 * PDE's Future Ready glossary defines Regular/Persistent Attendance as "a
 * lagging indicator indicating data is from the year prior to the reporting
 * year": the attendance element in the 2024-25 workbook describes 2023-24
 * attendance. Every served row is keyed by the year the students were
 * actually in school (the OBSERVATION year), so this metric is stored under
 * report year − 1. Other indicators keep the workbook's report year; add a
 * key here only with documentation from PDE (never shift a whole workbook).
 * (Corrected 2026-09-07 after an external audit: the site previously stored
 * the attendance element under the report year, which made the state's and
 * district's figures disagree by ~11 points in 2020-21/2021-22 — an artifact
 * of comparing different school years, not a change in the state's rules.)
 */
export const STATE_METRIC_YEAR_OFFSET: Record<string, number> = {
  attendance_persistence_rate: -1,
};

/** "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")}`;
}

/** The school year a state indicator's value describes, given the workbook's report year. */
export function observationYear(metricKey: string, reportYear: string): string {
  return shiftSchoolYear(reportYear, STATE_METRIC_YEAR_OFFSET[metricKey] ?? 0);
}

/**
 * Map the state's (AUN, PA/Schl code) key to a ULCS code. Two SDP schools
 * share a state code with their "Continuation Academy" program (Olney HS
 * 8508, Stetson MS 8507): the state publishes ONE row per code, which
 * belongs to the main school. Resolve duplicates deterministically — prefer
 * the school whose name does not contain "Continuation" — and log every
 * ambiguity so it is never silent. (Before this helper, the last directory
 * row iterated won, which is order-dependent.)
 */
export function buildPdeToUlcs(
  schools: { ulcsCode: string; aunCode: string; paCode: string | null; name?: string | null }[],
): Map<string, string> {
  const byKey = new Map<string, typeof schools>();
  for (const s of schools) {
    if (!s.paCode || s.paCode === "N/A") continue;
    const k = `${s.aunCode}-${s.paCode}`;
    byKey.set(k, [...(byKey.get(k) ?? []), s]);
  }
  const out = new Map<string, string>();
  for (const [k, list] of byKey) {
    if (list.length === 1) {
      out.set(k, list[0].ulcsCode);
      continue;
    }
    const preferred =
      list.find((s) => !/continuation/i.test(s.name ?? "")) ?? list[0];
    out.set(k, preferred.ulcsCode);
    console.warn(
      `[map] state code ${k} shared by ${list.map((s) => `${s.ulcsCode} ${s.name ?? ""}`).join(" | ")} → assigned to ${preferred.ulcsCode}`,
    );
  }
  return out;
}