@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 +17 -0
- package/README.md +2 -2
- package/dist/index.js +308 -56
- package/docs/schemas/doctor.json +1 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
|
1436
|
+
return requireValue(explicit, "--db", "the default database");
|
|
1426
1437
|
const fromEnv = process.env.OURA_DB_PATH;
|
|
1427
1438
|
if (fromEnv !== undefined)
|
|
1428
|
-
return
|
|
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
|
-
|
|
1443
|
-
|
|
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
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
-
|
|
1481
|
-
|
|
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
|
-
|
|
1689
|
-
|
|
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
|
|
2378
|
-
const
|
|
2417
|
+
async function fetchCollectionByPiece(client, c, start, end, tz) {
|
|
2418
|
+
const pieces = [];
|
|
2379
2419
|
for (const query of rangeQueries(c, start, end, tz)) {
|
|
2380
|
-
|
|
2381
|
-
rows.push(row);
|
|
2420
|
+
pieces.push(await client.fetch(c.endpoint, query));
|
|
2382
2421
|
}
|
|
2383
|
-
return
|
|
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
|
|
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
|
|
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
|
|
2530
|
-
|
|
2531
|
-
for (const
|
|
2532
|
-
|
|
2533
|
-
|
|
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] =
|
|
2536
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
|
3217
|
-
|
|
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
|
-
|
|
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
|
});
|
package/docs/schemas/doctor.json
CHANGED
|
@@ -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