Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/models/src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,16 @@ export function getRawDb(): Database.Database {
return _sqlite;
}

/** Execute dynamic SQL against the active database connection. */
export async function executeRaw(sql: string): Promise<{ rows: unknown[] }> {
if (_dbType === "postgres") {
if (!_pgClient) throw new Error("PostgreSQL database not initialized.");
return { rows: await _pgClient.unsafe(sql) };
}
if (!_sqlite) throw new Error("SQLite database not initialized.");
return { rows: _sqlite.prepare(sql).all() as unknown[] };
}

/**
* Verify the database connection is alive with a lightweight query.
* Dialect-aware: runs `select 1` over the active client. Throws if the
Expand Down Expand Up @@ -424,6 +434,7 @@ export function getCreateSchemaSql(): string {
"path" text,
"run_mode" text,
"workspace_id" text,
"project_id" text NOT NULL DEFAULT 'default',
"html_app" text,
"app_doc" text,
"receive_clipboard" integer,
Expand All @@ -433,12 +444,14 @@ export function getCreateSchemaSql(): string {
);
CREATE INDEX IF NOT EXISTS "idx_workflows_user_id" ON "nodetool_workflows" ("user_id");
CREATE INDEX IF NOT EXISTS "idx_workflows_access" ON "nodetool_workflows" ("access");
CREATE INDEX IF NOT EXISTS "idx_workflows_user_project" ON "nodetool_workflows" ("user_id", "project_id");

CREATE TABLE IF NOT EXISTS "nodetool_jobs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"job_type" text NOT NULL DEFAULT '',
"workflow_id" text NOT NULL,
"project_id" text NOT NULL DEFAULT 'default',
"status" text NOT NULL DEFAULT 'scheduled',
"name" text DEFAULT '',
"graph" text,
Expand Down Expand Up @@ -468,6 +481,7 @@ export function getCreateSchemaSql(): string {
CREATE INDEX IF NOT EXISTS "idx_jobs_worker_id" ON "nodetool_jobs" ("worker_id");
CREATE INDEX IF NOT EXISTS "idx_jobs_heartbeat_at" ON "nodetool_jobs" ("heartbeat_at");
CREATE INDEX IF NOT EXISTS "idx_jobs_recovery" ON "nodetool_jobs" ("status", "heartbeat_at");
CREATE INDEX IF NOT EXISTS "idx_jobs_user_project" ON "nodetool_jobs" ("user_id", "project_id");

CREATE TABLE IF NOT EXISTS "nodetool_messages" (
"id" text PRIMARY KEY NOT NULL,
Expand Down Expand Up @@ -502,12 +516,14 @@ export function getCreateSchemaSql(): string {
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"workflow_id" text,
"project_id" text NOT NULL DEFAULT 'default',
"title" text NOT NULL DEFAULT '',
"created_at" text NOT NULL,
"updated_at" text NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_threads_user_id" ON "nodetool_threads" ("user_id");
CREATE INDEX IF NOT EXISTS "idx_threads_user_workflow" ON "nodetool_threads" ("user_id", "workflow_id");
CREATE INDEX IF NOT EXISTS "idx_threads_user_project" ON "nodetool_threads" ("user_id", "project_id");

CREATE TABLE IF NOT EXISTS "nodetool_assets" (
"id" text PRIMARY KEY NOT NULL,
Expand Down Expand Up @@ -548,11 +564,13 @@ export function getCreateSchemaSql(): string {
"user_id" text NOT NULL,
"name" text NOT NULL DEFAULT '',
"path" text NOT NULL DEFAULT '',
"project_id" text NOT NULL DEFAULT 'default',
"is_default" integer DEFAULT 0,
"created_at" text NOT NULL,
"updated_at" text NOT NULL
);
CREATE INDEX IF NOT EXISTS "idx_workspaces_user_id" ON "nodetool_workspaces" ("user_id");
CREATE INDEX IF NOT EXISTS "idx_workspaces_user_project" ON "nodetool_workspaces" ("user_id", "project_id");

CREATE TABLE IF NOT EXISTS "nodetool_workflow_versions" (
"id" text PRIMARY KEY NOT NULL,
Expand Down
9 changes: 7 additions & 2 deletions packages/models/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,13 @@ export type {
ReserveInput
} from "./application-budget.js";
export { ApplicationDeployment } from "./application-deployment.js";
export { Project, LOOSE_PROJECT_ID } from "./project.js";
export type { ProjectResponse } from "./project.js";
export {
Project,
LOOSE_PROJECT_ID,
PERSONAL_PROJECT_KIND,
PERSONAL_PROJECT_NAME
} from "./project.js";
export type { PersonalMigrationReport, ProjectResponse } from "./project.js";
export {
listProjectDocuments,
listProjectEntities,
Expand Down
2 changes: 2 additions & 0 deletions packages/models/src/job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class Job extends DBModel {
declare user_id: string;
declare job_type: string;
declare workflow_id: string;
declare project_id: string;
declare status: JobStatus;
declare name: string;
declare graph: Record<string, unknown> | null;
Expand Down Expand Up @@ -61,6 +62,7 @@ export class Job extends DBModel {
this.id ??= createTimeOrderedUuid();
this.job_type ??= "";
this.status ??= "scheduled";
this.project_id ??= "default";
this.retry_count ??= 0;
this.max_retries ??= 3;
this.version ??= 0;
Expand Down
45 changes: 45 additions & 0 deletions packages/models/src/migrations/versions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3354,6 +3354,51 @@ export const migrations: MigrationDef[] = [
// The column stays: dropping one is unsafe across dialects and versions,
// and its value is a bucket id nothing else reads.
}
},

// ── Add ownership to legacy resource containers ────────────────────
// `default` is a compatibility value. Authenticated startup resolves a
// user's Personal project and moves only rows carrying that value.
{
version: "20260910_000000",
name: "add_project_ownership_to_legacy_resources",
createsTables: [],
modifiesTables: [
"nodetool_workflows",
"nodetool_threads",
"nodetool_jobs",
"nodetool_workspaces"
],
async up(db) {
const tables = [
"nodetool_workflows",
"nodetool_threads",
"nodetool_jobs",
"nodetool_workspaces"
];
for (const table of tables) {
if (!(await db.tableExists(table))) continue;
if (!(await db.columnExists(table, "project_id"))) {
await db.execute(
`ALTER TABLE ${table} ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'`
);
}
await db.execute(
`CREATE INDEX IF NOT EXISTS idx_${table.replace("nodetool_", "")}_user_project ` +
`ON ${table} (user_id, project_id)`
);
}
},
async down(db) {
for (const index of [
"workflows_user_project",
"threads_user_project",
"jobs_user_project",
"workspaces_user_project"
]) {
await db.execute(`DROP INDEX IF EXISTS idx_${index}`);
}
}
}
];

Expand Down
120 changes: 118 additions & 2 deletions packages/models/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,28 @@ import {
ModelObserver,
createTimeOrderedUuid
} from "./base-model.js";
import { getDb } from "./db.js";
import { executeRaw, getDb } from "./db.js";
import { projects } from "./schema/projects.js";
import { reassignProjectDocuments } from "./project-membership.js";
import { Thread } from "./thread.js";

/** The bucket documents land in when no project is active. */
export const LOOSE_PROJECT_ID = "default";
export const PERSONAL_PROJECT_KIND = "personal";
export const PERSONAL_PROJECT_NAME = "Personal";

export interface PersonalMigrationReport {
project: Project;
migrated: number;
dangling: number;
}

export interface ProjectResponse {
id: string;
name: string;
/** Free text — "spot", "trailer", "report". Not an enum on purpose. */
kind: string;
isPersonal: boolean;
/** The conversation that builds it, or null while nobody has asked for one. */
threadId: string | null;
createdAt: string;
Expand Down Expand Up @@ -67,6 +76,7 @@ export class Project extends DBModel {
id: this.id,
name: this.name,
kind: this.kind,
isPersonal: this.kind === PERSONAL_PROJECT_KIND,
threadId: this.thread_id,
createdAt: this.created_at,
updatedAt: this.updated_at
Expand All @@ -77,6 +87,98 @@ export class Project extends DBModel {
return Project.get<Project>(id);
}

static async ensurePersonal(userId: string): Promise<Project> {
const db = getDb();
const existing = await db
.select()
.from(projects)
.where(
and(eq(projects.user_id, userId), eq(projects.kind, PERSONAL_PROJECT_KIND))
)
.orderBy(projects.created_at)
.limit(1);
if (existing[0]) return new Project(existing[0]);

const created = await Project.insertNew({
id: `personal:${userId}`,
user_id: userId,
name: PERSONAL_PROJECT_NAME,
kind: PERSONAL_PROJECT_KIND
});
if (created) return created;

const resolved = await db
.select()
.from(projects)
.where(
and(eq(projects.user_id, userId), eq(projects.kind, PERSONAL_PROJECT_KIND))
)
.orderBy(projects.created_at)
.limit(1);
if (!resolved[0]) throw new Error("Unable to resolve Personal project");
return new Project(resolved[0]);
}

/** Claim only loose legacy rows. Explicit project ids are never rewritten. */
static async migrateToPersonal(userId: string): Promise<PersonalMigrationReport> {
const personal = await Project.ensurePersonal(userId);
const owner = userId.replace(/'/g, "''");
const target = personal.id.replace(/'/g, "''");
let migrated = 0;
// Restore the legacy project.thread_id association before claiming
// remaining threads for Personal.
await executeRaw(
`UPDATE nodetool_threads SET project_id = (` +
`SELECT p.id FROM projects p WHERE p.thread_id = nodetool_threads.id ` +
`AND p.user_id = nodetool_threads.user_id) ` +
`WHERE user_id = '${owner}' AND EXISTS (` +
`SELECT 1 FROM projects p WHERE p.thread_id = nodetool_threads.id ` +
`AND p.user_id = nodetool_threads.user_id) RETURNING id`
);
// Runs created from an already-assigned workflow inherit that ownership.
// Jobs without a project column were otherwise indistinguishable from
// genuinely unassigned runs.
await executeRaw(
`UPDATE nodetool_jobs SET project_id = (` +
`SELECT w.project_id FROM nodetool_workflows w ` +
`WHERE w.id = nodetool_jobs.workflow_id AND w.user_id = nodetool_jobs.user_id) ` +
`WHERE user_id = '${owner}' AND (project_id IS NULL OR project_id = '' ` +
`OR project_id = 'default') AND EXISTS (` +
`SELECT 1 FROM nodetool_workflows w WHERE w.id = nodetool_jobs.workflow_id ` +
`AND w.user_id = nodetool_jobs.user_id AND w.project_id <> 'default') RETURNING id`
);
const tables = [
"storyboards", "scripts", "timeline_sequences", "image_documents",
"applications", "js_scripts", "nodetool_assets", "nodetool_workflows",
"nodetool_threads", "nodetool_jobs", "nodetool_workspaces",
"nodetool_predictions"
];
for (const table of tables) {
const result = await executeRaw(
`UPDATE ${table} SET project_id = '${target}' ` +
`WHERE user_id = '${owner}' AND ` +
`(project_id IS NULL OR project_id = '' OR project_id = 'default') RETURNING id`
);
migrated += result.rows.length;
}
// Dangling non-default ids are intentionally left in place. They need a
// repair decision, and moving them would hide a broken legacy reference.
let dangling = 0;
for (const table of tables) {
const result = await executeRaw(
`SELECT COUNT(*) AS count FROM ${table} r ` +
`WHERE r.user_id = '${owner}' AND r.project_id IS NOT NULL ` +
`AND r.project_id <> 'default' AND r.project_id <> '${target}' ` +
`AND NOT EXISTS (SELECT 1 FROM projects p ` +
`WHERE p.id = r.project_id AND p.user_id = r.user_id)`
);
const rows = result.rows;
const count = (rows[0] as { count?: unknown } | undefined)?.count;
dangling += Number(count ?? 0);
}
return { project: personal, migrated, dangling };
}

static async findOwned(userId: string, id: string): Promise<Project | null> {
const row = await Project.findById(id);
return row && row.user_id === userId ? row : null;
Expand Down Expand Up @@ -158,6 +260,7 @@ export class Project extends DBModel {
static async deleteOwned(userId: string, id: string): Promise<boolean> {
const row = await Project.findOwned(userId, id);
if (!row) return false;
if (row.kind === PERSONAL_PROJECT_KIND) return false;
await reassignProjectDocuments(userId, id, LOOSE_PROJECT_ID);
await row.delete();
return true;
Expand All @@ -182,7 +285,8 @@ export class Project extends DBModel {

const thread = await Thread.create<Thread>({
user_id: userId,
title: project.name
title: project.name,
project_id: project.id
});
const db = getDb();
const rows = await db
Expand Down Expand Up @@ -210,6 +314,18 @@ export class Project extends DBModel {
id: string,
fields: Partial<{ name: string; kind: string; thread_id: string }>
): Promise<Project | null> {
const existing = await Project.findOwned(userId, id);
if (!existing) return null;
// Personal is a permanent account space. Keep its marker immutable, and
// do not let a named project be converted into the reserved kind.
if (
fields.kind !== undefined &&
fields.kind !== existing.kind &&
(existing.kind === PERSONAL_PROJECT_KIND ||
fields.kind === PERSONAL_PROJECT_KIND)
) {
return null;
}
const db = getDb();
const rows = await db
.update(projects)
Expand Down
4 changes: 3 additions & 1 deletion packages/models/src/schema-pg/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const jobs = pgTable(
user_id: text("user_id").notNull(),
job_type: text("job_type").notNull().default(""),
workflow_id: text("workflow_id").notNull(),
project_id: text("project_id").notNull().default("default"),
status: text("status").notNull().default("scheduled"),
name: text("name").default(""),
graph: jsonText<Record<string, unknown>>()("graph"),
Expand Down Expand Up @@ -39,6 +40,7 @@ export const jobs = pgTable(
index("idx_jobs_updated_at").on(table.updated_at),
index("idx_jobs_worker_id").on(table.worker_id),
index("idx_jobs_heartbeat_at").on(table.heartbeat_at),
index("idx_jobs_recovery").on(table.status, table.heartbeat_at)
index("idx_jobs_recovery").on(table.status, table.heartbeat_at),
index("idx_jobs_user_project").on(table.user_id, table.project_id)
]
);
4 changes: 3 additions & 1 deletion packages/models/src/schema-pg/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export const threads = pgTable(
// threads (e.g. the global chat). Lets the node editor scope its thread
// list to the open workflow.
workflow_id: text("workflow_id"),
project_id: text("project_id").notNull().default("default"),
title: text("title").notNull().default(""),
created_at: text("created_at").notNull(),
updated_at: text("updated_at").notNull()
},
(table) => [
index("idx_threads_user_id").on(table.user_id),
index("idx_threads_user_workflow").on(table.user_id, table.workflow_id)
index("idx_threads_user_workflow").on(table.user_id, table.workflow_id),
index("idx_threads_user_project").on(table.user_id, table.project_id)
]
);
2 changes: 2 additions & 0 deletions packages/models/src/schema-pg/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const workflows = pgTable(
path: text("path"),
run_mode: text("run_mode"),
workspace_id: text("workspace_id"),
project_id: text("project_id").notNull().default("default"),
html_app: text("html_app"),
app_doc: jsonText<Record<string, unknown>>()("app_doc"),
receive_clipboard: integer("receive_clipboard"),
Expand All @@ -30,6 +31,7 @@ export const workflows = pgTable(
},
(table) => [
index("idx_workflows_user_id").on(table.user_id),
index("idx_workflows_user_project").on(table.user_id, table.project_id),
index("idx_workflows_access").on(table.access)
]
);
6 changes: 5 additions & 1 deletion packages/models/src/schema-pg/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ export const workspaces = pgTable(
user_id: text("user_id").notNull(),
name: text("name").notNull().default(""),
path: text("path").notNull().default(""),
project_id: text("project_id").notNull().default("default"),
is_default: integer("is_default").default(0),
created_at: text("created_at").notNull(),
updated_at: text("updated_at").notNull()
},
(table) => [index("idx_workspaces_user_id").on(table.user_id)]
(table) => [
index("idx_workspaces_user_id").on(table.user_id),
index("idx_workspaces_user_project").on(table.user_id, table.project_id)
]
);
Loading
Loading