/** * Read-only adapter for the legacy NocoDB Port Nimara base. * * Used by the one-shot migration script (`scripts/migrate-from-nocodb.ts`) * to pull every Interest, Residential Interest, and Website Submission * row from the source-of-truth NocoDB tables. No mutations. * * Auth: `xc-token` header per NocoDB v2 API. * * The shape returned is a verbatim record of the row's fields — caller * is responsible for mapping to the new schema via `nocodb-transform.ts`. */ import { z } from 'zod'; // ─── Configuration ────────────────────────────────────────────────────────── const ConfigSchema = z.object({ url: z.string().url(), token: z.string().min(1), }); export interface NocoDbConfig { url: string; token: string; } export function loadNocoDbConfig(env: NodeJS.ProcessEnv = process.env): NocoDbConfig { return ConfigSchema.parse({ url: env.NOCODB_URL, token: env.NOCODB_TOKEN, }); } // ─── Table identifiers ────────────────────────────────────────────────────── // // These IDs are stable per the NocoDB base — they were captured during the // 2026-05-03 audit and won't change unless the base is rebuilt. If the // base is reset, regenerate them from `getTablesList`. export const NOCO_TABLES = { interests: 'mbs9hjauug4eseo', residentialInterests: 'mscfpwwwjuds4nt', websiteInterestSubmissions: 'mevkpcih67c6jsm', websiteContactFormSubmissions: 'mxk5cd0pmwnwlcl', websiteBerthEoiSupplements: 'mglmioo0ku8zgqj', berths: 'mczgos9hr3oa9qc', } as const; // ─── HTTP shape ───────────────────────────────────────────────────────────── interface NocoDbListResponse { list: T[]; pageInfo: { totalRows: number; page: number; pageSize: number; isFirstPage: boolean; isLastPage: boolean; }; } /** A row's `Id` is always present. The rest of the fields vary per table. */ export type NocoDbRow = Record & { Id: number }; // ─── Public API ───────────────────────────────────────────────────────────── /** * Fetch all rows from a NocoDB table. Auto-paginates until the API * reports `isLastPage`. The legacy base is small (252 Interests rows * being the largest table) so we keep this simple — no streaming. */ export async function fetchAllRows( tableId: string, config: NocoDbConfig, pageSize = 250, ): Promise { const all: NocoDbRow[] = []; let page = 1; // Hard cap to prevent infinite-loop bugs if pageInfo lies. Each page // pulls up to `pageSize` rows, so 200 pages * 250 = 50k rows is the // maximum we'll ever fetch from one table. const MAX_PAGES = 200; while (page <= MAX_PAGES) { const url = new URL(`${config.url}/api/v2/tables/${tableId}/records`); url.searchParams.set('limit', String(pageSize)); url.searchParams.set('offset', String((page - 1) * pageSize)); const res = await fetch(url, { headers: { 'xc-token': config.token, accept: 'application/json', }, }); if (!res.ok) { throw new Error( `NocoDB fetch failed: ${res.status} ${res.statusText} — table ${tableId} page ${page}`, ); } const json = (await res.json()) as NocoDbListResponse; all.push(...json.list); if (json.pageInfo.isLastPage || json.list.length === 0) break; page += 1; } return all; } /** * Convenience snapshot — pulls every table the migration cares about * in parallel. Returned shape is the input the transform layer expects. */ export interface NocoDbSnapshot { interests: NocoDbRow[]; residentialInterests: NocoDbRow[]; websiteInterestSubmissions: NocoDbRow[]; websiteContactFormSubmissions: NocoDbRow[]; websiteBerthEoiSupplements: NocoDbRow[]; berths: NocoDbRow[]; fetchedAt: string; } export async function fetchSnapshot(config: NocoDbConfig): Promise { const [ interests, residentialInterests, websiteInterestSubmissions, websiteContactFormSubmissions, websiteBerthEoiSupplements, berths, ] = await Promise.all([ fetchAllRows(NOCO_TABLES.interests, config), fetchAllRows(NOCO_TABLES.residentialInterests, config), fetchAllRows(NOCO_TABLES.websiteInterestSubmissions, config), fetchAllRows(NOCO_TABLES.websiteContactFormSubmissions, config), fetchAllRows(NOCO_TABLES.websiteBerthEoiSupplements, config), fetchAllRows(NOCO_TABLES.berths, config), ]); return { interests, residentialInterests, websiteInterestSubmissions, websiteContactFormSubmissions, websiteBerthEoiSupplements, berths, fetchedAt: new Date().toISOString(), }; }