Skip to content

feat(sync): declarative derive fields + real identity-key preview (v1.55.0) - #185

Merged
paulkr merged 2 commits into
mainfrom
feature/int-4408-add-declarative-derive-field-to-sync-profiles
Aug 11, 2026
Merged

feat(sync): declarative derive fields + real identity-key preview (v1.55.0)#185
paulkr merged 2 commits into
mainfrom
feature/int-4408-add-declarative-derive-field-to-sync-profiles

Conversation

@siddharth-bhansali

Copy link
Copy Markdown
Collaborator

Closes #129 · INT-4408

Ships the last two items on #129 — the ones I'd deferred out of #181.

1. derive — flat fields without a shell

#129 asked for the gmail from_email extraction to move out of a user-editable gmail-extract.jq and into the built-in profile.

It could not go in as a transform. transform is the only existing way to produce a derived field, and it runs as spawn('sh', ['-c', cmd]) — so a built-in relying on it would require jq on every user's PATH, and on Windows would ENOENT and silently resolve null, leaving the sync reporting success with the field absent. Not something to ship in a default profile.

So this adds a declarative alternative:

"derive": {
  "from_email": {
    "path": "messages[0].payload.headers[name=From].value",
    "extract": "email"
  },
  "company": "organization.name"
}
  • Reuses the identityKeys path resolver[] wildcards, [0] indexes, [name=From] filters. One path syntax for profile authors, not two.
  • extract: "email" applies the same address extraction identityKeys uses for email-prefixed values, so a display-name header yields jane@acme.com.
  • A path resolving to nothing omits the field rather than writing null — a null would break --where filters and leave every record carrying dead keys.
  • A path resolving to several values takes the first. It's a flat field by definition, and the path syntax is expressive enough to say which one you meant.

Applied inside writePageToMemory — deliberately, because that's the one point both sync phases pass through. Phase 1 writes list-shape records and phase 2 writes enriched ones; a derived field applied in only one would flicker between runs.

Shipped on profiles/gmail/gmailThreads.json, which is #129's actual ask.

2. sync test previews what actually resolves

Previously, for an enriching profile, the preview ran against list-shape samples where the participant paths don't exist yet — so it reported zero keys plus a resolvesAfterEnrich note. Accurate, but it never showed the author what would resolve, which is what acceptance criterion 4 wanted.

It now spends one detail call on the first sample and previews the merged shape, flagged previewedAfterEnrich. If that call fails (rate limit, permissions, an unreachable detail action) it falls back to the previous behaviour, so the command never gets worse.

The fetch reuses enrichSingleRow and the same resultsPath / fields / exclude / merge handling as the real phase, exported as enrichOneForPreview. A preview that constructed its own request would drift from what a sync actually writes, which is worse than no preview.

Worth a reviewer's opinion: this makes sync test perform a real (read-only) API call for enriching profiles, where before it was purely a dry check. That's the reading closest to the acceptance criterion, but if you'd rather it were behind --deep it's a small change.

Tests

24 new, 457 total, 0 skipped, all passing.

  • derive.test.ts — resolver edge cases: fan-out, missing paths, non-scalar values, email extraction with and without a display name, the built-in gmail profile, and an assertion that no built-in requires a shell.
  • mem-writer.test.ts — an integration test against live PGlite proving derived fields reach the stored record and that a missing path writes nothing. The wiring is where a silent no-op would hide, so it's covered separately from the resolver.

Notes

  • Version 1.55.0. Sits on top of fix(deps): bump better-sqlite3 to v12 for Node 24 support (v1.54.1) #184 (1.54.1); merge that first or this one needs a version rebase.
  • Docs updated in guide-content.ts (new "Derived fields" section placed before Record Transform, since it's what you should reach for first) and skills/one/SKILL.md. No CLI help change — derive is a profile field, not a flag.

….55.0) (#129)

Closes the last two items on #129.

1. `derive` — flat, queryable top-level fields computed from paths already in
   the record:

     "derive": { "from_email": {
       "path": "messages[0].payload.headers[name=From].value",
       "extract": "email" } }

   The issue asked for the gmail `from_email` extraction to move out of a
   user-editable jq transform and into the built-in profile. It could not go in
   as a `transform`: that spawns `sh -c`, so a built-in relying on it would
   need jq on PATH and would be a silent no-op on Windows. `derive` is the
   declarative replacement — pure, cross-platform, and reusing the same path
   resolver `identityKeys` already uses ([] wildcards, [0] indexes,
   [name=From] filters), so profile authors learn one path syntax rather than
   two. Shipped on the built-in gmail profile.

   Applied inside writePageToMemory, the one point BOTH sync phases pass
   through, so a derived field can't flicker between the list and enriched
   shapes. A path resolving to nothing omits the field rather than writing
   null, so --where filters behave.

2. `sync test` now previews the identity keys an enriching profile ACTUALLY
   resolves. It previously ran against list-shape samples, where participant
   paths don't exist yet, and reported zero keys plus a note explaining they'd
   resolve later — true, but it never showed what. It now spends one detail
   call on the first sample and previews the merged shape, falling back to the
   old behaviour if that call fails.

   The fetch reuses enrichSingleRow and the same resultsPath/fields/exclude/
   merge handling as the real phase (exported as enrichOneForPreview), because
   a preview that built its own request would drift from what a sync writes —
   worse than no preview.

24 new tests: the resolver's edge cases (fan-out, missing paths, non-scalar
values, email extraction), the built-in gmail profile, and an integration test
against live PGlite proving the fields reach the stored record — the wiring is
where a silent no-op would hide.
@siddharth-bhansali siddharth-bhansali self-assigned this Aug 11, 2026
@linear

linear Bot commented Aug 11, 2026

Copy link
Copy Markdown

INT-4408

@paulkr
paulkr merged commit 0fde561 into main Aug 11, 2026
7 checks passed
@paulkr
paulkr deleted the feature/int-4408-add-declarative-derive-field-to-sync-profiles branch August 11, 2026 15:41
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] Built-in gmail/threads profile: identity keys for all participants

2 participants