-
Notifications
You must be signed in to change notification settings - Fork 78
Expand file tree
/
Copy pathproject.ts
More file actions
338 lines (315 loc) · 11.5 KB
/
Copy pathproject.ts
File metadata and controls
338 lines (315 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
/**
* Project model — the unit of the workspace.
*
* Every document table already carries `project_id`; a project row is what
* that column points at. A project owns nothing on its own: its documents,
* status and spend are derived by reading the rows that name it
* (`project-summary.ts`).
*
* `"default"` stays the loose-documents bucket. It has no row here, and
* nothing migrates into one.
*/
import { and, desc, eq, isNull } from "drizzle-orm";
import {
DBModel,
ModelChangeEvent,
ModelObserver,
createTimeOrderedUuid
} from "./base-model.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;
updatedAt: string;
}
export class Project extends DBModel {
static override table = projects;
declare id: string;
declare user_id: string;
declare name: string;
declare kind: string;
declare thread_id: string | null;
declare created_at: string;
declare updated_at: string;
constructor(data: Record<string, unknown>) {
super(data);
const now = new Date().toISOString();
this.id ??= createTimeOrderedUuid();
this.name ??= "Untitled project";
this.kind ??= "";
this.thread_id ??= null;
this.created_at ??= now;
this.updated_at ??= now;
}
override beforeSave(): void {
this.updated_at = new Date().toISOString();
}
toResponse(): ProjectResponse {
return {
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
};
}
static async findById(id: string): Promise<Project | null> {
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;
}
/**
* The project whose agent thread this is. A chat turn knows its thread, so
* this is how a run learns which project the documents it creates belong to.
*/
static async findByThread(
userId: string,
threadId: string
): Promise<Project | null> {
const db = getDb();
const rows = await db
.select()
.from(projects)
.where(
and(eq(projects.user_id, userId), eq(projects.thread_id, threadId))
)
.limit(1);
const row = rows[0];
return row ? new Project(row as Record<string, unknown>) : null;
}
static async listByUser(userId: string, limit = 100): Promise<Project[]> {
const db = getDb();
const rows = await db
.select()
.from(projects)
.where(eq(projects.user_id, userId))
.orderBy(desc(projects.updated_at))
.limit(limit);
return rows.map((r: Record<string, unknown>) => new Project(r));
}
/**
* Create a project without ever rewriting a row that already exists.
*
* `save()` is an upsert, and the primary key is install-global with no
* `(user_id, id)` uniqueness — so an id the caller supplies could otherwise
* overwrite another user's project. This inserts and answers null on
* conflict, leaving the existing row untouched for the caller to report.
*/
static async insertNew(data: Record<string, unknown>): Promise<Project | null> {
const project = new Project(data);
project.beforeSave();
const db = getDb();
const rows = await db
.insert(projects)
.values({
id: project.id,
user_id: project.user_id,
name: project.name,
kind: project.kind,
thread_id: project.thread_id,
created_at: project.created_at,
updated_at: project.updated_at
})
.onConflictDoNothing()
.returning();
const row = rows[0];
if (!row) return null;
const created = new Project(row as Record<string, unknown>);
// `create()` notifies, and the websocket forwards that as the resource
// event an open project list refreshes on.
ModelObserver.notify(created, ModelChangeEvent.CREATED);
return created;
}
/**
* Delete a project the caller owns, moving its documents back into the loose
* bucket first. Leaving them pointing at a dead id loses them: the loose
* listing filters on {@link LOOSE_PROJECT_ID}, so an orphan appears in no
* project and in no unassigned list. Ledger rows keep the dead id — they are
* history, not something a user opens. Missing and not-yours answer the same,
* so a caller cannot probe ids.
*/
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;
}
/**
* The project's agent thread, created on first ask.
*
* The row is created here rather than left to the chat path, because the
* project has to be able to name it before anyone has said anything. The
* write claims the column only while it is still null, so two callers
* racing settle on one thread and the loser's row is dropped rather than
* left as a conversation nothing points at.
*/
static async ensureThread(
userId: string,
id: string
): Promise<string | null> {
const project = await Project.findOwned(userId, id);
if (!project) return null;
if (project.thread_id) return project.thread_id;
const thread = await Thread.create<Thread>({
user_id: userId,
title: project.name,
project_id: project.id
});
const db = getDb();
const rows = await db
.update(projects)
// `updated_at` is left alone: naming the thread is bookkeeping, not
// work on the project, and the list orders by when it was last worked on.
.set({ thread_id: thread.id })
.where(
and(
eq(projects.id, id),
eq(projects.user_id, userId),
isNull(projects.thread_id)
)
)
.returning();
if (rows.length > 0) return thread.id;
await thread.delete();
const winner = await Project.findOwned(userId, id);
return winner?.thread_id ?? null;
}
static async updateOwned(
userId: string,
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)
.set({ ...fields, updated_at: new Date().toISOString() })
.where(and(eq(projects.id, id), eq(projects.user_id, userId)))
.returning();
const row = rows[0];
return row ? new Project(row) : null;
}
}