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:
1 parent
a46fb60eee
commit
437ca143e5
9 files changed
+270
-18
No files matched your search
@@ -67,12 +67,12 @@ interface Resolved {
|
||||
}
|
||||
|
||||
async function resolve(k: K, userId: string | null, hint: WorkHint, actor: string): Promise<Resolved> {
|
||||
// 1. Any external ID.
|
||||
const byId = await findWorkByExternalIdsIn(k, hint.externalIds);
|
||||
// 1. Any external ID on a work this user may see (parents resolve with userId null: shared only).
|
||||
const byId = await findWorkByExternalIdsIn(k, hint.externalIds, userId);
|
||||
if (byId) {
|
||||
await updateWorkFromHintIn(k, byId, hint, actor);
|
||||
const owner = await k.selectFrom("works").select("owner_user_id").where("id", "=", byId).executeTakeFirstOrThrow();
|
||||
const workId = userId ? await resolveAliasIn(k, userId, byId) : byId;
|
||||
const owner = await k.selectFrom("works").select("owner_user_id").where("id", "=", workId).executeTakeFirstOrThrow();
|
||||
return { workId, created: false, draft: owner.owner_user_id !== null };
|
||||
}
|
||||
|
||||
@@ -160,7 +160,31 @@ async function suggest(k: K, userId: string, draftId: string, hint: WorkHint): P
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a hint to a work per spec §8.4. Never auto-links on fuzzy matches; those become suggestions. */
|
||||
export function matchWork(db: Db, userId: string, hint: WorkHint, actor: string): Promise<Resolved> {
|
||||
return db.k.transaction().execute((tx) => resolve(tx, userId, hint, actor));
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
/** SQLite "UNIQUE constraint failed", or Postgres SQLSTATE 23505, anywhere in the cause chain. */
|
||||
export function isUniqueViolation(err: unknown): boolean {
|
||||
for (let e: unknown = err, depth = 0; e && typeof e === "object" && depth < 5; e = (e as { cause?: unknown }).cause, depth++) {
|
||||
const { code, errno, message } = e as { code?: unknown; errno?: unknown; message?: unknown };
|
||||
if (code === "23505" || errno === "23505") return true;
|
||||
if (typeof message === "string" && (message.includes("UNIQUE constraint failed") || message.includes("duplicate key"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a hint to a work per spec §8.4. Never auto-links on fuzzy matches; those become suggestions.
|
||||
* A concurrent match may create the same work first (unique external id or parent+position); the losing
|
||||
* transaction is discarded and the whole resolve re-run, which then finds the winner's rows.
|
||||
*/
|
||||
export async function matchWork(db: Db, userId: string, hint: WorkHint, actor: string): Promise<Resolved> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
return await db.k.transaction().execute((tx) => resolve(tx, userId, hint, actor));
|
||||
} catch (err) {
|
||||
if (attempt >= MAX_ATTEMPTS || !isUniqueViolation(err)) throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user