@mongrov/analytics 0.23.1 → 0.24.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/dist/core/migrations.d.ts.map +1 -1
- package/dist/core/migrations.js +35 -0
- package/dist/core/migrations.js.map +1 -1
- package/dist/core/schemas.d.ts.map +1 -1
- package/dist/core/schemas.js +5 -2
- package/dist/core/schemas.js.map +1 -1
- package/dist/rules/compiler.d.ts.map +1 -1
- package/dist/rules/compiler.js +37 -16
- package/dist/rules/compiler.js.map +1 -1
- package/dist/sync/factory.d.ts +7 -0
- package/dist/sync/factory.d.ts.map +1 -1
- package/dist/sync/factory.js +31 -1
- package/dist/sync/factory.js.map +1 -1
- package/dist/sync/index.d.ts +2 -2
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/mapper/firmware.d.ts.map +1 -1
- package/dist/sync/mapper/firmware.js +7 -5
- package/dist/sync/mapper/firmware.js.map +1 -1
- package/dist/sync/mapper/schema.d.ts +29 -36
- package/dist/sync/mapper/schema.d.ts.map +1 -1
- package/dist/sync/mapper/schema.js +11 -12
- package/dist/sync/mapper/schema.js.map +1 -1
- package/dist/sync/mapper/sleep.d.ts +50 -43
- package/dist/sync/mapper/sleep.d.ts.map +1 -1
- package/dist/sync/mapper/sleep.js +99 -118
- package/dist/sync/mapper/sleep.js.map +1 -1
- package/dist/sync/mapper/types.d.ts +17 -18
- package/dist/sync/mapper/types.d.ts.map +1 -1
- package/dist/sync/sleep-correction/classify.d.ts +86 -0
- package/dist/sync/sleep-correction/classify.d.ts.map +1 -0
- package/dist/sync/sleep-correction/classify.js +247 -0
- package/dist/sync/sleep-correction/classify.js.map +1 -0
- package/dist/sync/sleep-correction/correct.d.ts +46 -0
- package/dist/sync/sleep-correction/correct.d.ts.map +1 -0
- package/dist/sync/sleep-correction/correct.js +60 -0
- package/dist/sync/sleep-correction/correct.js.map +1 -0
- package/dist/sync/sleep-correction/index.d.ts +8 -0
- package/dist/sync/sleep-correction/index.d.ts.map +1 -0
- package/dist/sync/sleep-correction/index.js +8 -0
- package/dist/sync/sleep-correction/index.js.map +1 -0
- package/dist/sync/sleep-correction/night.d.ts +26 -0
- package/dist/sync/sleep-correction/night.d.ts.map +1 -0
- package/dist/sync/sleep-correction/night.js +48 -0
- package/dist/sync/sleep-correction/night.js.map +1 -0
- package/dist/sync/sleep-correction/orchestrate.d.ts +50 -0
- package/dist/sync/sleep-correction/orchestrate.d.ts.map +1 -0
- package/dist/sync/sleep-correction/orchestrate.js +57 -0
- package/dist/sync/sleep-correction/orchestrate.js.map +1 -0
- package/dist/sync/sleep-correction/settle.d.ts +24 -0
- package/dist/sync/sleep-correction/settle.d.ts.map +1 -0
- package/dist/sync/sleep-correction/settle.js +59 -0
- package/dist/sync/sleep-correction/settle.js.map +1 -0
- package/dist/sync/sleep-correction/sql.d.ts +44 -0
- package/dist/sync/sleep-correction/sql.d.ts.map +1 -0
- package/dist/sync/sleep-correction/sql.js +329 -0
- package/dist/sync/sleep-correction/sql.js.map +1 -0
- package/dist/sync/sleep-correction/staging.d.ts +32 -0
- package/dist/sync/sleep-correction/staging.d.ts.map +1 -0
- package/dist/sync/sleep-correction/staging.js +51 -0
- package/dist/sync/sleep-correction/staging.js.map +1 -0
- package/dist/sync/sleep-derive.d.ts +55 -0
- package/dist/sync/sleep-derive.d.ts.map +1 -0
- package/dist/sync/sleep-derive.js +180 -0
- package/dist/sync/sleep-derive.js.map +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sleep correction — Phase 2 SQL, ported from DataFusion to DuckDB.
|
|
3
|
+
*
|
|
4
|
+
* Source: ziva_app `sleepCorrectionLayer.ts` v3.1. The query SHAPE is kept as
|
|
5
|
+
* v3.1 wrote it — same CTEs, same self-joins, same UNION ALL offset generator
|
|
6
|
+
* — so a diff against the original reads as a list of dialect changes only:
|
|
7
|
+
*
|
|
8
|
+
* - `TO_CHAR(TO_TIMESTAMP(d), fmt)` → `strftime(make_timestamp(d * 1000000), fmt)`.
|
|
9
|
+
* make_timestamp is naive UTC; `to_timestamp()` would be TIMESTAMPTZ and
|
|
10
|
+
* render in the session zone, breaking Phase 3's UTC parse.
|
|
11
|
+
* - `APPROX_PERCENTILE_CONT` → `quantile_cont` (exact). Expect ~1-unit
|
|
12
|
+
* drift on the tier/p75/p90 scalars; never retune constants to hide it.
|
|
13
|
+
* - The unused nested-window `session_start` column is dropped (DuckDB
|
|
14
|
+
* rejects nested windows; the final `start` recomputes it anyway).
|
|
15
|
+
* - Confidence literals are CAST to DOUBLE — DuckDB types `0.90` as DECIMAL,
|
|
16
|
+
* DataFusion as Float64.
|
|
17
|
+
*
|
|
18
|
+
* Numbers are inlined, as v3.1 does: every value is computed by this module
|
|
19
|
+
* (epochs, tier, percentiles), never user input, and inlining sidesteps
|
|
20
|
+
* react-native-duckdb's untyped-param bind (zivaone_app#70). `num()` refuses
|
|
21
|
+
* anything non-finite.
|
|
22
|
+
*
|
|
23
|
+
* Relations are the v3.1 raw firmware shapes: sleep(date, quality, start,
|
|
24
|
+
* "unitLength"), heartrate(date, "singleHR"), activity(date, step), epochs in
|
|
25
|
+
* seconds. Production supplies staging views over the warehouse; the parity
|
|
26
|
+
* harness supplies tables loaded from captures.
|
|
27
|
+
*/
|
|
28
|
+
import { CONF_ENVELOPE, CONF_FIRMWARE, CONF_GAP, EXTEND_AFTER, FETCH_AFTER, FETCH_BEFORE, HR_MAX, HR_MIN, LONG_GAP_HR_FRAC, LONG_GAP_MAX, LONG_GAP_MIN, STITCH_GAP_LONG, STITCH_GAP_MED, STITCH_GAP_SHORT, } from './classify';
|
|
29
|
+
export const DEFAULT_RELATIONS = {
|
|
30
|
+
sleep: 'stg_sleep',
|
|
31
|
+
heartrate: 'stg_heartrate',
|
|
32
|
+
activity: 'stg_activity',
|
|
33
|
+
};
|
|
34
|
+
const IDENT_RE = /^[a-z_][\w.]*$/i;
|
|
35
|
+
function rel(name) {
|
|
36
|
+
if (!IDENT_RE.test(name))
|
|
37
|
+
throw new Error(`sleep-correction: invalid relation name ${JSON.stringify(name)}`);
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
function num(v) {
|
|
41
|
+
if (!Number.isFinite(v))
|
|
42
|
+
throw new Error(`sleep-correction: non-finite SQL value ${String(v)}`);
|
|
43
|
+
return String(v);
|
|
44
|
+
}
|
|
45
|
+
/** Once per sync. `hr_interval_minutes` is 5, 10 or 30 (v3.1 CASE, NULL ⇒ 30). */
|
|
46
|
+
export function detectHrTierSql(r = DEFAULT_RELATIONS) {
|
|
47
|
+
return `
|
|
48
|
+
WITH hr_numbered AS (
|
|
49
|
+
SELECT date,
|
|
50
|
+
ROW_NUMBER() OVER (ORDER BY date) AS rn
|
|
51
|
+
FROM ${rel(r.heartrate)}
|
|
52
|
+
WHERE "singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
|
|
53
|
+
),
|
|
54
|
+
hr_intervals AS (
|
|
55
|
+
SELECT cur.date - COALESCE(prv.date, cur.date) AS gap_sec
|
|
56
|
+
FROM hr_numbered cur
|
|
57
|
+
LEFT JOIN hr_numbered prv ON prv.rn = cur.rn - 1
|
|
58
|
+
WHERE cur.date - COALESCE(prv.date, cur.date) BETWEEN 61 AND 3599
|
|
59
|
+
),
|
|
60
|
+
median_gap AS (
|
|
61
|
+
SELECT quantile_cont(gap_sec, 0.5) AS med
|
|
62
|
+
FROM hr_intervals
|
|
63
|
+
)
|
|
64
|
+
SELECT
|
|
65
|
+
med AS median_interval_seconds,
|
|
66
|
+
CASE WHEN med <= 360 THEN 5
|
|
67
|
+
WHEN med <= 900 THEN 10
|
|
68
|
+
ELSE 30
|
|
69
|
+
END AS hr_interval_minutes
|
|
70
|
+
FROM median_gap;`;
|
|
71
|
+
}
|
|
72
|
+
/** Once per sync. Sleeping-HR p90 / p75 since `cutoffEpoch`; 120 when empty. */
|
|
73
|
+
export function globalBaselinesSql(cutoffEpoch, r = DEFAULT_RELATIONS) {
|
|
74
|
+
return `
|
|
75
|
+
WITH hr_sleep AS (
|
|
76
|
+
SELECT h."singleHR" AS hr
|
|
77
|
+
FROM ${rel(r.heartrate)} h
|
|
78
|
+
INNER JOIN ${rel(r.sleep)} s ON ABS(h.date - s.date) <= 30
|
|
79
|
+
WHERE h."singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
|
|
80
|
+
AND CAST(s.quality AS INT) IN (1, 2, 3)
|
|
81
|
+
AND h.date >= ${num(cutoffEpoch)}
|
|
82
|
+
)
|
|
83
|
+
SELECT
|
|
84
|
+
COALESCE((SELECT quantile_cont(hr, 0.90) FROM hr_sleep), ${HR_MAX}) AS p90_global,
|
|
85
|
+
COALESCE((SELECT quantile_cont(hr, 0.75) FROM hr_sleep), ${HR_MAX}) AS p75_global;`;
|
|
86
|
+
}
|
|
87
|
+
/** Per night, before Phase 2: zero firmware sleep ⇒ skip the night entirely. */
|
|
88
|
+
export function hasSleepDataSql(windowStart, windowEnd, r = DEFAULT_RELATIONS) {
|
|
89
|
+
return `
|
|
90
|
+
SELECT COUNT(*) AS has_data
|
|
91
|
+
FROM ${rel(r.sleep)}
|
|
92
|
+
WHERE date >= ${num(windowStart)}
|
|
93
|
+
AND date < ${num(windowEnd)}
|
|
94
|
+
AND CAST(quality AS INT) IN (1, 2, 3, 5);`;
|
|
95
|
+
}
|
|
96
|
+
/** Phase 2 — envelope extension, gap recovery and session stitching. */
|
|
97
|
+
export function correctNightSql(windowStart, windowEnd, hrIntervalMin, globalP75, globalP90, r = DEFAULT_RELATIONS) {
|
|
98
|
+
const fetchStart = windowStart - FETCH_BEFORE;
|
|
99
|
+
const fetchEnd = windowEnd + FETCH_AFTER;
|
|
100
|
+
const offsets = Array.from({ length: hrIntervalMin }, (_, i) => `SELECT ${i} AS v`).join(' UNION ALL ');
|
|
101
|
+
const ws = num(windowStart);
|
|
102
|
+
const we = num(windowEnd);
|
|
103
|
+
return `
|
|
104
|
+
WITH sleep_raw AS (
|
|
105
|
+
SELECT
|
|
106
|
+
date,
|
|
107
|
+
CAST(quality AS INT) AS quality,
|
|
108
|
+
start AS fw_start,
|
|
109
|
+
CAST("unitLength" AS INT) AS unit_length
|
|
110
|
+
FROM ${rel(r.sleep)}
|
|
111
|
+
WHERE date >= ${ws}
|
|
112
|
+
AND date < ${we}
|
|
113
|
+
AND CAST(quality AS INT) IN (1, 2, 3, 5)
|
|
114
|
+
),
|
|
115
|
+
|
|
116
|
+
fw_bounds AS (
|
|
117
|
+
SELECT
|
|
118
|
+
COALESCE(MIN(date), ${ws}) AS first_fw,
|
|
119
|
+
COALESCE(MAX(date), ${we}) AS last_fw
|
|
120
|
+
FROM sleep_raw
|
|
121
|
+
WHERE quality IN (1, 2, 3, 5)
|
|
122
|
+
),
|
|
123
|
+
|
|
124
|
+
night_hr AS (
|
|
125
|
+
SELECT date, "singleHR" AS hr
|
|
126
|
+
FROM ${rel(r.heartrate)}
|
|
127
|
+
WHERE date >= ${num(fetchStart)}
|
|
128
|
+
AND date <= ${num(fetchEnd)}
|
|
129
|
+
AND "singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
|
|
130
|
+
),
|
|
131
|
+
|
|
132
|
+
effective AS (
|
|
133
|
+
SELECT
|
|
134
|
+
fw_bounds.first_fw AS eff_start,
|
|
135
|
+
fw_bounds.last_fw + ${EXTEND_AFTER} AS eff_end
|
|
136
|
+
FROM fw_bounds
|
|
137
|
+
),
|
|
138
|
+
|
|
139
|
+
fw_trimmed AS (
|
|
140
|
+
SELECT s.date, s.quality, s.fw_start, s.unit_length,
|
|
141
|
+
CAST(${CONF_FIRMWARE} AS DOUBLE) AS confidence
|
|
142
|
+
FROM sleep_raw s
|
|
143
|
+
CROSS JOIN effective e
|
|
144
|
+
WHERE s.date >= e.eff_start
|
|
145
|
+
AND s.quality IN (1, 2, 3, 5)
|
|
146
|
+
),
|
|
147
|
+
|
|
148
|
+
fw_has_rows AS (
|
|
149
|
+
SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END AS has_rows
|
|
150
|
+
FROM sleep_raw
|
|
151
|
+
WHERE quality IN (1, 2, 3, 5)
|
|
152
|
+
),
|
|
153
|
+
|
|
154
|
+
envelope_hr AS (
|
|
155
|
+
SELECT h.date AS hr_epoch
|
|
156
|
+
FROM night_hr h
|
|
157
|
+
CROSS JOIN effective e
|
|
158
|
+
CROSS JOIN fw_has_rows fhr
|
|
159
|
+
CROSS JOIN fw_bounds fb
|
|
160
|
+
WHERE fhr.has_rows = 1
|
|
161
|
+
AND h.date >= fb.first_fw
|
|
162
|
+
AND h.date <= e.eff_end
|
|
163
|
+
AND h.hr <= ${num(globalP90)}
|
|
164
|
+
AND NOT EXISTS (
|
|
165
|
+
SELECT 1 FROM sleep_raw fs
|
|
166
|
+
WHERE ABS(fs.date - h.date) <= 30
|
|
167
|
+
AND fs.quality IN (1, 2, 3, 5)
|
|
168
|
+
)
|
|
169
|
+
),
|
|
170
|
+
|
|
171
|
+
offsets AS (${offsets}),
|
|
172
|
+
|
|
173
|
+
envelope_expanded AS (
|
|
174
|
+
SELECT DISTINCT
|
|
175
|
+
eh.hr_epoch + (o.v * 60) AS date,
|
|
176
|
+
2 AS quality,
|
|
177
|
+
NULL AS fw_start,
|
|
178
|
+
1 AS unit_length,
|
|
179
|
+
CAST(${CONF_ENVELOPE} AS DOUBLE) AS confidence
|
|
180
|
+
FROM envelope_hr eh
|
|
181
|
+
CROSS JOIN offsets o
|
|
182
|
+
CROSS JOIN effective e
|
|
183
|
+
WHERE eh.hr_epoch + (o.v * 60) >= e.eff_start
|
|
184
|
+
AND eh.hr_epoch + (o.v * 60) <= e.eff_end
|
|
185
|
+
),
|
|
186
|
+
|
|
187
|
+
fw_sessions AS (
|
|
188
|
+
SELECT fw_start,
|
|
189
|
+
MIN(date) AS sess_start,
|
|
190
|
+
MAX(date) AS sess_end,
|
|
191
|
+
COUNT(*) AS sess_len
|
|
192
|
+
FROM sleep_raw
|
|
193
|
+
WHERE quality IN (1, 2, 3, 5)
|
|
194
|
+
GROUP BY fw_start
|
|
195
|
+
),
|
|
196
|
+
|
|
197
|
+
fw_sess_numbered AS (
|
|
198
|
+
SELECT *,
|
|
199
|
+
ROW_NUMBER() OVER (ORDER BY sess_start) AS rn
|
|
200
|
+
FROM fw_sessions
|
|
201
|
+
),
|
|
202
|
+
|
|
203
|
+
fw_gaps AS (
|
|
204
|
+
SELECT
|
|
205
|
+
cur.sess_end AS gap_start,
|
|
206
|
+
nxt.sess_start AS gap_end,
|
|
207
|
+
nxt.sess_start - cur.sess_end AS gap_sec
|
|
208
|
+
FROM fw_sess_numbered cur
|
|
209
|
+
LEFT JOIN fw_sess_numbered nxt ON nxt.rn = cur.rn + 1
|
|
210
|
+
WHERE nxt.sess_start IS NOT NULL
|
|
211
|
+
AND nxt.sess_start - cur.sess_end BETWEEN ${LONG_GAP_MIN} AND ${LONG_GAP_MAX}
|
|
212
|
+
),
|
|
213
|
+
|
|
214
|
+
gap_hr_check AS (
|
|
215
|
+
SELECT
|
|
216
|
+
fg.gap_start, fg.gap_end,
|
|
217
|
+
CASE
|
|
218
|
+
WHEN (SELECT has_rows FROM fw_has_rows) = 0 THEN 0
|
|
219
|
+
WHEN COUNT(h.date) >= 3
|
|
220
|
+
AND CAST(SUM(CASE WHEN h.hr <= ${num(globalP75)} THEN 1 ELSE 0 END) AS DOUBLE)
|
|
221
|
+
/ CAST(COUNT(h.date) AS DOUBLE) >= ${LONG_GAP_HR_FRAC}
|
|
222
|
+
THEN 1 ELSE 0
|
|
223
|
+
END AS qualifies
|
|
224
|
+
FROM fw_gaps fg
|
|
225
|
+
LEFT JOIN night_hr h
|
|
226
|
+
ON h.date > fg.gap_start
|
|
227
|
+
AND h.date < fg.gap_end
|
|
228
|
+
GROUP BY fg.gap_start, fg.gap_end
|
|
229
|
+
),
|
|
230
|
+
|
|
231
|
+
gap_recovered AS (
|
|
232
|
+
SELECT DISTINCT
|
|
233
|
+
h.date + (o.v * 60) AS date,
|
|
234
|
+
2 AS quality,
|
|
235
|
+
NULL AS fw_start,
|
|
236
|
+
1 AS unit_length,
|
|
237
|
+
CAST(${CONF_GAP} AS DOUBLE) AS confidence
|
|
238
|
+
FROM gap_hr_check gc
|
|
239
|
+
INNER JOIN night_hr h
|
|
240
|
+
ON h.date > gc.gap_start
|
|
241
|
+
AND h.date < gc.gap_end
|
|
242
|
+
CROSS JOIN offsets o
|
|
243
|
+
WHERE gc.qualifies = 1
|
|
244
|
+
AND h.date + (o.v * 60) > gc.gap_start
|
|
245
|
+
AND h.date + (o.v * 60) < gc.gap_end
|
|
246
|
+
),
|
|
247
|
+
|
|
248
|
+
combined_raw AS (
|
|
249
|
+
SELECT date, quality, fw_start, unit_length, confidence, 'firmware' AS source
|
|
250
|
+
FROM fw_trimmed
|
|
251
|
+
UNION ALL
|
|
252
|
+
SELECT date, quality, fw_start, unit_length, confidence, 'envelope' AS source
|
|
253
|
+
FROM envelope_expanded
|
|
254
|
+
UNION ALL
|
|
255
|
+
SELECT date, quality, fw_start, unit_length, confidence, 'gap' AS source
|
|
256
|
+
FROM gap_recovered
|
|
257
|
+
),
|
|
258
|
+
|
|
259
|
+
deduped AS (
|
|
260
|
+
SELECT date, quality, fw_start, unit_length, confidence, source
|
|
261
|
+
FROM (
|
|
262
|
+
SELECT *,
|
|
263
|
+
ROW_NUMBER() OVER (
|
|
264
|
+
PARTITION BY date
|
|
265
|
+
ORDER BY CASE source WHEN 'firmware' THEN 0 ELSE 1 END
|
|
266
|
+
) AS rn
|
|
267
|
+
FROM combined_raw
|
|
268
|
+
)
|
|
269
|
+
WHERE rn = 1
|
|
270
|
+
),
|
|
271
|
+
|
|
272
|
+
deduped_numbered AS (
|
|
273
|
+
SELECT *,
|
|
274
|
+
ROW_NUMBER() OVER (ORDER BY date) AS rn
|
|
275
|
+
FROM deduped
|
|
276
|
+
),
|
|
277
|
+
|
|
278
|
+
with_gaps AS (
|
|
279
|
+
SELECT
|
|
280
|
+
cur.date, cur.quality, cur.fw_start, cur.unit_length,
|
|
281
|
+
cur.confidence, cur.source, cur.rn,
|
|
282
|
+
cur.date - COALESCE(prv.date, cur.date) AS gap_to_prev,
|
|
283
|
+
COALESCE(prv.unit_length, 0) AS prev_unit_length,
|
|
284
|
+
COALESCE(prv.date, cur.date) AS prev_date
|
|
285
|
+
FROM deduped_numbered cur
|
|
286
|
+
LEFT JOIN deduped_numbered prv ON prv.rn = cur.rn - 1
|
|
287
|
+
),
|
|
288
|
+
|
|
289
|
+
stitched AS (
|
|
290
|
+
SELECT
|
|
291
|
+
date, quality, source, confidence,
|
|
292
|
+
SUM(CASE
|
|
293
|
+
WHEN gap_to_prev <= ${STITCH_GAP_SHORT} THEN 0
|
|
294
|
+
WHEN prev_unit_length = 120
|
|
295
|
+
AND gap_to_prev <= ${STITCH_GAP_MED} THEN 0
|
|
296
|
+
WHEN gap_to_prev <= ${STITCH_GAP_MED} THEN 0
|
|
297
|
+
WHEN gap_to_prev <= ${STITCH_GAP_LONG} THEN 0
|
|
298
|
+
ELSE 1
|
|
299
|
+
END) OVER (ORDER BY rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
|
|
300
|
+
FROM with_gaps
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
SELECT
|
|
304
|
+
strftime(make_timestamp(date * 1000000), '%Y.%m.%d %H:%M:%S') AS date,
|
|
305
|
+
quality,
|
|
306
|
+
strftime(make_timestamp(MIN(date) OVER (
|
|
307
|
+
PARTITION BY session_id
|
|
308
|
+
) * 1000000), '%Y.%m.%d %H:%M:%S') AS start,
|
|
309
|
+
1 AS "unitLength",
|
|
310
|
+
source,
|
|
311
|
+
confidence,
|
|
312
|
+
session_id
|
|
313
|
+
FROM stitched
|
|
314
|
+
ORDER BY date;`;
|
|
315
|
+
}
|
|
316
|
+
/** Phase 3 input — HR + step samples for the Step 5.6 END refinement. */
|
|
317
|
+
export function morningVitalsSql(windowStart, windowEnd, r = DEFAULT_RELATIONS) {
|
|
318
|
+
return `
|
|
319
|
+
SELECT 'hr' AS kind, date AS epoch, "singleHR" AS value
|
|
320
|
+
FROM ${rel(r.heartrate)}
|
|
321
|
+
WHERE date >= ${num(windowStart)} AND date < ${num(windowEnd + FETCH_AFTER)}
|
|
322
|
+
AND "singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
|
|
323
|
+
UNION ALL
|
|
324
|
+
SELECT 'step' AS kind, date AS epoch, step AS value
|
|
325
|
+
FROM ${rel(r.activity)}
|
|
326
|
+
WHERE date >= ${num(windowStart)} AND date < ${num(windowEnd + FETCH_AFTER)}
|
|
327
|
+
ORDER BY epoch;`;
|
|
328
|
+
}
|
|
329
|
+
//# sourceMappingURL=sql.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.js","sourceRoot":"","sources":["../../../src/sync/sleep-correction/sql.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging views: the warehouse, re-shaped into v3.1's raw firmware relations.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline's SQL reads sleep(date, quality, start, "unitLength"),
|
|
5
|
+
* heartrate(date, "singleHR") and activity(date, step) with epoch-second
|
|
6
|
+
* dates — the shapes v3.1 was validated on. Rather than rewrite the SQL for
|
|
7
|
+
* the warehouse columns, these TEMP views rename back at the boundary, so the
|
|
8
|
+
* ported queries stay as close to v3.1 as the parity suite proved them.
|
|
9
|
+
*
|
|
10
|
+
* - sleep comes from the LOCAL `sleep_raw` (not viewed: collected-only, and
|
|
11
|
+
* the nights a batch touches are local). Catalog-qualified, because after
|
|
12
|
+
* an R2 attach an unqualified name resolves to the remote catalog.
|
|
13
|
+
* - HR and steps come from `v_heart_rate` / `v_activity`, so a 30-day
|
|
14
|
+
* baseline lookback sees remote history too.
|
|
15
|
+
* - One user and one device: the correction is per ring.
|
|
16
|
+
*
|
|
17
|
+
* `sleep_raw.quality` must hold the firmware's own codes (1 deep / 2 light /
|
|
18
|
+
* 3 rem / 5 awake). That is true only once the producer sends raw rows
|
|
19
|
+
* (sleep-correction tasks §3); until then these views are exercised by tests.
|
|
20
|
+
*/
|
|
21
|
+
import type { SleepRelations } from './sql';
|
|
22
|
+
export interface StagingScope {
|
|
23
|
+
brand: string;
|
|
24
|
+
familyId: string;
|
|
25
|
+
userId: string;
|
|
26
|
+
deviceId: string;
|
|
27
|
+
/** Local DuckDB catalog holding `sleep_raw`. */
|
|
28
|
+
localCatalog?: string;
|
|
29
|
+
}
|
|
30
|
+
/** `CREATE OR REPLACE TEMP VIEW` statements for the three staging relations. */
|
|
31
|
+
export declare function stagingViewsSql(scope: StagingScope, r?: SleepRelations): string[];
|
|
32
|
+
//# sourceMappingURL=staging.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staging.d.ts","sourceRoot":"","sources":["../../../src/sync/sleep-correction/staging.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAA;AAI3C,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,gDAAgD;IAChD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AASD,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC,GAAE,cAAkC,GAAG,MAAM,EAAE,CAsBpG"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staging views: the warehouse, re-shaped into v3.1's raw firmware relations.
|
|
3
|
+
*
|
|
4
|
+
* The pipeline's SQL reads sleep(date, quality, start, "unitLength"),
|
|
5
|
+
* heartrate(date, "singleHR") and activity(date, step) with epoch-second
|
|
6
|
+
* dates — the shapes v3.1 was validated on. Rather than rewrite the SQL for
|
|
7
|
+
* the warehouse columns, these TEMP views rename back at the boundary, so the
|
|
8
|
+
* ported queries stay as close to v3.1 as the parity suite proved them.
|
|
9
|
+
*
|
|
10
|
+
* - sleep comes from the LOCAL `sleep_raw` (not viewed: collected-only, and
|
|
11
|
+
* the nights a batch touches are local). Catalog-qualified, because after
|
|
12
|
+
* an R2 attach an unqualified name resolves to the remote catalog.
|
|
13
|
+
* - HR and steps come from `v_heart_rate` / `v_activity`, so a 30-day
|
|
14
|
+
* baseline lookback sees remote history too.
|
|
15
|
+
* - One user and one device: the correction is per ring.
|
|
16
|
+
*
|
|
17
|
+
* `sleep_raw.quality` must hold the firmware's own codes (1 deep / 2 light /
|
|
18
|
+
* 3 rem / 5 awake). That is true only once the producer sends raw rows
|
|
19
|
+
* (sleep-correction tasks §3); until then these views are exercised by tests.
|
|
20
|
+
*/
|
|
21
|
+
import { DEFAULT_RELATIONS } from './sql';
|
|
22
|
+
const CATALOG_RE = /^[a-z_]\w*$/i;
|
|
23
|
+
const QUOTE_RE = /'/g;
|
|
24
|
+
function lit(s) {
|
|
25
|
+
return `'${s.replace(QUOTE_RE, '\'\'')}'`;
|
|
26
|
+
}
|
|
27
|
+
/** `CREATE OR REPLACE TEMP VIEW` statements for the three staging relations. */
|
|
28
|
+
export function stagingViewsSql(scope, r = DEFAULT_RELATIONS) {
|
|
29
|
+
const catalog = scope.localCatalog ?? 'memory';
|
|
30
|
+
if (!CATALOG_RE.test(catalog))
|
|
31
|
+
throw new Error(`sleep-correction: invalid catalog ${JSON.stringify(catalog)}`);
|
|
32
|
+
const tenant = `user_id = ${lit(scope.userId)} AND device_id = ${lit(scope.deviceId)}`;
|
|
33
|
+
return [
|
|
34
|
+
`CREATE OR REPLACE TEMP VIEW ${r.sleep} AS
|
|
35
|
+
SELECT CAST(epoch(ts) AS BIGINT) AS date,
|
|
36
|
+
quality,
|
|
37
|
+
CAST(epoch(ts_session_start) AS BIGINT) AS start,
|
|
38
|
+
unit_length AS "unitLength"
|
|
39
|
+
FROM ${catalog}.main.sleep_raw
|
|
40
|
+
WHERE brand = ${lit(scope.brand)} AND family_id = ${lit(scope.familyId)} AND ${tenant}`,
|
|
41
|
+
`CREATE OR REPLACE TEMP VIEW ${r.heartrate} AS
|
|
42
|
+
SELECT CAST(epoch(ts) AS BIGINT) AS date, bpm AS "singleHR"
|
|
43
|
+
FROM v_heart_rate
|
|
44
|
+
WHERE ${tenant}`,
|
|
45
|
+
`CREATE OR REPLACE TEMP VIEW ${r.activity} AS
|
|
46
|
+
SELECT CAST(epoch(ts) AS BIGINT) AS date, steps AS step
|
|
47
|
+
FROM v_activity
|
|
48
|
+
WHERE ${tenant}`,
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=staging.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"staging.js","sourceRoot":"","sources":["../../../src/sync/sleep-correction/staging.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAE,iBAAiB,EAAE,MAAM,OAAO,CAAA;AAWzC,MAAM,UAAU,GAAG,cAAc,CAAA;AACjC,MAAM,QAAQ,GAAG,IAAI,CAAA;AAErB,SAAS,GAAG,CAAC,CAAS;IACpB,OAAO,IAAI,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAA;AAC3C,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,eAAe,CAAC,KAAmB,EAAE,IAAoB,iBAAiB;IACxF,MAAM,OAAO,GAAG,KAAK,CAAC,YAAY,IAAI,QAAQ,CAAA;IAC9C,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC;QAC3B,MAAM,IAAI,KAAK,CAAC,qCAAqC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IACjF,MAAM,MAAM,GAAG,aAAa,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,oBAAoB,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAA;IACtF,OAAO;QACL,+BAA+B,CAAC,CAAC,KAAK;;;;;OAKnC,OAAO;gBACE,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,MAAM,EAAE;QACnF,+BAA+B,CAAC,CAAC,SAAS;;;QAGtC,MAAM,EAAE;QACZ,+BAA+B,CAAC,CAAC,QAAQ;;;QAGrC,MAAM,EAAE;KACb,CAAA;AACH,CAAC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived sleep — the correction step every batch cycle runs before
|
|
3
|
+
* `batch:complete` (sleep-correction §3).
|
|
4
|
+
*
|
|
5
|
+
* `sleep_raw` is written like any sensor table. Sessions and stages are not:
|
|
6
|
+
* they are derived from it by the validated correction pipeline, night by
|
|
7
|
+
* night, and written back into the SAME batch, so a `context: 'asleep'` rule
|
|
8
|
+
* woken by `batch:complete` sees corrected sleep, never raw minutes.
|
|
9
|
+
*
|
|
10
|
+
* Raw rows can arrive by three flush paths (scheduled cycle, manual flush,
|
|
11
|
+
* size/age triggers), so the deriver does not hang off any one flush: it
|
|
12
|
+
* notes every enqueued `sleep_raw` range, and the next batch cycle corrects
|
|
13
|
+
* all pending nights after its flushes land.
|
|
14
|
+
*
|
|
15
|
+
* Night replace (principle 66 as amended): a corrected night's END moves as
|
|
16
|
+
* later data arrives, and the principle-25 id moves with it, so a night is
|
|
17
|
+
* DELETEd locally before its new rows are written. Nights that come back with
|
|
18
|
+
* no primary block are left alone — replacing a night with nothing on a
|
|
19
|
+
* partial sync would erase sleep the next sync restores.
|
|
20
|
+
*
|
|
21
|
+
* Local only. Rows already pushed to a remote catalog are not rewritten; that
|
|
22
|
+
* needs a server-side answer before cloud sync is switched on.
|
|
23
|
+
*/
|
|
24
|
+
import type { HybridDuckDB } from '../core/engine';
|
|
25
|
+
import type { SensorBuffer } from './buffer';
|
|
26
|
+
import type { BatchFlusher, FlushReason } from './flusher';
|
|
27
|
+
import type { SchedulerLogger } from './scheduler';
|
|
28
|
+
export interface SleepDeriverDeps {
|
|
29
|
+
engine: HybridDuckDB;
|
|
30
|
+
buffer: SensorBuffer;
|
|
31
|
+
flusher: BatchFlusher;
|
|
32
|
+
/** The user's IANA zone (the factory's `resolveTimezone`). */
|
|
33
|
+
resolveTimezone: (userId: string) => Promise<string>;
|
|
34
|
+
/** Baseline lookback. v3.1 production: 30 days. */
|
|
35
|
+
lookbackDays?: number;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
logger?: SchedulerLogger;
|
|
38
|
+
localCatalog?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface SleepDeriver {
|
|
41
|
+
/** Record an enqueued `sleep_raw` batch so the next cycle corrects its nights. */
|
|
42
|
+
noteRaw: (batch: {
|
|
43
|
+
brand: string;
|
|
44
|
+
familyId: string;
|
|
45
|
+
userId: string;
|
|
46
|
+
deviceId: string;
|
|
47
|
+
rows: ReadonlyArray<Record<string, unknown>>;
|
|
48
|
+
}) => void;
|
|
49
|
+
/** (user, device) ranges still waiting for a cycle. */
|
|
50
|
+
pendingCount: () => number;
|
|
51
|
+
/** Correct every pending range and write its nights into `batchId`. Never throws. */
|
|
52
|
+
derive: (batchId: string, reason: FlushReason) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export declare function createSleepDeriver(deps: SleepDeriverDeps): SleepDeriver;
|
|
55
|
+
//# sourceMappingURL=sleep-derive.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sleep-derive.d.ts","sourceRoot":"","sources":["../../src/sync/sleep-derive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAiBlD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,YAAY,CAAA;IACpB,MAAM,EAAE,YAAY,CAAA;IACpB,OAAO,EAAE,YAAY,CAAA;IACrB,8DAA8D;IAC9D,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACpD,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,eAAe,CAAA;IACxB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,OAAO,EAAE,CAAC,KAAK,EAAE;QACf,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,MAAM,CAAA;QAChB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;KAC7C,KAAK,IAAI,CAAA;IACV,uDAAuD;IACvD,YAAY,EAAE,MAAM,MAAM,CAAA;IAC1B,qFAAqF;IACrF,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAChE;AA8CD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,YAAY,CAuHvE"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derived sleep — the correction step every batch cycle runs before
|
|
3
|
+
* `batch:complete` (sleep-correction §3).
|
|
4
|
+
*
|
|
5
|
+
* `sleep_raw` is written like any sensor table. Sessions and stages are not:
|
|
6
|
+
* they are derived from it by the validated correction pipeline, night by
|
|
7
|
+
* night, and written back into the SAME batch, so a `context: 'asleep'` rule
|
|
8
|
+
* woken by `batch:complete` sees corrected sleep, never raw minutes.
|
|
9
|
+
*
|
|
10
|
+
* Raw rows can arrive by three flush paths (scheduled cycle, manual flush,
|
|
11
|
+
* size/age triggers), so the deriver does not hang off any one flush: it
|
|
12
|
+
* notes every enqueued `sleep_raw` range, and the next batch cycle corrects
|
|
13
|
+
* all pending nights after its flushes land.
|
|
14
|
+
*
|
|
15
|
+
* Night replace (principle 66 as amended): a corrected night's END moves as
|
|
16
|
+
* later data arrives, and the principle-25 id moves with it, so a night is
|
|
17
|
+
* DELETEd locally before its new rows are written. Nights that come back with
|
|
18
|
+
* no primary block are left alone — replacing a night with nothing on a
|
|
19
|
+
* partial sync would erase sleep the next sync restores.
|
|
20
|
+
*
|
|
21
|
+
* Local only. Rows already pushed to a remote catalog are not rewritten; that
|
|
22
|
+
* needs a server-side answer before cloud sync is switched on.
|
|
23
|
+
*/
|
|
24
|
+
import { sessionsFromCorrected } from './mapper/sleep';
|
|
25
|
+
import { computeNightOf } from './mapper/time';
|
|
26
|
+
import { correctSleepNights, nightsForEpochs } from './sleep-correction/orchestrate';
|
|
27
|
+
import { stagingViewsSql } from './sleep-correction/staging';
|
|
28
|
+
const SIX_HOURS = 6 * 3600;
|
|
29
|
+
function epochOf(ts) {
|
|
30
|
+
if (ts instanceof Date)
|
|
31
|
+
return Math.floor(ts.getTime() / 1000);
|
|
32
|
+
if (typeof ts === 'number')
|
|
33
|
+
return Math.floor(ts > 1e12 ? ts / 1000 : ts);
|
|
34
|
+
if (typeof ts === 'string') {
|
|
35
|
+
const ms = Date.parse(ts.includes('T') ? ts : `${ts.replace(' ', 'T')}Z`);
|
|
36
|
+
return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** The `night_of` value a session starting inside this window is stored with. */
|
|
41
|
+
function storedNightOf(windowStart, timeZone) {
|
|
42
|
+
return computeNightOf(new Date((windowStart + 60) * 1000), timeZone).toISOString().slice(0, 10);
|
|
43
|
+
}
|
|
44
|
+
/** Every 6pm→6pm night between two instants, inclusive. */
|
|
45
|
+
function nightsBetween(minEpoch, maxEpoch, timeZone) {
|
|
46
|
+
const probes = [];
|
|
47
|
+
for (let e = minEpoch; e < maxEpoch; e += SIX_HOURS)
|
|
48
|
+
probes.push(e);
|
|
49
|
+
probes.push(maxEpoch);
|
|
50
|
+
return nightsForEpochs(probes, timeZone);
|
|
51
|
+
}
|
|
52
|
+
function DELETE_STAGES_SQL(catalog) {
|
|
53
|
+
return `
|
|
54
|
+
DELETE FROM ${catalog}.main.sleep_stage
|
|
55
|
+
WHERE session_id IN (
|
|
56
|
+
SELECT session_id FROM ${catalog}.main.sleep_session
|
|
57
|
+
WHERE brand = $brand AND family_id = $family_id AND user_id = $user_id
|
|
58
|
+
AND device_id = $device_id AND night_of = CAST($night AS DATE)
|
|
59
|
+
)`;
|
|
60
|
+
}
|
|
61
|
+
function DELETE_SESSIONS_SQL(catalog) {
|
|
62
|
+
return `
|
|
63
|
+
DELETE FROM ${catalog}.main.sleep_session
|
|
64
|
+
WHERE brand = $brand AND family_id = $family_id AND user_id = $user_id
|
|
65
|
+
AND device_id = $device_id AND night_of = CAST($night AS DATE)`;
|
|
66
|
+
}
|
|
67
|
+
export function createSleepDeriver(deps) {
|
|
68
|
+
const pending = new Map();
|
|
69
|
+
const catalog = deps.localCatalog ?? 'memory';
|
|
70
|
+
const now = deps.now ?? (() => Date.now());
|
|
71
|
+
const lookbackDays = deps.lookbackDays ?? 30;
|
|
72
|
+
async function replaceNight(ctx, night) {
|
|
73
|
+
const params = {
|
|
74
|
+
brand: ctx.brand,
|
|
75
|
+
family_id: ctx.familyId,
|
|
76
|
+
user_id: ctx.userId,
|
|
77
|
+
device_id: ctx.deviceId,
|
|
78
|
+
night,
|
|
79
|
+
};
|
|
80
|
+
await deps.engine.execute(DELETE_STAGES_SQL(catalog), params);
|
|
81
|
+
await deps.engine.execute(DELETE_SESSIONS_SQL(catalog), params);
|
|
82
|
+
}
|
|
83
|
+
async function deriveRange(p) {
|
|
84
|
+
const timeZone = await deps.resolveTimezone(p.userId);
|
|
85
|
+
const ctx = {
|
|
86
|
+
brand: p.brand,
|
|
87
|
+
familyId: p.familyId,
|
|
88
|
+
userId: p.userId,
|
|
89
|
+
deviceId: p.deviceId,
|
|
90
|
+
userTimezone: timeZone,
|
|
91
|
+
};
|
|
92
|
+
for (const sql of stagingViewsSql({ ...ctx, localCatalog: catalog }))
|
|
93
|
+
await deps.engine.execute(sql);
|
|
94
|
+
const result = await correctSleepNights(deps.engine, {
|
|
95
|
+
nights: nightsBetween(p.minEpoch, p.maxEpoch, timeZone),
|
|
96
|
+
timeZone,
|
|
97
|
+
cutoffEpoch: Math.floor(now() / 1000) - lookbackDays * 86400,
|
|
98
|
+
});
|
|
99
|
+
const sessions = [];
|
|
100
|
+
const stages = [];
|
|
101
|
+
for (const n of result.nights) {
|
|
102
|
+
if (n.status === 'failed')
|
|
103
|
+
deps.logger?.warn('sync.sleep-derive: night failed; previous rows kept', { night: n.night, err: n.error });
|
|
104
|
+
if (n.status !== 'ok' || !n.rows)
|
|
105
|
+
continue;
|
|
106
|
+
const out = sessionsFromCorrected(n.rows, ctx, { settleMin: n.settleMin });
|
|
107
|
+
if (out.sleep_session.length === 0)
|
|
108
|
+
continue;
|
|
109
|
+
// Two conventions meet here. v3.1 names a night by its MORNING date
|
|
110
|
+
// (window Jun 17 18:00 → Jun 18 18:00 is "2026-06-18"); `night_of` is
|
|
111
|
+
// the mapper's evening date, stored as the UTC date of `computeNightOf`.
|
|
112
|
+
// Deleting by `n.night` would wipe the FOLLOWING night — so the key is
|
|
113
|
+
// derived exactly as the writer derives it, from inside the window.
|
|
114
|
+
const nights = new Set([
|
|
115
|
+
storedNightOf(n.windowStart, timeZone),
|
|
116
|
+
...out.sleep_session.map(s => s.night_of.toISOString().slice(0, 10)),
|
|
117
|
+
]);
|
|
118
|
+
for (const night of nights)
|
|
119
|
+
await replaceNight(ctx, night);
|
|
120
|
+
sessions.push(...out.sleep_session);
|
|
121
|
+
stages.push(...out.sleep_stage);
|
|
122
|
+
}
|
|
123
|
+
return { sessions, stages };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
noteRaw(batch) {
|
|
127
|
+
let min = Number.POSITIVE_INFINITY;
|
|
128
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
129
|
+
for (const row of batch.rows) {
|
|
130
|
+
const e = epochOf(row.ts);
|
|
131
|
+
if (e == null)
|
|
132
|
+
continue;
|
|
133
|
+
if (e < min)
|
|
134
|
+
min = e;
|
|
135
|
+
if (e > max)
|
|
136
|
+
max = e;
|
|
137
|
+
}
|
|
138
|
+
if (!Number.isFinite(min))
|
|
139
|
+
return;
|
|
140
|
+
const key = `${batch.brand}|${batch.familyId}|${batch.userId}|${batch.deviceId}`;
|
|
141
|
+
const prev = pending.get(key);
|
|
142
|
+
pending.set(key, {
|
|
143
|
+
brand: batch.brand,
|
|
144
|
+
familyId: batch.familyId,
|
|
145
|
+
userId: batch.userId,
|
|
146
|
+
deviceId: batch.deviceId,
|
|
147
|
+
minEpoch: prev ? Math.min(prev.minEpoch, min) : min,
|
|
148
|
+
maxEpoch: prev ? Math.max(prev.maxEpoch, max) : max,
|
|
149
|
+
});
|
|
150
|
+
},
|
|
151
|
+
pendingCount: () => pending.size,
|
|
152
|
+
async derive(batchId, reason) {
|
|
153
|
+
if (pending.size === 0)
|
|
154
|
+
return;
|
|
155
|
+
const ranges = [...pending.values()];
|
|
156
|
+
pending.clear();
|
|
157
|
+
for (const p of ranges) {
|
|
158
|
+
try {
|
|
159
|
+
const { sessions, stages } = await deriveRange(p);
|
|
160
|
+
const tenant = { brand: p.brand, familyId: p.familyId, userId: p.userId, deviceId: p.deviceId };
|
|
161
|
+
if (sessions.length > 0)
|
|
162
|
+
await deps.buffer.push({ table: 'sleep_session', ...tenant, rows: sessions });
|
|
163
|
+
if (stages.length > 0)
|
|
164
|
+
await deps.buffer.push({ table: 'sleep_stage', ...tenant, rows: stages });
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
deps.logger?.warn('sync.sleep-derive: correction failed; raw sleep kept, nights unchanged', {
|
|
168
|
+
userId: p.userId,
|
|
169
|
+
deviceId: p.deviceId,
|
|
170
|
+
err: err instanceof Error ? err.message : String(err),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Land the derived rows inside this batch, so batch:complete follows them.
|
|
175
|
+
await deps.flusher.flush('sleep_session', reason, batchId);
|
|
176
|
+
await deps.flusher.flush('sleep_stage', reason, batchId);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=sleep-derive.js.map
|