153 lines
5.0 KiB
TypeScript
153 lines
5.0 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<T> {
|
||
|
|
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<string, unknown> & { 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<NocoDbRow[]> {
|
||
|
|
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<NocoDbRow>;
|
||
|
|
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<NocoDbSnapshot> {
|
||
|
|
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(),
|
||
|
|
};
|
||
|
|
}
|