health-sync 0.2.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.
@@ -0,0 +1,172 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { BUILTIN_PROVIDER_IDS } from '../config.js';
5
+ import { ObjectProviderPlugin } from './base.js';
6
+ import { builtInProviders } from '../providers/index.js';
7
+
8
+ function isClassLike(fn) {
9
+ if (typeof fn !== 'function') {
10
+ return false;
11
+ }
12
+ const proto = fn.prototype;
13
+ return !!proto && typeof proto === 'object' && typeof proto.sync === 'function';
14
+ }
15
+
16
+ async function coercePluginObject(moduleExports, moduleSpec, configuredId = null) {
17
+ const candidates = [];
18
+ if (moduleExports?.default !== undefined) {
19
+ candidates.push(moduleExports.default);
20
+ }
21
+ candidates.push(moduleExports);
22
+ if (moduleExports?.plugin !== undefined) {
23
+ candidates.push(moduleExports.plugin);
24
+ }
25
+ if (moduleExports?.createPlugin !== undefined) {
26
+ candidates.push(moduleExports.createPlugin);
27
+ }
28
+
29
+ let pluginObj = null;
30
+ for (const candidate of candidates) {
31
+ if (!candidate) {
32
+ continue;
33
+ }
34
+ if (typeof candidate === 'object' && typeof candidate.sync === 'function') {
35
+ pluginObj = candidate;
36
+ break;
37
+ }
38
+ if (isClassLike(candidate)) {
39
+ pluginObj = new candidate();
40
+ break;
41
+ }
42
+ if (typeof candidate === 'function') {
43
+ const produced = candidate();
44
+ pluginObj = produced && typeof produced.then === 'function' ? await produced : produced;
45
+ if (pluginObj && typeof pluginObj.sync === 'function') {
46
+ break;
47
+ }
48
+ pluginObj = null;
49
+ }
50
+ }
51
+
52
+ if (!pluginObj || typeof pluginObj.sync !== 'function') {
53
+ throw new Error(`Module ${moduleSpec} must export a provider object with a sync() method`);
54
+ }
55
+ if (!pluginObj.id || typeof pluginObj.id !== 'string') {
56
+ throw new Error(`Module ${moduleSpec} provider is missing string id`);
57
+ }
58
+ if (configuredId && configuredId !== pluginObj.id) {
59
+ throw new Error(`Plugin id mismatch for ${moduleSpec}: expected ${configuredId} but got ${pluginObj.id}`);
60
+ }
61
+
62
+ return new ObjectProviderPlugin(pluginObj.id, pluginObj, 'module', moduleSpec);
63
+ }
64
+
65
+ function resolveImportSpec(moduleSpec, cwd = process.cwd()) {
66
+ if (moduleSpec.startsWith('.') || moduleSpec.startsWith('/')) {
67
+ const absolute = path.resolve(cwd, moduleSpec);
68
+ return pathToFileURL(absolute).href;
69
+ }
70
+ return moduleSpec;
71
+ }
72
+
73
+ async function loadFromModuleSpec(moduleSpec, configuredId = null, cwd = process.cwd()) {
74
+ const importSpec = resolveImportSpec(moduleSpec, cwd);
75
+ const mod = await import(importSpec);
76
+ return coercePluginObject(mod, moduleSpec, configuredId);
77
+ }
78
+
79
+ function readPackageProviderSpecs(cwd = process.cwd()) {
80
+ const packagePath = path.join(cwd, 'package.json');
81
+ if (!fs.existsSync(packagePath)) {
82
+ return {};
83
+ }
84
+ try {
85
+ const parsed = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
86
+ const specs = parsed?.healthSyncProviders;
87
+ if (!specs || typeof specs !== 'object' || Array.isArray(specs)) {
88
+ return {};
89
+ }
90
+ return specs;
91
+ } catch {
92
+ return {};
93
+ }
94
+ }
95
+
96
+ export async function loadProviders(config, options = {}) {
97
+ const { cwd = process.cwd() } = options;
98
+ const providers = new Map();
99
+ const metadata = new Map();
100
+
101
+ for (const plugin of builtInProviders()) {
102
+ providers.set(plugin.id, plugin);
103
+ metadata.set(plugin.id, {
104
+ source: plugin.source,
105
+ moduleSpec: null,
106
+ builtin: true,
107
+ });
108
+ }
109
+
110
+ const packageSpecs = readPackageProviderSpecs(cwd);
111
+ for (const [id, moduleSpec] of Object.entries(packageSpecs)) {
112
+ if (!moduleSpec || typeof moduleSpec !== 'string') {
113
+ continue;
114
+ }
115
+ if (BUILTIN_PROVIDER_IDS.includes(id)) {
116
+ console.warn(`Ignoring package provider override for built-in provider ${id}`);
117
+ continue;
118
+ }
119
+ try {
120
+ const plugin = await loadFromModuleSpec(moduleSpec, id, cwd);
121
+ if (providers.has(plugin.id)) {
122
+ console.warn(`Ignoring duplicate provider id ${plugin.id} from ${moduleSpec}`);
123
+ continue;
124
+ }
125
+ providers.set(plugin.id, plugin);
126
+ metadata.set(plugin.id, {
127
+ source: plugin.source,
128
+ moduleSpec,
129
+ builtin: false,
130
+ });
131
+ } catch (err) {
132
+ console.warn(`Failed to load package provider ${id} (${moduleSpec}): ${err.message}`);
133
+ }
134
+ }
135
+
136
+ const pluginConfig = config?.plugins && typeof config.plugins === 'object' ? config.plugins : {};
137
+ for (const [configuredId, pluginSection] of Object.entries(pluginConfig)) {
138
+ if (!pluginSection || typeof pluginSection !== 'object' || Array.isArray(pluginSection)) {
139
+ continue;
140
+ }
141
+ const moduleSpec = typeof pluginSection.module === 'string' ? pluginSection.module.trim() : '';
142
+ if (!moduleSpec) {
143
+ continue;
144
+ }
145
+ if (BUILTIN_PROVIDER_IDS.includes(configuredId)) {
146
+ throw new Error(`Cannot override built-in provider \`${configuredId}\`.`);
147
+ }
148
+
149
+ let plugin;
150
+ try {
151
+ plugin = await loadFromModuleSpec(moduleSpec, configuredId, cwd);
152
+ } catch (err) {
153
+ throw new Error(`Failed to load configured plugin ${configuredId} (${moduleSpec}): ${err.message}`);
154
+ }
155
+
156
+ if (providers.has(plugin.id) && providers.get(plugin.id)?.source === 'builtin') {
157
+ throw new Error(`Cannot override built-in provider \`${plugin.id}\`.`);
158
+ }
159
+
160
+ providers.set(plugin.id, plugin);
161
+ metadata.set(plugin.id, {
162
+ source: plugin.source,
163
+ moduleSpec,
164
+ builtin: false,
165
+ });
166
+ }
167
+
168
+ return {
169
+ providers,
170
+ metadata,
171
+ };
172
+ }
@@ -0,0 +1,329 @@
1
+ import {
2
+ parseYYYYMMDD,
3
+ requestJson,
4
+ sha256Hex,
5
+ utcNowIso,
6
+ } from '../util.js';
7
+
8
+ function dateToYYYYMMDD(date) {
9
+ const y = date.getUTCFullYear();
10
+ const m = String(date.getUTCMonth() + 1).padStart(2, '0');
11
+ const d = String(date.getUTCDate()).padStart(2, '0');
12
+ return `${y}-${m}-${d}`;
13
+ }
14
+
15
+ function isoToDate(value) {
16
+ if (!value) {
17
+ return null;
18
+ }
19
+ const parsed = new Date(value);
20
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
21
+ }
22
+
23
+ function tokenExpiredSoon(expiresAtIso, skewSeconds = 60) {
24
+ if (!expiresAtIso) {
25
+ return true;
26
+ }
27
+ const expiryMs = Date.parse(expiresAtIso);
28
+ if (Number.isNaN(expiryMs)) {
29
+ return true;
30
+ }
31
+ return expiryMs <= (Date.now() + skewSeconds * 1000);
32
+ }
33
+
34
+ function tokenExtra(payload) {
35
+ const out = {};
36
+ for (const [key, value] of Object.entries(payload || {})) {
37
+ if (key === 'access_token' || key === 'expires_in') {
38
+ continue;
39
+ }
40
+ out[key] = value;
41
+ }
42
+ return out;
43
+ }
44
+
45
+ async function eightsleepRefreshIfNeeded(db, cfg) {
46
+ if (cfg.access_token) {
47
+ db.setOAuthToken('eightsleep', {
48
+ accessToken: String(cfg.access_token),
49
+ refreshToken: null,
50
+ tokenType: 'Bearer',
51
+ scope: null,
52
+ expiresAt: null,
53
+ extra: { method: 'static_access_token' },
54
+ });
55
+ return String(cfg.access_token);
56
+ }
57
+
58
+ const existing = db.getOAuthToken('eightsleep');
59
+ if (existing && !tokenExpiredSoon(existing.expiresAt)) {
60
+ return existing.accessToken;
61
+ }
62
+
63
+ if (!cfg.email || !cfg.password) {
64
+ throw new Error('Missing [eightsleep].email or [eightsleep].password, or provide [eightsleep].access_token.');
65
+ }
66
+
67
+ const tokenPayload = await requestJson(cfg.auth_url || 'https://auth-api.8slp.net/v1/tokens', {
68
+ method: 'POST',
69
+ json: {
70
+ client_id: cfg.client_id,
71
+ client_secret: cfg.client_secret,
72
+ grant_type: 'password',
73
+ username: cfg.email,
74
+ password: cfg.password,
75
+ },
76
+ });
77
+
78
+ if (!tokenPayload?.access_token) {
79
+ throw new Error(`Eight Sleep auth failed: ${JSON.stringify(tokenPayload)}`);
80
+ }
81
+
82
+ const expiresIn = Number.parseInt(String(tokenPayload.expires_in ?? 0), 10) || 0;
83
+ const expiresAt = expiresIn > 0 ? new Date(Date.now() + expiresIn * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z') : null;
84
+
85
+ db.setOAuthToken('eightsleep', {
86
+ accessToken: String(tokenPayload.access_token),
87
+ refreshToken: null,
88
+ tokenType: tokenPayload.token_type || 'Bearer',
89
+ scope: tokenPayload.scope || null,
90
+ expiresAt,
91
+ extra: tokenExtra(tokenPayload),
92
+ });
93
+
94
+ return String(tokenPayload.access_token);
95
+ }
96
+
97
+ function trendsFromDate(db, cfg) {
98
+ const overlapDays = Math.max(0, Number.parseInt(String(cfg.overlap_days ?? 2), 10) || 0);
99
+ const state = db.getSyncState('eightsleep', 'trends');
100
+ if (!state?.watermark) {
101
+ return String(cfg.start_date || '2010-01-01');
102
+ }
103
+
104
+ const wmDate = isoToDate(state.watermark);
105
+ if (!wmDate) {
106
+ return String(cfg.start_date || '2010-01-01');
107
+ }
108
+
109
+ const fromDate = new Date(Date.UTC(
110
+ wmDate.getUTCFullYear(),
111
+ wmDate.getUTCMonth(),
112
+ wmDate.getUTCDate(),
113
+ 0,
114
+ 0,
115
+ 0,
116
+ ));
117
+ fromDate.setUTCDate(fromDate.getUTCDate() - overlapDays);
118
+ return dateToYYYYMMDD(fromDate);
119
+ }
120
+
121
+ function collectDeviceUserIds(payload) {
122
+ const ids = [];
123
+ const root = payload?.result && typeof payload.result === 'object' ? payload.result : payload;
124
+ if (!root || typeof root !== 'object') {
125
+ return ids;
126
+ }
127
+
128
+ for (const key of ['leftUserId', 'rightUserId']) {
129
+ if (root[key] !== undefined && root[key] !== null) {
130
+ ids.push(String(root[key]));
131
+ }
132
+ }
133
+
134
+ const awaySides = root.awaySides;
135
+ if (awaySides && typeof awaySides === 'object') {
136
+ for (const value of Object.values(awaySides)) {
137
+ if (value !== undefined && value !== null && String(value).trim()) {
138
+ ids.push(String(value));
139
+ }
140
+ }
141
+ }
142
+
143
+ return ids;
144
+ }
145
+
146
+ function extractTrendEntries(payload) {
147
+ if (Array.isArray(payload?.result)) {
148
+ return payload.result;
149
+ }
150
+ if (Array.isArray(payload?.trends)) {
151
+ return payload.trends;
152
+ }
153
+ if (Array.isArray(payload?.days)) {
154
+ return payload.days;
155
+ }
156
+ return [];
157
+ }
158
+
159
+ function trendRecordId(userId, trend) {
160
+ if (trend?.day) {
161
+ return `${userId}:${String(trend.day)}`;
162
+ }
163
+ return `${userId}:${sha256Hex(JSON.stringify(trend))}`;
164
+ }
165
+
166
+ async function eightsleepAuth(db, config, helpers) {
167
+ const cfg = helpers.configFor('eightsleep');
168
+ await eightsleepRefreshIfNeeded(db, cfg);
169
+ console.log('Eight Sleep authorization succeeded.');
170
+ }
171
+
172
+ async function eightsleepSync(db, config, helpers) {
173
+ const cfg = helpers.configFor('eightsleep');
174
+ const accessToken = await eightsleepRefreshIfNeeded(db, cfg);
175
+
176
+ const apiBase = String(cfg.client_api_url || 'https://client-api.8slp.net/v1').replace(/\/$/, '');
177
+ const headers = {
178
+ Authorization: `Bearer ${accessToken}`,
179
+ };
180
+
181
+ let mePayload;
182
+ let meUserId = 'me';
183
+ let meUser = null;
184
+
185
+ await db.syncRun('eightsleep', 'users_me', async () => {
186
+ await db.transaction(async () => {
187
+ mePayload = await requestJson(`${apiBase}/users/me`, { headers });
188
+ meUser = mePayload?.user && typeof mePayload.user === 'object' ? mePayload.user : null;
189
+ meUserId = String(meUser?.id || mePayload?.id || 'me');
190
+ const nowIso = utcNowIso();
191
+ db.upsertRecord({
192
+ provider: 'eightsleep',
193
+ resource: 'users_me',
194
+ recordId: meUserId,
195
+ startTime: null,
196
+ endTime: null,
197
+ sourceUpdatedAt: nowIso,
198
+ payload: mePayload,
199
+ });
200
+ db.setSyncState('eightsleep', 'users_me', {
201
+ watermark: nowIso,
202
+ });
203
+ });
204
+ });
205
+
206
+ const userIds = new Set();
207
+ if (meUserId !== 'me') {
208
+ userIds.add(meUserId);
209
+ }
210
+
211
+ await db.syncRun('eightsleep', 'devices', async () => {
212
+ await db.transaction(async () => {
213
+ const devices = Array.isArray(meUser?.devices)
214
+ ? meUser.devices
215
+ : Array.isArray(mePayload?.devices)
216
+ ? mePayload.devices
217
+ : [];
218
+ const nowIso = utcNowIso();
219
+
220
+ for (const deviceEntry of devices) {
221
+ const deviceId = typeof deviceEntry === 'object' && deviceEntry !== null
222
+ ? deviceEntry.id || deviceEntry.deviceId
223
+ : deviceEntry;
224
+ if (!deviceId) {
225
+ continue;
226
+ }
227
+
228
+ const payload = await requestJson(`${apiBase}/devices/${encodeURIComponent(String(deviceId))}`, {
229
+ headers,
230
+ });
231
+
232
+ db.upsertRecord({
233
+ provider: 'eightsleep',
234
+ resource: 'devices',
235
+ recordId: String(deviceId),
236
+ startTime: null,
237
+ endTime: null,
238
+ sourceUpdatedAt: nowIso,
239
+ payload,
240
+ });
241
+
242
+ for (const uid of collectDeviceUserIds(payload)) {
243
+ userIds.add(uid);
244
+ }
245
+ }
246
+
247
+ db.setSyncState('eightsleep', 'devices', {
248
+ watermark: nowIso,
249
+ });
250
+ });
251
+ });
252
+
253
+ await db.syncRun('eightsleep', 'users', async () => {
254
+ await db.transaction(async () => {
255
+ const nowIso = utcNowIso();
256
+ for (const userId of userIds) {
257
+ const payload = await requestJson(`${apiBase}/users/${encodeURIComponent(String(userId))}`, {
258
+ headers,
259
+ });
260
+ db.upsertRecord({
261
+ provider: 'eightsleep',
262
+ resource: 'users',
263
+ recordId: String(userId),
264
+ startTime: null,
265
+ endTime: null,
266
+ sourceUpdatedAt: nowIso,
267
+ payload,
268
+ });
269
+ }
270
+
271
+ db.setSyncState('eightsleep', 'users', {
272
+ watermark: nowIso,
273
+ });
274
+ });
275
+ });
276
+
277
+ await db.syncRun('eightsleep', 'trends', async () => {
278
+ await db.transaction(async () => {
279
+ const timezone = String(cfg.timezone || 'UTC');
280
+ const fromDate = trendsFromDate(db, cfg);
281
+ const todayDate = dateToYYYYMMDD(new Date());
282
+ const nowIso = utcNowIso();
283
+
284
+ for (const userId of userIds) {
285
+ const payload = await requestJson(`${apiBase}/users/${encodeURIComponent(String(userId))}/trends`, {
286
+ headers,
287
+ params: {
288
+ tz: timezone,
289
+ from: fromDate,
290
+ to: todayDate,
291
+ 'include-main': 'false',
292
+ 'include-all-sessions': 'true',
293
+ 'model-version': 'v2',
294
+ },
295
+ });
296
+
297
+ const entries = extractTrendEntries(payload);
298
+ for (const trend of entries) {
299
+ const startTime = trend?.day || trend?.presenceStart || null;
300
+ const sourceUpdatedAt = trend?.updatedAt || trend?.presenceStart || startTime;
301
+ db.upsertRecord({
302
+ provider: 'eightsleep',
303
+ resource: 'trends',
304
+ recordId: trendRecordId(String(userId), trend),
305
+ startTime,
306
+ endTime: trend?.presenceEnd || null,
307
+ sourceUpdatedAt,
308
+ payload: trend,
309
+ });
310
+ }
311
+ }
312
+
313
+ db.setSyncState('eightsleep', 'trends', {
314
+ watermark: nowIso,
315
+ });
316
+ });
317
+ });
318
+ }
319
+
320
+ const eightsleepProvider = {
321
+ id: 'eightsleep',
322
+ source: 'builtin',
323
+ description: 'Eight Sleep user/device and trend data sync',
324
+ supportsAuth: true,
325
+ auth: eightsleepAuth,
326
+ sync: eightsleepSync,
327
+ };
328
+
329
+ export default eightsleepProvider;