194 lines
8.0 KiB
TypeScript
194 lines
8.0 KiB
TypeScript
#!/usr/bin/env tsx
|
|
import "dotenv/config";
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
|
|
import { createPrismaPlatformLeadService } from "@hivegrid/db/platform-leads-admin.server";
|
|
import { disconnectDbClients } from "@hivegrid/db/client.server";
|
|
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
|
|
|
|
import { closeSharedStores } from "../../apps/web/app/shared-store.server.js";
|
|
import { userHasPermission } from "../../apps/web/app/utils/db/permissions/userRoles.db.server.js";
|
|
|
|
import {
|
|
isPlatformLeadLegacyStore,
|
|
isSuccessfulPlatformLeadBackfillManifest,
|
|
PLATFORM_LEAD_SOURCE_KEY_BY_LEGACY_STORE,
|
|
PlatformLeadBackfillInputError,
|
|
runPlatformLeadBackfill,
|
|
type PlatformLeadBackfillInputFormat,
|
|
type PlatformLeadBackfillMode,
|
|
type PlatformLeadLegacyStore,
|
|
} from "./backfill.js";
|
|
|
|
const MAX_INPUT_BYTES = 100 * 1024 * 1024;
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
const IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
|
|
|
export type PlatformLeadBackfillCliOptions = {
|
|
mode: PlatformLeadBackfillMode;
|
|
legacyStore: PlatformLeadLegacyStore;
|
|
inputPath: string;
|
|
manifestPath: string;
|
|
format: PlatformLeadBackfillInputFormat;
|
|
runId: string;
|
|
actorAdminUserId?: string;
|
|
expectedInputSha256?: string;
|
|
expectedAccepted?: number;
|
|
};
|
|
|
|
export function parsePlatformLeadBackfillCliArgs(argv: readonly string[]): PlatformLeadBackfillCliOptions {
|
|
const values = parseNamedArguments(argv);
|
|
rejectUnknownKeys(values, ["mode", "store", "input", "manifest", "format", "run-id", "actor-admin-user-id", "expected-input-sha256", "expected-accepted"]);
|
|
|
|
const mode = requireOneOf(values.get("mode"), ["dry-run", "apply", "replay"] as const);
|
|
const storeValue = requireArgument(values.get("store"));
|
|
if (!isPlatformLeadLegacyStore(storeValue)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
const inputPath = requireArgument(values.get("input"));
|
|
const manifestPath = requireArgument(values.get("manifest"));
|
|
const format = requireOneOf(values.get("format") ?? "jsonl", ["jsonl"] as const);
|
|
const runId = values.get("run-id")?.trim() || randomUUID();
|
|
if (!/^[A-Za-z0-9][A-Za-z0-9_.:-]{0,190}$/u.test(runId)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
|
|
const options: PlatformLeadBackfillCliOptions = {
|
|
mode,
|
|
legacyStore: storeValue,
|
|
inputPath,
|
|
manifestPath,
|
|
format,
|
|
runId,
|
|
};
|
|
if (mode !== "dry-run") {
|
|
options.actorAdminUserId = requireIdentifier(values.get("actor-admin-user-id"));
|
|
options.expectedInputSha256 = requireSha256(values.get("expected-input-sha256"));
|
|
options.expectedAccepted = requireNonNegativeInteger(values.get("expected-accepted"));
|
|
} else if (values.has("actor-admin-user-id") || values.has("expected-input-sha256") || values.has("expected-accepted")) {
|
|
throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
}
|
|
return options;
|
|
}
|
|
|
|
export async function runPlatformLeadBackfillCli(argv: readonly string[] = process.argv.slice(2)): Promise<number> {
|
|
let options: PlatformLeadBackfillCliOptions;
|
|
try {
|
|
options = parsePlatformLeadBackfillCliArgs(argv);
|
|
} catch (error) {
|
|
writeSafeError(error);
|
|
return 1;
|
|
}
|
|
|
|
try {
|
|
const inputStat = await stat(options.inputPath);
|
|
if (!inputStat.isFile() || inputStat.size > MAX_INPUT_BYTES) throw new PlatformLeadBackfillInputError("INVALID_INPUT");
|
|
const bytes = await readFile(options.inputPath);
|
|
const common = {
|
|
mode: options.mode,
|
|
legacyStore: options.legacyStore,
|
|
format: options.format,
|
|
bytes,
|
|
runId: options.runId,
|
|
expectedInputSha256: options.expectedInputSha256,
|
|
expectedAccepted: options.expectedAccepted,
|
|
} as const;
|
|
|
|
const manifest =
|
|
options.mode === "dry-run"
|
|
? await runPlatformLeadBackfill(common)
|
|
: await runWithRlsContext({ userId: options.actorAdminUserId! }, async () => {
|
|
if (!(await userHasPermission(options.actorAdminUserId!, null, "admin.leads.admin"))) {
|
|
throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
}
|
|
const service = createPrismaPlatformLeadService();
|
|
const expectedSourceKey = PLATFORM_LEAD_SOURCE_KEY_BY_LEGACY_STORE[options.legacyStore];
|
|
const source = (await service.listSources()).find(({ key }) => key === expectedSourceKey);
|
|
if (!source) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
return runPlatformLeadBackfill({
|
|
...common,
|
|
sourceId: source.id,
|
|
actorAdminUserId: options.actorAdminUserId,
|
|
importer: (input) => service.importLead(input),
|
|
});
|
|
});
|
|
|
|
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
await writeFile(options.manifestPath, serialized, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
process.stdout.write(serialized);
|
|
return isSuccessfulPlatformLeadBackfillManifest(manifest) ? 0 : 2;
|
|
} catch (error) {
|
|
writeSafeError(error);
|
|
return 1;
|
|
} finally {
|
|
await Promise.allSettled([closeSharedStores(), disconnectDbClients()]);
|
|
}
|
|
}
|
|
|
|
function parseNamedArguments(argv: readonly string[]): Map<string, string> {
|
|
const values = new Map<string, string>();
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const argument = argv[index]!;
|
|
if (argument === "--" && index === 0) continue;
|
|
if (!argument.startsWith("--")) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
const equalsIndex = argument.indexOf("=");
|
|
const key = argument.slice(2, equalsIndex === -1 ? undefined : equalsIndex);
|
|
const value = equalsIndex === -1 ? argv[++index] : argument.slice(equalsIndex + 1);
|
|
if (!key || !value || value.startsWith("--") || values.has(key)) {
|
|
throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
}
|
|
values.set(key, value);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function rejectUnknownKeys(values: ReadonlyMap<string, string>, allowedKeys: readonly string[]): void {
|
|
const allowed = new Set(allowedKeys);
|
|
if ([...values.keys()].some((key) => !allowed.has(key))) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
}
|
|
|
|
function requireArgument(value: string | undefined): string {
|
|
const normalized = value?.trim();
|
|
if (!normalized || /[\u0000-\u001f\u007f]/u.test(normalized)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
return normalized;
|
|
}
|
|
|
|
function requireIdentifier(value: string | undefined): string {
|
|
const normalized = requireArgument(value);
|
|
if (normalized.length > 191 || !IDENTIFIER_PATTERN.test(normalized)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
return normalized;
|
|
}
|
|
|
|
function requireSha256(value: string | undefined): string {
|
|
const normalized = requireArgument(value).toLowerCase();
|
|
if (!SHA256_PATTERN.test(normalized)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
return normalized;
|
|
}
|
|
|
|
function requireNonNegativeInteger(value: string | undefined): number {
|
|
const normalized = requireArgument(value);
|
|
if (!/^\d+$/u.test(normalized)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
const parsed = Number(normalized);
|
|
if (!Number.isSafeInteger(parsed) || parsed < 0) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
return parsed;
|
|
}
|
|
|
|
function requireOneOf<const T extends readonly string[]>(value: string | undefined, allowed: T): T[number] {
|
|
const normalized = requireArgument(value);
|
|
if (!(allowed as readonly string[]).includes(normalized)) throw new PlatformLeadBackfillInputError("INVALID_CONFIGURATION");
|
|
return normalized as T[number];
|
|
}
|
|
|
|
function writeSafeError(error: unknown): void {
|
|
const code = error instanceof PlatformLeadBackfillInputError ? error.code : "OPERATION_FAILED";
|
|
process.stderr.write(`${JSON.stringify({ ok: false, code })}\n`);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
runPlatformLeadBackfillCli()
|
|
.then((exitCode) => {
|
|
process.exitCode = exitCode;
|
|
})
|
|
.catch(() => {
|
|
process.exitCode = 1;
|
|
});
|
|
}
|