Skip to content

feat(sync): re-enrich stale detail content — --re-enrich + enrich.invalidateOn (v1.53.0) - #180

Merged
paulkr merged 3 commits into
mainfrom
feature/int-4394-sync-enriching-profiles-never-re-enrich-full-refresh-leaves
Aug 11, 2026
Merged

feat(sync): re-enrich stale detail content — --re-enrich + enrich.invalidateOn (v1.53.0)#180
paulkr merged 3 commits into
mainfrom
feature/int-4394-sync-enriching-profiles-never-re-enrich-full-refresh-leaves

Conversation

@siddharth-bhansali

Copy link
Copy Markdown
Collaborator

Fixes #174 · INT-4394

Problem

For a profile that declares enrich, each record's detail endpoint was fetched exactly once, ever. Phase 2 selects only never-enriched rows (WHERE "<tsField>" IS NULL) and phase 1's upsert deliberately preserves _enriched_at, so nothing in the codebase ever cleared it.

--full-refresh did not help: it re-pulls every record from the list endpoint and reconciles deletions, but surviving rows keep their stamp — so phase 2 saw zero unenriched rows and made zero detail calls. Stale detail content was permanent, and the documented workaround was to delete .one/sync/data/<platform>.db.

Approach

@ayushsingh82's sequencing note on the issue argued for fingerprint invalidation as the default with the manual flag as the escape hatch, and that both share the same underlying "clear the stamp" path. That is what this implements — both halves, since the shared path is the bulk of the work either way.

enrich.invalidateOn — automatic, the common case. Name a list-endpoint field that moves when the detail changes (historyId for Gmail threads, updated_at for Fathom meetings). The value observed at enrich time is recorded in a _enrich_fp column; on the next sync, invalidateStaleEnrichments() clears the stamp for rows whose fingerprint moved, and phase 2 re-fetches exactly those. Records that did not change upstream cost nothing.

--re-enrich — manual, the escape hatch. clearEnrichmentStamps() unstamps everything for the model and phase 2 re-fetches all detail endpoints. One detail call per record, so it is opt-in per run and deliberately not implied by --full-refresh. For profiles with no sensible fingerprint field, or when the detail shape changed.

Additive by design. Rows with no recorded fingerprint are never auto-invalidated (_enrich_fp IS NOT NULL guard). Without it, adding invalidateOn to a built-in profile would re-enrich the user's entire table the first time they upgraded. Profiles that omit invalidateOn keep today's exact behaviour.

Also fixed

--no-memory leaked through phase 2. Phase 1 gates its memory write on options.toMemory !== false; the enrich phase's mirror ignored it and wrote anyway. This is the gap pinned by the test literally named PRE-EXISTING GAP: --no-memory does not stop phase 2 mirroring into memory, which the issue folded in as a secondary ask.

While editing the same doc section, corrected the documented enrich concurrency default from 3 to 5 — it did not match DEFAULT_CONCURRENCY.

Docs

Updated all four mandated surfaces: guide-content.ts (new "Re-enriching" section replacing the "Known gap" text, plus invalidateOn in the enrich config reference and the sync run flag row), skills/one/SKILL.md, and README.md. The "delete the mirror to force it" advice is gone from all three.

Tests

12 new tests in enrich-invalidation.test.ts, including the two guards most likely to regress: never invalidating never-fingerprinted rows, and degrading to a no-op when invalidateOn names a field the list endpoint does not return.

They drive a fake driver rather than the real one — better-sqlite3 is an optionalDependency and will not install on Node 24, so a test needing it would silently skip on the machines most likely to run it. They therefore assert the emitted SQL and the guard decisions, not SQLite's execution of them; the IS NOT null-safety and CAST(... AS TEXT) comparison are unexercised against a real database and are worth a look during review.

Verification notes

  • npx tsc --noEmit passes; new suite 12/12.
  • I did not run the full npm test suite: on Windows it reads and writes the developer's real ~/.one (tests sandbox process.env.HOME, which os.homedir() ignores on win32).
  • Version bumped 1.52.1 → 1.53.0 (minor: new flag + new profile config key). package-lock.json version fields hand-edited rather than regenerated, since npm install on Node 24 drops the pg optional dependency from the lockfile.
  • Stacks alongside fix(sync): never destroy a healthy database on a driver fault (v1.52.3) #179 (INT-4393); both branch from main and touch sync/index.ts in different places. Whichever merges second will need its version bump rebased.

🤖 Generated with Claude Code

…alidateOn (v1.53.0) (#174)

A record's detail endpoint was fetched exactly once, ever. Phase 2 selects
`WHERE <tsField> IS NULL` and phase 1's upsert deliberately preserves the
stamp, so nothing cleared it — including `--full-refresh`, which is the flag
a user would reasonably reach for. Stale detail content was permanent; the
documented workaround was to delete the SQLite mirror.

Implements the sequencing the issue and @ayushsingh82 both favoured:
fingerprint invalidation as the default, with a manual escape hatch.

- `enrich.invalidateOn`: name a list-endpoint field that moves when the detail
  changes (historyId for Gmail, updated_at for Fathom). The value seen at
  enrich time is recorded in `_enrich_fp`; on the next sync, rows whose
  fingerprint moved have their stamp cleared and are re-enriched. Unchanged
  records cost nothing.
- `one sync run <platform> --re-enrich`: clears every stamp and re-fetches all
  detail endpoints. One detail call per record, so it is opt-in per run and
  never implied by `--full-refresh`.
- `--no-memory` now applies to phase 2. Phase 1 has always honoured it; the
  enrich phase's memory mirror ignored it, so the flag silently leaked.

Invalidation is additive: rows with no recorded fingerprint are never
auto-invalidated, so adding `invalidateOn` to a profile cannot trigger a
whole-table re-enrich on the first run after upgrading. Profiles with no
suitable fingerprint field simply omit it.

Also corrects the documented enrich concurrency default (3 -> 5) in the same
section, which did not match DEFAULT_CONCURRENCY.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@linear

linear Bot commented Aug 11, 2026

Copy link
Copy Markdown

INT-4394

siddharth-bhansali and others added 2 commits August 11, 2026 19:45
… now fixed

`PRE-EXISTING GAP: --no-memory does not stop phase 2 mirroring into memory`
asserted the broken behaviour on purpose, "pinned here so it can't regress
silently in either direction". This PR is that change in the other direction:
phase 2's memory mirror is now gated on ctx.writeToMemory, so the record must
NOT be written.

Also asserts the detail endpoint is still called, so the test can't pass by
accidentally disabling enrichment altogether — --no-memory suppresses the
unified-memory dual-write, not the enrich phase.

I missed this locally because better-sqlite3 will not install on Node 24, so
the whole enrich-preserve suite skips here. CI caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@paulkr
paulkr merged commit 6520a73 into main Aug 11, 2026
7 checks passed
@paulkr
paulkr deleted the feature/int-4394-sync-enriching-profiles-never-re-enrich-full-refresh-leaves branch August 11, 2026 14:27
paulkr pushed a commit that referenced this pull request Aug 13, 2026
…alidateOn (v1.53.0) (#180)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sync: enriching profiles never re-enrich — --full-refresh doesn't clear _enriched_at, so stale detail content is permanent

2 participants