196 lines
5.9 KiB
JavaScript
196 lines
5.9 KiB
JavaScript
import { readFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import { Pool } from "pg";
|
|
|
|
class SafeImportError extends Error {}
|
|
|
|
function requiredDate(value, field) {
|
|
const date = new Date(value);
|
|
if (!value || !Number.isFinite(date.getTime())) {
|
|
throw new SafeImportError(`source contains an invalid ${field}`);
|
|
}
|
|
return date;
|
|
}
|
|
|
|
function optionalDate(value, field) {
|
|
if (value === null || value === undefined || value === "") return null;
|
|
return requiredDate(value, field);
|
|
}
|
|
|
|
function metadataValue(metadata, key) {
|
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
return null;
|
|
}
|
|
const value = metadata[key];
|
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
export function normalizeSupabaseUsers(source) {
|
|
if (!Array.isArray(source)) {
|
|
throw new SafeImportError("source must be a JSON array of auth users");
|
|
}
|
|
|
|
const emails = new Set();
|
|
return source.map((record) => {
|
|
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
throw new SafeImportError("source contains an invalid auth user");
|
|
}
|
|
|
|
const id = typeof record.id === "string" ? record.id.trim().toLowerCase() : "";
|
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) {
|
|
throw new SafeImportError("source contains an invalid user id");
|
|
}
|
|
const email =
|
|
typeof record.email === "string" ? record.email.trim().toLowerCase() : "";
|
|
if (!/^[^\s@]+@[^\s@]+$/.test(email)) {
|
|
throw new SafeImportError("source contains an invalid user email");
|
|
}
|
|
if (emails.has(email)) {
|
|
throw new SafeImportError("source contains duplicate canonical emails");
|
|
}
|
|
emails.add(email);
|
|
|
|
const emailVerifiedAt = optionalDate(
|
|
record.email_confirmed_at,
|
|
"email confirmation timestamp",
|
|
);
|
|
const metadata = record.raw_user_meta_data;
|
|
const name =
|
|
metadataValue(metadata, "full_name") ??
|
|
metadataValue(metadata, "name") ??
|
|
email.slice(0, email.indexOf("@"));
|
|
|
|
return {
|
|
id,
|
|
email,
|
|
emailVerified: emailVerifiedAt !== null,
|
|
emailVerifiedAt,
|
|
name,
|
|
image:
|
|
metadataValue(metadata, "avatar_url") ??
|
|
metadataValue(metadata, "picture"),
|
|
createdAt: requiredDate(record.created_at, "creation timestamp"),
|
|
updatedAt: requiredDate(record.updated_at, "update timestamp"),
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function applyIdentityUsers(client, users) {
|
|
await client.query("BEGIN");
|
|
try {
|
|
for (const user of users) {
|
|
await client.query(
|
|
`
|
|
insert into identity.users (
|
|
id, name, email, email_verified, email_verified_at, image,
|
|
created_at, updated_at
|
|
) values ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
on conflict (id) do update set
|
|
name = excluded.name,
|
|
email = excluded.email,
|
|
email_verified = excluded.email_verified,
|
|
email_verified_at = excluded.email_verified_at,
|
|
image = excluded.image,
|
|
created_at = excluded.created_at,
|
|
updated_at = excluded.updated_at
|
|
`,
|
|
[
|
|
user.id,
|
|
user.name,
|
|
user.email,
|
|
user.emailVerified,
|
|
user.emailVerifiedAt,
|
|
user.image,
|
|
user.createdAt,
|
|
user.updatedAt,
|
|
],
|
|
);
|
|
}
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function parseArguments(arguments_) {
|
|
const apply = arguments_.includes("--apply");
|
|
const positional = arguments_.filter((argument) => argument !== "--apply");
|
|
if (positional.length !== 1 || arguments_.some((argument) => argument.startsWith("--") && argument !== "--apply")) {
|
|
throw new SafeImportError(
|
|
"usage: node scripts/import-supabase-auth-users.mjs <export.json> [--apply]",
|
|
);
|
|
}
|
|
return { apply, sourcePath: positional[0] };
|
|
}
|
|
|
|
async function loadUsers(sourcePath) {
|
|
let contents;
|
|
try {
|
|
contents = await readFile(sourcePath, "utf8");
|
|
} catch {
|
|
throw new SafeImportError("unable to read source file");
|
|
}
|
|
|
|
try {
|
|
return normalizeSupabaseUsers(JSON.parse(contents));
|
|
} catch (error) {
|
|
if (error instanceof SafeImportError) throw error;
|
|
throw new SafeImportError("source file is not valid JSON");
|
|
}
|
|
}
|
|
|
|
export async function main(arguments_, env) {
|
|
const { apply, sourcePath } = parseArguments(arguments_);
|
|
const users = await loadUsers(sourcePath);
|
|
const summary = {
|
|
mode: apply ? "apply" : "dry-run",
|
|
users: users.length,
|
|
verified: users.filter((user) => user.emailVerified).length,
|
|
};
|
|
|
|
if (!apply) {
|
|
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
return;
|
|
}
|
|
|
|
const databaseUrl = env.IDENTITY_DATABASE_URL?.trim();
|
|
if (!databaseUrl) {
|
|
throw new SafeImportError("IDENTITY_DATABASE_URL is required for --apply");
|
|
}
|
|
if (!databaseUrl.startsWith("postgresql://")) {
|
|
throw new SafeImportError("IDENTITY_DATABASE_URL must be a PostgreSQL URL");
|
|
}
|
|
|
|
const pool = new Pool({
|
|
connectionString: databaseUrl,
|
|
options: "-c search_path=identity,pg_catalog",
|
|
application_name: "jyotisha-identity-import",
|
|
max: 1,
|
|
});
|
|
try {
|
|
const client = await pool.connect();
|
|
try {
|
|
await applyIdentityUsers(client, users);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch {
|
|
throw new SafeImportError("identity user import failed");
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
process.stdout.write(`${JSON.stringify(summary)}\n`);
|
|
}
|
|
|
|
const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : "";
|
|
if (import.meta.url === invokedPath) {
|
|
main(process.argv.slice(2), process.env).catch((error) => {
|
|
const message =
|
|
error instanceof SafeImportError ? error.message : "identity user import failed";
|
|
process.stderr.write(`${message}\n`);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|