@drakulavich/oura-cli 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] - 2026-09-09
10
+
11
+ ### Added
12
+ - `sync --prune=<collections>` applies removals the truncation guard refuses. The guard keeps rows when a response drops most of what one request described, which is right for a partial or short answer and wrong for a genuine large correction — and it cannot tell them apart, so a genuine one was refused on every run with no way through. Narrowing the window did not help: the scope is the returned rows' own bounds, so the ratio never moved, and `sync --from D --to D` refused identically. The flag is the user's answer to the question the guard cannot ask, per run and never stored.
13
+
14
+ It is scoped to the collections named because consent is per collection: the refusal message names the collection to pass (`re-run with --prune=hr`), so lifting the guard for one does not lift it for another that answers short in the same run — a loss that never comes back behind `--from`, since the next ordinary sync resumes from the watermark. `--prune=all` covers every collection; a valueless `--prune` is refused, because as a string flag it would otherwise swallow whatever came next on the line. Removals that went past the guard are counted separately from ordinary reconciliation, in the JSON as `pruned` and on the collection line as `(N past the truncation guard)`, so a bypass is never silent. `refused` and `pruned` carry the collection name alongside the count (`{rows, collection}`), because they are the only fields that imply a follow-up command and the command takes a name the table-keyed payload did not contain — `sync` is JSON-only off a TTY, and an agent that cannot map `heartrate` back to `hr` would reach for `--prune=all`. A run that passed the flag also records `pruneScope`, so a kept JSON log can say whether the guard was lifted and over what. A piece that returns nothing still removes nothing, flag or not. Where two pieces overlap, every piece covering a row is consulted before that row is decided, and one doubting piece is enough to keep it — a verdict taken while walking the pieces would have been settled by whichever came first, so the same responses in a different order could delete a row instead of keeping it. (#100)
15
+
16
+ ### Fixed
17
+ - `sync` kept both rows when Oura reclassified a heart-rate sample (`awake` → `workout` shares the timestamp, and the unique index is `(timestamp, source)`), and no command could repair it. A re-fetched window is now reconciled against the response: rows the API no longer has are removed, inside the same transaction as the inserts. Verified on a copy of a real cache, where it removed exactly the five stale samples the exploratory session had found. (#91)
18
+ - The same for the five tables whose `day` is not unique (`sleep_model`, `workouts`, `sessions`, `rest_mode_periods`, `enhanced_tags`): a record re-issued under a new id used to leave both rows, so `report` averaged one night twice. The scope of a removal always comes from the response and from one request at a time — a range wider than the endpoint allows is fetched in pieces, and a piece that answers with nothing describes nothing, so its days keep their rows. A piece whose answer would drop most of what is stored for it is treated as truncated: the removal is refused and reported. (#71)
19
+ - `sync` reports `removed` and `refused` per table in its JSON, and the progress line says how many stale rows went and how many were kept because the response looked truncated.
20
+ - `ensureSchema` read the schema version and applied migrations with nothing serialising the two, so concurrent commands recorded the same migration several times — harmless only while every migration is `CREATE ... IF NOT EXISTS`, and a hard failure for the loser as soon as one is an `ALTER TABLE`. Check and apply now run under `BEGIN IMMEDIATE`. Two processes creating the same cache also raced on `PRAGMA journal_mode = WAL`, which answers `SQLITE_BUSY` without waiting out `busy_timeout`; losing that race is no longer an error, since the winner set the mode this process wanted. (#77)
21
+ - A misspelled global flag left its value in the command position, so the CLI reported the value as the command name — and for `--tok <token>` that put a Personal Access Token into an error message. A near miss of a known flag is now blamed on the flag (`Unknown flag "--tok". Did you mean --token?`), and a value that cannot be a command name is never quoted back. (#95)
22
+ - An empty `--token` or `OURA_TOKEN` fell through to the token file, so a wrapper expanding an unset variable authenticated as whoever that file holds; an empty `OURA_TZ` fell through to the system zone and shifted every day boundary silently. Both are now `BAD_ARGS`, like the `--db` case fixed in 0.6.0, through one shared guard. (#92)
23
+ - A database in a directory the user cannot write reported `attempt to write a readonly database` with a hint about the file's format. The file is fine; SQLite needs to create `-wal` and `-shm` beside it. Permission failures now get their own hint. (#78)
24
+ - `doctor` runs SQLite's `quick_check` and reports it as a new `integrity` check, so a cache with a damaged page is named instead of passing every command until something touches the broken page. `doctor` also survives reading a corrupt cache rather than crashing part-way through its own checks, and `healthcheck`'s description now says it proves the file opens, not that its contents are intact. (#78)
25
+
9
26
  ## [0.6.0] - 2026-09-06
10
27
 
11
28
  ### Fixed
package/README.md CHANGED
@@ -54,7 +54,7 @@ oura-cli sync # first sync fetches the last 30 days; later syncs resume from
54
54
  oura-cli report # weekly digest in the terminal
55
55
  ```
56
56
 
57
- Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
57
+ Subsequent `oura-cli sync` re-fetches each collection from its own last stored day (Oura revises recent days, so the overlap is deliberate) and reports rows fetched (+new). Heart rate goes back two weeks each time, because Oura publishes workout samples days after the day they belong to. A re-fetched window ends up holding exactly what the API returned for it: a sample Oura reclassified, or a record it re-issued under a new id, replaces the row it supersedes instead of joining it. When a response drops most of what one request covered — the shape of a partial or short answer — those rows are kept rather than deleted, and `sync` names the collection and how many. `oura-cli sync --prune=hr` then applies them for that collection once you have judged the correction genuine; `oura-cli sync --prune=all` does it for every collection in the run, which also lifts the guard on any collection that answers short in the same run. `oura-cli sync --from 2026-08-01 [--to 2026-08-07]` re-fetches an explicit window for every collection instead — for example after an interrupted sync. `oura-cli db today` / `oura-cli db week` read the local cache instantly, no API call.
58
58
 
59
59
  ### If something looks wrong
60
60
 
@@ -183,7 +183,7 @@ Runtime: [Bun](https://bun.sh). Storage: built-in `bun:sqlite`. CLI parsing: [ci
183
183
  If you're driving the CLI from a script or LLM harness:
184
184
 
185
185
  - `oura-cli describe` — JSON manifest of every command, argument, and output schema. Agents discover capabilities without scraping `--help`.
186
- - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false.
186
+ - `oura-cli healthcheck` — `{ok, version, latencyMs}` JSON for liveness probes, plus `error` when `ok` is false. It proves the database opens and answers a query; it does not inspect the contents. `oura-cli doctor` runs SQLite's `quick_check` for that.
187
187
  - Gate on `.ok`, not on the exit code: `doctor` exits 0 with `ok: false` for any warning-level check (no data yet, stale data, Oura API unreachable), and `healthcheck` exits 0 with `ok: false` for an unusable database (the probe itself ran). `doctor --offline` skips the token-validation call, and a skipped check still counts towards `ok`.
188
188
  - Errors emit a stable JSON envelope on stderr: `{"error":{"code":"…","message":"…","hint":"…"}}`.
189
189
  - Documented exit codes: `0` success, `1` user error, `2` auth, `3` API, `4` storage.
package/dist/index.js CHANGED
@@ -1198,6 +1198,14 @@ import { resolve, dirname } from "path";
1198
1198
  import { homedir } from "os";
1199
1199
  import { chmodSync, existsSync, mkdirSync } from "fs";
1200
1200
 
1201
+ // src/lib/require-value.ts
1202
+ function requireValue(value, source, fallback) {
1203
+ if (value.trim() === "") {
1204
+ throw new CliError("BAD_ARGS", `${source} has no value`, `Pass a value, or remove ${source} to fall back to ${fallback}.`);
1205
+ }
1206
+ return value;
1207
+ }
1208
+
1201
1209
  // src/db/migrations.ts
1202
1210
  var MIGRATIONS = [
1203
1211
  {
@@ -1405,27 +1413,30 @@ CREATE INDEX IF NOT EXISTS idx_ring_battery_level_day ON ring_battery_level(day)
1405
1413
  // src/db/open.ts
1406
1414
  var DB_HINT = "Check the path in --db / OURA_DB_PATH and that the file is a SQLite database oura-cli created.";
1407
1415
  var BUSY_HINT = "Another oura-cli process is using this database; wait for it to finish and retry.";
1416
+ var PERMISSION_HINT = "Check that you can write both the file and the directory holding it \u2014 SQLite creates -wal and -shm files alongside the database.";
1408
1417
  var BUSY_TIMEOUT_MS = 5000;
1418
+ var WAL_SWITCH_ATTEMPTS = 20;
1419
+ var WAL_SWITCH_WAIT_MS = 25;
1420
+ function hintFor(detail) {
1421
+ if (/database is locked|SQLITE_BUSY/i.test(detail))
1422
+ return BUSY_HINT;
1423
+ if (/readonly database|read-only|EACCES|permission denied|unable to open database file/i.test(detail))
1424
+ return PERMISSION_HINT;
1425
+ return DB_HINT;
1426
+ }
1409
1427
  function dbError(what, err) {
1410
1428
  const detail = err instanceof Error ? err.message : String(err);
1411
- const hint = /database is locked|SQLITE_BUSY/i.test(detail) ? BUSY_HINT : DB_HINT;
1412
- return new CliError("DB_ERROR", `${what}: ${detail}`, hint);
1429
+ return new CliError("DB_ERROR", `${what}: ${detail}`, hintFor(detail));
1413
1430
  }
1414
1431
  function asDbError(err) {
1415
1432
  return err instanceof SQLiteError ? dbError("Database query failed", err) : undefined;
1416
1433
  }
1417
- function requirePath(value, source) {
1418
- if (value.trim() === "") {
1419
- throw new CliError("BAD_ARGS", `${source} has no path`, `Pass a path to a SQLite file, or remove ${source} to fall back to the default database.`);
1420
- }
1421
- return value;
1422
- }
1423
1434
  function getDbPath(explicit) {
1424
1435
  if (explicit !== undefined)
1425
- return requirePath(explicit, "--db");
1436
+ return requireValue(explicit, "--db", "the default database");
1426
1437
  const fromEnv = process.env.OURA_DB_PATH;
1427
1438
  if (fromEnv !== undefined)
1428
- return requirePath(fromEnv, "OURA_DB_PATH");
1439
+ return requireValue(fromEnv, "OURA_DB_PATH", "the default database");
1429
1440
  return resolve(homedir(), ".oura-cli", "oura.db");
1430
1441
  }
1431
1442
  function openDatabase(explicit) {
@@ -1439,15 +1450,31 @@ function openDatabase(explicit) {
1439
1450
  if (isNew)
1440
1451
  chmodSync(dbPath, 384);
1441
1452
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
1442
- const mode = db.query("PRAGMA journal_mode").get();
1443
- if (mode.journal_mode !== "wal")
1444
- db.exec("PRAGMA journal_mode = WAL");
1453
+ if (journalMode(db) !== "wal")
1454
+ enableWal(db);
1445
1455
  db.exec("PRAGMA foreign_keys = ON");
1446
1456
  return db;
1447
1457
  } catch (err) {
1448
1458
  throw dbError(`Cannot open database ${dbPath}`, err);
1449
1459
  }
1450
1460
  }
1461
+ function journalMode(db) {
1462
+ return db.query("PRAGMA journal_mode").get().journal_mode;
1463
+ }
1464
+ function enableWal(db) {
1465
+ for (let attempt = 0;; attempt++) {
1466
+ try {
1467
+ db.exec("PRAGMA journal_mode = WAL");
1468
+ return;
1469
+ } catch (err) {
1470
+ if (journalMode(db) === "wal")
1471
+ return;
1472
+ if (attempt >= WAL_SWITCH_ATTEMPTS)
1473
+ throw err;
1474
+ Bun.sleepSync(WAL_SWITCH_WAIT_MS + Math.random() * WAL_SWITCH_WAIT_MS);
1475
+ }
1476
+ }
1477
+ }
1451
1478
  function schemaVersion(db) {
1452
1479
  db.exec("CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER NOT NULL)");
1453
1480
  const row = db.query("SELECT MAX(version) AS v FROM _schema_version").get();
@@ -1455,12 +1482,23 @@ function schemaVersion(db) {
1455
1482
  }
1456
1483
  function ensureSchema(db, migrations = MIGRATIONS) {
1457
1484
  try {
1458
- const current = schemaVersion(db);
1459
- for (const m of migrations) {
1460
- if (m.version > current) {
1461
- db.exec(m.sql);
1462
- db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
1485
+ if (schemaVersion(db) >= Math.max(0, ...migrations.map((m) => m.version)))
1486
+ return;
1487
+ db.exec("BEGIN IMMEDIATE");
1488
+ try {
1489
+ const current = schemaVersion(db);
1490
+ for (const m of migrations) {
1491
+ if (m.version > current) {
1492
+ db.exec(m.sql);
1493
+ db.query("INSERT INTO _schema_version (version) VALUES (?)").run(m.version);
1494
+ }
1463
1495
  }
1496
+ db.exec("COMMIT");
1497
+ } catch (err) {
1498
+ try {
1499
+ db.exec("ROLLBACK");
1500
+ } catch {}
1501
+ throw err;
1464
1502
  }
1465
1503
  } catch (err) {
1466
1504
  throw dbError("Schema migration failed", err);
@@ -1475,10 +1513,11 @@ function defaultTokenPath() {
1475
1513
  return process.env.OURA_TOKEN_PATH ?? resolve2(homedir2(), ".oura-token");
1476
1514
  }
1477
1515
  function resolveToken(explicit, tokenPath) {
1478
- if (explicit)
1479
- return { token: explicit.trim(), source: "--token" };
1480
- if (process.env.OURA_TOKEN)
1481
- return { token: process.env.OURA_TOKEN.trim(), source: "OURA_TOKEN" };
1516
+ if (explicit !== undefined)
1517
+ return { token: requireValue(explicit, "--token", "the token file").trim(), source: "--token" };
1518
+ const fromEnv = process.env.OURA_TOKEN;
1519
+ if (fromEnv !== undefined)
1520
+ return { token: requireValue(fromEnv, "OURA_TOKEN", "the token file").trim(), source: "OURA_TOKEN" };
1482
1521
  const path = tokenPath ?? defaultTokenPath();
1483
1522
  try {
1484
1523
  return { token: readFileSync(path, "utf-8").trim(), source: path };
@@ -1685,8 +1724,9 @@ function localDateToUtcRange(localDate, timezone) {
1685
1724
  return [iso(localMidnightMs(localDate, timezone)), iso(localMidnightMs(shiftDay(localDate, 1), timezone))];
1686
1725
  }
1687
1726
  function resolveDefaultTimezone() {
1688
- if (process.env.OURA_TZ)
1689
- return process.env.OURA_TZ;
1727
+ const fromEnv = process.env.OURA_TZ;
1728
+ if (fromEnv !== undefined)
1729
+ return requireValue(fromEnv, "OURA_TZ", "the system timezone");
1690
1730
  try {
1691
1731
  return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1692
1732
  } catch {
@@ -2374,13 +2414,15 @@ function rangeQueries(c, start, end, tz) {
2374
2414
  return [];
2375
2415
  return c.rangeParams === "date" ? dateQueries(start, end, c.maxRangeDays, c.dayRangeOffset ?? [0, 0]) : datetimeQueries(start, end, tz, c.maxRangeDays);
2376
2416
  }
2377
- async function fetchCollection(client, c, start, end, tz) {
2378
- const rows = [];
2417
+ async function fetchCollectionByPiece(client, c, start, end, tz) {
2418
+ const pieces = [];
2379
2419
  for (const query of rangeQueries(c, start, end, tz)) {
2380
- for (const row of await client.fetch(c.endpoint, query))
2381
- rows.push(row);
2420
+ pieces.push(await client.fetch(c.endpoint, query));
2382
2421
  }
2383
- return rows;
2422
+ return pieces;
2423
+ }
2424
+ async function fetchCollection(client, c, start, end, tz) {
2425
+ return (await fetchCollectionByPiece(client, c, start, end, tz)).flat();
2384
2426
  }
2385
2427
 
2386
2428
  // src/commands/describe.ts
@@ -2482,15 +2524,116 @@ function describeCommand(version, getCommands) {
2482
2524
  });
2483
2525
  }
2484
2526
 
2527
+ // src/db/reconcile.ts
2528
+ function identityColumns(c) {
2529
+ const unique = c.columns.filter((col) => col.unique).map((col) => col.name);
2530
+ if (unique.length > 0)
2531
+ return unique;
2532
+ const pk = c.columns.filter((col) => col.pk).map((col) => col.name);
2533
+ if (pk.length > 0)
2534
+ return pk;
2535
+ return (c.indexes ?? []).find((i) => i.unique)?.columns ?? [];
2536
+ }
2537
+ var MAX_REMOVED_SHARE = 0.5;
2538
+ var ALWAYS_SAFE_TO_REMOVE = 5;
2539
+ var KEY_SEPARATOR = "\x00";
2540
+ function emptyPlan() {
2541
+ return { added: 0, stale: [], refused: 0, bypassed: 0 };
2542
+ }
2543
+ function keyOf(values) {
2544
+ return values.map((v) => String(v)).join(KEY_SEPARATOR);
2545
+ }
2546
+ function planWindow(db, c, pieces, options = {}) {
2547
+ const identity = identityColumns(c);
2548
+ if (identity.length === 0)
2549
+ return emptyPlan();
2550
+ const pickOf = (name) => c.columns.find((col) => col.name === name)?.pick;
2551
+ const identityPicks = identity.map(pickOf);
2552
+ const scopeName = c.rangeParams === "datetime" ? "timestamp" : "day";
2553
+ const scopePick = pickOf(scopeName);
2554
+ if (scopePick === undefined || identityPicks.some((p) => p === undefined))
2555
+ return emptyPlan();
2556
+ const keyFor = (row) => keyOf(identityPicks.map((pick) => pick(row)));
2557
+ const wanted = new Set(pieces.flat().map(keyFor));
2558
+ const plan = emptyPlan();
2559
+ const judged = pieces.map((piece) => {
2560
+ if (piece.length === 0)
2561
+ return null;
2562
+ const scopeValues = piece.map((row) => scopePick(row)).filter((v) => v !== null && v !== undefined).map(String);
2563
+ if (scopeValues.length === 0)
2564
+ return null;
2565
+ const days = [...new Set(scopeValues)];
2566
+ const [where, params] = c.rangeParams === "datetime" ? [`${scopeName} BETWEEN ? AND ?`, [minOf(scopeValues), maxOf(scopeValues)]] : [`${scopeName} IN (${days.map(() => "?").join(", ")})`, days];
2567
+ const stored = db.query(`SELECT ${identity.join(", ")} FROM ${c.table} WHERE ${where}`).all(...params);
2568
+ const storedKeys = new Set;
2569
+ const stale = [];
2570
+ for (const row of stored) {
2571
+ const values = identity.map((name) => row[name] ?? null);
2572
+ const key = keyOf(values);
2573
+ storedKeys.add(key);
2574
+ if (!wanted.has(key))
2575
+ stale.push(values);
2576
+ }
2577
+ const looksTruncated = stale.length > ALWAYS_SAFE_TO_REMOVE && stale.length > stored.length * MAX_REMOVED_SHARE;
2578
+ const fresh = [...new Set(piece.map(keyFor))].filter((key) => !storedKeys.has(key));
2579
+ return { stale, looksTruncated, fresh };
2580
+ });
2581
+ const doubted = new Set;
2582
+ for (const p of judged) {
2583
+ if (p?.looksTruncated)
2584
+ for (const values of p.stale)
2585
+ doubted.add(keyOf(values));
2586
+ }
2587
+ const countedNew = new Set;
2588
+ const countedStale = new Set;
2589
+ for (const p of judged) {
2590
+ if (p === null)
2591
+ continue;
2592
+ for (const key of p.fresh) {
2593
+ if (countedNew.has(key))
2594
+ continue;
2595
+ countedNew.add(key);
2596
+ plan.added += 1;
2597
+ }
2598
+ for (const values of p.stale) {
2599
+ const key = keyOf(values);
2600
+ if (countedStale.has(key))
2601
+ continue;
2602
+ countedStale.add(key);
2603
+ if (doubted.has(key) && !options.prune) {
2604
+ plan.refused += 1;
2605
+ } else {
2606
+ if (doubted.has(key))
2607
+ plan.bypassed += 1;
2608
+ plan.stale.push(values);
2609
+ }
2610
+ }
2611
+ }
2612
+ return plan;
2613
+ }
2614
+ function applyWindowPlan(db, c, plan) {
2615
+ if (plan.stale.length === 0)
2616
+ return 0;
2617
+ const identity = identityColumns(c);
2618
+ const del = db.query(`DELETE FROM ${c.table} WHERE ${identity.map((name) => `${name} IS ?`).join(" AND ")}`);
2619
+ let removed = 0;
2620
+ for (const values of plan.stale)
2621
+ removed += del.run(...values).changes;
2622
+ return removed;
2623
+ }
2624
+ function minOf(values) {
2625
+ return values.reduce((a, b) => b < a ? b : a);
2626
+ }
2627
+ function maxOf(values) {
2628
+ return values.reduce((a, b) => b > a ? b : a);
2629
+ }
2630
+
2485
2631
  // src/db/sync.ts
2486
2632
  var BACKFILL_DAYS = 30;
2487
2633
  function lastDay(db, table, end) {
2488
2634
  return db.query(`SELECT MAX(day) AS d FROM ${table} WHERE day <= ?`).get(end).d;
2489
2635
  }
2490
- function rowCount(db, table) {
2491
- return db.query(`SELECT COUNT(*) AS n FROM ${table}`).get().n;
2492
- }
2493
- async function importDaily(db, client, clock, log, window = {}) {
2636
+ async function importDaily(db, client, clock, log, window = {}, options = {}) {
2494
2637
  const { today, tz } = clock;
2495
2638
  const _log = log ?? (() => {});
2496
2639
  const end = window.to ?? today;
@@ -2505,10 +2648,19 @@ async function importDaily(db, client, clock, log, window = {}) {
2505
2648
  const isFirstSync = ranged.every((p) => p.last === null);
2506
2649
  const startDate = ranged.map((p) => p.resume).sort()[0];
2507
2650
  _log(isFirstSync && window.from === undefined ? `First sync \u2014 backfilling the last ${BACKFILL_DAYS} days: ${startDate} \u2192 ${end}` : `Syncing ${startDate} \u2192 ${end}`);
2651
+ if (options.prune !== undefined) {
2652
+ const scope = options.prune === "all" ? "every collection" : options.prune.join(", ");
2653
+ _log(`--prune: applying removals even where a response looks truncated (${scope})`);
2654
+ }
2508
2655
  const fetched = {};
2509
2656
  const added = {};
2657
+ const removed = {};
2658
+ const refused = {};
2659
+ const pruned = {};
2660
+ const mayPrune = (name) => options.prune === "all" || (options.prune?.includes(name) ?? false);
2510
2661
  for (const { c, start } of plan) {
2511
- const rows = await fetchCollection(client, c, start, end, tz);
2662
+ const pieces = await fetchCollectionByPiece(client, c, start, end, tz);
2663
+ const rows = pieces.flat();
2512
2664
  const stmt = db.query(insertSql(c));
2513
2665
  if (c.rangeParams === "none") {
2514
2666
  const pk = c.columns.find((col) => col.pk)?.name;
@@ -2526,17 +2678,37 @@ async function importDaily(db, client, clock, log, window = {}) {
2526
2678
  _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${rows.length === 0 ? ", table cleared" : ""}`);
2527
2679
  continue;
2528
2680
  }
2529
- const before = rowCount(db, c.table);
2530
- db.transaction((rs) => {
2531
- for (const r of rs)
2532
- stmt.run(...rowValues(c, r));
2533
- })(rows);
2681
+ const { windowPlan, gone } = db.transaction((ps) => {
2682
+ const windowPlan = planWindow(db, c, ps, { prune: mayPrune(c.name) });
2683
+ for (const piece of ps)
2684
+ for (const r of piece)
2685
+ stmt.run(...rowValues(c, r));
2686
+ return { windowPlan, gone: applyWindowPlan(db, c, windowPlan) };
2687
+ }).immediate(pieces);
2534
2688
  fetched[c.table] = rows.length;
2535
- added[c.table] = rowCount(db, c.table) - before;
2536
- _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new`);
2689
+ added[c.table] = windowPlan.added;
2690
+ if (gone > 0)
2691
+ removed[c.table] = gone;
2692
+ if (windowPlan.refused > 0)
2693
+ refused[c.table] = { rows: windowPlan.refused, collection: c.name };
2694
+ if (windowPlan.bypassed > 0)
2695
+ pruned[c.table] = { rows: windowPlan.bypassed, collection: c.name };
2696
+ const tail = gone > 0 ? `, ${gone} stale removed${windowPlan.bypassed > 0 ? ` (${windowPlan.bypassed} past the truncation guard)` : ""}` : "";
2697
+ const kept = windowPlan.refused > 0 ? `, ${windowPlan.refused} rows kept that the API did not return \u2014 too many to drop on one response; re-run with --prune=${c.name} to apply them` : "";
2698
+ _log(` + ${c.name} (${c.table}): ${rows.length} fetched, ${added[c.table]} new${tail}${kept}`);
2537
2699
  }
2538
2700
  _log("Import complete.");
2539
- return { startDate, endDate: end, fetched, added, isFirstSync };
2701
+ return {
2702
+ startDate,
2703
+ endDate: end,
2704
+ fetched,
2705
+ added,
2706
+ removed,
2707
+ refused,
2708
+ pruned,
2709
+ isFirstSync,
2710
+ ...options.prune === undefined ? {} : { pruneScope: options.prune }
2711
+ };
2540
2712
  }
2541
2713
 
2542
2714
  // src/db/queries.ts
@@ -2788,10 +2960,30 @@ function resolveWindow(opts, today) {
2788
2960
  }
2789
2961
  return { from, to };
2790
2962
  }
2791
- async function runSync(ctx, window = {}) {
2963
+ var PRUNE_ALL = "all";
2964
+ function resolvePruneScope(value) {
2965
+ if (value === undefined || value === false)
2966
+ return;
2967
+ const raw = value === true ? "" : String(value);
2968
+ const wanted = raw.split(",").map((s) => s.trim()).filter((s) => s !== "");
2969
+ if (wanted.length === 0 || wanted.some((n) => n.startsWith("-"))) {
2970
+ throw new CliError("BAD_ARGS", "--prune needs a value naming what to prune.", `Use --prune=<collection> (for example --prune=hr), a comma-separated list, or --prune=${PRUNE_ALL} for every collection.`);
2971
+ }
2972
+ if (wanted.includes(PRUNE_ALL)) {
2973
+ if (wanted.length === 1)
2974
+ return PRUNE_ALL;
2975
+ throw new CliError("BAD_ARGS", `--prune=${PRUNE_ALL} cannot be combined with collection names.`, `Use --prune=${PRUNE_ALL} on its own, or list the collections you mean.`);
2976
+ }
2977
+ const unknown = wanted.filter((n) => byName(n) === undefined);
2978
+ if (unknown.length > 0) {
2979
+ throw new CliError("BAD_ARGS", `--prune: unknown collection${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}.`, `Known collections: ${names().join(", ")}.`);
2980
+ }
2981
+ return [...new Set(wanted)];
2982
+ }
2983
+ async function runSync(ctx, window = {}, options = {}) {
2792
2984
  const lines = [];
2793
2985
  const log = ctx.format === "table" ? (m) => lines.push(m) : undefined;
2794
- const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window);
2986
+ const importResult = await importDaily(ctx.db, ctx.client, { today: ctx.today, tz: ctx.tz }, log, window, options);
2795
2987
  const today = getDaySummary(ctx.db, ctx.today);
2796
2988
  return {
2797
2989
  json: { import: importResult, today },
@@ -2799,15 +2991,18 @@ async function runSync(ctx, window = {}) {
2799
2991
  `)
2800
2992
  };
2801
2993
  }
2802
- var syncCommand = dataCommand({
2994
+ var syncArgs = {
2995
+ from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
2996
+ to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" },
2997
+ prune: { type: "string", description: "Apply removals sync kept back: --prune=hr, a list, or --prune=all" }
2998
+ };
2999
+ var syncDef = {
2803
3000
  meta: { name: "sync", description: "Import latest data from Oura API and return today's summary" },
2804
- args: {
2805
- from: { type: "string", description: "Re-fetch every collection from this day (YYYY-MM-DD) instead of from its last stored day" },
2806
- to: { type: "string", description: "End of the explicit window (YYYY-MM-DD, default: today); requires --from" }
2807
- },
3001
+ args: syncArgs,
2808
3002
  needs: { db: true, client: true },
2809
- run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today))
2810
- });
3003
+ run: (ctx, args) => runSync(ctx, resolveWindow({ from: args.from, to: args.to }, ctx.today), { prune: resolvePruneScope(args.prune) })
3004
+ };
3005
+ var syncCommand = dataCommand(syncDef);
2811
3006
 
2812
3007
  // src/commands/db.ts
2813
3008
  var SYNC_HINT = "Run `oura-cli sync` to download your data. Oura publishes a day's summary after that night's sleep syncs from the ring.";
@@ -3132,7 +3327,7 @@ var reportCommand = dataCommand({
3132
3327
  // src/commands/healthcheck.ts
3133
3328
  function healthcheckCommand(version) {
3134
3329
  return defineCommand({
3135
- meta: { name: "healthcheck", description: "Quick local DB health probe (JSON: {ok, version, latencyMs}, plus error when ok is false)." },
3330
+ meta: { name: "healthcheck", description: "Fast liveness probe: opens the local database and runs one query (JSON: {ok, version, latencyMs}, plus error when ok is false). It proves the file opens, not that its contents are intact \u2014 `doctor` checks that." },
3136
3331
  args: { ...commonArgs },
3137
3332
  run({ args }) {
3138
3333
  assertKnownArgs(commonArgs, args);
@@ -3213,8 +3408,18 @@ async function runChecks(deps) {
3213
3408
  checks.push({ id: "database", status: "fail", detail: msg });
3214
3409
  }
3215
3410
  if (db) {
3216
- const last = latestDataDay(db);
3217
- if (!last) {
3411
+ const damage = quickCheck(db);
3412
+ checks.push(damage === null ? { id: "integrity", status: "ok", detail: "Database passes SQLite quick_check." } : { id: "integrity", status: "fail", detail: `Database is damaged: ${damage}`, fix: "Delete the cache file (--db / OURA_DB_PATH) and run `oura-cli sync` to rebuild it." });
3413
+ let last = null;
3414
+ let readFailed;
3415
+ try {
3416
+ last = latestDataDay(db);
3417
+ } catch (err) {
3418
+ readFailed = err instanceof Error ? err.message : String(err);
3419
+ }
3420
+ if (readFailed !== undefined) {
3421
+ checks.push({ id: "data", status: "fail", detail: `Cannot read the cache: ${readFailed}`, fix: "Delete the cache file (--db / OURA_DB_PATH) and run `oura-cli sync` to rebuild it." });
3422
+ } else if (!last) {
3218
3423
  checks.push({ id: "data", status: "warn", detail: "No data in the local cache yet.", fix: "oura-cli sync" });
3219
3424
  } else {
3220
3425
  const ageDays = Math.round((new Date(`${deps.today}T00:00:00Z`).getTime() - new Date(`${last}T00:00:00Z`).getTime()) / 86400000);
@@ -3225,6 +3430,7 @@ async function runChecks(deps) {
3225
3430
  }
3226
3431
  }
3227
3432
  } else {
3433
+ checks.push({ id: "integrity", status: "fail", detail: "Cannot check integrity \u2014 database unavailable." });
3228
3434
  checks.push({ id: "data", status: "fail", detail: "Cannot check data \u2014 database unavailable." });
3229
3435
  }
3230
3436
  db?.close();
@@ -3233,6 +3439,15 @@ async function runChecks(deps) {
3233
3439
  const nextStep = checks.find((c) => !settled(c.status))?.fix ?? null;
3234
3440
  return { ok, checks, nextStep };
3235
3441
  }
3442
+ function quickCheck(db) {
3443
+ try {
3444
+ const rows = db.query("PRAGMA quick_check(1)").all();
3445
+ const first = rows[0] === undefined ? "ok" : Object.values(rows[0])[0] ?? "ok";
3446
+ return first === "ok" ? null : first;
3447
+ } catch (err) {
3448
+ return err instanceof Error ? err.message : String(err);
3449
+ }
3450
+ }
3236
3451
  var DATA_TABLES = ["daily_sleep", "daily_readiness", "daily_activity"];
3237
3452
  function latestDataDay(db) {
3238
3453
  let latest = null;
@@ -3375,7 +3590,36 @@ var fetchCommand = dataCommand({
3375
3590
 
3376
3591
  // src/lib/citty-error.ts
3377
3592
  var ANSI2 = /\u001b\[[0-9;]*m/g;
3378
- function fromCittyError(err, removedCommandHints = {}) {
3593
+ var COMMAND_NAME = /^[a-z][a-z0-9-]{0,19}$/;
3594
+ function editDistanceAtMostOne(a, b) {
3595
+ if (Math.abs(a.length - b.length) > 1)
3596
+ return false;
3597
+ let i = 0, j = 0, edits = 0;
3598
+ while (i < a.length && j < b.length) {
3599
+ if (a[i] === b[j]) {
3600
+ i++;
3601
+ j++;
3602
+ continue;
3603
+ }
3604
+ if (++edits > 1)
3605
+ return false;
3606
+ if (a.length > b.length)
3607
+ i++;
3608
+ else if (a.length < b.length)
3609
+ j++;
3610
+ else {
3611
+ i++;
3612
+ j++;
3613
+ }
3614
+ }
3615
+ return edits + (a.length - i) + (b.length - j) <= 1;
3616
+ }
3617
+ function nearestGlobalFlag(token) {
3618
+ if (!token.startsWith("--") || token.length < 4)
3619
+ return;
3620
+ return [...GLOBAL_FLAGS_WITH_VALUE].find((flag) => flag !== token && (editDistanceAtMostOne(flag, token) || flag.startsWith(token)));
3621
+ }
3622
+ function fromCittyError(err, removedCommandHints = {}, rawArgs = []) {
3379
3623
  const code = err?.code;
3380
3624
  if (typeof code !== "string")
3381
3625
  return err;
@@ -3383,6 +3627,14 @@ function fromCittyError(err, removedCommandHints = {}) {
3383
3627
  switch (code) {
3384
3628
  case "E_UNKNOWN_COMMAND": {
3385
3629
  const name = message.replace(/^Unknown command\s*/, "").trim();
3630
+ const before = rawArgs[rawArgs.lastIndexOf(name) - 1];
3631
+ const meant = before === undefined ? undefined : nearestGlobalFlag(before);
3632
+ if (meant) {
3633
+ return new CliError("BAD_ARGS", `Unknown flag "${before}".`, `Did you mean ${meant}? Its value was read as a command name.`);
3634
+ }
3635
+ if (!COMMAND_NAME.test(name)) {
3636
+ return new CliError("BAD_ARGS", "Unknown command.", "A value was read as a command name. Check the flags before it, and run `oura-cli --help` for the list of commands.");
3637
+ }
3386
3638
  const hint = Object.hasOwn(removedCommandHints, name) ? removedCommandHints[name] : "Run `oura-cli --help` for the list of commands.";
3387
3639
  return new CliError("BAD_ARGS", `Unknown command "${name}".`, hint);
3388
3640
  }
@@ -3437,7 +3689,7 @@ if (isVersionRequest(rawArgs)) {
3437
3689
  runMain(main, { rawArgs });
3438
3690
  } else {
3439
3691
  runCommand(main, { rawArgs }).catch((raw) => {
3440
- const err = fromCittyError(raw, REMOVED_COMMANDS);
3692
+ const err = fromCittyError(raw, REMOVED_COMMANDS, rawArgs);
3441
3693
  emitError(err, formatFromArgv(rawArgs, process.stdout.isTTY === true));
3442
3694
  process.exit(exitCodeFor(err));
3443
3695
  });
@@ -13,7 +13,7 @@
13
13
  "required": ["id", "status", "detail"],
14
14
  "additionalProperties": false,
15
15
  "properties": {
16
- "id": { "type": "string", "enum": ["token", "token-valid", "database", "data"] },
16
+ "id": { "type": "string", "enum": ["token", "token-valid", "database", "integrity", "data"] },
17
17
  "status": { "type": "string", "enum": ["ok", "warn", "fail", "skip"] },
18
18
  "detail": { "type": "string" },
19
19
  "fix": { "type": "string" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drakulavich/oura-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Oura Ring CLI — query and analyze Oura Ring health data from the command line, designed for humans and AI agents.",
5
5
  "keywords": [
6
6
  "oura",