health-sync 0.2.0 → 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Filipe Almeida
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,30 +1,161 @@
1
- # health-sync Node Port
1
+ # health-sync
2
2
 
3
- This directory contains a Node.js implementation of `health-sync` with parity-oriented features:
3
+ `health-sync` is an open-source CLI that pulls health and fitness data from multiple providers and stores it in a local SQLite database.
4
4
 
5
- - CLI commands: `init`, `init-db`, `auth`, `sync`, `providers`, `status`
6
- - SQLite storage (`records`, `sync_state`, `oauth_tokens`, `sync_runs`)
7
- - Built-in providers: Oura, Withings, Hevy, Strava, Eight Sleep
8
- - Plugin loading from package metadata (`healthSyncProviders`) and `[plugins.<id>] module=...`
5
+ It is designed as a personal data cache: first sync backfills history, then future syncs fetch incremental updates.
9
6
 
10
- ## Install
7
+ ## Purpose
8
+
9
+ - Keep your health data in one local database you control.
10
+ - Build your own dashboards, analysis scripts, or exports on top of raw provider data.
11
+ - Avoid building one-off sync scripts for each provider.
12
+
13
+ ## Supported Providers
14
+
15
+ - Oura (Cloud API v2, OAuth2)
16
+ - Withings (Advanced Health Data API, OAuth2)
17
+ - Hevy (public API, API key, Pro account required)
18
+ - Strava (OAuth2 or static access token)
19
+ - Eight Sleep (unofficial API)
20
+
21
+ ## Requirements
22
+
23
+ - Node.js 20+
24
+ - SQLite (bundled through `better-sqlite3`)
25
+ - Provider credentials (API key and/or OAuth client settings depending on provider)
26
+
27
+ ## Installation
28
+
29
+ Install globally from npm:
30
+
31
+ ```bash
32
+ npm install -g health-sync
33
+ ```
34
+
35
+ Or run from this repository:
11
36
 
12
37
  ```bash
13
- cd node
14
38
  npm install
39
+ npm link
40
+ ```
41
+
42
+ ## Quick Start
43
+
44
+ 1. Initialize config and DB:
45
+
46
+ ```bash
47
+ health-sync init
48
+ ```
49
+
50
+ 2. Edit `health-sync.toml`:
51
+ - Set `[app].db` if you do not want `./health.sqlite`
52
+
53
+ 3. Run provider auth one provider at a time:
54
+
55
+ ```bash
56
+ health-sync auth oura
57
+ health-sync auth withings
58
+ health-sync auth strava
59
+ health-sync auth eightsleep
15
60
  ```
16
61
 
17
- ## Run
62
+ 4. Sync data:
18
63
 
19
64
  ```bash
20
- npm start -- --config ../health-sync.toml providers --verbose
21
- npm start -- --config ../health-sync.toml status
65
+ health-sync sync
22
66
  ```
23
67
 
24
- ## CLI
68
+ 5. Inspect sync state and counts:
25
69
 
26
70
  ```bash
27
- health-sync [--config path] [--db path] <command> [options]
71
+ health-sync status
28
72
  ```
29
73
 
30
- Use the same `health-sync.toml` format as the Python implementation.
74
+ ## Basic Configuration
75
+
76
+ By default, `health-sync` reads `./health-sync.toml`.
77
+
78
+ Use a custom config file with:
79
+
80
+ ```bash
81
+ health-sync --config /path/to/health-sync.toml sync
82
+ ```
83
+
84
+ Minimal example:
85
+
86
+ ```toml
87
+ [app]
88
+ db = "./health.sqlite"
89
+
90
+ [hevy]
91
+ enabled = true
92
+ api_key = "YOUR_HEVY_API_KEY"
93
+
94
+ [strava]
95
+ enabled = true
96
+ client_id = "YOUR_CLIENT_ID"
97
+ client_secret = "YOUR_CLIENT_SECRET"
98
+ redirect_uri = "http://127.0.0.1:8486/callback"
99
+ ```
100
+
101
+ See `health-sync.example.toml` for all provider options.
102
+
103
+ ## CLI Commands
104
+
105
+ - `health-sync init`: create a scaffolded config (from `health-sync.example.toml`) and create DB tables
106
+ - `health-sync init-db`: create DB tables only (legacy)
107
+ - `health-sync auth <provider>`: run auth flow for one provider/plugin
108
+ - `health-sync sync`: run sync for all enabled providers
109
+ - `health-sync sync --providers oura strava`: sync only selected providers
110
+ - `health-sync providers`: list discovered providers and whether they are enabled
111
+ - `health-sync status`: print watermarks, record counts, and recent runs
112
+
113
+ `auth` notes:
114
+
115
+ - Oura, Withings, and Strava: OAuth flow (CLI prints auth URL and waits for callback URL/code).
116
+ - Eight Sleep: username/password grant (or static token).
117
+ - Hevy: no `auth` command; configure `[hevy].api_key` directly.
118
+
119
+ `auth` also scaffolds the provider section in `health-sync.toml` (enables it and, for Eight Sleep, writes default client id/secret if missing).
120
+
121
+ Global flags:
122
+
123
+ - `--config`: config file path
124
+ - `--db`: override SQLite DB path
125
+
126
+ ## Data Storage
127
+
128
+ The database keeps raw JSON payloads and sync metadata in generic tables:
129
+
130
+ - `records`: provider/resource records
131
+ - `sync_state`: per-resource watermarks/cursors
132
+ - `.health-sync.creds`: stored provider credentials and OAuth tokens
133
+ - `sync_runs`: run history and per-sync counters
134
+
135
+ This schema is intentionally generic so upstream API changes are less likely to require migrations.
136
+
137
+ ## Optional Plugin System
138
+
139
+ You can add external providers as in-process plugins.
140
+
141
+ - Discover installed plugins with `health-sync providers`
142
+ - Configure plugin blocks under `[plugins.<id>]`
143
+ - Enable them with `[plugins.<id>].enabled = true`
144
+
145
+ ## Notes
146
+
147
+ - Eight Sleep integration uses unofficial endpoints and may break if the upstream API changes.
148
+ - Some providers use overlap windows to ensure incremental sync correctness.
149
+
150
+ ## Development
151
+
152
+ Run checks and tests:
153
+
154
+ ```bash
155
+ npm run check
156
+ npm test
157
+ ```
158
+
159
+ ## License
160
+
161
+ MIT. See `LICENSE`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "health-sync",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Node.js port of health-sync",
6
6
  "files": [
package/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import { openDb } from './db.js';
8
8
  import { PluginHelpers, providerEnabled } from './plugins/base.js';
9
9
  import { loadProviders } from './plugins/loader.js';
10
+ import { setRequestJsonVerbose } from './util.js';
10
11
 
11
12
  const DEFAULT_CONFIG_PATH = 'health-sync.toml';
12
13
 
@@ -84,6 +85,12 @@ function parseAuthArgs(args) {
84
85
  throw new Error(`Unknown auth option: ${arg}`);
85
86
  }
86
87
  if (!out.provider) {
88
+ if (arg === '-h' || arg === '--help') {
89
+ throw new Error('auth requires PROVIDER argument (e.g. health-sync auth oura)');
90
+ }
91
+ if (arg.startsWith('-')) {
92
+ throw new Error(`Invalid provider id: ${arg}`);
93
+ }
87
94
  out.provider = arg;
88
95
  continue;
89
96
  }
@@ -98,17 +105,24 @@ function parseAuthArgs(args) {
98
105
  }
99
106
 
100
107
  function parseSyncArgs(args) {
101
- const providers = [];
108
+ const out = {
109
+ providers: [],
110
+ verbose: false,
111
+ };
102
112
 
103
113
  for (let i = 0; i < args.length; i += 1) {
104
114
  const arg = args[i];
115
+ if (arg === '-v' || arg === '--verbose') {
116
+ out.verbose = true;
117
+ continue;
118
+ }
105
119
  if (arg === '--providers') {
106
120
  let consumed = false;
107
121
  while (i + 1 < args.length && !args[i + 1].startsWith('-')) {
108
122
  consumed = true;
109
123
  const raw = args[i + 1];
110
124
  for (const piece of String(raw).split(',').map((v) => v.trim()).filter(Boolean)) {
111
- providers.push(piece);
125
+ out.providers.push(piece);
112
126
  }
113
127
  i += 1;
114
128
  }
@@ -120,16 +134,14 @@ function parseSyncArgs(args) {
120
134
  if (arg.startsWith('--providers=')) {
121
135
  const raw = arg.slice('--providers='.length);
122
136
  for (const piece of raw.split(',').map((v) => v.trim()).filter(Boolean)) {
123
- providers.push(piece);
137
+ out.providers.push(piece);
124
138
  }
125
139
  continue;
126
140
  }
127
141
  throw new Error(`Unknown sync option: ${arg}`);
128
142
  }
129
143
 
130
- return {
131
- providers,
132
- };
144
+ return out;
133
145
  }
134
146
 
135
147
  function parseProvidersArgs(args) {
@@ -196,6 +208,10 @@ function resolveDbPath(overrideDbPath, loadedConfig) {
196
208
  return './health.sqlite';
197
209
  }
198
210
 
211
+ function resolveCredsPath(configPath) {
212
+ return path.join(path.dirname(path.resolve(configPath)), '.health-sync.creds');
213
+ }
214
+
199
215
  function enableHint(providerId) {
200
216
  const builtin = new Set(['oura', 'withings', 'hevy', 'strava', 'eightsleep']);
201
217
  if (builtin.has(providerId)) {
@@ -214,7 +230,7 @@ function usage() {
214
230
  ' auth <provider> Run provider authentication flow',
215
231
  ' --listen-host <host> OAuth callback listen host (default 127.0.0.1)',
216
232
  ' --listen-port <port> OAuth callback listen port (default 0 -> config redirect port)',
217
- ' sync [--providers a,b,c] Sync enabled providers',
233
+ ' sync [--providers a,b,c] [-v|--verbose] Sync enabled providers',
218
234
  ' providers [--verbose] List discovered providers',
219
235
  ' status Show sync state, counts, and recent runs',
220
236
  ].join('\n');
@@ -236,11 +252,13 @@ async function loadContext(configPath) {
236
252
 
237
253
  async function cmdInit(parsed) {
238
254
  const configPath = path.resolve(parsed.configPath);
255
+ const explicitDbPath = parsed.dbPath ? String(parsed.dbPath) : null;
256
+
257
+ initConfigFile(configPath, explicitDbPath);
258
+
239
259
  const loaded = loadConfig(configPath);
240
260
  const dbPath = resolveDbPath(parsed.dbPath, loaded);
241
-
242
- initConfigFile(configPath, dbPath);
243
- const db = openDb(dbPath);
261
+ const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
244
262
  db.close();
245
263
 
246
264
  console.log(`Initialized config: ${configPath}`);
@@ -253,7 +271,7 @@ async function cmdInitDb(parsed) {
253
271
  const loaded = loadConfig(configPath);
254
272
  const dbPath = resolveDbPath(parsed.dbPath, loaded);
255
273
 
256
- const db = openDb(dbPath);
274
+ const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
257
275
  db.close();
258
276
  console.log(`Initialized database: ${path.resolve(dbPath)}`);
259
277
  return 0;
@@ -261,14 +279,17 @@ async function cmdInitDb(parsed) {
261
279
 
262
280
  async function cmdAuth(parsed) {
263
281
  const configPath = path.resolve(parsed.configPath);
282
+ const explicitDbPath = parsed.dbPath ? String(parsed.dbPath) : null;
283
+
284
+ initConfigFile(configPath, explicitDbPath);
285
+
264
286
  const loaded = loadConfig(configPath);
265
287
  const dbPath = resolveDbPath(parsed.dbPath, loaded);
266
- initConfigFile(configPath, dbPath);
267
288
 
268
289
  scaffoldProviderConfig(configPath, parsed.options.provider);
269
290
 
270
291
  const context = await loadContext(configPath);
271
- const db = openDb(dbPath);
292
+ const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
272
293
 
273
294
  try {
274
295
  const plugin = context.providers.get(parsed.options.provider);
@@ -304,7 +325,8 @@ async function cmdSync(parsed) {
304
325
  const configPath = path.resolve(parsed.configPath);
305
326
  const context = await loadContext(configPath);
306
327
  const dbPath = resolveDbPath(parsed.dbPath, context.loadedConfig);
307
- const db = openDb(dbPath);
328
+ const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
329
+ const previousVerboseLogging = setRequestJsonVerbose(parsed.options.verbose);
308
330
 
309
331
  try {
310
332
  const discoveredIds = Array.from(context.providers.keys()).sort();
@@ -353,6 +375,7 @@ async function cmdSync(parsed) {
353
375
  let successes = 0;
354
376
  const failures = [];
355
377
  for (const providerId of toSync) {
378
+ console.log(`Syncing provider: ${providerId}`);
356
379
  try {
357
380
  await context.providers.get(providerId).sync(db, context.loadedConfig.data, context.helpers, {
358
381
  configPath,
@@ -378,6 +401,7 @@ async function cmdSync(parsed) {
378
401
 
379
402
  return 0;
380
403
  } finally {
404
+ setRequestJsonVerbose(previousVerboseLogging);
381
405
  db.close();
382
406
  }
383
407
  }
@@ -409,7 +433,7 @@ async function cmdStatus(parsed) {
409
433
  const configPath = path.resolve(parsed.configPath);
410
434
  const loadedConfig = loadConfig(configPath);
411
435
  const dbPath = resolveDbPath(parsed.dbPath, loadedConfig);
412
- const db = openDb(dbPath);
436
+ const db = openDb(dbPath, { credsPath: resolveCredsPath(configPath) });
413
437
 
414
438
  try {
415
439
  const syncState = db.listSyncState();
package/src/config.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import TOML from '@iarna/toml';
4
5
 
6
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url));
7
+ const EXAMPLE_CONFIG_PATH = path.resolve(MODULE_DIR, '../health-sync.example.toml');
8
+
5
9
  const BUILTIN_DEFAULTS = {
6
10
  app: {
7
11
  db: './health.sqlite',
@@ -268,10 +272,43 @@ function writeToml(filePath, rawDoc) {
268
272
  fs.writeFileSync(filePath, rendered, 'utf8');
269
273
  }
270
274
 
271
- export function initConfigFile(configPath, dbPath) {
275
+ function readExampleConfigTemplate() {
276
+ if (fs.existsSync(EXAMPLE_CONFIG_PATH)) {
277
+ return fs.readFileSync(EXAMPLE_CONFIG_PATH, 'utf8');
278
+ }
279
+ return TOML.stringify(defaultConfig());
280
+ }
281
+
282
+ function renderScaffoldConfig(dbPath = null) {
283
+ const template = readExampleConfigTemplate();
284
+ if (!dbPath) {
285
+ return template;
286
+ }
287
+
288
+ const dbLine = `db = ${JSON.stringify(String(dbPath))}`;
289
+ if (template.includes('# db = "./health.sqlite"')) {
290
+ return template.replace('# db = "./health.sqlite"', dbLine);
291
+ }
292
+ if (template.includes('[app]')) {
293
+ return template.replace('[app]', `[app]\n${dbLine}`);
294
+ }
295
+ return `[app]\n${dbLine}\n\n${template}`;
296
+ }
297
+
298
+ export function initConfigFile(configPath, dbPath = null) {
272
299
  const resolved = path.resolve(configPath);
273
- const raw = fs.existsSync(resolved) ? TOML.parse(fs.readFileSync(resolved, 'utf8')) : {};
274
- upsertSectionValues(raw, ['app'], { db: dbPath || BUILTIN_DEFAULTS.app.db });
300
+
301
+ if (!fs.existsSync(resolved)) {
302
+ fs.writeFileSync(resolved, renderScaffoldConfig(dbPath), 'utf8');
303
+ return;
304
+ }
305
+
306
+ if (!dbPath) {
307
+ return;
308
+ }
309
+
310
+ const raw = TOML.parse(fs.readFileSync(resolved, 'utf8'));
311
+ upsertSectionValues(raw, ['app'], { db: dbPath });
275
312
  writeToml(resolved, raw);
276
313
  }
277
314
 
@@ -286,23 +323,34 @@ function scaffoldBuiltinDefaults(providerId) {
286
323
  };
287
324
  }
288
325
 
326
+ const PROVIDER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
327
+
328
+ function assertValidProviderId(providerId) {
329
+ const normalized = String(providerId || '').trim();
330
+ if (!normalized || !PROVIDER_ID_RE.test(normalized)) {
331
+ throw new Error(`Invalid provider id: ${providerId}`);
332
+ }
333
+ return normalized;
334
+ }
335
+
289
336
  export function scaffoldProviderConfig(configPath, providerId) {
337
+ const normalizedProviderId = assertValidProviderId(providerId);
290
338
  const resolved = path.resolve(configPath);
291
339
  const raw = fs.existsSync(resolved) ? TOML.parse(fs.readFileSync(resolved, 'utf8')) : {};
292
340
 
293
- const builtinDefaults = scaffoldBuiltinDefaults(providerId);
341
+ const builtinDefaults = scaffoldBuiltinDefaults(normalizedProviderId);
294
342
  if (builtinDefaults) {
295
- const sectionRaw = section(raw, providerId);
343
+ const sectionRaw = section(raw, normalizedProviderId);
296
344
  const merged = { ...builtinDefaults, ...sectionRaw, enabled: true };
297
- upsertSectionValues(raw, [providerId], merged);
345
+ upsertSectionValues(raw, [normalizedProviderId], merged);
298
346
  } else {
299
347
  const pluginsRaw = section(raw, 'plugins');
300
- const pluginRaw = section(pluginsRaw, providerId);
348
+ const pluginRaw = section(pluginsRaw, normalizedProviderId);
301
349
  const merged = { ...pluginRaw, enabled: true };
302
350
  if (!pluginRaw.module) {
303
351
  merged.module = pluginRaw.module ?? null;
304
352
  }
305
- upsertSectionValues(raw, ['plugins', providerId], merged);
353
+ upsertSectionValues(raw, ['plugins', normalizedProviderId], merged);
306
354
  }
307
355
 
308
356
  writeToml(resolved, raw);
package/src/db.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from 'node:fs';
1
2
  import path from 'node:path';
2
3
  import Database from 'better-sqlite3';
3
4
  import {
@@ -70,13 +71,18 @@ function normalizeWatermark(value) {
70
71
  }
71
72
 
72
73
  export class HealthSyncDb {
73
- constructor(dbPath) {
74
+ constructor(dbPath, options = {}) {
74
75
  this.path = path.resolve(dbPath);
76
+ this.credsPath = path.resolve(
77
+ options.credsPath || path.join(path.dirname(this.path), '.health-sync.creds'),
78
+ );
75
79
  this.conn = new Database(this.path);
76
80
  this.conn.pragma('journal_mode = WAL');
77
81
  this.conn.pragma('foreign_keys = ON');
78
82
  this._runStatsStack = [];
79
83
  this._transactionDepth = 0;
84
+ this._oauthMigrated = false;
85
+ this._credsParseWarned = false;
80
86
  }
81
87
 
82
88
  close() {
@@ -148,6 +154,7 @@ export class HealthSyncDb {
148
154
  `);
149
155
 
150
156
  this._normalizeLegacyTimestamps();
157
+ this._migrateOAuthTokensToCredsFile();
151
158
  }
152
159
 
153
160
  _normalizeLegacyTimestamps() {
@@ -178,6 +185,176 @@ export class HealthSyncDb {
178
185
  }
179
186
  }
180
187
 
188
+ _readCredsDoc() {
189
+ if (!fs.existsSync(this.credsPath)) {
190
+ return { version: 1, tokens: {} };
191
+ }
192
+
193
+ try {
194
+ const parsed = JSON.parse(fs.readFileSync(this.credsPath, 'utf8'));
195
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
196
+ return { version: 1, tokens: {} };
197
+ }
198
+
199
+ const tokens = (parsed.tokens && typeof parsed.tokens === 'object' && !Array.isArray(parsed.tokens))
200
+ ? parsed.tokens
201
+ : {};
202
+
203
+ return {
204
+ version: 1,
205
+ updatedAt: normalizeTimestamp(parsed.updatedAt || parsed.updated_at) || null,
206
+ tokens,
207
+ };
208
+ } catch {
209
+ if (!this._credsParseWarned) {
210
+ console.warn(`Ignoring invalid JSON in ${this.credsPath}`);
211
+ this._credsParseWarned = true;
212
+ }
213
+ return { version: 1, tokens: {} };
214
+ }
215
+ }
216
+
217
+ _writeCredsDoc(doc) {
218
+ const normalized = {
219
+ version: 1,
220
+ updatedAt: utcNowIso(),
221
+ tokens: doc?.tokens && typeof doc.tokens === 'object' && !Array.isArray(doc.tokens)
222
+ ? doc.tokens
223
+ : {},
224
+ };
225
+
226
+ fs.mkdirSync(path.dirname(this.credsPath), { recursive: true });
227
+ const tempPath = `${this.credsPath}.tmp-${process.pid}-${Date.now()}`;
228
+ fs.writeFileSync(tempPath, `${stableJsonStringify(normalized)}\n`, {
229
+ encoding: 'utf8',
230
+ mode: 0o600,
231
+ });
232
+ fs.renameSync(tempPath, this.credsPath);
233
+ try {
234
+ fs.chmodSync(this.credsPath, 0o600);
235
+ } catch {
236
+ // Ignore chmod failures on filesystems that do not support POSIX modes.
237
+ }
238
+ }
239
+
240
+ _normalizeStoredToken(provider, rawToken, contextLabel = `.health-sync.creds.${provider}`) {
241
+ if (!rawToken || typeof rawToken !== 'object' || Array.isArray(rawToken)) {
242
+ return null;
243
+ }
244
+
245
+ const accessTokenValue = rawToken.accessToken ?? rawToken.access_token;
246
+ if (accessTokenValue === null || accessTokenValue === undefined || String(accessTokenValue).trim() === '') {
247
+ return null;
248
+ }
249
+
250
+ let extra = rawToken.extra ?? null;
251
+ if (extra === null && typeof rawToken.extra_json === 'string') {
252
+ extra = jsonLoadsOrNull(rawToken.extra_json, `${contextLabel}.extra_json`);
253
+ }
254
+
255
+ return {
256
+ provider,
257
+ accessToken: String(accessTokenValue),
258
+ refreshToken: rawToken.refreshToken ?? rawToken.refresh_token ?? null,
259
+ tokenType: rawToken.tokenType ?? rawToken.token_type ?? null,
260
+ scope: rawToken.scope ?? null,
261
+ expiresAt: normalizeTimestamp(rawToken.expiresAt ?? rawToken.expires_at),
262
+ obtainedAt: normalizeTimestamp(rawToken.obtainedAt ?? rawToken.obtained_at),
263
+ extra,
264
+ };
265
+ }
266
+
267
+ _listLegacyOAuthTokens() {
268
+ let rows = [];
269
+ try {
270
+ rows = this.conn.prepare('SELECT * FROM oauth_tokens ORDER BY provider ASC').all();
271
+ } catch {
272
+ rows = [];
273
+ }
274
+
275
+ const out = {};
276
+ for (const row of rows) {
277
+ const normalized = this._normalizeStoredToken(
278
+ row.provider,
279
+ {
280
+ access_token: row.access_token,
281
+ refresh_token: row.refresh_token,
282
+ token_type: row.token_type,
283
+ scope: row.scope,
284
+ expires_at: row.expires_at,
285
+ obtained_at: row.obtained_at,
286
+ extra_json: row.extra_json,
287
+ },
288
+ `oauth_tokens.${row.provider}`,
289
+ );
290
+ if (normalized) {
291
+ out[row.provider] = normalized;
292
+ }
293
+ }
294
+
295
+ return out;
296
+ }
297
+
298
+ _upsertCredsToken(provider, token) {
299
+ const doc = this._readCredsDoc();
300
+ const tokens = {
301
+ ...(doc.tokens || {}),
302
+ [provider]: {
303
+ accessToken: token.accessToken,
304
+ refreshToken: token.refreshToken,
305
+ tokenType: token.tokenType,
306
+ scope: token.scope,
307
+ expiresAt: token.expiresAt,
308
+ obtainedAt: token.obtainedAt,
309
+ extra: token.extra,
310
+ },
311
+ };
312
+ this._writeCredsDoc({ ...doc, tokens });
313
+ }
314
+
315
+ _migrateOAuthTokensToCredsFile() {
316
+ if (this._oauthMigrated) {
317
+ return;
318
+ }
319
+ this._oauthMigrated = true;
320
+
321
+ const legacyTokens = this._listLegacyOAuthTokens();
322
+ if (!Object.keys(legacyTokens).length) {
323
+ return;
324
+ }
325
+
326
+ const doc = this._readCredsDoc();
327
+ const tokens = { ...(doc.tokens || {}) };
328
+ let changed = false;
329
+
330
+ for (const [provider, token] of Object.entries(legacyTokens)) {
331
+ const existing = this._normalizeStoredToken(provider, tokens[provider]);
332
+ if (existing) {
333
+ continue;
334
+ }
335
+ tokens[provider] = {
336
+ accessToken: token.accessToken,
337
+ refreshToken: token.refreshToken,
338
+ tokenType: token.tokenType,
339
+ scope: token.scope,
340
+ expiresAt: token.expiresAt,
341
+ obtainedAt: token.obtainedAt,
342
+ extra: token.extra,
343
+ };
344
+ changed = true;
345
+ }
346
+
347
+ const nextDoc = changed ? { ...doc, tokens } : doc;
348
+ if (changed) {
349
+ this._writeCredsDoc(nextDoc);
350
+ }
351
+
352
+ const migratedProviders = Object.keys(legacyTokens)
353
+ .filter((provider) => this._normalizeStoredToken(provider, nextDoc.tokens?.[provider]));
354
+ for (const provider of migratedProviders) {
355
+ this.conn.prepare('DELETE FROM oauth_tokens WHERE provider = ?').run(provider);
356
+ }
357
+ }
181
358
  _incrementRunStats(stats, op) {
182
359
  if (!stats) {
183
360
  return;
@@ -356,22 +533,43 @@ export class HealthSyncDb {
356
533
  }
357
534
 
358
535
  getOAuthToken(provider) {
536
+ this._migrateOAuthTokensToCredsFile();
537
+
538
+ const fromCreds = this._normalizeStoredToken(
539
+ provider,
540
+ this._readCredsDoc().tokens?.[provider],
541
+ );
542
+ if (fromCreds) {
543
+ return fromCreds;
544
+ }
545
+
359
546
  const row = this.conn
360
547
  .prepare('SELECT * FROM oauth_tokens WHERE provider = ?')
361
548
  .get(provider);
362
549
  if (!row) {
363
550
  return null;
364
551
  }
365
- return {
366
- provider: row.provider,
367
- accessToken: row.access_token,
368
- refreshToken: row.refresh_token,
369
- tokenType: row.token_type,
370
- scope: row.scope,
371
- expiresAt: normalizeTimestamp(row.expires_at),
372
- obtainedAt: normalizeTimestamp(row.obtained_at),
373
- extra: jsonLoadsOrNull(row.extra_json, `oauth_tokens.${provider}.extra_json`),
374
- };
552
+
553
+ const legacy = this._normalizeStoredToken(
554
+ provider,
555
+ {
556
+ access_token: row.access_token,
557
+ refresh_token: row.refresh_token,
558
+ token_type: row.token_type,
559
+ scope: row.scope,
560
+ expires_at: row.expires_at,
561
+ obtained_at: row.obtained_at,
562
+ extra_json: row.extra_json,
563
+ },
564
+ `oauth_tokens.${provider}`,
565
+ );
566
+ if (!legacy) {
567
+ return null;
568
+ }
569
+
570
+ this._upsertCredsToken(provider, legacy);
571
+ this.conn.prepare('DELETE FROM oauth_tokens WHERE provider = ?').run(provider);
572
+ return legacy;
375
573
  }
376
574
 
377
575
  setOAuthToken(provider, {
@@ -382,36 +580,24 @@ export class HealthSyncDb {
382
580
  expiresAt = null,
383
581
  extra = null,
384
582
  }) {
385
- const obtainedAt = utcNowIso();
386
- const normalizedExpiresAt = normalizeTimestamp(expiresAt);
387
- const extraJson = extra === null || extra === undefined ? null : stableJsonStringify(extra);
388
- this.conn.prepare(`
389
- INSERT INTO oauth_tokens (
390
- provider, access_token, refresh_token, token_type, scope,
391
- expires_at, obtained_at, extra_json
392
- ) VALUES (
393
- @provider, @access_token, @refresh_token, @token_type, @scope,
394
- @expires_at, @obtained_at, @extra_json
395
- )
396
- ON CONFLICT(provider)
397
- DO UPDATE SET
398
- access_token = excluded.access_token,
399
- refresh_token = excluded.refresh_token,
400
- token_type = excluded.token_type,
401
- scope = excluded.scope,
402
- expires_at = excluded.expires_at,
403
- obtained_at = excluded.obtained_at,
404
- extra_json = excluded.extra_json
405
- `).run({
583
+ if (accessToken === null || accessToken === undefined || String(accessToken).trim() === '') {
584
+ throw new Error('accessToken is required');
585
+ }
586
+
587
+ this._migrateOAuthTokensToCredsFile();
588
+
589
+ const token = {
406
590
  provider,
407
- access_token: accessToken,
408
- refresh_token: refreshToken,
409
- token_type: tokenType,
410
- scope,
411
- expires_at: normalizedExpiresAt,
412
- obtained_at: obtainedAt,
413
- extra_json: extraJson,
414
- });
591
+ accessToken: String(accessToken),
592
+ refreshToken: refreshToken === undefined ? null : refreshToken,
593
+ tokenType: tokenType === undefined ? null : tokenType,
594
+ scope: scope === undefined ? null : scope,
595
+ expiresAt: normalizeTimestamp(expiresAt),
596
+ obtainedAt: utcNowIso(),
597
+ extra: extra === undefined ? null : extra,
598
+ };
599
+
600
+ this._upsertCredsToken(provider, token);
415
601
  }
416
602
 
417
603
  startSyncRun(provider, resource, watermarkBefore = null) {
@@ -596,8 +782,8 @@ export class HealthSyncDb {
596
782
  }
597
783
  }
598
784
 
599
- export function openDb(dbPath) {
600
- const db = new HealthSyncDb(dbPath);
785
+ export function openDb(dbPath, options = {}) {
786
+ const db = new HealthSyncDb(dbPath, options);
601
787
  db.init();
602
788
  return db;
603
789
  }
package/src/util.js CHANGED
@@ -157,6 +157,19 @@ export function parseRetryAfterSeconds(retryAfter) {
157
157
  return Math.max(1, Math.ceil((retryAt - Date.now()) / 1000));
158
158
  }
159
159
 
160
+ let requestJsonVerbose = false;
161
+
162
+ export function setRequestJsonVerbose(enabled) {
163
+ const previous = requestJsonVerbose;
164
+ requestJsonVerbose = Boolean(enabled);
165
+ return previous;
166
+ }
167
+
168
+ function logRequestJson(message) {
169
+ if (requestJsonVerbose) {
170
+ console.log(message);
171
+ }
172
+ }
160
173
  function buildHttpError(method, url, response, parsedBody, rawText) {
161
174
  const detailCandidates = [];
162
175
  if (parsedBody && typeof parsedBody === 'object') {
@@ -233,6 +246,7 @@ export async function requestJson(url, options = {}) {
233
246
 
234
247
  let lastError = null;
235
248
  const maxAttempts = Math.max(1, retries);
249
+ const requestLabel = `${method.toUpperCase()} ${target.toString()}`;
236
250
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
237
251
  const controller = new AbortController();
238
252
  const timeout = setTimeout(() => {
@@ -240,6 +254,7 @@ export async function requestJson(url, options = {}) {
240
254
  }, timeoutMs);
241
255
 
242
256
  try {
257
+ logRequestJson(`[http] -> ${requestLabel} (attempt ${attempt}/${maxAttempts})`);
243
258
  const response = await fetch(target, {
244
259
  method,
245
260
  headers: requestHeaders,
@@ -248,6 +263,7 @@ export async function requestJson(url, options = {}) {
248
263
  });
249
264
 
250
265
  clearTimeout(timeout);
266
+ logRequestJson(`[http] <- ${response.status} ${response.statusText} ${requestLabel}`);
251
267
 
252
268
  const rawText = await response.text();
253
269
  let parsedBody = null;
@@ -266,6 +282,7 @@ export async function requestJson(url, options = {}) {
266
282
  const delayMs = retryAfterSeconds !== null
267
283
  ? Math.min(60000, Math.max(1000, retryAfterSeconds * 1000))
268
284
  : Math.min(60000, retryBackoffMs * (2 ** (attempt - 1)));
285
+ logRequestJson(`[http] retry in ${delayMs}ms (${requestLabel})`);
269
286
  await sleep(delayMs);
270
287
  continue;
271
288
  }
@@ -296,6 +313,7 @@ export async function requestJson(url, options = {}) {
296
313
  || error?.cause?.code === 'ETIMEDOUT';
297
314
  if (attempt < maxAttempts && retryable) {
298
315
  const delayMs = Math.min(60000, retryBackoffMs * (2 ** (attempt - 1)));
316
+ logRequestJson(`[http] error ${error?.message || String(error)}; retry in ${delayMs}ms (${requestLabel})`);
299
317
  await sleep(delayMs);
300
318
  continue;
301
319
  }