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:
2026-03-06 11:35:11 +00:00
co-authored by Claude Opus 4.6
parent daf5580c39
commit 5129f7827f
12 changed files with 871 additions and 625 deletions
@@ -48,10 +48,8 @@ CREATE TABLE "user_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"profile" jsonb,
"server_integration_id" integer,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_user_integrations_user_provider" UNIQUE("user_id","provider")
@@ -115,28 +113,16 @@ CREATE TABLE "chat_sessions" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "projects" (
CREATE TABLE "dashboard_defaults" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"slug" text NOT NULL,
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
"host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug")
CONSTRAINT "dashboard_defaults_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
CREATE TABLE "screens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
CREATE TABLE "dashboards" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
@@ -147,7 +133,31 @@ CREATE TABLE "workspaces" (
"sort_order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_workspaces_user_id" UNIQUE("user_id","id")
CONSTRAINT "uq_dashboards_user_id" UNIQUE("user_id","id")
);
--> statement-breakpoint
CREATE TABLE "projects" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"slug" text NOT NULL,
"meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"terminals" jsonb DEFAULT '[]'::jsonb NOT NULL,
"host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_projects_user_slug" UNIQUE("user_id","slug")
);
--> statement-breakpoint
CREATE TABLE "screens" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"name" text NOT NULL,
"layout" jsonb DEFAULT '[]'::jsonb NOT NULL,
"terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
"host_terminals" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_screens_user_name" UNIQUE("user_id","name")
);
--> statement-breakpoint
CREATE TABLE "extensions" (
@@ -185,20 +195,6 @@ CREATE TABLE "processes" (
CONSTRAINT "uq_processes_scope_user_dir" UNIQUE("scope","user_id","dir_name")
);
--> statement-breakpoint
CREATE TABLE "resources" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
"dir_name" text NOT NULL,
"name" text NOT NULL,
"description" text,
"body" text,
"version" integer DEFAULT 1 NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_resources_scope_dir" UNIQUE("scope","dir_name")
);
--> statement-breakpoint
CREATE TABLE "skills" (
"id" serial PRIMARY KEY NOT NULL,
"scope" text NOT NULL,
@@ -296,18 +292,49 @@ CREATE TABLE "server_config" (
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "server_integrations" (
"id" serial PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "server_integrations_provider_unique" UNIQUE("provider")
);
--> statement-breakpoint
CREATE TABLE "email_accounts" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"provider" text NOT NULL,
"email" text NOT NULL,
"display_name" text,
"imap_host" text NOT NULL,
"imap_port" integer NOT NULL,
"imap_secure" boolean DEFAULT true NOT NULL,
"auth_type" text NOT NULL,
"credentials" jsonb DEFAULT '{}'::jsonb NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"status" text DEFAULT 'connected' NOT NULL,
"sync_meta" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "uq_email_accounts_user_email" UNIQUE("user_id","email")
);
--> statement-breakpoint
ALTER TABLE "passkey_challenges" ADD CONSTRAINT "passkey_challenges_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "passkeys" ADD CONSTRAINT "passkeys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dock_configs" ADD CONSTRAINT "dock_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_integrations" ADD CONSTRAINT "user_integrations_server_integration_id_server_integrations_id_fk" FOREIGN KEY ("server_integration_id") REFERENCES "public"."server_integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_settings" ADD CONSTRAINT "user_settings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_state" ADD CONSTRAINT "user_state_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_messages" ADD CONSTRAINT "chat_messages_session_id_chat_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."chat_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_sessions" ADD CONSTRAINT "chat_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboard_defaults" ADD CONSTRAINT "dashboard_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "projects" ADD CONSTRAINT "projects_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "screens" ADD CONSTRAINT "screens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspaces" ADD CONSTRAINT "workspaces_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "extensions" ADD CONSTRAINT "extensions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "processes" ADD CONSTRAINT "processes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "skills" ADD CONSTRAINT "skills_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
@@ -316,6 +343,7 @@ ALTER TABLE "tools" ADD CONSTRAINT "tools_user_id_users_id_fk" FOREIGN KEY ("use
ALTER TABLE "queue_jobs" ADD CONSTRAINT "queue_jobs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "task_logs" ADD CONSTRAINT "task_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "terminal_containers" ADD CONSTRAINT "terminal_containers_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "email_accounts" ADD CONSTRAINT "email_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_token_blacklist_expires" ON "token_blacklist" USING btree ("expires_at");--> statement-breakpoint
CREATE INDEX "idx_chat_groups_user" ON "chat_groups" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_messages_session_order" ON "chat_messages" USING btree ("session_id","sort_order");--> statement-breakpoint
@@ -327,7 +355,6 @@ CREATE INDEX "idx_extensions_user" ON "extensions" USING btree ("user_id");--> s
CREATE INDEX "idx_item_chats_type_item" ON "item_chats" USING btree ("item_type","item_id");--> statement-breakpoint
CREATE INDEX "idx_processes_scope" ON "processes" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_processes_user" ON "processes" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_resources_scope" ON "resources" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_skills_scope" ON "skills" USING btree ("scope");--> statement-breakpoint
CREATE INDEX "idx_skills_user" ON "skills" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "idx_tasks_scope" ON "tasks" USING btree ("scope");--> statement-breakpoint
@@ -1,4 +0,0 @@
ALTER TABLE "workspaces" RENAME TO "dashboards";--> statement-breakpoint
ALTER TABLE "dashboards" DROP CONSTRAINT "workspaces_user_id_users_id_fk";--> statement-breakpoint
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "dashboards" RENAME CONSTRAINT "uq_workspaces_user_id" TO "uq_dashboards_user_id";
@@ -1,5 +1,5 @@
{
"id": "65f7442c-6226-471f-b6b3-d559e820ab61",
"id": "7187e43e-021e-4623-8c74-8e5e7bc73926",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -347,29 +347,18 @@
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"server_integration_id": {
"name": "server_integration_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"profile": {
"name": "profile",
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": false
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
@@ -400,6 +389,19 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_integrations_server_integration_id_server_integrations_id_fk": {
"name": "user_integrations_server_integration_id_server_integrations_id_fk",
"tableFrom": "user_integrations",
"tableTo": "server_integrations",
"columnsFrom": [
"server_integration_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -929,6 +931,177 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.dashboard_defaults": {
"name": "dashboard_defaults",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"terminals": {
"name": "terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"host_terminals": {
"name": "host_terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"dashboard_defaults_user_id_users_id_fk": {
"name": "dashboard_defaults_user_id_users_id_fk",
"tableFrom": "dashboard_defaults",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"dashboard_defaults_user_id_unique": {
"name": "dashboard_defaults_user_id_unique",
"nullsNotDistinct": false,
"columns": [
"user_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.dashboards": {
"name": "dashboards",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"layout": {
"name": "layout",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"terminals": {
"name": "terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"host_terminals": {
"name": "host_terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"dashboards_user_id_users_id_fk": {
"name": "dashboards_user_id_users_id_fk",
"tableFrom": "dashboards",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"uq_dashboards_user_id": {
"name": "uq_dashboards_user_id",
"nullsNotDistinct": false,
"columns": [
"user_id",
"id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.projects": {
"name": "projects",
"schema": "",
@@ -972,6 +1145,13 @@
"notNull": true,
"default": "'[]'::jsonb"
},
"host_terminals": {
"name": "host_terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
@@ -1047,6 +1227,20 @@
"notNull": true,
"default": "'[]'::jsonb"
},
"terminals": {
"name": "terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"host_terminals": {
"name": "host_terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
@@ -1086,109 +1280,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.workspaces": {
"name": "workspaces",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"layout": {
"name": "layout",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"terminals": {
"name": "terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"host_terminals": {
"name": "host_terminals",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"workspaces_user_id_users_id_fk": {
"name": "workspaces_user_id_users_id_fk",
"tableFrom": "workspaces",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"uq_workspaces_user_id": {
"name": "uq_workspaces_user_id",
"nullsNotDistinct": false,
"columns": [
"user_id",
"id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.extensions": {
"name": "extensions",
"schema": "",
@@ -1514,108 +1605,6 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.resources": {
"name": "resources",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": true
},
"dir_name": {
"name": "dir_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_resources_scope": {
"name": "idx_resources_scope",
"columns": [
{
"expression": "scope",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"uq_resources_scope_dir": {
"name": "uq_resources_scope_dir",
"nullsNotDistinct": false,
"columns": [
"scope",
"dir_name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.skills": {
"name": "skills",
"schema": "",
@@ -2427,6 +2416,200 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.server_integrations": {
"name": "server_integrations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"server_integrations_provider_unique": {
"name": "server_integrations_provider_unique",
"nullsNotDistinct": false,
"columns": [
"provider"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.email_accounts": {
"name": "email_accounts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"imap_host": {
"name": "imap_host",
"type": "text",
"primaryKey": false,
"notNull": true
},
"imap_port": {
"name": "imap_port",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"imap_secure": {
"name": "imap_secure",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"auth_type": {
"name": "auth_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"credentials": {
"name": "credentials",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'connected'"
},
"sync_meta": {
"name": "sync_meta",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"email_accounts_user_id_users_id_fk": {
"name": "email_accounts_user_id_users_id_fk",
"tableFrom": "email_accounts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"uq_email_accounts_user_email": {
"name": "uq_email_accounts_user_email",
"nullsNotDistinct": false,
"columns": [
"user_id",
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
@@ -5,15 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1772121115932,
"tag": "0000_clammy_meteorite",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772265600000,
"tag": "0001_rename_workspaces_to_dashboards",
"when": 1772795711164,
"tag": "0000_futuristic_ink",
"breakpoints": true
}
]
+12
View File
@@ -52,5 +52,17 @@ export {
getAllSyncedAccounts,
} from './queries/email-accounts';
export {
getAllDashboardState,
upsertDashboard,
deleteDashboard,
upsertScreen,
deleteScreen,
upsertProject,
deleteProject,
getDefaults,
upsertDefaults,
} from './queries/dashboards';
export { db } from './db';
export * as schema from './schema';
@@ -0,0 +1,219 @@
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { dashboards, screens, projects, dashboardDefaults } from '../schema';
// ── Full state read ──
export async function getAllDashboardState(userId: number): Promise<Record<string, unknown>> {
const result: Record<string, unknown> = {};
const [dashRows, screenRows, projectRows, defaultsRow] = await Promise.all([
db.select().from(dashboards).where(eq(dashboards.userId, userId)),
db.select().from(screens).where(eq(screens.userId, userId)),
db.select().from(projects).where(eq(projects.userId, userId)),
db
.select()
.from(dashboardDefaults)
.where(eq(dashboardDefaults.userId, userId))
.then((rows) => rows[0]),
]);
// Dashboards → workspaces array + per-dashboard keys
if (dashRows.length > 0) {
const sorted = [...dashRows].sort((a, b) => a.sortOrder - b.sortOrder);
result['workspaces'] = sorted.map((d) => ({ id: d.id, name: d.name, ...(d.config as object) }));
for (const d of dashRows) {
result[`ws-layout-${d.id}`] = d.layout;
result[`ws-terminals-${d.id}`] = d.terminals;
result[`ws-host-terminals-${d.id}`] = d.hostTerminals;
}
}
// Defaults
if (defaultsRow) {
result['ws-terminals-default'] = defaultsRow.terminals;
result['ws-host-terminals-default'] = defaultsRow.hostTerminals;
}
// Screens
for (const s of screenRows) {
result[`screens/${s.name}`] = s.layout;
}
// Projects
const projectList = projectRows.map((p) => ({
...(p.meta as object),
id: p.slug,
cwd: `/Projects/${p.slug}`,
}));
result['projects'] = projectList;
for (const p of projectRows) {
result[`proj-layout-${p.slug}`] = p.layout;
result[`proj-terminals-${p.slug}`] = p.terminals;
result[`proj-host-terminals-${p.slug}`] = p.hostTerminals;
}
return result;
}
// ── Dashboard CRUD ──
type UpsertDashboardData = {
name?: string;
config?: unknown;
layout?: unknown;
terminals?: unknown;
hostTerminals?: unknown;
sortOrder?: number;
};
export async function upsertDashboard(userId: number, id: string, data: UpsertDashboardData): Promise<void> {
const now = new Date();
const existing = await db
.select()
.from(dashboards)
.where(and(eq(dashboards.userId, userId), eq(dashboards.id, id)))
.then((rows) => rows[0]);
if (existing) {
const set: Record<string, unknown> = { updatedAt: now };
if (data.name !== undefined) set.name = data.name;
if (data.config !== undefined) set.config = data.config;
if (data.layout !== undefined) set.layout = data.layout;
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
if (data.sortOrder !== undefined) set.sortOrder = data.sortOrder;
await db.update(dashboards).set(set).where(eq(dashboards.id, id));
} else {
await db.insert(dashboards).values({
id,
userId,
name: data.name ?? id,
config: data.config ?? {},
layout: data.layout ?? [],
terminals: data.terminals ?? [],
hostTerminals: data.hostTerminals ?? [],
sortOrder: data.sortOrder ?? 0,
createdAt: now,
updatedAt: now,
});
}
}
export async function deleteDashboard(userId: number, id: string): Promise<void> {
await db.delete(dashboards).where(and(eq(dashboards.userId, userId), eq(dashboards.id, id)));
}
// ── Screen CRUD ──
type UpsertScreenData = {
layout?: unknown;
terminals?: unknown;
hostTerminals?: unknown;
};
export async function upsertScreen(userId: number, name: string, data: UpsertScreenData): Promise<void> {
const now = new Date();
const set: Record<string, unknown> = { updatedAt: now };
if (data.layout !== undefined) set.layout = data.layout;
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
await db
.insert(screens)
.values({
userId,
name,
layout: (data.layout ?? []) as never,
terminals: (data.terminals ?? {}) as never,
hostTerminals: (data.hostTerminals ?? {}) as never,
updatedAt: now,
})
.onConflictDoUpdate({
target: [screens.userId, screens.name],
set,
});
}
export async function deleteScreen(userId: number, name: string): Promise<void> {
await db.delete(screens).where(and(eq(screens.userId, userId), eq(screens.name, name)));
}
// ── Project CRUD ──
type UpsertProjectData = {
meta?: unknown;
layout?: unknown;
terminals?: unknown;
hostTerminals?: unknown;
};
export async function upsertProject(userId: number, slug: string, data: UpsertProjectData): Promise<void> {
const now = new Date();
const existing = await db
.select()
.from(projects)
.where(and(eq(projects.userId, userId), eq(projects.slug, slug)))
.then((rows) => rows[0]);
if (existing) {
const set: Record<string, unknown> = { updatedAt: now };
if (data.meta !== undefined) set.meta = data.meta;
if (data.layout !== undefined) set.layout = data.layout;
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
await db
.update(projects)
.set(set)
.where(and(eq(projects.userId, userId), eq(projects.slug, slug)));
} else {
await db.insert(projects).values({
userId,
slug,
meta: (data.meta ?? {}) as never,
layout: (data.layout ?? []) as never,
terminals: (data.terminals ?? []) as never,
hostTerminals: (data.hostTerminals ?? {}) as never,
createdAt: now,
updatedAt: now,
});
}
}
export async function deleteProject(userId: number, slug: string): Promise<void> {
await db.delete(projects).where(and(eq(projects.userId, userId), eq(projects.slug, slug)));
}
// ── Defaults ──
type UpsertDefaultsData = {
terminals?: unknown;
hostTerminals?: unknown;
};
export async function getDefaults(userId: number): Promise<{ terminals: unknown; hostTerminals: unknown }> {
const [row] = await db.select().from(dashboardDefaults).where(eq(dashboardDefaults.userId, userId));
return { terminals: row?.terminals ?? {}, hostTerminals: row?.hostTerminals ?? {} };
}
export async function upsertDefaults(userId: number, data: UpsertDefaultsData): Promise<void> {
const now = new Date();
const set: Record<string, unknown> = { updatedAt: now };
if (data.terminals !== undefined) set.terminals = data.terminals;
if (data.hostTerminals !== undefined) set.hostTerminals = data.hostTerminals;
await db
.insert(dashboardDefaults)
.values({
userId,
terminals: (data.terminals ?? {}) as never,
hostTerminals: (data.hostTerminals ?? {}) as never,
updatedAt: now,
})
.onConflictDoUpdate({
target: dashboardDefaults.userId,
set,
});
}
@@ -1,40 +1,66 @@
import { pgTable, serial, text, integer, timestamp, jsonb, unique } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const dashboards = pgTable('dashboards', {
id: text('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
hostTerminals: jsonb('host_terminals').notNull().default([]),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_dashboards_user_id').on(table.userId, table.id),
]);
export const dashboards = pgTable(
'dashboards',
{
id: text('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
config: jsonb('config').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
hostTerminals: jsonb('host_terminals').notNull().default([]),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique('uq_dashboards_user_id').on(table.userId, table.id)],
);
export const screens = pgTable('screens', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
layout: jsonb('layout').notNull().default([]),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_screens_user_name').on(table.userId, table.name),
]);
export const screens = pgTable(
'screens',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default({}),
hostTerminals: jsonb('host_terminals').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique('uq_screens_user_name').on(table.userId, table.name)],
);
export const projects = pgTable('projects', {
export const projects = pgTable(
'projects',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
slug: text('slug').notNull(),
meta: jsonb('meta').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
hostTerminals: jsonb('host_terminals').notNull().default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [unique('uq_projects_user_slug').on(table.userId, table.slug)],
);
export const dashboardDefaults = pgTable('dashboard_defaults', {
id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
slug: text('slug').notNull(),
meta: jsonb('meta').notNull().default({}),
layout: jsonb('layout').notNull().default([]),
terminals: jsonb('terminals').notNull().default([]),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' })
.unique(),
terminals: jsonb('terminals').notNull().default({}),
hostTerminals: jsonb('host_terminals').notNull().default({}),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
unique('uq_projects_user_slug').on(table.userId, table.slug),
]);
});
+107 -45
View File
@@ -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);
});
-2
View File
@@ -1,3 +1 @@
export * from './dashboards';
export * from './types';
export * from './utils';
-3
View File
@@ -1,3 +0,0 @@
export type KeyMapping = { file: string; dir?: string };
export type ResolveDirs = { dashDir: string; screensDir: string; projDir: string };
-263
View File
@@ -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),
};
}
-4
View File
@@ -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');