merge: feat/phase1 into task/9
This commit is contained in:
+70
-48
@@ -41,6 +41,76 @@ No index: `grep -n '^## ' docs/DECISIONS.md` lists every entry (date, title, lin
|
||||
within 7 days; else `changes`. A failed run is rethrown so the worker retries it.
|
||||
**Consequences:** Reload re-imports plugin modules with `?v=<Date.now()>`; old module instances are not freed by Bun.
|
||||
|
||||
|
||||
## 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 and superseding.** A pending `changed`/`conflict` for the same (record, field, incoming
|
||||
value) is never filed twice. A different newer value for the same (record, field) resolves the older
|
||||
pending event as `ignored` with `payload.supersededBy = <new event id>`, so at most one is pending per
|
||||
field. If the source comes round to the value STTracker already holds, its pending events for that field
|
||||
are resolved `ignored` with `payload.reason = "source_agrees"`. `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`. Its result is committed
|
||||
at once as the record's row (`current_hash ""`, `target_entity "work"`), so a failed apply is retried
|
||||
against the same work and an ID-less hint never leaks a second draft. A changed work hint refreshes the
|
||||
mapped work via `updateWorkFromHint` (shared works only). `moveEntryToWork` re-points `source_records`
|
||||
from a merged-away entry to the survivor in the same transaction, so a user's merge never orphans a source.
|
||||
- **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 fields that are
|
||||
empty and that no source has set. Every other differing field goes through the per-field decision
|
||||
(so a disagreeing rating is a `conflict`), except the date that identified the play, whose difference is
|
||||
within the tolerance by definition.
|
||||
- **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`. A merged-away record
|
||||
(`merged_into_key` set, or a pending `merged` event) is expected to vanish: no removal, ever. Accepting
|
||||
`removed` on an engagement other sources still report only deletes this connection's
|
||||
`engagement_sources` row; the engagement is soft-deleted when no source remains. Run counts include only
|
||||
records whose transaction committed.
|
||||
- **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. Per batch of 500:
|
||||
one lookup of existing keys, each distinct recording resolved once, one multi-row insert.
|
||||
- **Lists.** A new `list` record creates a user list (kind `list`, description `Imported from <plugin id>`),
|
||||
items matched outside the transaction (an ID-less item first reuses the user's own private work with the
|
||||
same media type and title). Lists have no `field_sources`; ownership is derived from `user_history`:
|
||||
`name`/`description` belong to whoever last changed them, `items` to the user once any user edit exists.
|
||||
A user-owned field becomes a pending `changed` (for items, the whole incoming item set); otherwise the
|
||||
fields are applied and items replaced (`replaceListItems`, one history row).
|
||||
- **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`.
|
||||
- **Gone targets.** Accept/keep_mine on an event whose target is deleted (or merged away with no live
|
||||
survivor, found through the user's work alias) resolves it `ignored` with `payload.reason = "target_gone"`.
|
||||
|
||||
**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:** Concurrent runs of one connection are not locked here (Task 9's worker; `docs/TODO.md`).
|
||||
|
||||
## 2026-10-30: Export format `sttracker-export/v1`: per-user zip, camelCase JSONL, deleted rows included, no secrets
|
||||
**Status:** accepted
|
||||
**Context:** Task 15 (`packages/core/src/{export,backup}.ts`) fixed the on-disk bundle format that backups and restore share.
|
||||
@@ -67,54 +137,6 @@ 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`).
|
||||
|
||||
Reference in New Issue
Block a user