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:
co-authored by
Claude Opus 5.5
parent
a46fb60eee
commit
437ca143e5
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user