@vimoxshah/tokenflow 1.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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified usage schema.
|
|
3
|
+
*
|
|
4
|
+
* Every provider adapter emits objects of this shape. Nothing downstream —
|
|
5
|
+
* analytics, UI, export — knows anything about a specific vendor.
|
|
6
|
+
*
|
|
7
|
+
* ## Missing-value contract (this is load-bearing)
|
|
8
|
+
*
|
|
9
|
+
* null the source does not report this field -> "not available"
|
|
10
|
+
* undefined normalised to null on construction
|
|
11
|
+
* 0 the source reported zero -> a real, measured zero
|
|
12
|
+
*
|
|
13
|
+
* Analytics NEVER coerce null to 0. Sums skip nulls and carry a parallel
|
|
14
|
+
* `na` (not-available) counter so the UI can say "cache tokens unreported by
|
|
15
|
+
* 22% of records in this slice" instead of silently drawing a zero.
|
|
16
|
+
*
|
|
17
|
+
* ## Token accounting (this is the part everyone gets wrong)
|
|
18
|
+
*
|
|
19
|
+
* BILLABLE_TOKEN_FIELDS are mutually exclusive and sum to total_tokens:
|
|
20
|
+
* input_tokens fresh, uncached prompt tokens
|
|
21
|
+
* cache_read_tokens prompt tokens served from a prompt cache (cheap)
|
|
22
|
+
* cache_write_tokens prompt tokens written into a prompt cache (premium)
|
|
23
|
+
* output_tokens generated tokens
|
|
24
|
+
*
|
|
25
|
+
* BREAKDOWN_TOKEN_FIELDS are SUBSETS of the above and must never be added
|
|
26
|
+
* into a total:
|
|
27
|
+
* cache_refresh_tokens subset of cache_write_tokens (long-TTL / refreshed
|
|
28
|
+
* cache writes, e.g. Anthropic's ephemeral_1h)
|
|
29
|
+
* reasoning_tokens subset of output_tokens (thinking / reasoning)
|
|
30
|
+
*
|
|
31
|
+
* Providers differ on whether `input_tokens` is inclusive of cache reads.
|
|
32
|
+
* Anthropic reports them separately; OpenAI/Codex reports a single
|
|
33
|
+
* `input_tokens` that INCLUDES `cached_input_tokens`. Each adapter is
|
|
34
|
+
* responsible for converting to the exclusive convention above, and the
|
|
35
|
+
* adapter tests assert it.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const BILLABLE_TOKEN_FIELDS = /** @type {const} */ ([
|
|
39
|
+
'input_tokens',
|
|
40
|
+
'cache_read_tokens',
|
|
41
|
+
'cache_write_tokens',
|
|
42
|
+
'output_tokens',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const BREAKDOWN_TOKEN_FIELDS = /** @type {const} */ ([
|
|
46
|
+
'cache_refresh_tokens',
|
|
47
|
+
'reasoning_tokens',
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
export const TOKEN_FIELDS = [...BILLABLE_TOKEN_FIELDS, ...BREAKDOWN_TOKEN_FIELDS];
|
|
51
|
+
|
|
52
|
+
/** How much a record can be trusted to contribute to token totals. */
|
|
53
|
+
export const MEASUREMENT = /** @type {const} */ ({
|
|
54
|
+
/** Authoritative per-request usage reported by the model API. Counted in totals. */
|
|
55
|
+
PRIMARY: 'primary',
|
|
56
|
+
/** A second view of traffic already counted elsewhere (gateway/proxy logs).
|
|
57
|
+
* Excluded from totals by default to avoid double counting; used for
|
|
58
|
+
* independent cost cross-checks. */
|
|
59
|
+
OVERLAY: 'overlay',
|
|
60
|
+
/** AI activity with no token accounting at all (IDE edits, sessions with no
|
|
61
|
+
* usage block, git commits). Contributes to activity metrics only. */
|
|
62
|
+
ACTIVITY: 'activity',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Interface / surface the request came from. Never inferred from the model. */
|
|
66
|
+
export const INTERFACE = /** @type {const} */ ({
|
|
67
|
+
CLI: 'CLI',
|
|
68
|
+
IDE: 'IDE',
|
|
69
|
+
DESKTOP: 'Desktop App',
|
|
70
|
+
WEB: 'Web',
|
|
71
|
+
API: 'API',
|
|
72
|
+
SDK: 'SDK',
|
|
73
|
+
EXTENSION: 'Extension',
|
|
74
|
+
UNKNOWN: 'Unknown',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const INTERFACE_ORDER = [
|
|
78
|
+
INTERFACE.CLI,
|
|
79
|
+
INTERFACE.SDK,
|
|
80
|
+
INTERFACE.IDE,
|
|
81
|
+
INTERFACE.DESKTOP,
|
|
82
|
+
INTERFACE.WEB,
|
|
83
|
+
INTERFACE.API,
|
|
84
|
+
INTERFACE.EXTENSION,
|
|
85
|
+
INTERFACE.UNKNOWN,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/** Coarse grouping used by the "CLI vs GUI" comparison. */
|
|
89
|
+
export function interfaceClass(iface) {
|
|
90
|
+
if (iface === INTERFACE.CLI || iface === INTERFACE.SDK) return 'CLI / headless';
|
|
91
|
+
if (iface === INTERFACE.IDE || iface === INTERFACE.EXTENSION) return 'IDE';
|
|
92
|
+
if (iface === INTERFACE.DESKTOP || iface === INTERFACE.WEB) return 'Desktop / Web';
|
|
93
|
+
if (iface === INTERFACE.API) return 'API';
|
|
94
|
+
return 'Unknown';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const UNKNOWN = 'unknown';
|
|
98
|
+
export const NOT_AVAILABLE = null;
|
|
99
|
+
|
|
100
|
+
const NUM = (v) => (v === undefined || v === null || Number.isNaN(v) ? null : Number(v));
|
|
101
|
+
const STR = (v) => (v === undefined || v === null || v === '' ? null : String(v));
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @typedef {Object} UsageRecord
|
|
105
|
+
* @property {string} id stable dedup key
|
|
106
|
+
* @property {string} timestamp ISO-8601 UTC
|
|
107
|
+
* @property {string} date YYYY-MM-DD in the capture timezone
|
|
108
|
+
* @property {number} hour 0-23 in the capture timezone
|
|
109
|
+
* @property {number} dow 0=Mon .. 6=Sun in the capture timezone
|
|
110
|
+
* @property {number} tz_offset capture tz offset in minutes
|
|
111
|
+
* @property {string} provider canonical vendor slug
|
|
112
|
+
* @property {string} provider_label
|
|
113
|
+
* @property {string|null} gateway routing layer (proxy/router), if any
|
|
114
|
+
* @property {string} model raw model identifier from the source
|
|
115
|
+
* @property {string} model_family
|
|
116
|
+
* @property {string} client tool that made the call
|
|
117
|
+
* @property {string} application human label for the client
|
|
118
|
+
* @property {string} interface one of INTERFACE
|
|
119
|
+
* @property {number|null} input_tokens
|
|
120
|
+
* @property {number|null} output_tokens
|
|
121
|
+
* @property {number|null} cache_read_tokens
|
|
122
|
+
* @property {number|null} cache_write_tokens
|
|
123
|
+
* @property {number|null} cache_refresh_tokens
|
|
124
|
+
* @property {number|null} reasoning_tokens
|
|
125
|
+
* @property {number|null} total_tokens
|
|
126
|
+
* @property {boolean} total_is_partial true when some billable field was N/A
|
|
127
|
+
* @property {string|null} session_id
|
|
128
|
+
* @property {string|null} conversation_id
|
|
129
|
+
* @property {string|null} request_id
|
|
130
|
+
* @property {string|null} project
|
|
131
|
+
* @property {string|null} repository
|
|
132
|
+
* @property {string|null} git_branch
|
|
133
|
+
* @property {string|null} category
|
|
134
|
+
* @property {string|null} service_tier billing tier (standard / priority / batch / ...)
|
|
135
|
+
* @property {number|null} estimated_cost
|
|
136
|
+
* @property {'measured'|'estimated'|null} cost_basis
|
|
137
|
+
* @property {string} source adapter id
|
|
138
|
+
* @property {string} measurement one of MEASUREMENT
|
|
139
|
+
* @property {string|null} user
|
|
140
|
+
* @property {string|null} machine
|
|
141
|
+
* @property {number|null} duration_ms
|
|
142
|
+
* @property {Object} metadata
|
|
143
|
+
* @property {string} [_fileId] internal: which source file this came from
|
|
144
|
+
* @property {number} [_gen] internal: that file's generation, for supersede
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a fully-populated record from a partial one. Missing fields become
|
|
149
|
+
* `null` (not available) rather than 0, and totals are derived, never trusted
|
|
150
|
+
* from the source unless the source is the only thing that has them.
|
|
151
|
+
* @param {Partial<UsageRecord>} p
|
|
152
|
+
* @returns {UsageRecord}
|
|
153
|
+
*/
|
|
154
|
+
export function createRecord(p) {
|
|
155
|
+
const r = /** @type {UsageRecord} */ ({
|
|
156
|
+
id: p.id ?? '',
|
|
157
|
+
timestamp: p.timestamp ?? null,
|
|
158
|
+
date: p.date ?? null,
|
|
159
|
+
hour: p.hour ?? null,
|
|
160
|
+
dow: p.dow ?? null,
|
|
161
|
+
tz_offset: p.tz_offset ?? null,
|
|
162
|
+
|
|
163
|
+
provider: p.provider ?? UNKNOWN,
|
|
164
|
+
provider_label: p.provider_label ?? 'Unknown',
|
|
165
|
+
gateway: STR(p.gateway),
|
|
166
|
+
model: p.model ?? UNKNOWN,
|
|
167
|
+
model_family: p.model_family ?? 'Unknown',
|
|
168
|
+
client: p.client ?? UNKNOWN,
|
|
169
|
+
application: p.application ?? 'Unknown',
|
|
170
|
+
interface: p.interface ?? INTERFACE.UNKNOWN,
|
|
171
|
+
|
|
172
|
+
input_tokens: NUM(p.input_tokens),
|
|
173
|
+
output_tokens: NUM(p.output_tokens),
|
|
174
|
+
cache_read_tokens: NUM(p.cache_read_tokens),
|
|
175
|
+
cache_write_tokens: NUM(p.cache_write_tokens),
|
|
176
|
+
cache_refresh_tokens: NUM(p.cache_refresh_tokens),
|
|
177
|
+
reasoning_tokens: NUM(p.reasoning_tokens),
|
|
178
|
+
total_tokens: null,
|
|
179
|
+
total_is_partial: false,
|
|
180
|
+
|
|
181
|
+
session_id: STR(p.session_id),
|
|
182
|
+
conversation_id: STR(p.conversation_id),
|
|
183
|
+
request_id: STR(p.request_id),
|
|
184
|
+
project: STR(p.project),
|
|
185
|
+
repository: STR(p.repository),
|
|
186
|
+
git_branch: STR(p.git_branch),
|
|
187
|
+
category: STR(p.category),
|
|
188
|
+
// A billing tier, not incidental metadata: OpenAI's Fast mode is 4x the
|
|
189
|
+
// standard rate, so this changes the cost of an otherwise identical request.
|
|
190
|
+
service_tier: STR(p.service_tier),
|
|
191
|
+
|
|
192
|
+
estimated_cost: NUM(p.estimated_cost),
|
|
193
|
+
cost_basis: p.cost_basis ?? null,
|
|
194
|
+
|
|
195
|
+
source: p.source ?? UNKNOWN,
|
|
196
|
+
measurement: p.measurement ?? MEASUREMENT.PRIMARY,
|
|
197
|
+
user: STR(p.user),
|
|
198
|
+
machine: STR(p.machine),
|
|
199
|
+
duration_ms: NUM(p.duration_ms),
|
|
200
|
+
metadata: p.metadata ?? {},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const t = computeTotal(r);
|
|
204
|
+
r.total_tokens = t.total;
|
|
205
|
+
r.total_is_partial = t.partial;
|
|
206
|
+
return r;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Total = sum of the four mutually-exclusive billable buckets.
|
|
211
|
+
* Returns `{total: null}` only when EVERY billable field is unavailable —
|
|
212
|
+
* a record that reports some fields still gets a total, flagged partial.
|
|
213
|
+
* @param {Partial<UsageRecord>} r
|
|
214
|
+
*/
|
|
215
|
+
export function computeTotal(r) {
|
|
216
|
+
let total = 0;
|
|
217
|
+
let seen = 0;
|
|
218
|
+
let partial = false;
|
|
219
|
+
for (const f of BILLABLE_TOKEN_FIELDS) {
|
|
220
|
+
const v = r[f];
|
|
221
|
+
if (v === null || v === undefined) partial = true;
|
|
222
|
+
else {
|
|
223
|
+
total += v;
|
|
224
|
+
seen++;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (seen === 0) return { total: null, partial: true };
|
|
228
|
+
return { total, partial };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Timezone-aware date parts, computed once at ingest so the UI never guesses. */
|
|
232
|
+
export function dateParts(timestamp, tz) {
|
|
233
|
+
const d = new Date(timestamp);
|
|
234
|
+
if (Number.isNaN(d.getTime())) return null;
|
|
235
|
+
// Use Intl to resolve the wall-clock in the capture timezone. `tz`
|
|
236
|
+
// undefined means "this machine's zone", which is what a local-first
|
|
237
|
+
// ingest wants.
|
|
238
|
+
const fmt = partsFormatter(tz);
|
|
239
|
+
const parts = {};
|
|
240
|
+
for (const p of fmt.formatToParts(d)) parts[p.type] = p.value;
|
|
241
|
+
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
|
242
|
+
const hour = Number(parts.hour) % 24;
|
|
243
|
+
// 0=Mon..6=Sun
|
|
244
|
+
const jsDow = new Date(`${date}T00:00:00Z`).getUTCDay();
|
|
245
|
+
const dow = (jsDow + 6) % 7;
|
|
246
|
+
const offset = tzOffsetMinutes(d, tz);
|
|
247
|
+
return { date, hour, dow, tz_offset: offset, iso: d.toISOString() };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const _fmtCache = new Map();
|
|
251
|
+
function partsFormatter(tz) {
|
|
252
|
+
const key = tz || 'local';
|
|
253
|
+
let f = _fmtCache.get(key);
|
|
254
|
+
if (!f) {
|
|
255
|
+
f = new Intl.DateTimeFormat('en-CA', {
|
|
256
|
+
timeZone: tz || undefined,
|
|
257
|
+
year: 'numeric',
|
|
258
|
+
month: '2-digit',
|
|
259
|
+
day: '2-digit',
|
|
260
|
+
hour: '2-digit',
|
|
261
|
+
hour12: false,
|
|
262
|
+
});
|
|
263
|
+
_fmtCache.set(key, f);
|
|
264
|
+
}
|
|
265
|
+
return f;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Offset of `tz` east of UTC in minutes at instant `d` (fallback: host zone). */
|
|
269
|
+
export function tzOffsetMinutes(d, tz) {
|
|
270
|
+
if (!tz) return -d.getTimezoneOffset();
|
|
271
|
+
try {
|
|
272
|
+
const dtf = new Intl.DateTimeFormat('en-US', {
|
|
273
|
+
timeZone: tz,
|
|
274
|
+
hour12: false,
|
|
275
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
276
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
277
|
+
});
|
|
278
|
+
const p = {};
|
|
279
|
+
for (const x of dtf.formatToParts(d)) p[x.type] = x.value;
|
|
280
|
+
const asUTC = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute, +p.second);
|
|
281
|
+
return Math.round((asUTC - d.getTime()) / 60000);
|
|
282
|
+
} catch {
|
|
283
|
+
return -d.getTimezoneOffset();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Fast, stable, non-cryptographic 64-bit id. Used for dedup across refreshes.
|
|
289
|
+
* Two independent 32-bit FNV-1a passes; ~1e-10 collision risk at 10^6 keys.
|
|
290
|
+
*/
|
|
291
|
+
export function hashId(...parts) {
|
|
292
|
+
const s = parts.join('\x00');
|
|
293
|
+
let h1 = 0x811c9dc5;
|
|
294
|
+
let h2 = 0x01000193;
|
|
295
|
+
for (let i = 0; i < s.length; i++) {
|
|
296
|
+
const c = s.charCodeAt(i);
|
|
297
|
+
h1 ^= c;
|
|
298
|
+
h1 = Math.imul(h1, 0x01000193) >>> 0;
|
|
299
|
+
h2 = Math.imul(h2 ^ c, 0x85ebca6b) >>> 0;
|
|
300
|
+
h2 = (h2 ^ (h2 >>> 13)) >>> 0;
|
|
301
|
+
}
|
|
302
|
+
return h1.toString(36).padStart(7, '0') + h2.toString(36).padStart(7, '0');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Dimensions the cube and the filter bar share. Order matters for the UI. */
|
|
306
|
+
export const DIMENSIONS = [
|
|
307
|
+
{ key: 'provider', label: 'Provider', cube: 'p' },
|
|
308
|
+
{ key: 'model', label: 'Model', cube: 'm' },
|
|
309
|
+
{ key: 'model_family', label: 'Model family', cube: 'mf' },
|
|
310
|
+
{ key: 'client', label: 'Client', cube: 'c' },
|
|
311
|
+
{ key: 'interface', label: 'Interface', cube: 'i' },
|
|
312
|
+
{ key: 'gateway', label: 'Gateway', cube: 'g' },
|
|
313
|
+
{ key: 'project', label: 'Project', cube: 'pj' },
|
|
314
|
+
{ key: 'repository', label: 'Repository', cube: 'rp' },
|
|
315
|
+
{ key: 'service_tier', label: 'Service tier', cube: 'st' },
|
|
316
|
+
];
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only SQLite access via Node's built-in `node:sqlite` (Node >= 22.5).
|
|
3
|
+
* Zero dependencies, and never writes to the user's database.
|
|
4
|
+
*
|
|
5
|
+
* Live databases are often mid-transaction with a hot -wal file. Opening the
|
|
6
|
+
* original can fail or read a stale page, so we snapshot the db plus its
|
|
7
|
+
* sidecars into the cache dir and read the copy. Slower by a few milliseconds;
|
|
8
|
+
* correct, and it cannot corrupt someone's editor state.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
|
|
15
|
+
const require_ = createRequire(import.meta.url);
|
|
16
|
+
|
|
17
|
+
let _sqlite = null;
|
|
18
|
+
function sqlite() {
|
|
19
|
+
if (_sqlite) return _sqlite;
|
|
20
|
+
// Silence just the one experimental warning, without muting the channel.
|
|
21
|
+
const orig = process.emitWarning;
|
|
22
|
+
process.emitWarning = (w, ...rest) => {
|
|
23
|
+
const s = typeof w === 'string' ? w : w?.message || '';
|
|
24
|
+
if (/SQLite is an experimental feature/i.test(s)) return;
|
|
25
|
+
return orig.call(process, w, ...rest);
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
_sqlite = require_('node:sqlite');
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const e = new Error('node:sqlite is unavailable — Node 22.5 or newer is required for SQLite sources');
|
|
31
|
+
e.cause = err;
|
|
32
|
+
throw e;
|
|
33
|
+
} finally {
|
|
34
|
+
process.emitWarning = orig;
|
|
35
|
+
}
|
|
36
|
+
return _sqlite;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sqliteAvailable() {
|
|
40
|
+
try {
|
|
41
|
+
return !!sqlite().DatabaseSync;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function openReadOnly(file, { snapshot = true } = {}) {
|
|
48
|
+
const { DatabaseSync } = sqlite();
|
|
49
|
+
let target = file;
|
|
50
|
+
let tmpDir = null;
|
|
51
|
+
if (snapshot) {
|
|
52
|
+
// Deliberately the OS temp dir, not the config dir: the config dir may sit
|
|
53
|
+
// on a network share or sandboxed mount that refuses unlink, and a snapshot
|
|
54
|
+
// we cannot delete is worse than a slightly less tidy temp path.
|
|
55
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tokenflow-sqlite-'));
|
|
56
|
+
target = path.join(tmpDir, path.basename(file));
|
|
57
|
+
fs.copyFileSync(file, target);
|
|
58
|
+
for (const ext of ['-wal', '-shm']) {
|
|
59
|
+
if (fs.existsSync(file + ext)) fs.copyFileSync(file + ext, target + ext);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const db = new DatabaseSync(target);
|
|
63
|
+
const origClose = db.close.bind(db);
|
|
64
|
+
db.close = () => {
|
|
65
|
+
try {
|
|
66
|
+
origClose();
|
|
67
|
+
} finally {
|
|
68
|
+
// Never let cleanup failure sink an otherwise successful ingest.
|
|
69
|
+
if (tmpDir) {
|
|
70
|
+
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* temp dir left behind */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
return db;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** List the tables in a database — used by the generic SQLite importer. */
|
|
78
|
+
export function tables(file) {
|
|
79
|
+
const db = openReadOnly(file);
|
|
80
|
+
try {
|
|
81
|
+
return db.prepare("SELECT name, sql FROM sqlite_master WHERE type='table' ORDER BY name").all();
|
|
82
|
+
} finally {
|
|
83
|
+
db.close();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Column names of a table, for interactive field mapping. */
|
|
88
|
+
export function columns(file, table) {
|
|
89
|
+
const db = openReadOnly(file);
|
|
90
|
+
try {
|
|
91
|
+
const row = db.prepare(`SELECT * FROM "${String(table).replace(/"/g, '""')}" LIMIT 1`).get();
|
|
92
|
+
return row ? Object.keys(row) : [];
|
|
93
|
+
} finally {
|
|
94
|
+
db.close();
|
|
95
|
+
}
|
|
96
|
+
}
|