fix: enforce work product primary invariant
This commit is contained in:
1 parent
6c757f2081
commit
c850d70bfc
5 files changed
+356
-48
No files matched your search
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { statSync } from "node:fs";
|
||||
import { isAbsolute, resolve as resolvePath, relative as relativePath } from "node:path";
|
||||
import type { DatabaseSync, SQLInputValue } from "node:sqlite";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import type { ReadAll, Transaction } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
|
||||
function resolveWorkspaceRoot(
|
||||
@@ -76,6 +76,7 @@ function joinWorkspaceRelative(root: string, rel: string): string | null {
|
||||
export function buildWorkProductMethods(
|
||||
getDb: () => DatabaseSync,
|
||||
readAll: ReadAll,
|
||||
transaction: Transaction,
|
||||
) {
|
||||
return {
|
||||
createWorkProduct(input: {
|
||||
@@ -86,26 +87,29 @@ export function buildWorkProductMethods(
|
||||
isPrimary?: boolean; summary?: string | null; metadataJson?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
}): string {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
if (input.isPrimary && input.taskId) {
|
||||
return transaction(() => {
|
||||
const id = randomUUID();
|
||||
const ts = nowIso();
|
||||
const status = input.status ?? "active";
|
||||
if (input.isPrimary && input.taskId && status !== "archived") {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived'",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
}
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ?",
|
||||
).run(ts, input.companyId, input.taskId, input.type);
|
||||
}
|
||||
getDb().prepare(
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId ?? null,
|
||||
input.workspaceId ?? null,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, input.status ?? "active",
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
input.summary ?? null, input.metadataJson ?? null,
|
||||
input.createdByRunId ?? null, input.createdByRunId ?? null, ts, ts,
|
||||
);
|
||||
return id;
|
||||
`INSERT INTO work_products (id, company_id, project_id, task_id, workspace_id, type, provider, external_id, title, url, status, review_state, is_primary, summary, metadata_json, created_by_run_id, last_touched_by_run_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
id, input.companyId, input.projectId ?? null, input.taskId ?? null,
|
||||
input.workspaceId ?? null,
|
||||
input.type, input.provider, input.externalId ?? null,
|
||||
input.title, input.url ?? null, status,
|
||||
input.reviewState ?? "none", input.isPrimary ? 1 : 0,
|
||||
input.summary ?? null, input.metadataJson ?? null,
|
||||
input.createdByRunId ?? null, input.createdByRunId ?? null, ts, ts,
|
||||
);
|
||||
return id;
|
||||
});
|
||||
},
|
||||
|
||||
findWorkProductByExternalId(
|
||||
@@ -152,34 +156,52 @@ export function buildWorkProductMethods(
|
||||
lastTouchedByRunId?: string | null;
|
||||
},
|
||||
): number {
|
||||
const ts = nowIso();
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: SQLInputValue[] = [ts];
|
||||
const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
|
||||
["type", "type", (v) => (v as string) ?? null],
|
||||
["provider", "provider", (v) => (v as string) ?? null],
|
||||
["externalId", "external_id", (v) => (v as string | null) ?? null],
|
||||
["workspaceId", "workspace_id", (v) => (v as string | null) ?? null],
|
||||
["title", "title", (v) => (v as string) ?? null],
|
||||
["url", "url", (v) => (v as string | null) ?? null],
|
||||
["status", "status", (v) => (v as string) ?? null],
|
||||
["reviewState", "review_state", (v) => (v as string) ?? null],
|
||||
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
|
||||
["summary", "summary", (v) => (v as string | null) ?? null],
|
||||
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
|
||||
["lastTouchedByRunId", "last_touched_by_run_id", (v) => (v as string | null) ?? null],
|
||||
];
|
||||
for (const [key, col, coerce] of map) {
|
||||
if (key in patch) {
|
||||
sets.push(`${col} = ?`);
|
||||
values.push(coerce(patch[key]));
|
||||
return transaction(() => {
|
||||
const current = getDb().prepare(
|
||||
"SELECT task_id as taskId, type, status, is_primary as isPrimary FROM work_products WHERE id = ? AND company_id = ?",
|
||||
).get(id, companyId) as
|
||||
| { taskId: string | null; type: string; status: string; isPrimary: number }
|
||||
| undefined;
|
||||
if (!current) return 0;
|
||||
|
||||
const ts = nowIso();
|
||||
const nextType = patch.type ?? current.type;
|
||||
const nextStatus = patch.status ?? current.status;
|
||||
const nextIsPrimary = patch.isPrimary ?? Boolean(current.isPrimary);
|
||||
if (nextIsPrimary && current.taskId && nextStatus !== "archived") {
|
||||
getDb().prepare(
|
||||
"UPDATE work_products SET is_primary = 0, updated_at = ? WHERE company_id = ? AND task_id = ? AND type = ? AND status <> 'archived' AND id <> ?",
|
||||
).run(ts, companyId, current.taskId, nextType, id);
|
||||
}
|
||||
}
|
||||
values.push(id, companyId);
|
||||
const result = getDb()
|
||||
.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`)
|
||||
.run(...values);
|
||||
return Number(result.changes ?? 0);
|
||||
|
||||
const sets: string[] = ["updated_at = ?"];
|
||||
const values: SQLInputValue[] = [ts];
|
||||
const map: Array<[keyof typeof patch, string, (v: unknown) => SQLInputValue]> = [
|
||||
["type", "type", (v) => (v as string) ?? null],
|
||||
["provider", "provider", (v) => (v as string) ?? null],
|
||||
["externalId", "external_id", (v) => (v as string | null) ?? null],
|
||||
["workspaceId", "workspace_id", (v) => (v as string | null) ?? null],
|
||||
["title", "title", (v) => (v as string) ?? null],
|
||||
["url", "url", (v) => (v as string | null) ?? null],
|
||||
["status", "status", (v) => (v as string) ?? null],
|
||||
["reviewState", "review_state", (v) => (v as string) ?? null],
|
||||
["isPrimary", "is_primary", (v) => (v ? 1 : 0)],
|
||||
["summary", "summary", (v) => (v as string | null) ?? null],
|
||||
["metadataJson", "metadata_json", (v) => (v as string | null) ?? null],
|
||||
["lastTouchedByRunId", "last_touched_by_run_id", (v) => (v as string | null) ?? null],
|
||||
];
|
||||
for (const [key, col, coerce] of map) {
|
||||
if (key in patch) {
|
||||
sets.push(`${col} = ?`);
|
||||
values.push(coerce(patch[key]));
|
||||
}
|
||||
}
|
||||
values.push(id, companyId);
|
||||
const result = getDb()
|
||||
.prepare(`UPDATE work_products SET ${sets.join(", ")} WHERE id = ? AND company_id = ?`)
|
||||
.run(...values);
|
||||
return Number(result.changes ?? 0);
|
||||
});
|
||||
},
|
||||
|
||||
resolveWorkProductTarget(
|
||||
|
||||
@@ -55,7 +55,7 @@ export function buildDocumentMethods(
|
||||
) {
|
||||
return {
|
||||
...buildConfigRevisionMethods(getDb, readAll, transaction),
|
||||
...buildWorkProductMethods(getDb, readAll),
|
||||
...buildWorkProductMethods(getDb, readAll, transaction),
|
||||
saveDocument(input: {
|
||||
id?: string | null; companyId: string; type: string; title: string; content?: string;
|
||||
authorAgentId?: string | null; reviewerAgentId?: string | null; projectId?: string | null;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { Migration, MigrationDeps } from "./types";
|
||||
import { tableExists } from "./types";
|
||||
|
||||
export function createMigrationsV56V56(deps: MigrationDeps): Migration[] {
|
||||
const { db } = deps;
|
||||
return [
|
||||
{
|
||||
version: 56,
|
||||
name: "work_products_primary_invariant",
|
||||
fn: () => {
|
||||
if (!tableExists(db, "work_products")) {
|
||||
log.info("[migration v56] work_products table missing; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
UPDATE work_products
|
||||
SET is_primary = 0,
|
||||
updated_at = datetime('now')
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT
|
||||
id,
|
||||
row_number() OVER (
|
||||
PARTITION BY company_id, task_id, type
|
||||
ORDER BY updated_at DESC, created_at DESC, id DESC
|
||||
) AS rank_in_group
|
||||
FROM work_products
|
||||
WHERE task_id IS NOT NULL
|
||||
AND is_primary = 1
|
||||
AND status <> 'archived'
|
||||
)
|
||||
WHERE rank_in_group > 1
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_products_primary_active
|
||||
ON work_products(company_id, task_id, type)
|
||||
WHERE task_id IS NOT NULL
|
||||
AND is_primary = 1
|
||||
AND status <> 'archived';
|
||||
`);
|
||||
|
||||
log.info("[migration v56] work_products active primary invariant installed");
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { createMigrationsV52V52 } from "./migrations/v52-v52";
|
||||
import { createMigrationsV53V53 } from "./migrations/v53-v53";
|
||||
import { createMigrationsV54V54 } from "./migrations/v54-v54";
|
||||
import { createMigrationsV55V55 } from "./migrations/v55-v55";
|
||||
import { createMigrationsV56V56 } from "./migrations/v56-v56";
|
||||
import {
|
||||
columnExists,
|
||||
nowIso,
|
||||
@@ -223,6 +224,7 @@ export function runMigrations(
|
||||
...createMigrationsV53V53(deps),
|
||||
...createMigrationsV54V54(deps),
|
||||
...createMigrationsV55V55(deps),
|
||||
...createMigrationsV56V56(deps),
|
||||
];
|
||||
|
||||
// Pre-migration snapshot. If any migration is pending we take a full
|
||||
|
||||
Reference in new issue
Block a user