hae-vault 0.1.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/.env.example +7 -0
- package/CLAUDE.md +220 -0
- package/README.md +206 -0
- package/SKILL.md +60 -0
- package/dist/cli/dashboard.d.ts +3 -0
- package/dist/cli/dashboard.d.ts.map +1 -0
- package/dist/cli/dashboard.js +206 -0
- package/dist/cli/dashboard.js.map +1 -0
- package/dist/cli/import.d.ts +3 -0
- package/dist/cli/import.d.ts.map +1 -0
- package/dist/cli/import.js +78 -0
- package/dist/cli/import.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +31 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/info.d.ts +5 -0
- package/dist/cli/info.d.ts.map +1 -0
- package/dist/cli/info.js +34 -0
- package/dist/cli/info.js.map +1 -0
- package/dist/cli/metrics.d.ts +3 -0
- package/dist/cli/metrics.d.ts.map +1 -0
- package/dist/cli/metrics.js +20 -0
- package/dist/cli/metrics.js.map +1 -0
- package/dist/cli/query.d.ts +3 -0
- package/dist/cli/query.d.ts.map +1 -0
- package/dist/cli/query.js +18 -0
- package/dist/cli/query.js.map +1 -0
- package/dist/cli/serve.d.ts +3 -0
- package/dist/cli/serve.d.ts.map +1 -0
- package/dist/cli/serve.js +19 -0
- package/dist/cli/serve.js.map +1 -0
- package/dist/cli/sleep.d.ts +3 -0
- package/dist/cli/sleep.d.ts.map +1 -0
- package/dist/cli/sleep.js +19 -0
- package/dist/cli/sleep.js.map +1 -0
- package/dist/cli/summary.d.ts +3 -0
- package/dist/cli/summary.d.ts.map +1 -0
- package/dist/cli/summary.js +53 -0
- package/dist/cli/summary.js.map +1 -0
- package/dist/cli/trends.d.ts +3 -0
- package/dist/cli/trends.d.ts.map +1 -0
- package/dist/cli/trends.js +77 -0
- package/dist/cli/trends.js.map +1 -0
- package/dist/cli/watch.d.ts +12 -0
- package/dist/cli/watch.d.ts.map +1 -0
- package/dist/cli/watch.js +89 -0
- package/dist/cli/watch.js.map +1 -0
- package/dist/cli/workouts.d.ts +3 -0
- package/dist/cli/workouts.d.ts.map +1 -0
- package/dist/cli/workouts.js +19 -0
- package/dist/cli/workouts.js.map +1 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +25 -0
- package/dist/config.js.map +1 -0
- package/dist/db/importLog.d.ts +5 -0
- package/dist/db/importLog.d.ts.map +1 -0
- package/dist/db/importLog.js +10 -0
- package/dist/db/importLog.js.map +1 -0
- package/dist/db/metrics.d.ts +4 -0
- package/dist/db/metrics.d.ts.map +1 -0
- package/dist/db/metrics.js +14 -0
- package/dist/db/metrics.js.map +1 -0
- package/dist/db/schema.d.ts +5 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +100 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/sleep.d.ts +4 -0
- package/dist/db/sleep.d.ts.map +1 -0
- package/dist/db/sleep.js +13 -0
- package/dist/db/sleep.js.map +1 -0
- package/dist/db/workouts.d.ts +4 -0
- package/dist/db/workouts.d.ts.map +1 -0
- package/dist/db/workouts.js +11 -0
- package/dist/db/workouts.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/parse/metrics.d.ts +17 -0
- package/dist/parse/metrics.d.ts.map +1 -0
- package/dist/parse/metrics.js +33 -0
- package/dist/parse/metrics.js.map +1 -0
- package/dist/parse/sleep.d.ts +23 -0
- package/dist/parse/sleep.d.ts.map +1 -0
- package/dist/parse/sleep.js +58 -0
- package/dist/parse/sleep.js.map +1 -0
- package/dist/parse/time.d.ts +4 -0
- package/dist/parse/time.d.ts.map +1 -0
- package/dist/parse/time.js +41 -0
- package/dist/parse/time.js.map +1 -0
- package/dist/parse/workouts.d.ts +17 -0
- package/dist/parse/workouts.d.ts.map +1 -0
- package/dist/parse/workouts.js +24 -0
- package/dist/parse/workouts.js.map +1 -0
- package/dist/server/app.d.ts +5 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +39 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/ingest.d.ts +15 -0
- package/dist/server/ingest.d.ts.map +1 -0
- package/dist/server/ingest.js +41 -0
- package/dist/server/ingest.js.map +1 -0
- package/dist/types/hae.d.ts +103 -0
- package/dist/types/hae.d.ts.map +1 -0
- package/dist/types/hae.js +2 -0
- package/dist/types/hae.js.map +1 -0
- package/dist/util/zip.d.ts +3 -0
- package/dist/util/zip.d.ts.map +1 -0
- package/dist/util/zip.js +24 -0
- package/dist/util/zip.js.map +1 -0
- package/docs/COMMANDS.md +315 -0
- package/docs/plans/2026-02-18-hae-vault-initial-implementation.md +2015 -0
- package/docs/plans/2026-02-18-readme-dashboard-design.md +213 -0
- package/docs/plans/2026-02-18-readme-dashboard-plan.md +1306 -0
- package/docs/plans/2026-02-18-zip-env-watch-design.md +213 -0
- package/docs/plans/2026-02-18-zip-env-watch.md +966 -0
- package/package.json +57 -0
- package/src/cli/dashboard.ts +242 -0
- package/src/cli/import.ts +85 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/info.ts +36 -0
- package/src/cli/metrics.ts +20 -0
- package/src/cli/query.ts +17 -0
- package/src/cli/serve.ts +18 -0
- package/src/cli/sleep.ts +19 -0
- package/src/cli/summary.ts +58 -0
- package/src/cli/trends.ts +103 -0
- package/src/cli/watch.ts +111 -0
- package/src/cli/workouts.ts +19 -0
- package/src/config.ts +28 -0
- package/src/db/importLog.ts +18 -0
- package/src/db/metrics.ts +15 -0
- package/src/db/schema.ts +105 -0
- package/src/db/sleep.ts +15 -0
- package/src/db/workouts.ts +13 -0
- package/src/index.ts +4 -0
- package/src/parse/metrics.ts +50 -0
- package/src/parse/sleep.ts +82 -0
- package/src/parse/time.ts +43 -0
- package/src/parse/workouts.ts +42 -0
- package/src/server/app.ts +46 -0
- package/src/server/ingest.ts +68 -0
- package/src/types/hae.ts +94 -0
- package/src/util/zip.ts +24 -0
- package/tests/cli-watch.test.ts +64 -0
- package/tests/db-import-log.test.ts +40 -0
- package/tests/db-metrics.test.ts +44 -0
- package/tests/db-schema.test.ts +55 -0
- package/tests/db-sleep.test.ts +36 -0
- package/tests/db-workouts.test.ts +34 -0
- package/tests/ingest.test.ts +99 -0
- package/tests/parse-metrics.test.ts +55 -0
- package/tests/parse-sleep.test.ts +65 -0
- package/tests/parse-time.test.ts +48 -0
- package/tests/parse-workouts.test.ts +43 -0
- package/tests/types.test.ts +27 -0
- package/tests/util-zip.test.ts +46 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import express, { type Request, type Response } from 'express';
|
|
2
|
+
import type Database from 'better-sqlite3';
|
|
3
|
+
import { ingest } from './ingest.js';
|
|
4
|
+
import type { HaePayload } from '../types/hae.js';
|
|
5
|
+
|
|
6
|
+
export function createApp(db: Database.Database, opts: { token?: string } = {}) {
|
|
7
|
+
const app = express();
|
|
8
|
+
app.use(express.json({ limit: '50mb' }));
|
|
9
|
+
|
|
10
|
+
app.post('/api/ingest', (req: Request, res: Response) => {
|
|
11
|
+
// Optional bearer token auth
|
|
12
|
+
if (opts.token) {
|
|
13
|
+
const authHeader = req.headers['authorization'] ?? '';
|
|
14
|
+
const apiKey = (req.headers['x-api-key'] as string) ?? '';
|
|
15
|
+
const bearer = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
16
|
+
if (bearer !== opts.token && apiKey !== opts.token) {
|
|
17
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const payload = req.body as HaePayload;
|
|
24
|
+
if (!payload?.data) {
|
|
25
|
+
res.status(400).json({ error: 'Missing data field' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
ingest(db, payload, {
|
|
30
|
+
target: (req.query['target'] as string) ?? 'default',
|
|
31
|
+
sessionId: (req.headers['session-id'] as string) ?? null,
|
|
32
|
+
automationName: req.headers['automation-name'] as string | undefined,
|
|
33
|
+
automationPeriod: req.headers['automation-period'] as string | undefined,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
res.json({ ok: true });
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('Ingest error:', err);
|
|
39
|
+
res.status(400).json({ error: String(err) });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
|
44
|
+
|
|
45
|
+
return app;
|
|
46
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { HaePayload, SleepDatapoint } from '../types/hae.js';
|
|
3
|
+
import { parseMetric } from '../parse/metrics.js';
|
|
4
|
+
import { normalizeSleep } from '../parse/sleep.js';
|
|
5
|
+
import { parseWorkout } from '../parse/workouts.js';
|
|
6
|
+
import { upsertMetrics } from '../db/metrics.js';
|
|
7
|
+
import { upsertSleep } from '../db/sleep.js';
|
|
8
|
+
import { upsertWorkout } from '../db/workouts.js';
|
|
9
|
+
|
|
10
|
+
export interface IngestOptions {
|
|
11
|
+
target: string;
|
|
12
|
+
sessionId: string | null;
|
|
13
|
+
automationName?: string;
|
|
14
|
+
automationPeriod?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface IngestResult {
|
|
18
|
+
metricsAdded: number;
|
|
19
|
+
sleepAdded: number;
|
|
20
|
+
workoutsAdded: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ingest(db: Database.Database, payload: HaePayload, opts: IngestOptions): IngestResult {
|
|
24
|
+
const { target, sessionId } = opts;
|
|
25
|
+
const { data } = payload;
|
|
26
|
+
|
|
27
|
+
let metricsAdded = 0;
|
|
28
|
+
let sleepAdded = 0;
|
|
29
|
+
let workoutsAdded = 0;
|
|
30
|
+
|
|
31
|
+
// Process metrics
|
|
32
|
+
for (const m of data.metrics ?? []) {
|
|
33
|
+
if (m.name === 'sleep_analysis') {
|
|
34
|
+
for (const dp of (m.data as SleepDatapoint[])) {
|
|
35
|
+
const row = normalizeSleep(dp, target, sessionId);
|
|
36
|
+
upsertSleep(db, row);
|
|
37
|
+
sleepAdded++;
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
const rows = parseMetric(m, target, sessionId);
|
|
41
|
+
upsertMetrics(db, rows);
|
|
42
|
+
metricsAdded += rows.length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Process workouts
|
|
47
|
+
for (const w of data.workouts ?? []) {
|
|
48
|
+
const row = parseWorkout(w, target, sessionId);
|
|
49
|
+
upsertWorkout(db, row);
|
|
50
|
+
workoutsAdded++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Log sync
|
|
54
|
+
db.prepare(`
|
|
55
|
+
INSERT INTO sync_log (received_at, target, session_id, metrics_count, workouts_count, automation_name, automation_period)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
57
|
+
`).run(
|
|
58
|
+
new Date().toISOString(),
|
|
59
|
+
target,
|
|
60
|
+
sessionId,
|
|
61
|
+
metricsAdded,
|
|
62
|
+
workoutsAdded,
|
|
63
|
+
opts.automationName ?? null,
|
|
64
|
+
opts.automationPeriod ?? null,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return { metricsAdded, sleepAdded, workoutsAdded };
|
|
68
|
+
}
|
package/src/types/hae.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Raw datapoint from HAE — most metrics
|
|
2
|
+
export interface RawDatapoint {
|
|
3
|
+
date: string;
|
|
4
|
+
qty?: number;
|
|
5
|
+
// heart_rate: Min/Avg/Max instead of qty
|
|
6
|
+
Min?: number;
|
|
7
|
+
Avg?: number;
|
|
8
|
+
Max?: number;
|
|
9
|
+
// blood_pressure: systolic/diastolic
|
|
10
|
+
systolic?: number;
|
|
11
|
+
diastolic?: number;
|
|
12
|
+
units?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Non-aggregated sleep_analysis (each phase as a separate entry)
|
|
17
|
+
export interface SleepAnalysisRaw {
|
|
18
|
+
startDate: string;
|
|
19
|
+
endDate: string;
|
|
20
|
+
value: string; // 'ASLEEP_CORE' | 'ASLEEP_DEEP' | 'ASLEEP_REM' | 'INBED' | 'AWAKE'
|
|
21
|
+
source: string;
|
|
22
|
+
qty?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Aggregated sleep v2 (HAE >= 6.6.2): has `source` field
|
|
26
|
+
export interface AggregatedSleepV2 {
|
|
27
|
+
sleepStart: string;
|
|
28
|
+
sleepEnd: string;
|
|
29
|
+
core: number;
|
|
30
|
+
deep: number;
|
|
31
|
+
rem: number;
|
|
32
|
+
awake: number;
|
|
33
|
+
asleep: number;
|
|
34
|
+
inBed: number;
|
|
35
|
+
source: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Aggregated sleep v1 (HAE < 6.6.2): has sleepSource/inBedSource instead
|
|
39
|
+
export interface AggregatedSleepV1 {
|
|
40
|
+
sleepStart: string;
|
|
41
|
+
sleepEnd: string;
|
|
42
|
+
inBedStart: string;
|
|
43
|
+
inBedEnd: string;
|
|
44
|
+
asleep: number;
|
|
45
|
+
inBed: number;
|
|
46
|
+
sleepSource?: string;
|
|
47
|
+
inBedSource?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type SleepDatapoint = SleepAnalysisRaw | AggregatedSleepV2 | AggregatedSleepV1;
|
|
51
|
+
|
|
52
|
+
export interface MetricData {
|
|
53
|
+
name: string;
|
|
54
|
+
units: string;
|
|
55
|
+
data: RawDatapoint[] | SleepDatapoint[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkoutData {
|
|
59
|
+
name: string;
|
|
60
|
+
start: string;
|
|
61
|
+
end: string;
|
|
62
|
+
duration?: number;
|
|
63
|
+
activeEnergyBurned?: { qty: number; units: string };
|
|
64
|
+
distance?: { qty: number; units: string };
|
|
65
|
+
heartRateData?: Array<{ date: string; qty: number; units: string }>;
|
|
66
|
+
heartRateRecovery?: Array<{ date: string; qty: number; units: string }>;
|
|
67
|
+
route?: Array<{ lat: number; lon: number; altitude: number; timestamp: string }>;
|
|
68
|
+
elevation?: { ascent: number; descent: number; units: string };
|
|
69
|
+
[key: string]: unknown; // dynamic fields
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface HaePayload {
|
|
73
|
+
data: {
|
|
74
|
+
metrics?: MetricData[];
|
|
75
|
+
workouts?: WorkoutData[];
|
|
76
|
+
stateOfMind?: unknown[];
|
|
77
|
+
medications?: unknown[];
|
|
78
|
+
symptoms?: unknown[];
|
|
79
|
+
cycleTracking?: unknown[];
|
|
80
|
+
ecg?: unknown[];
|
|
81
|
+
heartRateNotifications?: unknown[];
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// HAE request headers
|
|
86
|
+
export interface HaeHeaders {
|
|
87
|
+
'automation-name'?: string;
|
|
88
|
+
'automation-id'?: string;
|
|
89
|
+
'automation-aggregation'?: string;
|
|
90
|
+
'automation-period'?: string;
|
|
91
|
+
'session-id'?: string;
|
|
92
|
+
'authorization'?: string;
|
|
93
|
+
'x-api-key'?: string;
|
|
94
|
+
}
|
package/src/util/zip.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import AdmZip from 'adm-zip';
|
|
2
|
+
import type { HaePayload } from '../types/hae.js';
|
|
3
|
+
|
|
4
|
+
const HAE_JSON_PATTERN = /HealthAutoExport.*\.json$/i;
|
|
5
|
+
|
|
6
|
+
export function extractPayloadFromZip(buf: Buffer): HaePayload | null {
|
|
7
|
+
try {
|
|
8
|
+
const zip = new AdmZip(buf);
|
|
9
|
+
const entry = zip.getEntries().find(e => HAE_JSON_PATTERN.test(e.entryName));
|
|
10
|
+
if (!entry) return null;
|
|
11
|
+
|
|
12
|
+
let payload: unknown;
|
|
13
|
+
try {
|
|
14
|
+
payload = JSON.parse(entry.getData().toString('utf-8'));
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!payload || typeof payload !== 'object' || !('data' in payload)) return null;
|
|
20
|
+
return payload as HaePayload;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import { tick } from '../src/cli/watch.js';
|
|
8
|
+
import type Database from 'better-sqlite3';
|
|
9
|
+
|
|
10
|
+
const testDir = join(tmpdir(), 'hae-vault-test-watch-' + Date.now());
|
|
11
|
+
const watchDir = join(testDir, 'watch');
|
|
12
|
+
let db: Database.Database;
|
|
13
|
+
|
|
14
|
+
const haeJson = JSON.stringify({
|
|
15
|
+
data: {
|
|
16
|
+
metrics: [{ name: 'step_count', units: 'count', data: [{ date: '2026-01-10 10:00:00 +0000', qty: 5000 }] }],
|
|
17
|
+
workouts: []
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
before(() => {
|
|
22
|
+
mkdirSync(testDir, { recursive: true });
|
|
23
|
+
mkdirSync(watchDir, { recursive: true });
|
|
24
|
+
db = openDb(join(testDir, 'test.db'));
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
after(() => {
|
|
28
|
+
closeDb(db);
|
|
29
|
+
rmSync(testDir, { recursive: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('tick imports a new HealthAutoExport-*.json file', () => {
|
|
33
|
+
writeFileSync(join(watchDir, 'HealthAutoExport-test.json'), haeJson);
|
|
34
|
+
const result = tick(db, watchDir, 'default');
|
|
35
|
+
assert.equal(result.found, 1);
|
|
36
|
+
assert.equal(result.imported, 1);
|
|
37
|
+
assert.equal(result.skipped, 0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('tick skips already-imported file (dedup)', () => {
|
|
41
|
+
// Same file still in dir — hash already in import_log
|
|
42
|
+
const result = tick(db, watchDir, 'default');
|
|
43
|
+
assert.equal(result.found, 1);
|
|
44
|
+
assert.equal(result.imported, 0);
|
|
45
|
+
assert.equal(result.skipped, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('tick ignores non-HAE files', () => {
|
|
49
|
+
writeFileSync(join(watchDir, 'random.txt'), 'not a health export');
|
|
50
|
+
writeFileSync(join(watchDir, 'workout.gpx'), '<gpx/>');
|
|
51
|
+
const result = tick(db, watchDir, 'default');
|
|
52
|
+
assert.equal(result.found, 1); // only the HAE json counts as "found"
|
|
53
|
+
assert.equal(result.imported, 0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('tick records import in import_log so second tick skips', () => {
|
|
57
|
+
writeFileSync(join(watchDir, 'HealthAutoExport-new.json'), JSON.stringify({
|
|
58
|
+
data: { metrics: [{ name: 'step_count', units: 'count', data: [{ date: '2026-01-11 10:00:00 +0000', qty: 6000 }] }], workouts: [] }
|
|
59
|
+
}));
|
|
60
|
+
tick(db, watchDir, 'default');
|
|
61
|
+
// Second tick: all files should be skipped now
|
|
62
|
+
const result2 = tick(db, watchDir, 'default');
|
|
63
|
+
assert.equal(result2.imported, 0);
|
|
64
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import { hasBeenImported, logImport } from '../src/db/importLog.js';
|
|
8
|
+
import type Database from 'better-sqlite3';
|
|
9
|
+
|
|
10
|
+
const testDir = join(tmpdir(), 'hae-vault-test-importlog-' + Date.now());
|
|
11
|
+
let db: Database.Database;
|
|
12
|
+
before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
|
|
13
|
+
after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
|
|
14
|
+
|
|
15
|
+
test('hasBeenImported returns false for unknown hash', () => {
|
|
16
|
+
assert.equal(hasBeenImported(db, 'deadbeef1234'), false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('logImport records the import', () => {
|
|
20
|
+
logImport(db, 'export.zip', 'abc123hash', { metricsAdded: 100, sleepAdded: 7, workoutsAdded: 3 });
|
|
21
|
+
const row = db.prepare('SELECT * FROM import_log WHERE file_hash = ?').get('abc123hash') as {
|
|
22
|
+
filename: string; metrics_added: number; sleep_added: number; workouts_added: number;
|
|
23
|
+
} | undefined;
|
|
24
|
+
assert.ok(row);
|
|
25
|
+
assert.equal(row!.filename, 'export.zip');
|
|
26
|
+
assert.equal(row!.metrics_added, 100);
|
|
27
|
+
assert.equal(row!.sleep_added, 7);
|
|
28
|
+
assert.equal(row!.workouts_added, 3);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('hasBeenImported returns true after logImport', () => {
|
|
32
|
+
assert.equal(hasBeenImported(db, 'abc123hash'), true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('logImport with duplicate hash is a no-op (INSERT OR IGNORE)', () => {
|
|
36
|
+
// Should not throw
|
|
37
|
+
logImport(db, 'export-copy.zip', 'abc123hash', { metricsAdded: 999, sleepAdded: 999, workoutsAdded: 999 });
|
|
38
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM import_log WHERE file_hash = ?').get('abc123hash') as { c: number };
|
|
39
|
+
assert.equal(count.c, 1); // still only one row
|
|
40
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import { upsertMetrics } from '../src/db/metrics.js';
|
|
8
|
+
import type Database from 'better-sqlite3';
|
|
9
|
+
|
|
10
|
+
const testDir = join(tmpdir(), 'hae-vault-test-metrics-' + Date.now());
|
|
11
|
+
let db: Database.Database;
|
|
12
|
+
|
|
13
|
+
before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
|
|
14
|
+
after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
|
|
15
|
+
|
|
16
|
+
const row = {
|
|
17
|
+
ts: '2026-01-15T10:00:00.000Z', date: '2026-01-15',
|
|
18
|
+
metric: 'step_count', qty: 5000, min: null, avg: null, max: null,
|
|
19
|
+
units: 'count', source: 'iPhone', target: 'default', meta: null, session_id: 'sess-1'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
test('inserts a metric row', () => {
|
|
23
|
+
upsertMetrics(db, [row]);
|
|
24
|
+
const result = db.prepare('SELECT * FROM metrics WHERE metric = ?').get('step_count') as { qty: number };
|
|
25
|
+
assert.equal(result.qty, 5000);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('upserts on conflict — updates qty', () => {
|
|
29
|
+
upsertMetrics(db, [{ ...row, qty: 6000 }]);
|
|
30
|
+
const result = db.prepare('SELECT COUNT(*) as c FROM metrics WHERE metric = ?').get('step_count') as { c: number };
|
|
31
|
+
assert.equal(result.c, 1);
|
|
32
|
+
const updated = db.prepare('SELECT qty FROM metrics WHERE metric = ?').get('step_count') as { qty: number };
|
|
33
|
+
assert.equal(updated.qty, 6000);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('inserts multiple rows in a single call', () => {
|
|
37
|
+
const rows = [
|
|
38
|
+
{ ...row, ts: '2026-01-16T10:00:00.000Z', date: '2026-01-16', qty: 7000 },
|
|
39
|
+
{ ...row, ts: '2026-01-17T10:00:00.000Z', date: '2026-01-17', qty: 8000 },
|
|
40
|
+
];
|
|
41
|
+
upsertMetrics(db, rows);
|
|
42
|
+
const result = db.prepare('SELECT COUNT(*) as c FROM metrics WHERE metric = ?').get('step_count') as { c: number };
|
|
43
|
+
assert.equal(result.c, 3);
|
|
44
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import type Database from 'better-sqlite3';
|
|
8
|
+
|
|
9
|
+
const testDir = join(tmpdir(), 'hae-vault-test-schema-' + Date.now());
|
|
10
|
+
let db: Database.Database;
|
|
11
|
+
|
|
12
|
+
before(() => {
|
|
13
|
+
mkdirSync(testDir, { recursive: true });
|
|
14
|
+
db = openDb(join(testDir, 'test.db'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
after(() => {
|
|
18
|
+
closeDb(db);
|
|
19
|
+
rmSync(testDir, { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('creates metrics table', () => {
|
|
23
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='metrics'").get();
|
|
24
|
+
assert.ok(row);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('creates sleep table', () => {
|
|
28
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sleep'").get();
|
|
29
|
+
assert.ok(row);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('creates workouts table', () => {
|
|
33
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workouts'").get();
|
|
34
|
+
assert.ok(row);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('creates sync_log table', () => {
|
|
38
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='sync_log'").get();
|
|
39
|
+
assert.ok(row);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('WAL mode is enabled', () => {
|
|
43
|
+
const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
|
|
44
|
+
assert.equal(row.journal_mode, 'wal');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('openDb is idempotent — calling twice does not throw', () => {
|
|
48
|
+
const db2 = openDb(join(testDir, 'test.db'));
|
|
49
|
+
closeDb(db2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('creates import_log table', () => {
|
|
53
|
+
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='import_log'").get();
|
|
54
|
+
assert.ok(row);
|
|
55
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import { upsertSleep } from '../src/db/sleep.js';
|
|
8
|
+
import type Database from 'better-sqlite3';
|
|
9
|
+
|
|
10
|
+
const testDir = join(tmpdir(), 'hae-vault-test-sleep-' + Date.now());
|
|
11
|
+
let db: Database.Database;
|
|
12
|
+
before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
|
|
13
|
+
after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
|
|
14
|
+
|
|
15
|
+
const row = {
|
|
16
|
+
date: '2026-01-15',
|
|
17
|
+
sleep_start: '2026-01-14T22:00:00.000Z', sleep_end: '2026-01-15T06:00:00.000Z',
|
|
18
|
+
in_bed_start: null, in_bed_end: null,
|
|
19
|
+
core_h: 3.0, deep_h: 1.0, rem_h: 1.5, awake_h: 0.5, asleep_h: 5.5, in_bed_h: 6.0,
|
|
20
|
+
schema_ver: 'aggregated_v2' as const,
|
|
21
|
+
source: 'Apple Watch', target: 'default', meta: null, session_id: 'sess-1'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test('inserts sleep row', () => {
|
|
25
|
+
upsertSleep(db, row);
|
|
26
|
+
const result = db.prepare('SELECT core_h FROM sleep WHERE date = ?').get('2026-01-15') as { core_h: number };
|
|
27
|
+
assert.equal(result.core_h, 3.0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('upserts sleep — overwrites on same date+source+target', () => {
|
|
31
|
+
upsertSleep(db, { ...row, core_h: 3.5 });
|
|
32
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM sleep').get() as { c: number };
|
|
33
|
+
assert.equal(count.c, 1);
|
|
34
|
+
const updated = db.prepare('SELECT core_h FROM sleep WHERE date = ?').get('2026-01-15') as { core_h: number };
|
|
35
|
+
assert.equal(updated.core_h, 3.5);
|
|
36
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import { upsertWorkout } from '../src/db/workouts.js';
|
|
8
|
+
import type Database from 'better-sqlite3';
|
|
9
|
+
|
|
10
|
+
const testDir = join(tmpdir(), 'hae-vault-test-workouts-' + Date.now());
|
|
11
|
+
let db: Database.Database;
|
|
12
|
+
before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
|
|
13
|
+
after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
|
|
14
|
+
|
|
15
|
+
const row = {
|
|
16
|
+
ts: '2026-01-15T07:00:00.000Z', date: '2026-01-15',
|
|
17
|
+
name: 'Running', duration_s: 2700, calories_kj: 450,
|
|
18
|
+
distance: 5.2, distance_unit: 'km', avg_hr: 145, max_hr: 165,
|
|
19
|
+
target: 'default', meta: '{}', session_id: 'sess-1'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
test('inserts workout row', () => {
|
|
23
|
+
upsertWorkout(db, row);
|
|
24
|
+
const result = db.prepare('SELECT name FROM workouts WHERE date = ?').get('2026-01-15') as { name: string };
|
|
25
|
+
assert.equal(result.name, 'Running');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('upserts workout — overwrites on same ts+name+target', () => {
|
|
29
|
+
upsertWorkout(db, { ...row, calories_kj: 500 });
|
|
30
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM workouts').get() as { c: number };
|
|
31
|
+
assert.equal(count.c, 1);
|
|
32
|
+
const updated = db.prepare('SELECT calories_kj FROM workouts WHERE date = ?').get('2026-01-15') as { calories_kj: number };
|
|
33
|
+
assert.equal(updated.calories_kj, 500);
|
|
34
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { test, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { openDb, closeDb } from '../src/db/schema.js';
|
|
7
|
+
import { ingest } from '../src/server/ingest.js';
|
|
8
|
+
import type Database from 'better-sqlite3';
|
|
9
|
+
import type { HaePayload } from '../src/types/hae.js';
|
|
10
|
+
|
|
11
|
+
const testDir = join(tmpdir(), 'hae-vault-test-ingest-' + Date.now());
|
|
12
|
+
let db: Database.Database;
|
|
13
|
+
before(() => { mkdirSync(testDir, { recursive: true }); db = openDb(join(testDir, 'test.db')); });
|
|
14
|
+
after(() => { closeDb(db); rmSync(testDir, { recursive: true }); });
|
|
15
|
+
|
|
16
|
+
const payload: HaePayload = {
|
|
17
|
+
data: {
|
|
18
|
+
metrics: [
|
|
19
|
+
{ name: 'step_count', units: 'count', data: [{ date: '2026-01-15 10:00:00 +0000', qty: 8532 }] },
|
|
20
|
+
{ name: 'heart_rate', units: 'bpm', data: [{ date: '2026-01-15 10:00:00 +0000', Min: 55, Avg: 72, Max: 130 }] },
|
|
21
|
+
{
|
|
22
|
+
name: 'sleep_analysis', units: 'hr',
|
|
23
|
+
data: [{
|
|
24
|
+
sleepStart: '2026-01-14 22:00:00 +0000',
|
|
25
|
+
sleepEnd: '2026-01-15 06:00:00 +0000',
|
|
26
|
+
core: 3.0, deep: 1.0, rem: 1.5, awake: 0.5, asleep: 5.5, inBed: 6.0,
|
|
27
|
+
source: 'Apple Watch'
|
|
28
|
+
}]
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
workouts: [
|
|
32
|
+
{ name: 'Running', start: '2026-01-15 07:00:00 +0000', end: '2026-01-15 07:45:00 +0000', activeEnergyBurned: { qty: 450, units: 'kJ' } }
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
test('ingests metrics into DB', () => {
|
|
38
|
+
ingest(db, payload, { target: 'default', sessionId: 'sess-1', automationName: 'morning', automationPeriod: 'Today' });
|
|
39
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number };
|
|
40
|
+
assert.ok(count.c >= 2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('ingests sleep into DB', () => {
|
|
44
|
+
const row = db.prepare('SELECT * FROM sleep WHERE date = ?').get('2026-01-14') as { core_h: number } | undefined;
|
|
45
|
+
assert.ok(row);
|
|
46
|
+
assert.equal(row!.core_h, 3.0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('ingests workout into DB', () => {
|
|
50
|
+
const row = db.prepare("SELECT * FROM workouts WHERE name = 'Running'").get() as { calories_kj: number } | undefined;
|
|
51
|
+
assert.ok(row);
|
|
52
|
+
assert.equal(row!.calories_kj, 450);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('writes to sync_log', () => {
|
|
56
|
+
const row = db.prepare('SELECT * FROM sync_log WHERE session_id = ?').get('sess-1') as { metrics_count: number } | undefined;
|
|
57
|
+
assert.ok(row);
|
|
58
|
+
assert.ok(row!.metrics_count >= 2);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('second ingest with same data is idempotent — no duplicates', () => {
|
|
62
|
+
ingest(db, payload, { target: 'default', sessionId: 'sess-2', automationName: 'morning', automationPeriod: 'Today' });
|
|
63
|
+
const metricsCount = db.prepare('SELECT COUNT(*) as c FROM metrics').get() as { c: number };
|
|
64
|
+
const sleepCount = db.prepare('SELECT COUNT(*) as c FROM sleep').get() as { c: number };
|
|
65
|
+
const workoutsCount = db.prepare('SELECT COUNT(*) as c FROM workouts').get() as { c: number };
|
|
66
|
+
// Upsert should not create duplicates — same data for same timestamps
|
|
67
|
+
assert.ok(metricsCount.c >= 2);
|
|
68
|
+
assert.equal(sleepCount.c, 1);
|
|
69
|
+
assert.equal(workoutsCount.c, 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('ingest returns IngestResult with counts', () => {
|
|
73
|
+
const freshPayload: HaePayload = {
|
|
74
|
+
data: {
|
|
75
|
+
metrics: [
|
|
76
|
+
{ name: 'step_count', units: 'count', data: [{ date: '2026-02-01 10:00:00 +0000', qty: 1000 }] },
|
|
77
|
+
{
|
|
78
|
+
name: 'sleep_analysis', units: 'hr',
|
|
79
|
+
data: [{
|
|
80
|
+
sleepStart: '2026-02-01 22:00:00 +0000',
|
|
81
|
+
sleepEnd: '2026-02-02 06:00:00 +0000',
|
|
82
|
+
core: 2.0, deep: 1.0, rem: 1.0, awake: 0.2, asleep: 4.0, inBed: 4.5,
|
|
83
|
+
source: 'Apple Watch'
|
|
84
|
+
}]
|
|
85
|
+
}
|
|
86
|
+
],
|
|
87
|
+
workouts: [
|
|
88
|
+
{ name: 'Cycling', start: '2026-02-01 07:00:00 +0000', end: '2026-02-01 08:00:00 +0000' }
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
const result = ingest(db, freshPayload, { target: 'test', sessionId: 'sess-count', automationName: 'test', automationPeriod: 'manual' });
|
|
93
|
+
assert.ok(typeof result.metricsAdded === 'number');
|
|
94
|
+
assert.ok(typeof result.sleepAdded === 'number');
|
|
95
|
+
assert.ok(typeof result.workoutsAdded === 'number');
|
|
96
|
+
assert.equal(result.metricsAdded, 1); // step_count only (sleep goes to sleep table)
|
|
97
|
+
assert.equal(result.sleepAdded, 1);
|
|
98
|
+
assert.equal(result.workoutsAdded, 1);
|
|
99
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseMetric } from '../src/parse/metrics.js';
|
|
4
|
+
import type { MetricData } from '../src/types/hae.js';
|
|
5
|
+
|
|
6
|
+
const stepMetric: MetricData = {
|
|
7
|
+
name: 'step_count', units: 'count',
|
|
8
|
+
data: [
|
|
9
|
+
{ date: '2026-01-15 10:00:00 +0000', qty: 5000 },
|
|
10
|
+
{ date: '2026-01-15 11:00:00 +0000', qty: 3000 },
|
|
11
|
+
]
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const hrMetric: MetricData = {
|
|
15
|
+
name: 'heart_rate', units: 'bpm',
|
|
16
|
+
data: [{ date: '2026-01-15 10:00:00 +0000', Min: 55, Avg: 72, Max: 130 }]
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const bpMetric: MetricData = {
|
|
20
|
+
name: 'blood_pressure', units: 'mmHg',
|
|
21
|
+
data: [{ date: '2026-01-15 10:00:00 +0000', systolic: 120, diastolic: 80 }]
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
test('parses basic step_count metric', () => {
|
|
25
|
+
const rows = parseMetric(stepMetric, 'default', 'sess-1');
|
|
26
|
+
assert.equal(rows.length, 2);
|
|
27
|
+
assert.equal(rows[0].metric, 'step_count');
|
|
28
|
+
assert.equal(rows[0].qty, 5000);
|
|
29
|
+
assert.equal(rows[0].units, 'count');
|
|
30
|
+
assert.ok(rows[0].ts);
|
|
31
|
+
assert.ok(rows[0].date);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('parses heart_rate metric with Min/Avg/Max', () => {
|
|
35
|
+
const rows = parseMetric(hrMetric, 'default', 'sess-1');
|
|
36
|
+
assert.equal(rows.length, 1);
|
|
37
|
+
assert.equal(rows[0].min, 55);
|
|
38
|
+
assert.equal(rows[0].avg, 72);
|
|
39
|
+
assert.equal(rows[0].max, 130);
|
|
40
|
+
assert.equal(rows[0].qty, null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('parses blood_pressure into meta JSON', () => {
|
|
44
|
+
const rows = parseMetric(bpMetric, 'default', 'sess-1');
|
|
45
|
+
assert.equal(rows.length, 1);
|
|
46
|
+
const meta = JSON.parse(rows[0].meta!);
|
|
47
|
+
assert.equal(meta.systolic, 120);
|
|
48
|
+
assert.equal(meta.diastolic, 80);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('returns empty array for sleep_analysis (handled elsewhere)', () => {
|
|
52
|
+
const sleepMetric: MetricData = { name: 'sleep_analysis', units: 'hr', data: [] };
|
|
53
|
+
const rows = parseMetric(sleepMetric, 'default', 'sess-1');
|
|
54
|
+
assert.equal(rows.length, 0);
|
|
55
|
+
});
|