Skip to content

Commit 473a8d1

Browse files
author
Sprite
committed
feat: migrate legacy resources into Personal
1 parent e15e3ca commit 473a8d1

21 files changed

Lines changed: 310 additions & 25 deletions

File tree

packages/models/src/db.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ export function getCreateSchemaSql(): string {
424424
"path" text,
425425
"run_mode" text,
426426
"workspace_id" text,
427+
"project_id" text NOT NULL DEFAULT 'default',
427428
"html_app" text,
428429
"app_doc" text,
429430
"receive_clipboard" integer,
@@ -433,12 +434,14 @@ export function getCreateSchemaSql(): string {
433434
);
434435
CREATE INDEX IF NOT EXISTS "idx_workflows_user_id" ON "nodetool_workflows" ("user_id");
435436
CREATE INDEX IF NOT EXISTS "idx_workflows_access" ON "nodetool_workflows" ("access");
437+
CREATE INDEX IF NOT EXISTS "idx_workflows_user_project" ON "nodetool_workflows" ("user_id", "project_id");
436438
437439
CREATE TABLE IF NOT EXISTS "nodetool_jobs" (
438440
"id" text PRIMARY KEY NOT NULL,
439441
"user_id" text NOT NULL,
440442
"job_type" text NOT NULL DEFAULT '',
441443
"workflow_id" text NOT NULL,
444+
"project_id" text NOT NULL DEFAULT 'default',
442445
"status" text NOT NULL DEFAULT 'scheduled',
443446
"name" text DEFAULT '',
444447
"graph" text,
@@ -468,6 +471,7 @@ export function getCreateSchemaSql(): string {
468471
CREATE INDEX IF NOT EXISTS "idx_jobs_worker_id" ON "nodetool_jobs" ("worker_id");
469472
CREATE INDEX IF NOT EXISTS "idx_jobs_heartbeat_at" ON "nodetool_jobs" ("heartbeat_at");
470473
CREATE INDEX IF NOT EXISTS "idx_jobs_recovery" ON "nodetool_jobs" ("status", "heartbeat_at");
474+
CREATE INDEX IF NOT EXISTS "idx_jobs_user_project" ON "nodetool_jobs" ("user_id", "project_id");
471475
472476
CREATE TABLE IF NOT EXISTS "nodetool_messages" (
473477
"id" text PRIMARY KEY NOT NULL,
@@ -502,12 +506,14 @@ export function getCreateSchemaSql(): string {
502506
"id" text PRIMARY KEY NOT NULL,
503507
"user_id" text NOT NULL,
504508
"workflow_id" text,
509+
"project_id" text NOT NULL DEFAULT 'default',
505510
"title" text NOT NULL DEFAULT '',
506511
"created_at" text NOT NULL,
507512
"updated_at" text NOT NULL
508513
);
509514
CREATE INDEX IF NOT EXISTS "idx_threads_user_id" ON "nodetool_threads" ("user_id");
510515
CREATE INDEX IF NOT EXISTS "idx_threads_user_workflow" ON "nodetool_threads" ("user_id", "workflow_id");
516+
CREATE INDEX IF NOT EXISTS "idx_threads_user_project" ON "nodetool_threads" ("user_id", "project_id");
511517
512518
CREATE TABLE IF NOT EXISTS "nodetool_assets" (
513519
"id" text PRIMARY KEY NOT NULL,
@@ -548,11 +554,13 @@ export function getCreateSchemaSql(): string {
548554
"user_id" text NOT NULL,
549555
"name" text NOT NULL DEFAULT '',
550556
"path" text NOT NULL DEFAULT '',
557+
"project_id" text NOT NULL DEFAULT 'default',
551558
"is_default" integer DEFAULT 0,
552559
"created_at" text NOT NULL,
553560
"updated_at" text NOT NULL
554561
);
555562
CREATE INDEX IF NOT EXISTS "idx_workspaces_user_id" ON "nodetool_workspaces" ("user_id");
563+
CREATE INDEX IF NOT EXISTS "idx_workspaces_user_project" ON "nodetool_workspaces" ("user_id", "project_id");
556564
557565
CREATE TABLE IF NOT EXISTS "nodetool_workflow_versions" (
558566
"id" text PRIMARY KEY NOT NULL,

packages/models/src/index.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,13 @@ export type {
255255
ReserveInput
256256
} from "./application-budget.js";
257257
export { ApplicationDeployment } from "./application-deployment.js";
258-
export { Project, LOOSE_PROJECT_ID } from "./project.js";
259-
export type { ProjectResponse } from "./project.js";
258+
export {
259+
Project,
260+
LOOSE_PROJECT_ID,
261+
PERSONAL_PROJECT_KIND,
262+
PERSONAL_PROJECT_NAME
263+
} from "./project.js";
264+
export type { PersonalMigrationReport, ProjectResponse } from "./project.js";
260265
export {
261266
listProjectDocuments,
262267
listProjectEntities,

packages/models/src/job.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export class Job extends DBModel {
2626
declare user_id: string;
2727
declare job_type: string;
2828
declare workflow_id: string;
29+
declare project_id: string;
2930
declare status: JobStatus;
3031
declare name: string;
3132
declare graph: Record<string, unknown> | null;
@@ -61,6 +62,7 @@ export class Job extends DBModel {
6162
this.id ??= createTimeOrderedUuid();
6263
this.job_type ??= "";
6364
this.status ??= "scheduled";
65+
this.project_id ??= "default";
6466
this.retry_count ??= 0;
6567
this.max_retries ??= 3;
6668
this.version ??= 0;

packages/models/src/migrations/versions.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3354,6 +3354,51 @@ export const migrations: MigrationDef[] = [
33543354
// The column stays: dropping one is unsafe across dialects and versions,
33553355
// and its value is a bucket id nothing else reads.
33563356
}
3357+
},
3358+
3359+
// ── Add ownership to legacy resource containers ────────────────────
3360+
// `default` is a compatibility value. Authenticated startup resolves a
3361+
// user's Personal project and moves only rows carrying that value.
3362+
{
3363+
version: "20260910_000000",
3364+
name: "add_project_ownership_to_legacy_resources",
3365+
createsTables: [],
3366+
modifiesTables: [
3367+
"nodetool_workflows",
3368+
"nodetool_threads",
3369+
"nodetool_jobs",
3370+
"nodetool_workspaces"
3371+
],
3372+
async up(db) {
3373+
const tables = [
3374+
"nodetool_workflows",
3375+
"nodetool_threads",
3376+
"nodetool_jobs",
3377+
"nodetool_workspaces"
3378+
];
3379+
for (const table of tables) {
3380+
if (!(await db.tableExists(table))) continue;
3381+
if (!(await db.columnExists(table, "project_id"))) {
3382+
await db.execute(
3383+
`ALTER TABLE ${table} ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'`
3384+
);
3385+
}
3386+
await db.execute(
3387+
`CREATE INDEX IF NOT EXISTS idx_${table.replace("nodetool_", "")}_user_project ` +
3388+
`ON ${table} (user_id, project_id)`
3389+
);
3390+
}
3391+
},
3392+
async down(db) {
3393+
for (const index of [
3394+
"workflows_user_project",
3395+
"threads_user_project",
3396+
"jobs_user_project",
3397+
"workspaces_user_project"
3398+
]) {
3399+
await db.execute(`DROP INDEX IF EXISTS idx_${index}`);
3400+
}
3401+
}
33573402
}
33583403
];
33593404

packages/models/src/project.ts

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* nothing migrates into one.
1111
*/
1212

13-
import { and, desc, eq, isNull } from "drizzle-orm";
13+
import { and, desc, eq, isNull, sql } from "drizzle-orm";
1414
import {
1515
DBModel,
1616
ModelChangeEvent,
@@ -24,12 +24,21 @@ import { Thread } from "./thread.js";
2424

2525
/** The bucket documents land in when no project is active. */
2626
export const LOOSE_PROJECT_ID = "default";
27+
export const PERSONAL_PROJECT_KIND = "personal";
28+
export const PERSONAL_PROJECT_NAME = "Personal";
29+
30+
export interface PersonalMigrationReport {
31+
project: Project;
32+
migrated: number;
33+
dangling: number;
34+
}
2735

2836
export interface ProjectResponse {
2937
id: string;
3038
name: string;
3139
/** Free text — "spot", "trailer", "report". Not an enum on purpose. */
3240
kind: string;
41+
isPersonal: boolean;
3342
/** The conversation that builds it, or null while nobody has asked for one. */
3443
threadId: string | null;
3544
createdAt: string;
@@ -67,6 +76,7 @@ export class Project extends DBModel {
6776
id: this.id,
6877
name: this.name,
6978
kind: this.kind,
79+
isPersonal: this.kind === PERSONAL_PROJECT_KIND,
7080
threadId: this.thread_id,
7181
createdAt: this.created_at,
7282
updatedAt: this.updated_at
@@ -77,6 +87,110 @@ export class Project extends DBModel {
7787
return Project.get<Project>(id);
7888
}
7989

90+
static async ensurePersonal(userId: string): Promise<Project> {
91+
const db = getDb();
92+
const existing = await db
93+
.select()
94+
.from(projects)
95+
.where(
96+
and(eq(projects.user_id, userId), eq(projects.kind, PERSONAL_PROJECT_KIND))
97+
)
98+
.orderBy(projects.created_at)
99+
.limit(1);
100+
if (existing[0]) return new Project(existing[0]);
101+
102+
const created = await Project.insertNew({
103+
id: `personal:${userId}`,
104+
user_id: userId,
105+
name: PERSONAL_PROJECT_NAME,
106+
kind: PERSONAL_PROJECT_KIND
107+
});
108+
if (created) return created;
109+
110+
const resolved = await db
111+
.select()
112+
.from(projects)
113+
.where(
114+
and(eq(projects.user_id, userId), eq(projects.kind, PERSONAL_PROJECT_KIND))
115+
)
116+
.orderBy(projects.created_at)
117+
.limit(1);
118+
if (!resolved[0]) throw new Error("Unable to resolve Personal project");
119+
return new Project(resolved[0]);
120+
}
121+
122+
/** Claim only loose legacy rows. Explicit project ids are never rewritten. */
123+
static async migrateToPersonal(userId: string): Promise<PersonalMigrationReport> {
124+
const personal = await Project.ensurePersonal(userId);
125+
const db = getDb();
126+
const owner = userId.replace(/'/g, "''");
127+
const target = personal.id.replace(/'/g, "''");
128+
let migrated = 0;
129+
// Restore the legacy project.thread_id association before claiming
130+
// remaining threads for Personal.
131+
await db.execute(
132+
sql.raw(
133+
`UPDATE nodetool_threads SET project_id = (` +
134+
`SELECT p.id FROM projects p WHERE p.thread_id = nodetool_threads.id ` +
135+
`AND p.user_id = nodetool_threads.user_id) ` +
136+
`WHERE user_id = '${owner}' AND EXISTS (` +
137+
`SELECT 1 FROM projects p WHERE p.thread_id = nodetool_threads.id ` +
138+
`AND p.user_id = nodetool_threads.user_id)`
139+
)
140+
);
141+
// Runs created from an already-assigned workflow inherit that ownership.
142+
// Jobs without a project column were otherwise indistinguishable from
143+
// genuinely unassigned runs.
144+
await db.execute(
145+
sql.raw(
146+
`UPDATE nodetool_jobs SET project_id = (` +
147+
`SELECT w.project_id FROM nodetool_workflows w ` +
148+
`WHERE w.id = nodetool_jobs.workflow_id AND w.user_id = nodetool_jobs.user_id) ` +
149+
`WHERE user_id = '${owner}' AND (project_id IS NULL OR project_id = '' ` +
150+
`OR project_id = 'default') AND EXISTS (` +
151+
`SELECT 1 FROM nodetool_workflows w WHERE w.id = nodetool_jobs.workflow_id ` +
152+
`AND w.user_id = nodetool_jobs.user_id AND w.project_id <> 'default')`
153+
)
154+
);
155+
const tables = [
156+
"storyboards", "scripts", "timeline_sequences", "image_documents",
157+
"applications", "js_scripts", "nodetool_assets", "nodetool_workflows",
158+
"nodetool_threads", "nodetool_jobs", "nodetool_workspaces",
159+
"nodetool_predictions"
160+
];
161+
for (const table of tables) {
162+
const result = await db.execute(
163+
sql.raw(
164+
`UPDATE ${table} SET project_id = '${target}' ` +
165+
`WHERE user_id = '${owner}' AND ` +
166+
`(project_id IS NULL OR project_id = '' OR project_id = 'default')`
167+
)
168+
);
169+
const changes = (result as { changes?: unknown }).changes;
170+
if (typeof changes === "number") migrated += changes;
171+
}
172+
// Dangling non-default ids are intentionally left in place. They need a
173+
// repair decision, and moving them would hide a broken legacy reference.
174+
let dangling = 0;
175+
for (const table of tables) {
176+
const result = await db.execute(
177+
sql.raw(
178+
`SELECT COUNT(*) AS count FROM ${table} r ` +
179+
`WHERE r.user_id = '${owner}' AND r.project_id IS NOT NULL ` +
180+
`AND r.project_id <> 'default' AND r.project_id <> '${target}' ` +
181+
`AND NOT EXISTS (SELECT 1 FROM projects p ` +
182+
`WHERE p.id = r.project_id AND p.user_id = r.user_id)`
183+
)
184+
);
185+
const rows = Array.isArray(result)
186+
? result
187+
: ((result as { rows?: unknown[] }).rows ?? []);
188+
const count = (rows[0] as { count?: unknown } | undefined)?.count;
189+
dangling += Number(count ?? 0);
190+
}
191+
return { project: personal, migrated, dangling };
192+
}
193+
80194
static async findOwned(userId: string, id: string): Promise<Project | null> {
81195
const row = await Project.findById(id);
82196
return row && row.user_id === userId ? row : null;
@@ -158,6 +272,7 @@ export class Project extends DBModel {
158272
static async deleteOwned(userId: string, id: string): Promise<boolean> {
159273
const row = await Project.findOwned(userId, id);
160274
if (!row) return false;
275+
if (row.kind === PERSONAL_PROJECT_KIND) return false;
161276
await reassignProjectDocuments(userId, id, LOOSE_PROJECT_ID);
162277
await row.delete();
163278
return true;
@@ -182,7 +297,8 @@ export class Project extends DBModel {
182297

183298
const thread = await Thread.create<Thread>({
184299
user_id: userId,
185-
title: project.name
300+
title: project.name,
301+
project_id: project.id
186302
});
187303
const db = getDb();
188304
const rows = await db

packages/models/src/schema-pg/jobs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const jobs = pgTable(
88
user_id: text("user_id").notNull(),
99
job_type: text("job_type").notNull().default(""),
1010
workflow_id: text("workflow_id").notNull(),
11+
project_id: text("project_id").notNull().default("default"),
1112
status: text("status").notNull().default("scheduled"),
1213
name: text("name").default(""),
1314
graph: jsonText<Record<string, unknown>>()("graph"),
@@ -39,6 +40,7 @@ export const jobs = pgTable(
3940
index("idx_jobs_updated_at").on(table.updated_at),
4041
index("idx_jobs_worker_id").on(table.worker_id),
4142
index("idx_jobs_heartbeat_at").on(table.heartbeat_at),
42-
index("idx_jobs_recovery").on(table.status, table.heartbeat_at)
43+
index("idx_jobs_recovery").on(table.status, table.heartbeat_at),
44+
index("idx_jobs_user_project").on(table.user_id, table.project_id)
4345
]
4446
);

packages/models/src/schema-pg/threads.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ export const threads = pgTable(
99
// threads (e.g. the global chat). Lets the node editor scope its thread
1010
// list to the open workflow.
1111
workflow_id: text("workflow_id"),
12+
project_id: text("project_id").notNull().default("default"),
1213
title: text("title").notNull().default(""),
1314
created_at: text("created_at").notNull(),
1415
updated_at: text("updated_at").notNull()
1516
},
1617
(table) => [
1718
index("idx_threads_user_id").on(table.user_id),
18-
index("idx_threads_user_workflow").on(table.user_id, table.workflow_id)
19+
index("idx_threads_user_workflow").on(table.user_id, table.workflow_id),
20+
index("idx_threads_user_project").on(table.user_id, table.project_id)
1921
]
2022
);

packages/models/src/schema-pg/workflows.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export const workflows = pgTable(
2121
path: text("path"),
2222
run_mode: text("run_mode"),
2323
workspace_id: text("workspace_id"),
24+
project_id: text("project_id").notNull().default("default"),
2425
html_app: text("html_app"),
2526
app_doc: jsonText<Record<string, unknown>>()("app_doc"),
2627
receive_clipboard: integer("receive_clipboard"),
@@ -30,6 +31,7 @@ export const workflows = pgTable(
3031
},
3132
(table) => [
3233
index("idx_workflows_user_id").on(table.user_id),
34+
index("idx_workflows_user_project").on(table.user_id, table.project_id),
3335
index("idx_workflows_access").on(table.access)
3436
]
3537
);

packages/models/src/schema-pg/workspaces.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ export const workspaces = pgTable(
77
user_id: text("user_id").notNull(),
88
name: text("name").notNull().default(""),
99
path: text("path").notNull().default(""),
10+
project_id: text("project_id").notNull().default("default"),
1011
is_default: integer("is_default").default(0),
1112
created_at: text("created_at").notNull(),
1213
updated_at: text("updated_at").notNull()
1314
},
14-
(table) => [index("idx_workspaces_user_id").on(table.user_id)]
15+
(table) => [
16+
index("idx_workspaces_user_id").on(table.user_id),
17+
index("idx_workspaces_user_project").on(table.user_id, table.project_id)
18+
]
1519
);

packages/models/src/schema/jobs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const jobs = sqliteTable(
1414
user_id: text("user_id").notNull(),
1515
job_type: text("job_type").notNull().default(""),
1616
workflow_id: text("workflow_id").notNull(),
17+
project_id: text("project_id").notNull().default("default"),
1718
status: text("status").notNull().default("scheduled"),
1819
name: text("name").default(""),
1920
graph: jsonText<Record<string, unknown>>()("graph"),
@@ -45,6 +46,7 @@ export const jobs = sqliteTable(
4546
index("idx_jobs_updated_at").on(table.updated_at),
4647
index("idx_jobs_worker_id").on(table.worker_id),
4748
index("idx_jobs_heartbeat_at").on(table.heartbeat_at),
48-
index("idx_jobs_recovery").on(table.status, table.heartbeat_at)
49+
index("idx_jobs_recovery").on(table.status, table.heartbeat_at),
50+
index("idx_jobs_user_project").on(table.user_id, table.project_id)
4951
]
5052
);

0 commit comments

Comments
 (0)