fix(core): race-safe matching, visibility-checked ID matches, child-preserving merges

- migration 0002: unique index on works (parent_id, position)
- matchWork retries the whole transaction on a unique violation (3 attempts)
- addMissingExternalIds uses ON CONFLICT DO NOTHING
- ID matches only return shared works or the user's own; private works cannot carry external ids
- adminMergeWorks requires shared works and repoints non-colliding children
- draft is computed for the aliased work id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas Winther
2026-10-30 19:29:48 +01:00
co-authored by Claude Opus 5.5
parent a46fb60eee
commit 437ca143e5
9 changed files with 270 additions and 18 deletions
+45 -9
View File
@@ -45,6 +45,9 @@ export async function createWorkIn(
): Promise<string> {
const { hint } = input;
if (hint.title.trim() === "") throw new ValidationError("title", "required");
if (input.ownerUserId && Object.keys(hint.externalIds).length > 0) {
throw new ValidationError("externalIds", "private works cannot carry external ids");
}
const id = newId();
const now = nowIso();
await k
@@ -111,7 +114,15 @@ export async function finalWorkId(k: K, id: string): Promise<string> {
return (await getWorkIn(k, id))?.id ?? id;
}
export async function findWorkByExternalIdsIn(k: K, ids: Record<string, string>): Promise<string | null> {
/**
* First work matching any (namespace, value) pair, following merges. Only works visible to `userId`
* count (shared, or private to that user); with no user, only shared works. Others are treated as absent.
*/
export async function findWorkByExternalIdsIn(
k: K,
ids: Record<string, string>,
userId: string | null = null,
): Promise<string | null> {
for (const [namespace, value] of Object.entries(ids)) {
const row = await k
.selectFrom("external_ids")
@@ -119,14 +130,16 @@ export async function findWorkByExternalIdsIn(k: K, ids: Record<string, string>)
.where("namespace", "=", namespace)
.where("value", "=", value)
.executeTakeFirst();
if (row) return finalWorkId(k, row.work_id);
if (!row) continue;
const work = await getWorkIn(k, row.work_id);
if (work && (work.owner_user_id === null || work.owner_user_id === userId)) return work.id;
}
return null;
}
/** First work matching any (namespace, value) pair, following merges. */
export function findWorkByExternalIds(db: Db, ids: Record<string, string>): Promise<string | null> {
return findWorkByExternalIdsIn(db.k, ids);
/** First work visible to `userId` (shared works only when omitted) matching any (namespace, value) pair. */
export function findWorkByExternalIds(db: Db, ids: Record<string, string>, userId: string | null = null): Promise<string | null> {
return findWorkByExternalIdsIn(db.k, ids, userId);
}
async function addMissingExternalIds(k: K, workId: string, ids: Record<string, string>): Promise<string[]> {
@@ -139,8 +152,13 @@ async function addMissingExternalIds(k: K, workId: string, ids: Record<string, s
.where("value", "=", value)
.executeTakeFirst();
if (exists) continue;
await k.insertInto("external_ids").values({ id: newId(), work_id: workId, namespace, value }).execute();
added.push(`${namespace}:${value}`);
// A concurrent sync may have added the same pair since the check: skip it rather than fail.
const res = await k
.insertInto("external_ids")
.values({ id: newId(), work_id: workId, namespace, value })
.onConflict((oc) => oc.columns(["namespace", "value"]).doNothing())
.executeTakeFirst();
if (Number(res.numInsertedOrUpdatedRows ?? 0n) > 0) added.push(`${namespace}:${value}`);
}
return added;
}
@@ -231,7 +249,8 @@ export function resolveAlias(db: Db, userId: string, workId: string): Promise<st
/**
* Admin-only global merge of shared work `fromId` into `toId`. Leaves a `merged_into_id` tombstone and
* moves external IDs. Users without an entry on `toId` get their entry repointed; users who already
* moves external IDs and the children whose position `toId` does not already have. Both works must
* be shared. Users without an entry on `toId` get their entry repointed; users who already
* have one keep their `from` entry and get a pending `merged` change event.
*/
export async function adminMergeWorks(db: Db, adminUserId: string, fromId: string, toId: string): Promise<void> {
@@ -245,12 +264,29 @@ export async function adminMergeWorks(db: Db, adminUserId: string, fromId: strin
if (!to) throw new NotFoundError("work");
if (from.merged_into_id) throw new ValidationError("fromId", "already merged");
if (to.merged_into_id) throw new ValidationError("toId", "already merged");
if (from.owner_user_id !== null) throw new ValidationError("fromId", "only shared works can be merged");
if (to.owner_user_id !== null) throw new ValidationError("toId", "only shared works can be merged");
const now = nowIso();
await tx.updateTable("works").set({ merged_into_id: toId, updated_at: now }).where("id", "=", fromId).execute();
const moved = await tx.selectFrom("external_ids").select(["namespace", "value"]).where("work_id", "=", fromId).execute();
await tx.updateTable("external_ids").set({ work_id: toId }).where("work_id", "=", fromId).execute();
// Repoint children so matching through `toId` finds them. A child whose position `toId` already
// has stays under `fromId` (unique parent_id+position); it remains reachable via the tombstone.
const toPositions = new Set(
(await tx.selectFrom("works").select("position").where("parent_id", "=", toId).where("position", "is not", null).execute()).map(
(r) => r.position,
),
);
const children = await tx.selectFrom("works").select(["id", "position"]).where("parent_id", "=", fromId).execute();
const movedChildren: string[] = [];
for (const c of children) {
if (c.position !== null && toPositions.has(c.position)) continue;
await tx.updateTable("works").set({ parent_id: toId, updated_at: now }).where("id", "=", c.id).execute();
movedChildren.push(c.id);
}
const entries = await tx.selectFrom("entries").select(["id", "user_id"]).where("work_id", "=", fromId).execute();
for (const e of entries) {
const existing = await tx
@@ -288,7 +324,7 @@ export async function adminMergeWorks(db: Db, adminUserId: string, fromId: strin
.execute();
await tx
.insertInto("work_history")
.values({ id: newId(), work_id: toId, before: "{}", after: toJson({ mergedFrom: fromId, externalIds: moved }), actor: adminUserId, created_at: now })
.values({ id: newId(), work_id: toId, before: "{}", after: toJson({ mergedFrom: fromId, externalIds: moved, children: movedChildren }), actor: adminUserId, created_at: now })
.execute();
});
}