@withone/cli 1.52.2 → 1.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -423,8 +423,11 @@ one sync run stripe --since 90d
423
423
  one mem sync run stripe # identical (alias)
424
424
  # Profiles with `enrich` run a second detail pass. It enriches each record ONCE;
425
425
  # the list pass thereafter merges rather than replaces, so the enriched payload
426
- # survives (reported as `memPreserved`). `--full-refresh` does not re-enrich —
427
- # delete .one/sync/data/<platform>.db to force that.
426
+ # survives (reported as `memPreserved`). `--full-refresh` reconciles deletions,
427
+ # it does not refresh detail content. To refresh detail:
428
+ one sync run gmail --re-enrich # re-fetch every detail endpoint
429
+ # ...or set `enrich.invalidateOn` in the profile (e.g. "historyId", "updated_at")
430
+ # so only records that actually changed upstream are re-enriched, automatically.
428
431
 
429
432
  # Query + search (reads from memory)
430
433
  one sync query stripe/balanceTransactions --where "status=available" --limit 20
@@ -216,6 +216,32 @@ function listSyncedPlatforms() {
216
216
  if (!fs3.existsSync(DATA_DIR)) return [];
217
217
  return fs3.readdirSync(DATA_DIR).filter((f) => f.endsWith(".db")).map((f) => f.replace(/\.db$/, ""));
218
218
  }
219
+ function isDriverFault(err) {
220
+ const code = err?.code;
221
+ if (code === "ERR_DLOPEN_FAILED") return true;
222
+ const msg = err instanceof Error ? err.message : String(err ?? "");
223
+ return /NODE_MODULE_VERSION|compiled against a different Node\.js version|Could not locate the bindings file|invalid ELF header|wrong ELF class|symbol not found|image not found|not a valid Win32 application/i.test(msg);
224
+ }
225
+ function passesIntegrityCheck(Ctor, dbPath) {
226
+ let probe;
227
+ try {
228
+ probe = new Ctor(dbPath, { readonly: true, fileMustExist: true });
229
+ const result = probe.pragma("quick_check");
230
+ const first = Array.isArray(result) ? result[0] : result;
231
+ const verdict = typeof first === "string" ? first : first?.quick_check;
232
+ return verdict === "ok";
233
+ } catch {
234
+ return false;
235
+ } finally {
236
+ try {
237
+ probe?.close();
238
+ } catch {
239
+ }
240
+ }
241
+ }
242
+ function backupPathFor(dbPath, now = /* @__PURE__ */ new Date()) {
243
+ return `${dbPath}.bak.${now.toISOString().replace(/[:.]/g, "-")}`;
244
+ }
219
245
  async function openDatabase(platform, opts = {}) {
220
246
  const Database = await loadSqlite();
221
247
  fs3.mkdirSync(DATA_DIR, { recursive: true });
@@ -226,13 +252,38 @@ async function openDatabase(platform, opts = {}) {
226
252
  let db;
227
253
  try {
228
254
  db = new Database(dbPath);
229
- } catch {
230
- const backupPath = dbPath + ".bak";
231
- if (fs3.existsSync(dbPath)) {
232
- fs3.renameSync(dbPath, backupPath);
233
- process.stderr.write(`Database corrupted, starting fresh. Backup saved at ${backupPath}
234
- `);
255
+ } catch (err) {
256
+ if (isDriverFault(err)) {
257
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
258
+ throw new Error(
259
+ `The local sync engine (better-sqlite3) could not load in this Node process.
260
+ Your database was NOT modified.
261
+
262
+ This usually means the CLI is running under a different Node than the one
263
+ better-sqlite3 was built for \u2014 currently node ${process.version} (NODE_MODULE_VERSION ${process.versions.modules}). \`one\` is a #!/usr/bin/env node shim, so a minimal PATH under cron, launchd,
264
+ or an agent runner can select a different interpreter than your shell does.
265
+
266
+ Rebuild it against this Node with:
267
+ one sync install
268
+
269
+ Underlying error: ${detail}`
270
+ );
235
271
  }
272
+ if (!fs3.existsSync(dbPath)) throw err;
273
+ if (passesIntegrityCheck(Database, dbPath)) {
274
+ const detail = err instanceof Error ? err.message.split("\n")[0] : String(err);
275
+ throw new Error(
276
+ `Could not open ${dbPath}, but it passes SQLite's integrity check \u2014 so it is not corrupt and has been left untouched.
277
+
278
+ Underlying error: ${detail}`
279
+ );
280
+ }
281
+ const backupPath = backupPathFor(dbPath);
282
+ fs3.renameSync(dbPath, backupPath);
283
+ process.stderr.write(
284
+ `Database at ${dbPath} failed its integrity check. Backup saved at ${backupPath}, starting fresh.
285
+ `
286
+ );
236
287
  db = new Database(dbPath);
237
288
  }
238
289
  db.pragma("journal_mode = WAL");
package/dist/index.js CHANGED
@@ -65,7 +65,7 @@ import {
65
65
  writeDraftProfile,
66
66
  writePageToMemory,
67
67
  writeProfile
68
- } from "./chunk-FKHUH223.js";
68
+ } from "./chunk-IKYXS7EI.js";
69
69
  import {
70
70
  getByDotPath
71
71
  } from "./chunk-44CV5IMX.js";
@@ -5672,6 +5672,52 @@ function findEnrichedIds(db, model, idField, config2, records, tableCreated) {
5672
5672
  }
5673
5673
  return enriched;
5674
5674
  }
5675
+ var ENRICH_FINGERPRINT_COLUMN = "_enrich_fp";
5676
+ function ensureColumn(db, safeTable, column, type) {
5677
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all();
5678
+ if (!cols.some((c) => c.name === column)) {
5679
+ db.exec(`ALTER TABLE "${safeTable}" ADD COLUMN "${column}" ${type}`);
5680
+ }
5681
+ }
5682
+ function clearEnrichmentStamps(db, model, timestampField = "_enriched_at") {
5683
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5684
+ const exists = db.prepare(
5685
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`
5686
+ ).get(safeTable);
5687
+ if (!exists) return 0;
5688
+ const cols = db.prepare(`PRAGMA table_info("${safeTable}")`).all();
5689
+ if (!cols.some((c) => c.name === timestampField)) return 0;
5690
+ const result = db.prepare(
5691
+ `UPDATE "${safeTable}" SET "${timestampField}" = NULL WHERE "${timestampField}" IS NOT NULL`
5692
+ ).run();
5693
+ return result.changes;
5694
+ }
5695
+ function invalidateStaleEnrichments(db, model, config2) {
5696
+ const fpField = config2.invalidateOn;
5697
+ if (!fpField) return 0;
5698
+ const tsField = config2.timestampField ?? "_enriched_at";
5699
+ const safeTable = model.replace(/[^a-zA-Z0-9_]/g, "_");
5700
+ const exists = db.prepare(
5701
+ `SELECT name FROM sqlite_master WHERE type='table' AND name = ?`
5702
+ ).get(safeTable);
5703
+ if (!exists) return 0;
5704
+ const cols = new Set(
5705
+ db.prepare(`PRAGMA table_info("${safeTable}")`).all().map((c) => c.name)
5706
+ );
5707
+ if (!cols.has(fpField)) return 0;
5708
+ if (!cols.has(tsField)) return 0;
5709
+ if (!cols.has(ENRICH_FINGERPRINT_COLUMN)) {
5710
+ ensureColumn(db, safeTable, ENRICH_FINGERPRINT_COLUMN, "TEXT");
5711
+ return 0;
5712
+ }
5713
+ const result = db.prepare(
5714
+ `UPDATE "${safeTable}" SET "${tsField}" = NULL
5715
+ WHERE "${tsField}" IS NOT NULL
5716
+ AND "${ENRICH_FINGERPRINT_COLUMN}" IS NOT NULL
5717
+ AND "${ENRICH_FINGERPRINT_COLUMN}" IS NOT CAST("${fpField}" AS TEXT)`
5718
+ ).run();
5719
+ return result.changes;
5720
+ }
5675
5721
  async function enrichPhase(api, db, config2, model, idField, connectionKey, platform, ctx = {}) {
5676
5722
  const startTime = Date.now();
5677
5723
  const tsField = config2.timestampField ?? "_enriched_at";
@@ -5740,6 +5786,10 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5740
5786
  }
5741
5787
  const merged = config2.merge !== false ? deepMerge(row, enrichedData) : { ...enrichedData, [idField]: id };
5742
5788
  merged[tsField] = now;
5789
+ if (config2.invalidateOn) {
5790
+ const fp = merged[config2.invalidateOn] ?? row[config2.invalidateOn];
5791
+ merged[ENRICH_FINGERPRINT_COLUMN] = fp == null ? null : String(fp);
5792
+ }
5743
5793
  pending.push({ merged, id });
5744
5794
  } else if (result.status === "fulfilled" && result.value === null) {
5745
5795
  rateLimited++;
@@ -5803,7 +5853,7 @@ async function enrichPhase(api, db, config2, model, idField, connectionKey, plat
5803
5853
  hookEvents.push({ type: "update", platform, model, record: merged, timestamp: now });
5804
5854
  }
5805
5855
  }
5806
- if (ctx.profile && memoryBatch.length > 0) {
5856
+ if (ctx.profile && ctx.writeToMemory !== false && memoryBatch.length > 0) {
5807
5857
  try {
5808
5858
  await writePageToMemory(ctx.profile, memoryBatch);
5809
5859
  } catch (err) {
@@ -6458,6 +6508,19 @@ async function syncModel(api, profile, options) {
6458
6508
  }
6459
6509
  let enrichResult = null;
6460
6510
  if (profile.enrich && db && tableCreated && !options.dryRun) {
6511
+ if (options.reEnrich) {
6512
+ const cleared = clearEnrichmentStamps(db, model, profile.enrich.timestampField);
6513
+ if (cleared > 0 && !isAgentMode()) {
6514
+ console.log(` Re-enriching ${cleared} record(s) (--re-enrich)`);
6515
+ }
6516
+ } else {
6517
+ const invalidated = invalidateStaleEnrichments(db, model, profile.enrich);
6518
+ if (invalidated > 0 && !isAgentMode()) {
6519
+ console.log(
6520
+ ` ${invalidated} record(s) changed upstream (${profile.enrich.invalidateOn}) \u2014 re-enriching those`
6521
+ );
6522
+ }
6523
+ }
6461
6524
  enrichResult = await enrichPhase(
6462
6525
  api,
6463
6526
  db,
@@ -6476,7 +6539,9 @@ async function syncModel(api, profile, options) {
6476
6539
  // Pass the full profile so enrich can mirror merged rows into the
6477
6540
  // memory store. Without this, memory holds only the pre-enrich
6478
6541
  // list payload — see enrich.ts:memoryBatch writeback.
6479
- profile
6542
+ profile,
6543
+ // Phase 1 honours --no-memory; phase 2 used to ignore it. (#174)
6544
+ writeToMemory: options.toMemory !== false
6480
6545
  }
6481
6546
  );
6482
6547
  enrichedTotal = enrichResult.enriched;
@@ -7731,6 +7796,7 @@ async function syncDoctorCommand() {
7731
7796
  checks.push({ name: "better-sqlite3 loads", ok: false, detail: err instanceof Error ? err.message : String(err) });
7732
7797
  }
7733
7798
  const allOk = checks.every((c) => c.ok);
7799
+ if (!allOk) process.exitCode = 1;
7734
7800
  if (isAgentMode()) {
7735
7801
  json({ ok: allOk, checks });
7736
7802
  return;
@@ -8286,7 +8352,7 @@ async function maybeAutoMigrateLegacy(platform, models) {
8286
8352
  ` detected legacy .one/sync/data/${platform}.db (${dbSize}) \u2014 auto-migrating into memory before sync.
8287
8353
  `
8288
8354
  );
8289
- const { memMigrateCommand: memMigrateCommand3 } = await import("./migrate-LA7I3OQN.js");
8355
+ const { memMigrateCommand: memMigrateCommand3 } = await import("./migrate-U2MZVGU6.js");
8290
8356
  await memMigrateCommand3({ platform, yes: true });
8291
8357
  return;
8292
8358
  }
@@ -8295,7 +8361,7 @@ async function maybeAutoMigrateLegacy(platform, models) {
8295
8361
  initialValue: true
8296
8362
  });
8297
8363
  if (p7.isCancel(shouldMigrate) || !shouldMigrate) return;
8298
- const { memMigrateCommand: memMigrateCommand2 } = await import("./migrate-LA7I3OQN.js");
8364
+ const { memMigrateCommand: memMigrateCommand2 } = await import("./migrate-U2MZVGU6.js");
8299
8365
  await memMigrateCommand2({ platform, yes: true });
8300
8366
  }
8301
8367
  async function syncSuggestSearchableCommand(platformModel, options = {}) {
@@ -8595,7 +8661,7 @@ function registerSyncSubcommands(sync) {
8595
8661
  sync.command("suggest-searchable <platform/model>").description("Rank candidate memory.searchable paths by signal density (for profiles with memory.embed: true)").option("--limit <n>", "Top N paths to return (default 15)").action(async (platformModel, options) => {
8596
8662
  await syncSuggestSearchableCommand(platformModel, options);
8597
8663
  });
8598
- sync.command("run <platform>").description("Run sync for a platform (syncs all configured models, or specify --models)").option("--models <models>", "Comma-separated list of models to sync").option("--since <duration>", "Sync records since duration (e.g. 90d, 30d, 7d) or date").option("--force", "Ignore existing sync state and start fresh").option("--max-pages <n>", "Maximum number of pages to fetch").option("--dry-run", "Fetch first page only, show results without persisting").option("--full-refresh", "Fetch ALL records and delete local rows no longer in the source (handles deletions)").option("--no-memory", "Skip the unified memory dual-write (default: memory is always written)").option("--embed", "Embed synced rows under the configured model, regardless of profile.memory.embed", false).option("--no-embed", "Skip embedding even if the profile opts in").option("--to-memory", "(deprecated \u2014 memory is now always written; flag kept for back-compat)").action(async (platform, options) => {
8664
+ sync.command("run <platform>").description("Run sync for a platform (syncs all configured models, or specify --models)").option("--models <models>", "Comma-separated list of models to sync").option("--since <duration>", "Sync records since duration (e.g. 90d, 30d, 7d) or date").option("--force", "Ignore existing sync state and start fresh").option("--max-pages <n>", "Maximum number of pages to fetch").option("--dry-run", "Fetch first page only, show results without persisting").option("--full-refresh", "Fetch ALL records and delete local rows no longer in the source (handles deletions)").option("--re-enrich", 'Re-fetch the detail endpoint for every already-enriched record (profiles with an "enrich" block). Costs one detail call per record \u2014 for routine freshness set enrich.invalidateOn in the profile instead').option("--no-memory", "Skip the unified memory dual-write (default: memory is always written)").option("--embed", "Embed synced rows under the configured model, regardless of profile.memory.embed", false).option("--no-embed", "Skip embedding even if the profile opts in").option("--to-memory", "(deprecated \u2014 memory is now always written; flag kept for back-compat)").action(async (platform, options) => {
8599
8665
  await syncRunCommand(platform, {
8600
8666
  models: options.models?.split(",").map((m) => m.trim()),
8601
8667
  since: options.since,
@@ -8603,6 +8669,7 @@ function registerSyncSubcommands(sync) {
8603
8669
  maxPages: options.maxPages ? parseInt(options.maxPages, 10) : void 0,
8604
8670
  dryRun: options.dryRun,
8605
8671
  fullRefresh: options.fullRefresh,
8672
+ reEnrich: options.reEnrich,
8606
8673
  // Commander inverts `--no-memory` / `--no-embed` into options.X === false.
8607
8674
  toMemory: options.memory !== false,
8608
8675
  // Only override when the flag was actually passed — leaving
@@ -10739,6 +10806,7 @@ When a list endpoint returns lightweight records (e.g. just IDs), add an \`enric
10739
10806
  "enrich": {
10740
10807
  "actionId": "<get-message-action-id>",
10741
10808
  "pathVars": { "messageId": "{{id}}" },
10809
+ "invalidateOn": "historyId",
10742
10810
  "concurrency": 3,
10743
10811
  "delayMs": 200
10744
10812
  }
@@ -10746,7 +10814,8 @@ When a list endpoint returns lightweight records (e.g. just IDs), add an \`enric
10746
10814
  \`\`\`
10747
10815
 
10748
10816
  - \`pathVars\` / \`queryParams\` / \`body\` support \`{{field}}\` interpolation from the list record
10749
- - \`concurrency\` controls parallel detail requests per page (default: 3, lower = safer for rate limits)
10817
+ - \`invalidateOn\` names a list field that acts as a change fingerprint, so records whose detail went stale are re-enriched automatically (see "Re-enriching" below). Omit it to keep enrich-exactly-once behaviour
10818
+ - \`concurrency\` controls parallel detail requests per page (default: 5, lower = safer for rate limits)
10750
10819
  - \`delayMs\` is the pause between batches (default: 200ms)
10751
10820
  - \`resultsPath\` extracts a sub-object from the detail response before merging
10752
10821
  - \`merge: false\` replaces the record entirely instead of deep-merging
@@ -10802,7 +10871,19 @@ So for a row the mirror reports as already enriched, Phase 1 writes **non-author
10802
10871
 
10803
10872
  The write still happens, so \`--embed\`, profile edits, and the store's un-archive self-heal all keep working on enriched rows. The one thing given up: a field that disappears from the *list* shape upstream no longer disappears from an enriched record, because a merge cannot delete. \`sync run\` reports the count as \`memPreserved\`.
10804
10873
 
10805
- **Known gap:** \`--full-refresh\` does **not** clear \`_enriched_at\`, so it does not re-enrich \u2014 and there is currently no way to re-enrich a record whose upstream detail content changed after its first enrichment. Delete the mirror (\`.one/sync/data/<platform>.db\`) to force a full re-enrich.
10874
+ **Re-enriching.** \`--full-refresh\` re-pulls the list but deliberately does **not** clear \`_enriched_at\` \u2014 it reconciles deletions, it is not a detail refresh. There are two ways to refresh detail content:
10875
+
10876
+ - **\`enrich.invalidateOn\` (preferred, automatic).** Name a list-endpoint field that acts as a change fingerprint \u2014 \`historyId\` for Gmail threads, \`updated_at\` for Fathom meetings. The value seen at enrich time is recorded, and on the next sync any record whose fingerprint has moved is re-enriched. Records that did not change upstream cost nothing.
10877
+
10878
+ \`\`\`json
10879
+ { "enrich": { "actionId": "...", "invalidateOn": "historyId" } }
10880
+ \`\`\`
10881
+
10882
+ Additive by design: rows enriched before a fingerprint existed are never auto-invalidated (that would re-enrich the whole table on the first run after upgrading) \u2014 they pick one up the next time they enrich. Profiles with no sensible fingerprint field simply omit it and keep enrich-exactly-once behaviour.
10883
+
10884
+ - **\`one sync run <platform> --re-enrich\` (manual escape hatch).** Clears every enrichment stamp and re-fetches all detail endpoints. Costs one detail call per record, so it is opt-in per run and never implied by \`--full-refresh\`. Use it when the detail *shape* changed, or for profiles with no fingerprint field.
10885
+
10886
+ Deleting the mirror (\`.one/sync/data/<platform>.db\`) is no longer necessary.
10806
10887
 
10807
10888
  ## Cross-Platform Identity
10808
10889
 
@@ -10903,12 +10984,12 @@ Every \`sync X\` command is also exposed as \`mem sync X\` \u2014 same handlers,
10903
10984
  | Command | What it does |
10904
10985
  |---------|-------------|
10905
10986
  | \`sync profiles [platform]\` | List built-in pre-validated profiles |
10906
- | \`sync doctor\` | Verify sync engine health |
10987
+ | \`sync doctor\` | Verify sync engine health. **Exits non-zero when not ready**, so \`one sync doctor && one sync run <platform>\` is a safe gate \u2014 run it first from cron/launchd, where a bare PATH can select a Node the native driver wasn't built for |
10907
10988
  | \`sync models <platform>\` | Discover available models |
10908
10989
  | \`sync init <plat> <model>\` | Create/patch profile (seeds from built-in, auto-tests) |
10909
10990
  | \`sync test <plat>/<model>\` | Validate profile. \`--show-searchable\` previews embedded text across 5 samples with per-path hit rates |
10910
10991
  | \`sync suggest-searchable <plat>/<model>\` | Rank candidate \`memory.searchable\` paths by signal density; emits paste-ready config |
10911
- | \`sync run <platform>\` | Sync data (\`--full-refresh\`, \`--since\`, \`--dry-run\`, \`--no-memory\`) |
10992
+ | \`sync run <platform>\` | Sync data (\`--full-refresh\`, \`--since\`, \`--dry-run\`, \`--no-memory\`, \`--re-enrich\`) |
10912
10993
  | \`sync query <plat>/<model>\` | Query memory with \`--where\` (dotted paths), \`--after/before\` |
10913
10994
  | \`sync schema <plat>/<model>\` | Inspect the JSON structure of synced records (field paths, types, examples) \u2014 run before writing \`--where\` / query paths |
10914
10995
  | \`sync search "<query>"\` | Hybrid FTS + semantic across all synced data |
@@ -3,7 +3,7 @@ import {
3
3
  dotPathToJsonbExpr,
4
4
  memMigrateCommand,
5
5
  reviveStringifiedJson
6
- } from "./chunk-FKHUH223.js";
6
+ } from "./chunk-IKYXS7EI.js";
7
7
  import "./chunk-44CV5IMX.js";
8
8
  import "./chunk-GNSR3NYN.js";
9
9
  import "./chunk-DDCDPVJH.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@withone/cli",
3
- "version": "1.52.2",
3
+ "version": "1.53.0",
4
4
  "description": "CLI for managing One",
5
5
  "type": "module",
6
6
  "files": [
@@ -318,7 +318,7 @@ Without declared paths, the default walker concatenates every string in the reco
318
318
 
319
319
  Both are queryable with `one --agent mem find-by-key <prefix>:<value>`.
320
320
 
321
- **Enriching profiles** (`gmail/gmailThreads`, `fathom/meetings`) sync in two phases: a list pass, then a detail pass that fetches full bodies/transcripts. Two things follow. Enrichment happens **once per record** — phase 2 only visits rows it has never enriched, and `--full-refresh` does *not* reset that, so re-running a sync will not refresh detail content (delete `.one/sync/data/<platform>.db` to force it). And the list pass never overwrites an enriched record: `data` merges rather than replaces, and `searchable_text` / `identity_keys[]` are left alone. `sync run` reports these as `memPreserved`. So on an enriching profile, a record whose upstream *detail* changed will look stale until the mirror is cleared — that is expected, not a sync failure.
321
+ **Enriching profiles** (`gmail/gmailThreads`, `fathom/meetings`) sync in two phases: a list pass, then a detail pass that fetches full bodies/transcripts. Two things follow. Enrichment happens **once per record by default** — phase 2 only visits rows it has never enriched, and `--full-refresh` does *not* reset that (it reconciles deletions, it is not a detail refresh). To refresh detail content, either set `enrich.invalidateOn` in the profile to a list field that moves when the detail changes (`historyId`, `updated_at`) so only genuinely-changed records re-enrich automatically, or run `one sync run <platform> --re-enrich` to re-fetch every detail endpoint. And the list pass never overwrites an enriched record: `data` merges rather than replaces, and `searchable_text` / `identity_keys[]` are left alone. `sync run` reports these as `memPreserved`. So on an enriching profile without `invalidateOn`, a record whose upstream *detail* changed will look stale until you re-enrich — that is expected, not a sync failure.
322
322
 
323
323
  **Advanced features** (enrich, transform, exclude, hooks, --full-refresh, alternative backends, embedding tuning): run `one guide memory` or `one guide sync` for the full reference.
324
324