merge: task 8 sync engine

This commit is contained in:
Lucas Winther
2026-10-30 20:35:37 +01:00
12 changed files with 1861 additions and 7 deletions
+48
View File
@@ -30,6 +30,54 @@ No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, lin
are built in `<stamp>.tmp` then renamed (modes 0700/0600), and prune to the newest `keep` (default 14).
**Consequences:** Any change to these files after release needs `v2` and a migration path for old bundles.
## 2026-10-30: Sync engine: hash composition, anti-flip-flop, no revive, root ancestor, dedupe
**Status:** accepted
**Context:** Task 8 built the change engine (`packages/core/src/sync/`). Spec §8.3 fixes the outcomes; these
are the mechanics a maintainer would otherwise reverse-engineer.
**Decision:**
- **Hash composition.** sha256 of the stable-key-ordered JSON of `{kind, work, entry|engagement|listen|list,
deleted, mergedIntoKey}`. `key` and `raw` are excluded; `deleted` is always a boolean and `mergedIntoKey`
always present (null), so an omitted flag and an explicit `false` hash alike. A version row stores
base64(`Bun.gzipSync`) of `{normalized, raw}`: the normalised record is what the next change is diffed
against (field-level diff = previous version vs. incoming), the raw payload is kept for the record.
- **Per-field decision.** Only fields that differ from the previous version are considered, and of those
only values that differ from the current STTracker value. `field_sources[f]` unset or this connection's
actor → applied; `"user"` → `pending changed {field, from: current, to: incoming}`; another source →
`pending conflict {field, current, currentSource, incoming, incomingSource}`. A new record landing on an
entry that already exists goes through the same per-field decision, so a first sync never overwrites
what the user or another source wrote. Unchanged values are never sent, so a plugin never takes
ownership of a field it did not change.
- **Anti-flip-flop.** A pending `changed`/`conflict` for the same (record, field, incoming value) is never
filed twice. `keep_mine` re-stamps `field_sources[f] = "user"`, so later values from any source become a
user-decided `changed`, never another source-vs-source conflict.
- **No revive.** If the mapped entry/engagement is soft-deleted, the record is still snapshotted but nothing
is applied and no event is filed; a new record on a work whose entry the user deleted adds nothing.
Only the user revives.
- **Root ancestor.** Creating an entry on a child work also creates a `status "none"` entry (plugin actor,
empty `field_sources`) on the root ancestor work, unless the user has one, live or deleted.
- **Mapped records are never re-matched.** Only new records go through `matchWork`. A changed work hint
refreshes the mapped work via `updateWorkFromHint` (shared works only).
- **Engagement dedupe.** Among live engagements on the entry not already sourced by this connection:
finish dates match when both are datetimes ≤ 24 h apart as instants, or when both are day precision and
their dates (first 10 chars, computed with `Date.UTC`, no timezone shift) are ≤ 1 day apart; or start
datetimes are the same instant. A match attaches an `engagement_sources` row and fills only fields that
are empty and that no source has set; the first source's values otherwise stand.
- **Removal.** Absence counts only after a `full` pull whose iteration finished and whose records all
applied without error; any record error fails the run and skips removal. A removed record that comes
back clears `deleted_at` and retires its still-pending `removed` event as `ignored`.
- **Listens skip snapshots.** Listen records are not stored as `source_records` (they are high-volume and
append-only). `listens` insert-or-ignore is the idempotence; they are never removed.
- **Merge accept.** The target work is the merge target record's mapped entry's work, else `matchWork` on
its latest hint. Admin-merge events (`toWorkId`, no connection) resolve through the same `resolveChange`.
**Alternatives:**
- Diffing against the live entry instead of the previous version would re-file decided conflicts each run.
- Hashing `raw` would turn every cosmetic API change into a new version.
- Keeping one run "ok" despite failing records risks marking those records removed.
**Consequences:** `list` records are snapshotted but not applied, and removal of a de-duplicated engagement
deletes it even if another source still reports it (both in `docs/TODO.md`).
## 2026-10-30: User layer: per-user moves merge entries, keep aliases, and roll the library up by hierarchy
**Status:** accepted
**Context:** Task 6 built the user layer (`packages/core/src/{entries,engagements,history,lists,copies}.ts`).