dashboards: migrate from filesystem to postgresql
Replace JSON file storage with DB tables for dashboard layouts, screens, projects, and terminal defaults. Fresh drizzle migration with dashboardDefaults table and new columns on screens/projects. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import { mkdir, readdir, rm, cp } from 'node:fs/promises';
|
||||
import { mkdir, rm, cp } from 'node:fs/promises';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { getDirs, migrateDashboardsDir, migrateFromState, migrateHomepageToScreens, readAllDashboardsState, resolveKey, writeJsonFile } from './utils';
|
||||
import { getUserProjectsDir } from '@@/data-path';
|
||||
import {
|
||||
getAllDashboardState,
|
||||
upsertDashboard,
|
||||
deleteDashboard,
|
||||
upsertScreen,
|
||||
deleteScreen,
|
||||
upsertProject,
|
||||
deleteProject,
|
||||
upsertDefaults,
|
||||
} from 'officerdb';
|
||||
|
||||
const TEMPLATES_DIR = resolve(import.meta.dir, '../../../../seed/project-templates');
|
||||
|
||||
@@ -10,51 +20,96 @@ export const dashboardsRouter = createRouter();
|
||||
|
||||
// GET /dashboards
|
||||
dashboardsRouter.get('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
|
||||
await migrateDashboardsDir(email);
|
||||
|
||||
const dirs = getDirs(email);
|
||||
|
||||
const dirFile = Bun.file(join(dirs.dashDir, 'index.json'));
|
||||
if (!(await dirFile.exists())) {
|
||||
await migrateFromState(email, dirs);
|
||||
}
|
||||
|
||||
await migrateHomepageToScreens(dirs, email);
|
||||
|
||||
const state = await readAllDashboardsState(dirs);
|
||||
const userId = ctx.get('user').id;
|
||||
const state = await getAllDashboardState(userId);
|
||||
return ctx.json(state);
|
||||
});
|
||||
|
||||
// PATCH /dashboards
|
||||
dashboardsRouter.patch('/', async (ctx) => {
|
||||
const email = ctx.get('user').email;
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id;
|
||||
const body = ctx.get('body') as Record<string, unknown>;
|
||||
const dirs = getDirs(email);
|
||||
|
||||
await mkdir(dirs.dashDir, { recursive: true });
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
// Handle proj-meta-{slug}: create/update/delete project
|
||||
// workspaces — array of dashboard definitions with ordering
|
||||
if (key === 'workspaces') {
|
||||
const workspaces = value as Array<{ id: string; name: string; [k: string]: unknown }>;
|
||||
for (let i = 0; i < workspaces.length; i++) {
|
||||
const ws = workspaces[i]!;
|
||||
const { id, name, ...config } = ws;
|
||||
await upsertDashboard(userId, id, { name, config, sortOrder: i });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ws-layout-{id}
|
||||
const wsLayoutMatch = key.match(/^ws-layout-(.+)$/);
|
||||
if (wsLayoutMatch) {
|
||||
const id = wsLayoutMatch[1]!;
|
||||
if (value === null) {
|
||||
await deleteDashboard(userId, id);
|
||||
} else {
|
||||
await upsertDashboard(userId, id, { layout: value });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ws-terminals-default / ws-host-terminals-default
|
||||
if (key === 'ws-terminals-default') {
|
||||
await upsertDefaults(userId, { terminals: value });
|
||||
continue;
|
||||
}
|
||||
if (key === 'ws-host-terminals-default') {
|
||||
await upsertDefaults(userId, { hostTerminals: value });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ws-terminals-{id}
|
||||
const wsTerminalsMatch = key.match(/^ws-terminals-(.+)$/);
|
||||
if (wsTerminalsMatch) {
|
||||
const id = wsTerminalsMatch[1]!;
|
||||
await upsertDashboard(userId, id, { terminals: value });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ws-host-terminals-{id}
|
||||
const wsHostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
|
||||
if (wsHostTerminalsMatch) {
|
||||
const id = wsHostTerminalsMatch[1]!;
|
||||
await upsertDashboard(userId, id, { hostTerminals: value });
|
||||
continue;
|
||||
}
|
||||
|
||||
// screens/{name}
|
||||
const screensMatch = key.match(/^screens\/(.+)$/);
|
||||
if (screensMatch) {
|
||||
const name = screensMatch[1]!;
|
||||
if (value === null) {
|
||||
await deleteScreen(userId, name);
|
||||
} else {
|
||||
await upsertScreen(userId, name, { layout: value });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// proj-meta-{slug} — create/update/delete project
|
||||
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
|
||||
if (projMetaMatch) {
|
||||
const slug = projMetaMatch[1]!;
|
||||
const projectDir = join(dirs.projDir, slug);
|
||||
const projectDir = join(getUserProjectsDir(user.email), slug);
|
||||
|
||||
if (value === null) {
|
||||
await deleteProject(userId, slug);
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
const officerdevDir = join(projectDir, '.officerdev');
|
||||
const metaFile = join(officerdevDir, 'meta.json');
|
||||
const isNew = !(await Bun.file(metaFile).exists());
|
||||
await mkdir(officerdevDir, { recursive: true });
|
||||
await writeJsonFile(metaFile, value);
|
||||
const meta = value as Record<string, unknown>;
|
||||
const isNew = !(await Bun.file(join(projectDir, '.officerdev', 'meta.json')).exists());
|
||||
await upsertProject(userId, slug, { meta: value });
|
||||
|
||||
if (isNew) {
|
||||
const meta = value as Record<string, unknown>;
|
||||
if (meta.projectType === 'app') {
|
||||
const templateDir = join(TEMPLATES_DIR, 'simple-app-template');
|
||||
const entries = readdirSync(templateDir);
|
||||
@@ -63,7 +118,9 @@ dashboardsRouter.patch('/', async (ctx) => {
|
||||
await cp(join(templateDir, entry), join(projectDir, entry), { recursive: true });
|
||||
}
|
||||
const pkgPath = join(projectDir, 'package.json');
|
||||
const pkg = await Bun.file(pkgPath).json().catch(() => null);
|
||||
const pkg = await Bun.file(pkgPath)
|
||||
.json()
|
||||
.catch(() => null);
|
||||
if (pkg) {
|
||||
pkg.name = slug;
|
||||
await Bun.write(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
@@ -77,26 +134,31 @@ dashboardsRouter.patch('/', async (ctx) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapping = resolveKey(dirs, key);
|
||||
if (!mapping) continue;
|
||||
|
||||
if (value === null) {
|
||||
try {
|
||||
await rm(mapping.file, { force: true });
|
||||
if (mapping.dir) {
|
||||
const remaining = await readdir(mapping.dir);
|
||||
if (remaining.length === 0) await rm(mapping.dir, { recursive: true, force: true });
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// proj-layout-{slug}
|
||||
const projLayoutMatch = key.match(/^proj-layout-(.+)$/);
|
||||
if (projLayoutMatch) {
|
||||
const slug = projLayoutMatch[1]!;
|
||||
await upsertProject(userId, slug, { layout: value });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
|
||||
await writeJsonFile(mapping.file, value);
|
||||
// proj-terminals-{slug}
|
||||
const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/);
|
||||
if (projTerminalsMatch) {
|
||||
const slug = projTerminalsMatch[1]!;
|
||||
await upsertProject(userId, slug, { terminals: value });
|
||||
continue;
|
||||
}
|
||||
|
||||
// proj-host-terminals-{slug}
|
||||
const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
|
||||
if (projHostTerminalsMatch) {
|
||||
const slug = projHostTerminalsMatch[1]!;
|
||||
await upsertProject(userId, slug, { hostTerminals: value });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const state = await readAllDashboardsState(dirs);
|
||||
const state = await getAllDashboardState(userId);
|
||||
return ctx.json(state);
|
||||
});
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export * from './dashboards';
|
||||
export * from './types';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export type KeyMapping = { file: string; dir?: string };
|
||||
|
||||
export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string };
|
||||
@@ -1,263 +0,0 @@
|
||||
import { mkdir, readdir, rename, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { getUserDashboardsDir, getUserHomepageDashboardDir, getUserStateFile, getUserProjectsDir, DATA_PATH } from '@@/data-path';
|
||||
import type { KeyMapping, ResolveDirs } from './types';
|
||||
|
||||
const RESERVED_DIRS = new Set(['screens']);
|
||||
|
||||
export function resolveKey(dirs: ResolveDirs, key: string): KeyMapping | null {
|
||||
if (key === 'workspaces') return { file: join(dirs.dashDir, 'index.json') };
|
||||
if (key === 'ws-terminals-default') return { file: join(dirs.dashDir, 'default-terminals.json') };
|
||||
if (key === 'ws-host-terminals-default') return { file: join(dirs.dashDir, 'default-host-terminals.json') };
|
||||
|
||||
// screens/{name} → screens/{name}/layout.json
|
||||
const screensMatch = key.match(/^screens\/(.+)$/);
|
||||
if (screensMatch) {
|
||||
const name = screensMatch[1]!;
|
||||
const dir = join(dirs.screensDir, name);
|
||||
return { file: join(dir, 'layout.json'), dir };
|
||||
}
|
||||
|
||||
const layoutMatch = key.match(/^ws-layout-(.+)$/);
|
||||
if (layoutMatch) {
|
||||
const id = layoutMatch[1]!;
|
||||
const dir = join(dirs.dashDir, id);
|
||||
return { file: join(dir, 'layout.json'), dir };
|
||||
}
|
||||
|
||||
const terminalsMatch = key.match(/^ws-terminals-(.+)$/);
|
||||
if (terminalsMatch) {
|
||||
const id = terminalsMatch[1]!;
|
||||
const dir = join(dirs.dashDir, id);
|
||||
return { file: join(dir, 'terminals.json'), dir };
|
||||
}
|
||||
|
||||
const hostTerminalsMatch = key.match(/^ws-host-terminals-(.+)$/);
|
||||
if (hostTerminalsMatch) {
|
||||
const id = hostTerminalsMatch[1]!;
|
||||
const dir = join(dirs.dashDir, id);
|
||||
return { file: join(dir, 'host-terminals.json'), dir };
|
||||
}
|
||||
|
||||
// Project keys — stored in {projDir}/{slug}/.officerdev/
|
||||
const projMetaMatch = key.match(/^proj-meta-(.+)$/);
|
||||
if (projMetaMatch) {
|
||||
const slug = projMetaMatch[1]!;
|
||||
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||
return { file: join(dir, 'meta.json'), dir };
|
||||
}
|
||||
|
||||
const projLayoutMatch = key.match(/^proj-layout-(.+)$/);
|
||||
if (projLayoutMatch) {
|
||||
const slug = projLayoutMatch[1]!;
|
||||
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||
return { file: join(dir, 'layout.json'), dir };
|
||||
}
|
||||
|
||||
const projTerminalsMatch = key.match(/^proj-terminals-(.+)$/);
|
||||
if (projTerminalsMatch) {
|
||||
const slug = projTerminalsMatch[1]!;
|
||||
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||
return { file: join(dir, 'terminals.json'), dir };
|
||||
}
|
||||
|
||||
const projHostTerminalsMatch = key.match(/^proj-host-terminals-(.+)$/);
|
||||
if (projHostTerminalsMatch) {
|
||||
const slug = projHostTerminalsMatch[1]!;
|
||||
const dir = join(dirs.projDir, slug, '.officerdev');
|
||||
return { file: join(dir, 'host-terminals.json'), dir };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function readJsonFile(path: string): Promise<unknown | null> {
|
||||
try {
|
||||
const file = Bun.file(path);
|
||||
if (!(await file.exists())) return null;
|
||||
return await file.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFile(path: string, data: unknown) {
|
||||
await Bun.write(path, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export async function migrateFromState(email: string, dirs: ResolveDirs) {
|
||||
const stateFile = getUserStateFile(email);
|
||||
const file = Bun.file(stateFile);
|
||||
if (!(await file.exists())) return;
|
||||
|
||||
let state: Record<string, unknown>;
|
||||
try { state = (await file.json()) as Record<string, unknown>; } catch { return; }
|
||||
const wsKeys = Object.keys(state).filter(
|
||||
(k) => k === 'workspaces' || k.startsWith('ws-layout-') || k.startsWith('ws-terminals-') || k.startsWith('ws-host-terminals-'),
|
||||
);
|
||||
if (wsKeys.length === 0) return;
|
||||
|
||||
await mkdir(dirs.dashDir, { recursive: true });
|
||||
|
||||
for (const key of wsKeys) {
|
||||
const migratedKey = key === 'ws-layout-workspaces' ? 'screens/homepage' : key;
|
||||
const mapping = resolveKey(dirs, migratedKey);
|
||||
if (!mapping) continue;
|
||||
if (mapping.dir) await mkdir(mapping.dir, { recursive: true });
|
||||
await writeJsonFile(mapping.file, state[key]);
|
||||
}
|
||||
|
||||
const cleaned = { ...state };
|
||||
for (const key of wsKeys) delete cleaned[key];
|
||||
await Bun.write(stateFile, JSON.stringify(cleaned, null, 2));
|
||||
}
|
||||
|
||||
export async function migrateHomepageToScreens(dirs: ResolveDirs, email: string) {
|
||||
const oldHomepageDir = getUserHomepageDashboardDir(email);
|
||||
const layoutFile = join(oldHomepageDir, 'layout.json');
|
||||
if (!(await Bun.file(layoutFile).exists())) return;
|
||||
|
||||
const targetDir = join(dirs.screensDir, 'homepage');
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
|
||||
for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) {
|
||||
const src = join(oldHomepageDir, name);
|
||||
if (await Bun.file(src).exists()) {
|
||||
await rename(src, join(targetDir, name));
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = await readdir(oldHomepageDir);
|
||||
if (remaining.length === 0) await rm(oldHomepageDir, { recursive: true, force: true });
|
||||
|
||||
const oldWsHomepageDir = join(dirs.dashDir, 'ws-homepage');
|
||||
const oldWsLayout = join(oldWsHomepageDir, 'layout.json');
|
||||
if (!(await Bun.file(oldWsLayout).exists())) return;
|
||||
|
||||
for (const name of ['layout.json', 'terminals.json', 'host-terminals.json']) {
|
||||
const src = join(oldWsHomepageDir, name);
|
||||
const target = join(targetDir, name);
|
||||
if (await Bun.file(src).exists() && !(await Bun.file(target).exists())) {
|
||||
await rename(src, target);
|
||||
}
|
||||
}
|
||||
|
||||
const wsRemaining = await readdir(oldWsHomepageDir);
|
||||
if (wsRemaining.length === 0) await rm(oldWsHomepageDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Migrate on-disk directory from old 'workspaces' name to 'dashboards'
|
||||
export async function migrateDashboardsDir(email: string) {
|
||||
const oldDir = join(DATA_PATH, email, 'workspaces');
|
||||
const newDir = getUserDashboardsDir(email);
|
||||
try {
|
||||
const oldExists = await Bun.file(join(oldDir, 'index.json')).exists() || await readdir(oldDir).then(() => true).catch(() => false);
|
||||
if (oldExists) {
|
||||
const newExists = await readdir(newDir).then(() => true).catch(() => false);
|
||||
if (!newExists) {
|
||||
await rename(oldDir, newDir);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore — old dir doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
async function readDashboardDir(dirPath: string, id: string, result: Record<string, unknown>) {
|
||||
const layout = await readJsonFile(join(dirPath, 'layout.json'));
|
||||
if (layout !== null) result[`ws-layout-${id}`] = layout;
|
||||
|
||||
const terminals = await readJsonFile(join(dirPath, 'terminals.json'));
|
||||
if (terminals !== null) result[`ws-terminals-${id}`] = terminals;
|
||||
|
||||
const hostTerminals = await readJsonFile(join(dirPath, 'host-terminals.json'));
|
||||
if (hostTerminals !== null) result[`ws-host-terminals-${id}`] = hostTerminals;
|
||||
}
|
||||
|
||||
async function readProjectDir(projectPath: string, slug: string, result: Record<string, unknown>): Promise<unknown | null> {
|
||||
const officerdevDir = join(projectPath, '.officerdev');
|
||||
const meta = await readJsonFile(join(officerdevDir, 'meta.json'));
|
||||
if (!meta) return null;
|
||||
|
||||
const layout = await readJsonFile(join(officerdevDir, 'layout.json'));
|
||||
if (layout !== null) result[`proj-layout-${slug}`] = layout;
|
||||
|
||||
const terminals = await readJsonFile(join(officerdevDir, 'terminals.json'));
|
||||
if (terminals !== null) result[`proj-terminals-${slug}`] = terminals;
|
||||
|
||||
const hostTerminals = await readJsonFile(join(officerdevDir, 'host-terminals.json'));
|
||||
if (hostTerminals !== null) result[`proj-host-terminals-${slug}`] = hostTerminals;
|
||||
|
||||
return { ...(meta as object), id: slug, cwd: `/Projects/${slug}` };
|
||||
}
|
||||
|
||||
async function readScreensDir(screensDir: string, result: Record<string, unknown>) {
|
||||
let entries: import('node:fs').Dirent[] = [];
|
||||
try {
|
||||
entries = await readdir(screensDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const layout = await readJsonFile(join(screensDir, entry.name, 'layout.json'));
|
||||
if (layout !== null) result[`screens/${entry.name}`] = layout;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readAllDashboardsState(dirs: ResolveDirs): Promise<Record<string, unknown>> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const indexData = await readJsonFile(join(dirs.dashDir, 'index.json'));
|
||||
if (indexData !== null) result['workspaces'] = indexData;
|
||||
|
||||
const defaultTerminals = await readJsonFile(join(dirs.dashDir, 'default-terminals.json'));
|
||||
if (defaultTerminals !== null) result['ws-terminals-default'] = defaultTerminals;
|
||||
|
||||
const defaultHostTerminals = await readJsonFile(join(dirs.dashDir, 'default-host-terminals.json'));
|
||||
if (defaultHostTerminals !== null) result['ws-host-terminals-default'] = defaultHostTerminals;
|
||||
|
||||
// Read screens
|
||||
await readScreensDir(dirs.screensDir, result);
|
||||
|
||||
// Read per-dashboard subdirs
|
||||
let entries: import('node:fs').Dirent[] = [];
|
||||
try {
|
||||
entries = await readdir(dirs.dashDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || RESERVED_DIRS.has(entry.name)) continue;
|
||||
await readDashboardDir(join(dirs.dashDir, entry.name), entry.name, result);
|
||||
}
|
||||
|
||||
// Read project data — scan directories containing .officerdev/meta.json
|
||||
const projects: unknown[] = [];
|
||||
let projEntries: import('node:fs').Dirent[] = [];
|
||||
try {
|
||||
projEntries = await readdir(dirs.projDir, { withFileTypes: true });
|
||||
} catch {
|
||||
result['projects'] = projects;
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const entry of projEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const meta = await readProjectDir(join(dirs.projDir, entry.name), entry.name, result);
|
||||
if (meta) projects.push(meta);
|
||||
}
|
||||
result['projects'] = projects;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getDirs(email: string): ResolveDirs {
|
||||
return {
|
||||
dashDir: getUserDashboardsDir(email),
|
||||
screensDir: join(getUserDashboardsDir(email), 'screens'),
|
||||
projDir: getUserProjectsDir(email),
|
||||
};
|
||||
}
|
||||
@@ -37,12 +37,8 @@ export const getUserStateDir = (email: string) => join(DATA_PATH, email, 'state'
|
||||
|
||||
export const getUserStateFile = (email: string) => join(DATA_PATH, email, 'state', 'state.json');
|
||||
|
||||
export const getUserDashboardsDir = (email: string) => join(DATA_PATH, email, 'dashboards');
|
||||
|
||||
export const getUserProjectsDir = (email: string) => join(DATA_PATH, email, 'home', 'Projects');
|
||||
|
||||
export const getUserHomepageDashboardDir = (email: string) => join(DATA_PATH, email, 'ws-homepage');
|
||||
|
||||
export const getNativeSkillsDir = () => join(SEED_PATH, 'skills');
|
||||
|
||||
export const getGlobalSkillsDir = () => join(DATA_PATH, 'skills');
|
||||
|
||||
Reference in New Issue
Block a user