fix(core): sync review round 1: lists, supersede, dedupe conflicts, retries
- dedupe files conflicts for disagreeing fields instead of dropping them - merged-away records never file removals - matched work committed before apply so retries reuse it (no draft leaks) - stale pending events superseded by newer values - multi-source engagement removal only detaches the source - list records applied with history-derived ownership - gone targets resolve as ignored; counts only for committed records Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0604c323c3
commit
a003c3eb7d
@@ -1,14 +1,16 @@
|
||||
import type { Db } from "@sttracker/db/db";
|
||||
import { nowIso } from "@sttracker/db/ids";
|
||||
import { fromJson } from "@sttracker/db/json";
|
||||
import { fromJson, toJson } from "@sttracker/db/json";
|
||||
import type { ChangeStatus, ChangeType, Database } from "@sttracker/db/schema";
|
||||
import type { WorkHint } from "@sttracker/plugin-sdk/types";
|
||||
import { type EngagementPatch, softDeleteEngagement, updateEngagement } from "../engagements.ts";
|
||||
import { type EntryPatch, moveEntryToWork, softDeleteEntry, upsertEntry } from "../entries.ts";
|
||||
import { NotFoundError, ValidationError } from "../errors.ts";
|
||||
import type { Actor, Executor } from "../history.ts";
|
||||
import { softDeleteList } from "../lists.ts";
|
||||
import { matchWork } from "../matching.ts";
|
||||
import { engagementValues, entryValues } from "./apply.ts";
|
||||
import { resolveAliasIn } from "../works.ts";
|
||||
import { applyPatch, engagementValues, entryValues } from "./apply.ts";
|
||||
import { decodeVersion } from "./snapshot.ts";
|
||||
|
||||
export interface ChangeEvent {
|
||||
@@ -65,10 +67,12 @@ const STATUS_FOR = { accept: "accepted", ignore: "ignored", keep_mine: "kept_min
|
||||
/**
|
||||
* Resolve a pending event.
|
||||
* - `accept`: changed/conflict applies the incoming value as the event's plugin actor (which then owns
|
||||
* the field); removed soft-deletes the target; merged moves the entry to the merge target's work.
|
||||
* the field); removed soft-deletes the target (an engagement other sources still report only loses
|
||||
* this connection's `engagement_sources` row); merged moves the entry to the merge target's work.
|
||||
* - `keep_mine`: changed/conflict set `field_sources[field] = "user"`, so that source stops overriding it.
|
||||
* - `ignore`: data untouched.
|
||||
* All three set `status` and `resolved_at`.
|
||||
* All three set `status` and `resolved_at`. Accept/keep_mine on a target that is gone (deleted, or merged
|
||||
* away with no live survivor) resolves the event as `ignored` with `payload.reason = "target_gone"`.
|
||||
*/
|
||||
export async function resolveChange(
|
||||
db: Db,
|
||||
@@ -76,7 +80,7 @@ export async function resolveChange(
|
||||
changeId: string,
|
||||
action: "accept" | "ignore" | "keep_mine",
|
||||
): Promise<void> {
|
||||
if (!(action in STATUS_FOR)) throw new ValidationError("action", "invalid");
|
||||
if (!Object.hasOwn(STATUS_FOR, action)) throw new ValidationError("action", "invalid");
|
||||
const row = await db.k.selectFrom("change_events").selectAll().where("id", "=", changeId).where("user_id", "=", userId).executeTakeFirst();
|
||||
if (!row) throw new NotFoundError("change");
|
||||
if (row.status !== "pending") throw new ValidationError("status", "not_pending");
|
||||
@@ -96,19 +100,26 @@ export async function resolveChange(
|
||||
.executeTakeFirst();
|
||||
if (Number(claimed.numUpdatedRows) === 0) throw new ValidationError("status", "not_pending");
|
||||
if (action === "ignore") return;
|
||||
if (ev.type === "changed" || ev.type === "conflict") {
|
||||
const field = String(ev.payload.field);
|
||||
if (action === "accept") {
|
||||
const value = ev.type === "conflict" ? ev.payload.incoming : ev.payload.to;
|
||||
await setField(db, tx, userId, ev, field, value, actor);
|
||||
} else {
|
||||
await setField(db, tx, userId, ev, field, undefined, "user");
|
||||
}
|
||||
const isField = ev.type === "changed" || ev.type === "conflict";
|
||||
if (action === "keep_mine" && !isField) return;
|
||||
|
||||
const targetId = await liveTargetId(tx, userId, ev);
|
||||
if (!targetId) {
|
||||
await tx
|
||||
.updateTable("change_events")
|
||||
.set({ status: "ignored", payload: toJson({ ...ev.payload, reason: "target_gone" }) })
|
||||
.where("id", "=", ev.id)
|
||||
.execute();
|
||||
return;
|
||||
}
|
||||
if (action !== "accept") return;
|
||||
if (ev.type === "removed") return removeTarget(db, tx, userId, ev, actor);
|
||||
if (ev.type === "merged" && mergeTarget) return moveTo(db, tx, userId, ev, mergeTarget, actor);
|
||||
if (isField) {
|
||||
const field = String(ev.payload.field);
|
||||
const value = action === "keep_mine" ? undefined : ev.type === "conflict" ? ev.payload.incoming : ev.payload.to;
|
||||
await setField(db, tx, userId, ev.targetEntity, targetId, field, value, action === "keep_mine" ? "user" : actor);
|
||||
return;
|
||||
}
|
||||
if (ev.type === "removed") return removeTarget(db, tx, userId, ev, targetId, actor);
|
||||
if (ev.type === "merged" && mergeTarget) return moveTo(db, tx, userId, targetId, mergeTarget, actor);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,51 +129,29 @@ async function actorFor(db: Db, connectionId: string | null): Promise<Actor> {
|
||||
return c ? `plugin:${c.plugin_id}:${c.id}` : "user";
|
||||
}
|
||||
|
||||
/** Write `value` to the target's field as `actor`; with `value` undefined, re-stamp the current value. */
|
||||
async function setField(db: Db, k: Executor, userId: string, ev: ChangeEvent, field: string, value: unknown, actor: Actor): Promise<void> {
|
||||
if (!ev.targetId) throw new NotFoundError(ev.targetEntity);
|
||||
/**
|
||||
* The event's target if it is still live. A soft-deleted entry that was merged into another (the user's
|
||||
* work alias leads to a work with a live entry) resolves to that survivor. Otherwise null.
|
||||
*/
|
||||
async function liveTargetId(k: Executor, userId: string, ev: ChangeEvent): Promise<string | null> {
|
||||
if (!ev.targetId) return null;
|
||||
if (ev.targetEntity === "entry") {
|
||||
const r = await k
|
||||
const r = await k.selectFrom("entries").select(["id", "work_id", "deleted_at"]).where("id", "=", ev.targetId).where("user_id", "=", userId).executeTakeFirst();
|
||||
if (!r) return null;
|
||||
if (r.deleted_at === null) return r.id;
|
||||
const to = await resolveAliasIn(k, userId, r.work_id);
|
||||
if (to === r.work_id) return null;
|
||||
const survivor = await k
|
||||
.selectFrom("entries")
|
||||
.selectAll()
|
||||
.where("id", "=", ev.targetId)
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.where("work_id", "=", to)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("entry");
|
||||
const v = value === undefined ? entryValues(r)[field] : value;
|
||||
await upsertEntry(db, userId, r.work_id, { [field]: v } as Partial<EntryPatch>, actor, k);
|
||||
return;
|
||||
return survivor?.id ?? null;
|
||||
}
|
||||
if (ev.targetEntity === "engagement") {
|
||||
const r = await k
|
||||
.selectFrom("engagements as g")
|
||||
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||
.selectAll("g")
|
||||
.where("g.id", "=", ev.targetId)
|
||||
.where("e.user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (!r) throw new NotFoundError("engagement");
|
||||
const v = value === undefined ? engagementValues(r)[field] : value;
|
||||
await updateEngagement(db, userId, r.id, { [field]: v } as Partial<EngagementPatch>, actor, k);
|
||||
return;
|
||||
}
|
||||
throw new ValidationError("targetEntity", "unsupported");
|
||||
}
|
||||
|
||||
async function removeTarget(db: Db, k: Executor, userId: string, ev: ChangeEvent, actor: Actor): Promise<void> {
|
||||
if (!ev.targetId) return;
|
||||
if (ev.targetEntity === "entry") {
|
||||
const live = await k
|
||||
.selectFrom("entries")
|
||||
.select("id")
|
||||
.where("id", "=", ev.targetId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (live) await softDeleteEntry(db, userId, live.id, actor, k);
|
||||
} else if (ev.targetEntity === "engagement") {
|
||||
const live = await k
|
||||
.selectFrom("engagements as g")
|
||||
.innerJoin("entries as e", "e.id", "g.entry_id")
|
||||
.select("g.id")
|
||||
@@ -171,7 +160,51 @@ async function removeTarget(db: Db, k: Executor, userId: string, ev: ChangeEvent
|
||||
.where("g.deleted_at", "is", null)
|
||||
.where("e.deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (live) await softDeleteEngagement(db, userId, live.id, actor, k);
|
||||
return r?.id ?? null;
|
||||
}
|
||||
if (ev.targetEntity === "list") {
|
||||
const r = await k
|
||||
.selectFrom("lists")
|
||||
.select("id")
|
||||
.where("id", "=", ev.targetId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
return r?.id ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Write `value` to a live target's field as `actor`; with `value` undefined, re-stamp the current value. */
|
||||
async function setField(db: Db, k: Executor, userId: string, entity: string, id: string, field: string, value: unknown, actor: Actor): Promise<void> {
|
||||
if (entity === "entry") {
|
||||
const r = await k.selectFrom("entries").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const v = value === undefined ? entryValues(r)[field] : value;
|
||||
await upsertEntry(db, userId, r.work_id, { [field]: v } as Partial<EntryPatch>, actor, k);
|
||||
} else if (entity === "engagement") {
|
||||
const r = await k.selectFrom("engagements").selectAll().where("id", "=", id).executeTakeFirstOrThrow();
|
||||
const v = value === undefined ? engagementValues(r)[field] : value;
|
||||
await updateEngagement(db, userId, id, { [field]: v } as Partial<EngagementPatch>, actor, k);
|
||||
} else if (entity === "list") {
|
||||
// Lists have no field_sources: ownership comes from history, so keep_mine needs no write.
|
||||
if (value === undefined) return;
|
||||
await applyPatch(k, db, userId, { entity: "list", id }, { [field]: value }, actor);
|
||||
} else {
|
||||
throw new ValidationError("targetEntity", "unsupported");
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTarget(db: Db, k: Executor, userId: string, ev: ChangeEvent, targetId: string, actor: Actor): Promise<void> {
|
||||
if (ev.targetEntity === "entry") return softDeleteEntry(db, userId, targetId, actor, k);
|
||||
if (ev.targetEntity === "list") return softDeleteList(db, userId, targetId, actor, k);
|
||||
if (ev.targetEntity === "engagement") {
|
||||
// The same play may still be reported by other sources: detach this one, delete only when none remain.
|
||||
if (ev.connectionId) {
|
||||
await k.deleteFrom("engagement_sources").where("engagement_id", "=", targetId).where("connection_id", "=", ev.connectionId).execute();
|
||||
}
|
||||
const left = await k.selectFrom("engagement_sources").select("id").where("engagement_id", "=", targetId).executeTakeFirst();
|
||||
if (left) return;
|
||||
await softDeleteEngagement(db, userId, targetId, actor, k);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +224,9 @@ async function mergeTargetWork(db: Db, userId: string, ev: ChangeEvent, actor: A
|
||||
.where("key", "=", intoKey)
|
||||
.executeTakeFirst();
|
||||
if (!target) throw new ValidationError("intoKey", "unknown_record");
|
||||
if (target.target_id) {
|
||||
// A record matched but not yet applied stores its work directly.
|
||||
if (target.target_entity === "work" && target.target_id) return target.target_id;
|
||||
if (target.target_id && (target.target_entity === "entry" || target.target_entity === "engagement")) {
|
||||
const entryId =
|
||||
target.target_entity === "entry"
|
||||
? target.target_id
|
||||
@@ -214,25 +249,9 @@ async function mergeTargetWork(db: Db, userId: string, ev: ChangeEvent, actor: A
|
||||
return (await matchWork(db, userId, hint, actor)).workId;
|
||||
}
|
||||
|
||||
async function moveTo(db: Db, k: Executor, userId: string, ev: ChangeEvent, workId: string, actor: Actor): Promise<void> {
|
||||
if (!ev.targetId) return;
|
||||
const entry = await k
|
||||
.selectFrom("entries")
|
||||
.select(["id", "work_id"])
|
||||
.where("id", "=", ev.targetId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("deleted_at", "is", null)
|
||||
.executeTakeFirst();
|
||||
if (!entry) throw new NotFoundError("entry");
|
||||
/** Move the live entry to the work; `moveEntryToWork` re-points source records if it merges. */
|
||||
async function moveTo(db: Db, k: Executor, userId: string, entryId: string, workId: string, actor: Actor): Promise<void> {
|
||||
const entry = await k.selectFrom("entries").select("work_id").where("id", "=", entryId).executeTakeFirstOrThrow();
|
||||
if (entry.work_id === workId) return;
|
||||
const result = await moveEntryToWork(db, userId, entry.id, workId, actor, k);
|
||||
if (result.id !== entry.id) {
|
||||
// The entry merged into an existing one: records that mapped to it now map to the survivor.
|
||||
await k
|
||||
.updateTable("source_records")
|
||||
.set({ target_id: result.id })
|
||||
.where("target_entity", "=", "entry")
|
||||
.where("target_id", "=", entry.id)
|
||||
.execute();
|
||||
}
|
||||
await moveEntryToWork(db, userId, entryId, workId, actor, k);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user