migration to postgres

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 17:42:59 +00:00
co-authored by Claude Opus 4.6
parent 7f04ecd644
commit 500a70910e
54 changed files with 4016 additions and 264 deletions
+138
View File
@@ -0,0 +1,138 @@
/**
* Migration script: auth data from JSON files → PostgreSQL
*
* Migrates:
* - users.json → users table
* - passkeys.json → passkeys table (email → userId FK)
* - token-blacklist.json → token_blacklist table
*
* Usage: bun run scripts/migrate-auth-to-pg.ts
*/
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { users, passkeys, tokenBlacklist } from 'officerdb/schema';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const AUTH_DIR = join(DATA_PATH, 'auth');
type OldUser = {
id: number;
email: string;
password: string | null;
role: string;
status: string;
name: string | null;
username: string | null;
avatar: string | null;
passwordChangedAt: number | null;
};
type OldPasskey = {
id: number;
email: string;
origin: string | null;
credentialId: string | null;
publicKey: string | null;
counter: number;
};
type OldBlacklistEntry = {
jti: string;
expiresAt: number;
};
async function readJson<T>(path: string, fallback: T): Promise<T> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return fallback;
return (await file.json()) as T;
} catch {
return fallback;
}
}
async function migrate() {
console.log(`[migrate] Reading JSON files from ${AUTH_DIR}`);
const oldUsers = await readJson<OldUser[]>(join(AUTH_DIR, 'users.json'), []);
const oldPasskeys = await readJson<OldPasskey[]>(join(AUTH_DIR, 'passkeys.json'), []);
const oldBlacklist = await readJson<OldBlacklistEntry[]>(join(AUTH_DIR, 'token-blacklist.json'), []);
console.log(`[migrate] Found: ${oldUsers.length} users, ${oldPasskeys.length} passkeys, ${oldBlacklist.length} blacklisted tokens`);
if (oldUsers.length === 0) {
console.log('[migrate] No users to migrate. Done.');
process.exit(0);
}
// Build email → userId map for passkey migration
const emailToUserId = new Map<string, number>();
// Migrate users
console.log('[migrate] Migrating users...');
for (const u of oldUsers) {
const [inserted] = await db
.insert(users)
.values({
email: u.email,
password: u.password,
role: u.role as 'Member' | 'Admin' | 'Owner' | 'Super Admin',
status: u.status as 'Unverified' | 'Active' | 'Prospect' | 'Invited' | 'Blocked' | 'Banned' | 'Deleted',
name: u.name,
username: u.username,
avatar: u.avatar,
passwordChangedAt: u.passwordChangedAt ? new Date(u.passwordChangedAt) : null,
})
.returning();
emailToUserId.set(u.email, inserted!.id);
console.log(` [user] ${u.email} (old id=${u.id} → new id=${inserted!.id})`);
}
// Migrate passkeys
if (oldPasskeys.length > 0) {
console.log('[migrate] Migrating passkeys...');
for (const p of oldPasskeys) {
const userId = emailToUserId.get(p.email);
if (!userId) {
console.warn(` [passkey] Skipping passkey for unknown email: ${p.email}`);
continue;
}
await db.insert(passkeys).values({
userId,
origin: p.origin,
credentialId: p.credentialId,
publicKey: p.publicKey,
counter: p.counter,
});
console.log(` [passkey] ${p.email} / ${p.origin}`);
}
}
// Migrate token blacklist
if (oldBlacklist.length > 0) {
const now = Math.floor(Date.now() / 1000);
const active = oldBlacklist.filter((b) => b.expiresAt >= now);
console.log(`[migrate] Migrating ${active.length} active blacklisted tokens (${oldBlacklist.length - active.length} expired, skipped)...`);
for (const b of active) {
await db
.insert(tokenBlacklist)
.values({
jti: b.jti,
expiresAt: new Date(b.expiresAt * 1000),
})
.onConflictDoNothing();
}
}
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
/**
* Migration script: server-settings.json → PostgreSQL server_config table
*
* Usage: bun run scripts/migrate-server-settings-to-pg.ts
*/
import { join } from 'node:path';
import { writeServerSettings } from 'officerdb';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
const settingsPath = join(DATA_PATH, 'server-settings', 'server-settings.json');
async function migrate() {
console.log(`[migrate] Reading ${settingsPath}`);
const file = Bun.file(settingsPath);
if (!(await file.exists())) {
console.log('[migrate] No server-settings.json found. Done.');
process.exit(0);
}
let settings: Record<string, unknown>;
try {
settings = await file.json();
} catch {
console.log('[migrate] Could not parse server-settings.json. Done.');
process.exit(0);
}
const keys = Object.keys(settings);
console.log(`[migrate] Found ${keys.length} keys: ${keys.join(', ')}`);
await writeServerSettings(settings);
console.log('[migrate] Written to server_config table.');
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Migration script: per-user settings.json and state.json → PostgreSQL
*
* Reads from $DATA_PATH/{email}/settings/settings.json and state/state.json
* Writes to user_settings and user_state tables
*
* Usage: bun run scripts/migrate-user-settings-to-pg.ts
*/
import { join } from 'node:path';
import { readdirSync } from 'node:fs';
import { getUserByEmail, setUserSettings, patchUserState } from 'officerdb';
const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data');
async function readJson<T>(path: string): Promise<T | null> {
try {
const file = Bun.file(path);
if (!(await file.exists())) return null;
return (await file.json()) as T;
} catch {
return null;
}
}
async function migrate() {
console.log(`[migrate] Scanning ${DATA_PATH} for user data dirs`);
// User data dirs are named by email (contain @)
const entries = readdirSync(DATA_PATH, { withFileTypes: true });
const userDirs = entries.filter((e) => e.isDirectory() && e.name.includes('@'));
console.log(`[migrate] Found ${userDirs.length} user dirs: ${userDirs.map((d) => d.name).join(', ')}`);
for (const dir of userDirs) {
const email = dir.name;
const dbUser = await getUserByEmail(email);
if (!dbUser) {
console.warn(` [skip] ${email} — no matching user in DB`);
continue;
}
// Settings
const settingsPath = join(DATA_PATH, email, 'settings', 'settings.json');
const settings = await readJson<Record<string, unknown>>(settingsPath);
if (settings && Object.keys(settings).length > 0) {
await setUserSettings(dbUser.id, settings);
console.log(` [settings] ${email}${Object.keys(settings).length} keys`);
}
// State
const statePath = join(DATA_PATH, email, 'state', 'state.json');
const state = await readJson<Record<string, unknown>>(statePath);
if (state && Object.keys(state).length > 0) {
await patchUserState(dbUser.id, state);
console.log(` [state] ${email}${Object.keys(state).length} keys`);
}
}
console.log('[migrate] Done!');
process.exit(0);
}
migrate().catch((err) => {
console.error('[migrate] Failed:', err);
process.exit(1);
});