Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/scripts/platform-leads/backfill-cli.ts
T

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;
});
}