@vibescore/tracker 0.0.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.
@@ -0,0 +1,88 @@
1
+ const fs = require('node:fs/promises');
2
+ const fssync = require('node:fs');
3
+ const readline = require('node:readline');
4
+
5
+ const { ensureDir, readJson, writeJson } = require('./fs');
6
+ const { ingestEvents } = require('./vibescore-api');
7
+
8
+ async function drainQueueToCloud({ baseUrl, deviceToken, queuePath, queueStatePath, maxBatches, batchSize, onProgress }) {
9
+ await ensureDir(require('node:path').dirname(queueStatePath));
10
+
11
+ const state = (await readJson(queueStatePath)) || { offset: 0 };
12
+ let offset = Number(state.offset || 0);
13
+ let inserted = 0;
14
+ let skipped = 0;
15
+ let attempted = 0;
16
+
17
+ const cb = typeof onProgress === 'function' ? onProgress : null;
18
+ const queueSize = await safeFileSize(queuePath);
19
+ const maxEvents = Math.max(1, Math.floor(Number(batchSize || 200)));
20
+
21
+ for (let batch = 0; batch < maxBatches; batch++) {
22
+ const res = await readBatch(queuePath, offset, maxEvents);
23
+ if (res.events.length === 0) break;
24
+
25
+ attempted += res.events.length;
26
+ const ingest = await ingestEvents({ baseUrl, deviceToken, events: res.events });
27
+ inserted += ingest.inserted || 0;
28
+ skipped += ingest.skipped || 0;
29
+
30
+ offset = res.nextOffset;
31
+ state.offset = offset;
32
+ state.updatedAt = new Date().toISOString();
33
+ await writeJson(queueStatePath, state);
34
+
35
+ if (cb) {
36
+ cb({
37
+ batch: batch + 1,
38
+ maxBatches,
39
+ offset,
40
+ queueSize,
41
+ inserted,
42
+ skipped
43
+ });
44
+ }
45
+ }
46
+
47
+ return { inserted, skipped, attempted };
48
+ }
49
+
50
+ async function readBatch(queuePath, startOffset, maxEvents) {
51
+ const st = await fs.stat(queuePath).catch(() => null);
52
+ if (!st || !st.isFile()) return { events: [], nextOffset: startOffset };
53
+ if (startOffset >= st.size) return { events: [], nextOffset: startOffset };
54
+
55
+ const stream = fssync.createReadStream(queuePath, { encoding: 'utf8', start: startOffset });
56
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
57
+
58
+ const events = [];
59
+ let offset = startOffset;
60
+ for await (const line of rl) {
61
+ const bytes = Buffer.byteLength(line, 'utf8') + 1;
62
+ offset += bytes;
63
+ if (!line.trim()) continue;
64
+ let ev;
65
+ try {
66
+ ev = JSON.parse(line);
67
+ } catch (_e) {
68
+ continue;
69
+ }
70
+ events.push(ev);
71
+ if (events.length >= maxEvents) break;
72
+ }
73
+
74
+ rl.close();
75
+ stream.close?.();
76
+ return { events, nextOffset: offset };
77
+ }
78
+
79
+ async function safeFileSize(p) {
80
+ try {
81
+ const st = await fs.stat(p);
82
+ return st && st.isFile() ? st.size : 0;
83
+ } catch (_e) {
84
+ return 0;
85
+ }
86
+ }
87
+
88
+ module.exports = { drainQueueToCloud };
@@ -0,0 +1,163 @@
1
+ 'use strict';
2
+
3
+ const { createInsforgeClient } = require('./insforge-client');
4
+
5
+ async function signInWithPassword({ baseUrl, email, password }) {
6
+ const client = createInsforgeClient({ baseUrl });
7
+ const { data, error } = await client.auth.signInWithPassword({ email, password });
8
+ if (error) throw normalizeSdkError(error, 'Sign-in failed');
9
+
10
+ const accessToken = data?.accessToken;
11
+ if (typeof accessToken !== 'string' || accessToken.length === 0) {
12
+ throw new Error('Sign-in failed: missing accessToken');
13
+ }
14
+
15
+ return accessToken;
16
+ }
17
+
18
+ async function issueDeviceToken({ baseUrl, accessToken, deviceName, platform = 'macos' }) {
19
+ const data = await invokeFunction({
20
+ baseUrl,
21
+ accessToken,
22
+ slug: 'vibescore-device-token-issue',
23
+ method: 'POST',
24
+ body: { device_name: deviceName, platform },
25
+ errorPrefix: 'Device token issue failed'
26
+ });
27
+
28
+ const token = data?.token;
29
+ const deviceId = data?.device_id;
30
+ if (typeof token !== 'string' || token.length === 0) throw new Error('Device token issue failed: missing token');
31
+ if (typeof deviceId !== 'string' || deviceId.length === 0) {
32
+ throw new Error('Device token issue failed: missing device_id');
33
+ }
34
+
35
+ return { token, deviceId };
36
+ }
37
+
38
+ async function ingestEvents({ baseUrl, deviceToken, events }) {
39
+ const data = await invokeFunctionWithRetry({
40
+ baseUrl,
41
+ accessToken: deviceToken,
42
+ slug: 'vibescore-ingest',
43
+ method: 'POST',
44
+ body: { events },
45
+ errorPrefix: 'Ingest failed',
46
+ retry: { maxRetries: 3, baseDelayMs: 500, maxDelayMs: 5000 }
47
+ });
48
+
49
+ return {
50
+ inserted: Number(data?.inserted || 0),
51
+ skipped: Number(data?.skipped || 0)
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ signInWithPassword,
57
+ issueDeviceToken,
58
+ ingestEvents
59
+ };
60
+
61
+ async function invokeFunction({ baseUrl, accessToken, slug, method, body, errorPrefix }) {
62
+ const client = createInsforgeClient({ baseUrl, accessToken });
63
+ const { data, error } = await client.functions.invoke(slug, { method, body });
64
+ if (error) throw normalizeSdkError(error, errorPrefix);
65
+ return data;
66
+ }
67
+
68
+ async function invokeFunctionWithRetry({ baseUrl, accessToken, slug, method, body, errorPrefix, retry }) {
69
+ const retryOptions = normalizeRetryOptions(retry);
70
+ let attempt = 0;
71
+
72
+ while (true) {
73
+ try {
74
+ return await invokeFunction({ baseUrl, accessToken, slug, method, body, errorPrefix });
75
+ } catch (e) {
76
+ if (!shouldRetry({ err: e, attempt, retryOptions })) throw e;
77
+ const delayMs = computeRetryDelayMs({ retryOptions, attempt, err: e });
78
+ await sleep(delayMs);
79
+ attempt += 1;
80
+ }
81
+ }
82
+ }
83
+
84
+ function normalizeSdkError(error, errorPrefix) {
85
+ const raw = error?.message || String(error || 'Unknown error');
86
+ const msg = normalizeBackendErrorMessage(raw);
87
+ const err = new Error(errorPrefix ? `${errorPrefix}: ${msg}` : msg);
88
+ const status = error?.statusCode ?? error?.status;
89
+ if (typeof status === 'number') err.status = status;
90
+ err.retryable = isRetryableStatus(status) || isRetryableMessage(raw);
91
+ if (msg !== raw) err.originalMessage = raw;
92
+ if (error?.nextActions) err.nextActions = error.nextActions;
93
+ return err;
94
+ }
95
+
96
+ function normalizeBackendErrorMessage(message) {
97
+ if (!isBackendRuntimeDownMessage(message)) return String(message || 'Unknown error');
98
+ return 'Backend runtime unavailable (InsForge). Please retry later.';
99
+ }
100
+
101
+ function isBackendRuntimeDownMessage(message) {
102
+ const s = String(message || '').toLowerCase();
103
+ if (!s) return false;
104
+ if (s.includes('deno:') || s.includes('deno')) return true;
105
+ if (s.includes('econnreset') || s.includes('econnrefused')) return true;
106
+ if (s.includes('etimedout')) return true;
107
+ if (s.includes('timeout') && s.includes('request')) return true;
108
+ if (s.includes('upstream') && (s.includes('deno') || s.includes('connect'))) return true;
109
+ return false;
110
+ }
111
+
112
+ function isRetryableStatus(status) {
113
+ return status === 429 || status === 502 || status === 503 || status === 504;
114
+ }
115
+
116
+ function isRetryableMessage(message) {
117
+ const s = String(message || '').toLowerCase();
118
+ if (!s) return false;
119
+ if (isBackendRuntimeDownMessage(s)) return true;
120
+ if (s.includes('econnreset') || s.includes('econnrefused')) return true;
121
+ if (s.includes('etimedout') || s.includes('timeout')) return true;
122
+ if (s.includes('networkerror') || s.includes('failed to fetch')) return true;
123
+ if (s.includes('socket hang up') || s.includes('connection reset')) return true;
124
+ return false;
125
+ }
126
+
127
+ function normalizeRetryOptions(retry) {
128
+ if (!retry) {
129
+ return { maxRetries: 0, baseDelayMs: 0, maxDelayMs: 0, jitterRatio: 0.0 };
130
+ }
131
+ const maxRetries = clampInt(retry.maxRetries, 0, 10);
132
+ const baseDelayMs = clampInt(retry.baseDelayMs ?? 300, 50, 60_000);
133
+ const maxDelayMs = clampInt(retry.maxDelayMs ?? baseDelayMs * 4, baseDelayMs, 120_000);
134
+ const jitterRatio = typeof retry.jitterRatio === 'number' ? Math.max(0, Math.min(0.5, retry.jitterRatio)) : 0.2;
135
+ return { maxRetries, baseDelayMs, maxDelayMs, jitterRatio };
136
+ }
137
+
138
+ function shouldRetry({ err, attempt, retryOptions }) {
139
+ if (!retryOptions || retryOptions.maxRetries <= 0) return false;
140
+ if (attempt >= retryOptions.maxRetries) return false;
141
+ return Boolean(err && err.retryable);
142
+ }
143
+
144
+ function computeRetryDelayMs({ retryOptions, attempt, err }) {
145
+ if (!retryOptions || retryOptions.maxRetries <= 0) return 0;
146
+ const exp = retryOptions.baseDelayMs * Math.pow(2, attempt);
147
+ const capped = Math.min(retryOptions.maxDelayMs, exp);
148
+ const jitter = capped * retryOptions.jitterRatio * Math.random();
149
+ const backoff = Math.round(capped + jitter);
150
+ const retryAfter = typeof err?.retryAfterMs === 'number' ? err.retryAfterMs : 0;
151
+ return Math.max(backoff, retryAfter || 0);
152
+ }
153
+
154
+ function clampInt(value, min, max) {
155
+ const n = Number(value);
156
+ if (!Number.isFinite(n)) return min;
157
+ return Math.min(max, Math.max(min, Math.floor(n)));
158
+ }
159
+
160
+ function sleep(ms) {
161
+ if (!ms || ms <= 0) return Promise.resolve();
162
+ return new Promise((resolve) => setTimeout(resolve, ms));
163
+ }