tokentracker-cli 1.1.3 → 1.1.4
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/README.de.md +5 -4
- package/README.ja.md +5 -4
- package/README.ko.md +5 -4
- package/README.md +5 -4
- package/README.zh-CN.md +5 -4
- package/dashboard/dist/assets/{AchievementBadge-dt_UDU_h.js → AchievementBadge-BGA-HT_B.js} +1 -1
- package/dashboard/dist/assets/{AchievementsPage-B5DJ3S9l.js → AchievementsPage-CtRh5AjH.js} +2 -2
- package/dashboard/dist/assets/{AchievementsSection-CDxcyig2.js → AchievementsSection-BarRcKaN.js} +1 -1
- package/dashboard/dist/assets/{BadgeDetailModal-Cp98zNn2.js → BadgeDetailModal-Bg0BDAN2.js} +1 -1
- package/dashboard/dist/assets/{BotAnimated-CR7MiUpo.js → BotAnimated-By9J3-dn.js} +1 -1
- package/dashboard/dist/assets/{Card-C8IdDvlg.js → Card-CIoJYi2j.js} +1 -1
- package/dashboard/dist/assets/{ClawdAnimated-COeTlW8z.js → ClawdAnimated-Dkvc2xe8.js} +2 -2
- package/dashboard/dist/assets/{CommandPalette-Co9k2CCc.js → CommandPalette-BssDtHRl.js} +1 -1
- package/dashboard/dist/assets/{CommunityStatsModal-C6XQeRgw.js → CommunityStatsModal-CQrgLlDb.js} +1 -1
- package/dashboard/dist/assets/{ConfirmModal-Ai4182O-.js → ConfirmModal-C3hBPgYc.js} +1 -1
- package/dashboard/dist/assets/{Controls-BbKzN-Pe.js → Controls-BSW6a1HS.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-Dhv7fejy.js +1 -0
- package/dashboard/dist/assets/{DevicePage-CZbrtJmb.js → DevicePage-D5eZn0wo.js} +1 -1
- package/dashboard/dist/assets/{DialogClose-DMl8tAoX.js → DialogClose-BltQpaJo.js} +1 -1
- package/dashboard/dist/assets/DialogDescription-Cof52Icj.js +1 -0
- package/dashboard/dist/assets/{DialogTitle-BJxpp2t-.js → DialogTitle-CVddcRPZ.js} +1 -1
- package/dashboard/dist/assets/{FadeIn-hU_MXl6R.js → FadeIn-goPv73MT.js} +1 -1
- package/dashboard/dist/assets/{HeaderGithubStar-CM5RSxxN.js → HeaderGithubStar-BDBrrkzt.js} +1 -1
- package/dashboard/dist/assets/{HoverTooltip-BZKfQyZQ.js → HoverTooltip-qyMezXOs.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-BjDx-zja.js → IpCheckPage-Ces8JIPb.js} +1 -1
- package/dashboard/dist/assets/{LandingPage-CAt8BCDv.js → LandingPage-B2TmLGno.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardAvatar-DiCrakjI.js → LeaderboardAvatar-CVVRoNua.js} +1 -1
- package/dashboard/dist/assets/{LeaderboardPage-Dj3pHv6U.js → LeaderboardPage-xtt-bOY8.js} +2 -2
- package/dashboard/dist/assets/{LeaderboardProfileModal-DEWbcP9p.js → LeaderboardProfileModal-BM4d_I_r.js} +3 -3
- package/dashboard/dist/assets/{LeaderboardProfilePage-CTbWHQkY.js → LeaderboardProfilePage-BWqhFLQX.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-V-98OUhF.js → LimitsPage-BZL4Pcl8.js} +1 -1
- package/dashboard/dist/assets/{LocalOnlyNotice-a4PqtUZY.js → LocalOnlyNotice-D4gnOEJj.js} +1 -1
- package/dashboard/dist/assets/{LoginCard-oD1A2Ru8.js → LoginCard-CB17FQ5w.js} +1 -1
- package/dashboard/dist/assets/{LoginModal-D8aSmxok.js → LoginModal-BI-O1rH4.js} +1 -1
- package/dashboard/dist/assets/{LoginPage-DIjhBv2X.js → LoginPage-DnGzsvJ4.js} +1 -1
- package/dashboard/dist/assets/{PetPage-D7601BTX.js → PetPage-NRnIKMaJ.js} +1 -1
- package/dashboard/dist/assets/{PopoverPopup-aPnmKopO.js → PopoverPopup-DHoK6UJZ.js} +1 -1
- package/dashboard/dist/assets/{ProviderIcon-rwyfcNlT.js → ProviderIcon-BTkdx9o2.js} +1 -1
- package/dashboard/dist/assets/{ResetPasswordPage-AF3SBgW2.js → ResetPasswordPage-C6K0w318.js} +1 -1
- package/dashboard/dist/assets/{SegmentedControl-fnlScNMT.js → SegmentedControl-gEoWioBE.js} +1 -1
- package/dashboard/dist/assets/{Select-DDjC3RjB.js → Select-CI-qtEYu.js} +1 -1
- package/dashboard/dist/assets/{SelectItemText-VhBWE2O8.js → SelectItemText-q-yhzzjw.js} +1 -1
- package/dashboard/dist/assets/{ServiceStatusPage-BL3qqEsp.js → ServiceStatusPage-C4xD1CEe.js} +1 -1
- package/dashboard/dist/assets/{SessionsPage-DXYiuP2x.js → SessionsPage-CS0Yun2z.js} +1 -1
- package/dashboard/dist/assets/{SettingsPage-DTeA_ahr.js → SettingsPage-GVY_ktz4.js} +2 -2
- package/dashboard/dist/assets/{SkillsPage-CtTRwVQH.js → SkillsPage-COPRL95O.js} +1 -1
- package/dashboard/dist/assets/{TokenGalaxy-CifK91V7.js → TokenGalaxy-Czi0Ic48.js} +1 -1
- package/dashboard/dist/assets/{ToolbarRootContext-ClGAIYoa.js → ToolbarRootContext-BZkTiBJB.js} +1 -1
- package/dashboard/dist/assets/{TrendMonitor-D1evFHRM.js → TrendMonitor-CnIsuxV-.js} +1 -1
- package/dashboard/dist/assets/{WidgetsPage-Cq4RYJDE.js → WidgetsPage-OdX_Fsul.js} +1 -1
- package/dashboard/dist/assets/{WrappedPage-CoC5RJH7.js → WrappedPage-h9N63-F8.js} +1 -1
- package/dashboard/dist/assets/__vite-browser-external-BFNWaGsk.js +45 -0
- package/dashboard/dist/assets/{agent-logos-QLdA9OeA.js → agent-logos-BDiPxrST.js} +1 -1
- package/dashboard/dist/assets/{arrow-up-right-BYLgB3bH.js → arrow-up-right-DtFQUeJG.js} +1 -1
- package/dashboard/dist/assets/{check-DfnIvqZD.js → check-C35onaNL.js} +1 -1
- package/dashboard/dist/assets/{composite--JllTpIX.js → composite-qxBzts0W.js} +1 -1
- package/dashboard/dist/assets/{copy-ClKVBIgD.js → copy-D_D71TLc.js} +1 -1
- package/dashboard/dist/assets/{download-Di1-UFJX.js → download-DMQI2hQe.js} +1 -1
- package/dashboard/dist/assets/{external-link-D7zPVFLO.js → external-link-CemM4dWf.js} +1 -1
- package/dashboard/dist/assets/{flame-BMJTxjVg.js → flame-CIvk0SP5.js} +1 -1
- package/dashboard/dist/assets/{format-tokens-DCZM5Nfn.js → format-tokens-D3JSvcFl.js} +1 -1
- package/dashboard/dist/assets/{icons-I615W2IU.js → icons-Ccf5W0AL.js} +1 -1
- package/dashboard/dist/assets/{index-BejnmdPe.js → index-Brd2evyz.js} +1 -1
- package/dashboard/dist/assets/{index-BX3CXVdD.js → index-C8Wri_HI.js} +1 -1
- package/dashboard/dist/assets/{index-CwczM3BZ.js → index-CFXmD_KX.js} +1 -1
- package/dashboard/dist/assets/{info-CFJPny5Q.js → info-CafrKEG8.js} +1 -1
- package/dashboard/dist/assets/{link-2-D1ydWh_w.js → link-2-Ds-9Nzlp.js} +1 -1
- package/dashboard/dist/assets/{loader-circle-Cq-J-OFg.js → loader-circle-DA4I55-V.js} +1 -1
- package/dashboard/dist/assets/{maximize-2-kIoSq57I.js → maximize-2-CjMPt5kU.js} +1 -1
- package/dashboard/dist/assets/{modifiers.esm-CLojthuF.js → modifiers.esm-DIxTS09h.js} +1 -1
- package/dashboard/dist/assets/module-WSx8viXK.js +3 -0
- package/dashboard/dist/assets/{monitor-smartphone-DX2qWHYV.js → monitor-smartphone-IZpIOxn3.js} +1 -1
- package/dashboard/dist/assets/{plus-B-JEpo3V.js → plus-vbLFNs70.js} +1 -1
- package/dashboard/dist/assets/{provider-display-DJDdrTAS.js → provider-display-BAu_UIfV.js} +2 -2
- package/dashboard/dist/assets/{react-Ctoe8iLl.js → react-Djb4VGKb.js} +1 -1
- package/dashboard/dist/assets/{search-BTSQrlW-.js → search-Ct6LY9Ke.js} +1 -1
- package/dashboard/dist/assets/{skills-api-DKQyM8h7.js → skills-api-BnGI2gEp.js} +1 -1
- package/dashboard/dist/assets/{sortable.esm-pOEL13xU.js → sortable.esm-CjCGd6g5.js} +1 -1
- package/dashboard/dist/assets/{store-B2RplzvW.js → store-CBxkOqRe.js} +1 -1
- package/dashboard/dist/assets/{terminal-BH_6_EW_.js → terminal-Dlpdp5Az.js} +1 -1
- package/dashboard/dist/assets/{trash-CC444Mz6.js → trash-pxUSKv66.js} +1 -1
- package/dashboard/dist/assets/{use-community-stats-CBXLdD_j.js → use-community-stats-tHaixRIw.js} +1 -1
- package/dashboard/dist/assets/{use-latest-request-guard-DMuPfZQI.js → use-latest-request-guard-Cs44WPfL.js} +1 -1
- package/dashboard/dist/assets/{use-limits-display-prefs-CmsGTBA4.js → use-limits-display-prefs-Dw56PXWy.js} +1 -1
- package/dashboard/dist/assets/{use-ordered-list-BNJNpXjB.js → use-ordered-list-DpLx8oLV.js} +1 -1
- package/dashboard/dist/assets/use-reduced-motion-DrlJ9X6p.js +1 -0
- package/dashboard/dist/assets/{use-session-efficiency-pref-g_0ISxe1.js → use-session-efficiency-pref-BN3FtBvr.js} +1 -1
- package/dashboard/dist/assets/{use-transform-D36BHcXk.js → use-transform-C4nTGgzb.js} +1 -1
- package/dashboard/dist/assets/{use-usage-limits-Dxw3vzsD.js → use-usage-limits-Dmwdc1yo.js} +1 -1
- package/dashboard/dist/assets/{useCurrency-V2FE4TMo.js → useCurrency-DSc3A_Ih.js} +1 -1
- package/dashboard/dist/assets/{useTokenFormat-CEOwvzMt.js → useTokenFormat-BHjcMgm8.js} +1 -1
- package/dashboard/dist/assets/{zap-BS7ZU76I.js → zap-BE8i_wu2.js} +1 -1
- package/dashboard/dist/brand-logos/trae.svg +1 -0
- package/dashboard/dist/index.html +13 -11
- package/dashboard/dist/ip-check.html +1 -1
- package/dashboard/dist/leaderboard.html +1 -1
- package/dashboard/dist/llms.txt +3 -2
- package/dashboard/dist/share.html +1 -1
- package/package.json +6 -4
- package/src/commands/init.js +9 -7
- package/src/commands/status.js +9 -9
- package/src/commands/sync.js +25 -0
- package/src/lib/pricing/curated-overrides.json +7 -0
- package/src/lib/pricing/matcher.js +5 -0
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +197 -18
- package/src/lib/trae-db.js +127 -0
- package/src/lib/trae-snapshot.js +230 -0
- package/src/lib/trae-sqlite.js +143 -0
- package/src/lib/trae-usage.js +121 -0
- package/dashboard/dist/assets/DashboardPage-DTcVGG4e.js +0 -1
- package/dashboard/dist/assets/DialogDescription-haPz7CX9.js +0 -1
- package/dashboard/dist/assets/__vite-browser-external-BDugA6I0.js +0 -45
- package/dashboard/dist/assets/module-DtWfRKM4.js +0 -3
- package/dashboard/dist/assets/use-reduced-motion-CNpG-Yoi.js +0 -1
package/src/lib/rollout.js
CHANGED
|
@@ -47,6 +47,8 @@ const {
|
|
|
47
47
|
buildDevinUsageEvents,
|
|
48
48
|
} = require("./devin-usage");
|
|
49
49
|
const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
|
|
50
|
+
const { resolveTraeDbPaths, readTraeUsageRows } = require("./trae-db");
|
|
51
|
+
const { normalizeTraeUsage, normalizeTraeModel, traeTimestamp } = require("./trae-usage");
|
|
50
52
|
|
|
51
53
|
const DEFAULT_SOURCE = "codex";
|
|
52
54
|
const DEFAULT_MODEL = "unknown";
|
|
@@ -19662,12 +19664,13 @@ function mergeGrokUsagePrecision(current, next) {
|
|
|
19662
19664
|
return "mixed";
|
|
19663
19665
|
}
|
|
19664
19666
|
|
|
19665
|
-
function
|
|
19667
|
+
function clearSourceHourlyBuckets(hourlyState, source) {
|
|
19666
19668
|
if (!hourlyState || typeof hourlyState !== "object") return;
|
|
19669
|
+
const prefix = `${source}${BUCKET_SEPARATOR}`;
|
|
19667
19670
|
const buckets = hourlyState.buckets && typeof hourlyState.buckets === "object" ? hourlyState.buckets : null;
|
|
19668
19671
|
if (buckets) {
|
|
19669
19672
|
for (const key of Object.keys(buckets)) {
|
|
19670
|
-
if (key.startsWith(
|
|
19673
|
+
if (key.startsWith(prefix)) delete buckets[key];
|
|
19671
19674
|
}
|
|
19672
19675
|
}
|
|
19673
19676
|
const groupQueued =
|
|
@@ -19676,12 +19679,12 @@ function clearGrokHourlyBuckets(hourlyState) {
|
|
|
19676
19679
|
: null;
|
|
19677
19680
|
if (groupQueued) {
|
|
19678
19681
|
for (const key of Object.keys(groupQueued)) {
|
|
19679
|
-
if (key.startsWith(
|
|
19682
|
+
if (key.startsWith(prefix)) delete groupQueued[key];
|
|
19680
19683
|
}
|
|
19681
19684
|
}
|
|
19682
19685
|
}
|
|
19683
19686
|
|
|
19684
|
-
async function
|
|
19687
|
+
async function retractStaleSourceQueueRows(queuePath, source, keepKeys) {
|
|
19685
19688
|
if (!queuePath) return 0;
|
|
19686
19689
|
let raw = "";
|
|
19687
19690
|
try {
|
|
@@ -19691,7 +19694,7 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
|
|
|
19691
19694
|
throw error;
|
|
19692
19695
|
}
|
|
19693
19696
|
|
|
19694
|
-
const
|
|
19697
|
+
const latestRows = new Map();
|
|
19695
19698
|
for (const line of raw.split("\n")) {
|
|
19696
19699
|
if (!line.trim()) continue;
|
|
19697
19700
|
let row;
|
|
@@ -19700,21 +19703,21 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
|
|
|
19700
19703
|
} catch {
|
|
19701
19704
|
continue;
|
|
19702
19705
|
}
|
|
19703
|
-
if ((row?.source || "") !==
|
|
19706
|
+
if ((row?.source || "") !== source) continue;
|
|
19704
19707
|
const model = normalizeModelInput(row.model) || DEFAULT_MODEL;
|
|
19705
19708
|
const hourStart = typeof row.hour_start === "string" ? row.hour_start : null;
|
|
19706
19709
|
if (!hourStart) continue;
|
|
19707
|
-
|
|
19710
|
+
latestRows.set(bucketKey(source, model, hourStart), { model, hour_start: hourStart, row });
|
|
19708
19711
|
}
|
|
19709
19712
|
|
|
19710
19713
|
const zero = initTotals();
|
|
19711
19714
|
const lines = [];
|
|
19712
|
-
for (const [key, entry] of
|
|
19715
|
+
for (const [key, entry] of latestRows.entries()) {
|
|
19713
19716
|
if (keepKeys.has(key)) continue;
|
|
19714
19717
|
if (totalsKey(entry.row) === totalsKey(zero)) continue;
|
|
19715
19718
|
lines.push(
|
|
19716
19719
|
JSON.stringify({
|
|
19717
|
-
source
|
|
19720
|
+
source,
|
|
19718
19721
|
model: entry.model,
|
|
19719
19722
|
hour_start: entry.hour_start,
|
|
19720
19723
|
...zero,
|
|
@@ -19814,7 +19817,7 @@ async function parseGrokBuildIncremental({
|
|
|
19814
19817
|
legacySeen: true,
|
|
19815
19818
|
};
|
|
19816
19819
|
}
|
|
19817
|
-
|
|
19820
|
+
clearSourceHourlyBuckets(hourlyState, "grok");
|
|
19818
19821
|
} else {
|
|
19819
19822
|
sessionSnapshots = normalizeGrokSessionSnapshots(grokState);
|
|
19820
19823
|
}
|
|
@@ -20195,7 +20198,7 @@ async function parseGrokBuildIncremental({
|
|
|
20195
20198
|
if (!key.startsWith("grok|") || !bucket?.totals) continue;
|
|
20196
20199
|
keepKeys.add(key);
|
|
20197
20200
|
}
|
|
20198
|
-
const retracted = await
|
|
20201
|
+
const retracted = await retractStaleSourceQueueRows(queuePath, "grok", keepKeys);
|
|
20199
20202
|
bucketsQueued += retracted;
|
|
20200
20203
|
}
|
|
20201
20204
|
|
|
@@ -21687,13 +21690,188 @@ function isCjkCodePoint(code) {
|
|
|
21687
21690
|
// ── Trae SOLO (ByteDance AI IDE) ─────────────────────────────────────────────
|
|
21688
21691
|
// https://www.trae.ai
|
|
21689
21692
|
//
|
|
21690
|
-
//
|
|
21691
|
-
//
|
|
21692
|
-
//
|
|
21693
|
-
//
|
|
21694
|
-
//
|
|
21695
|
-
//
|
|
21696
|
-
|
|
21693
|
+
// International TRAE usage comes from local encrypted chat_turn metadata.
|
|
21694
|
+
//
|
|
21695
|
+
// The ledger keeps one compact tuple per turn, keyed by a 128-bit digest, so a
|
|
21696
|
+
// correction can retract exactly what the turn contributed even after TRAE
|
|
21697
|
+
// deletes its history. total_tokens is the five token columns plus any input
|
|
21698
|
+
// without a cache split, which is counted but unpriced (normalizeTraeUsage).
|
|
21699
|
+
const TRAE_CURSOR_VERSION = 2;
|
|
21700
|
+
const HALF_HOUR_MS = 30 * 60 * 1000;
|
|
21701
|
+
const [T_SESSION, T_MODEL, T_BUCKET, T_MODIFIED, T_STORE, T_INPUT, T_CACHED, T_WRITTEN,
|
|
21702
|
+
T_OUTPUT, T_REASONING, T_CONVERSATIONS, T_ESTIMATED, T_UNPRICED] = Array.from({ length: 13 }, (_, i) => i);
|
|
21703
|
+
|
|
21704
|
+
function packTraeTurn({ session, model, bucketStart, totals, modifiedAt, store }) {
|
|
21705
|
+
return [session, model, Date.parse(bucketStart) / HALF_HOUR_MS, modifiedAt, store,
|
|
21706
|
+
totals.input_tokens, totals.cached_input_tokens, totals.cache_creation_input_tokens,
|
|
21707
|
+
totals.output_tokens, totals.reasoning_output_tokens, totals.conversation_count,
|
|
21708
|
+
totals.usage_precision === "estimated" ? 1 : 0, totals.unpriced_input_tokens || 0];
|
|
21709
|
+
}
|
|
21710
|
+
|
|
21711
|
+
function unpackTraeTotals(turn) {
|
|
21712
|
+
const totals = {
|
|
21713
|
+
input_tokens: turn[T_INPUT],
|
|
21714
|
+
cached_input_tokens: turn[T_CACHED],
|
|
21715
|
+
cache_creation_input_tokens: turn[T_WRITTEN],
|
|
21716
|
+
output_tokens: turn[T_OUTPUT],
|
|
21717
|
+
reasoning_output_tokens: turn[T_REASONING],
|
|
21718
|
+
total_tokens: turn[T_INPUT] + turn[T_CACHED] + turn[T_WRITTEN] + turn[T_OUTPUT] + turn[T_REASONING]
|
|
21719
|
+
+ turn[T_UNPRICED],
|
|
21720
|
+
conversation_count: turn[T_CONVERSATIONS],
|
|
21721
|
+
};
|
|
21722
|
+
if (turn[T_ESTIMATED]) totals.usage_precision = "estimated";
|
|
21723
|
+
return totals;
|
|
21724
|
+
}
|
|
21725
|
+
|
|
21726
|
+
function traeTurnBucketStart(turn) {
|
|
21727
|
+
return new Date(turn[T_BUCKET] * HALF_HOUR_MS).toISOString();
|
|
21728
|
+
}
|
|
21729
|
+
|
|
21730
|
+
async function parseTraeIncremental({
|
|
21731
|
+
dbPaths,
|
|
21732
|
+
cursors,
|
|
21733
|
+
queuePath,
|
|
21734
|
+
env = process.env,
|
|
21735
|
+
onProgress,
|
|
21736
|
+
readUsageRows = readTraeUsageRows,
|
|
21737
|
+
} = {}) {
|
|
21738
|
+
const paths = dbPaths || resolveTraeDbPaths(env);
|
|
21739
|
+
const hourlyState = normalizeHourlyState(cursors.hourly);
|
|
21740
|
+
// Queue writes and reads may fail. Publish cursor changes only after the
|
|
21741
|
+
// append succeeds, including copies of the mutable bucket totals.
|
|
21742
|
+
hourlyState.buckets = Object.fromEntries(Object.entries(hourlyState.buckets).map(([key, bucket]) => [
|
|
21743
|
+
key, key.startsWith("trae|") ? { ...bucket, totals: { ...bucket.totals } } : bucket,
|
|
21744
|
+
]));
|
|
21745
|
+
hourlyState.groupQueued = { ...hourlyState.groupQueued };
|
|
21746
|
+
// A ledger with another TRAE_CURSOR_VERSION cannot retract what it
|
|
21747
|
+
// contributed, so rebuild the TRAE buckets from the stores instead of
|
|
21748
|
+
// adding every turn on top of them.
|
|
21749
|
+
const prior = cursors.trae?.version === TRAE_CURSOR_VERSION ? cursors.trae : null;
|
|
21750
|
+
const rebuilding = !prior && Boolean(cursors.trae);
|
|
21751
|
+
if (rebuilding) clearSourceHourlyBuckets(hourlyState, "trae");
|
|
21752
|
+
const turns = { ...prior?.turns };
|
|
21753
|
+
const databases = { ...prior?.databases };
|
|
21754
|
+
const stores = [...(prior?.stores || [])];
|
|
21755
|
+
const touchedBuckets = new Set();
|
|
21756
|
+
const conversations = new Set(Object.values(turns)
|
|
21757
|
+
.filter((turn) => turn[T_CONVERSATIONS] > 0).map((turn) => turn[T_SESSION]));
|
|
21758
|
+
// 128-bit digests: identifiers are hashed before they are stored.
|
|
21759
|
+
const digest = (value) => crypto.createHash("sha256").update(value).digest().subarray(0, 16).toString("base64url");
|
|
21760
|
+
let recordsProcessed = 0;
|
|
21761
|
+
let eventsAggregated = 0;
|
|
21762
|
+
let recordsSkipped = 0;
|
|
21763
|
+
let estimatedRecords = 0;
|
|
21764
|
+
let unpricedRecords = 0;
|
|
21765
|
+
const errors = [];
|
|
21766
|
+
for (const dbPath of [...new Set(paths)]) {
|
|
21767
|
+
const databaseKey = digest(path.resolve(dbPath));
|
|
21768
|
+
let store = stores.indexOf(databaseKey);
|
|
21769
|
+
if (store < 0) store = stores.push(databaseKey) - 1;
|
|
21770
|
+
let rows;
|
|
21771
|
+
let fingerprint;
|
|
21772
|
+
let finalFingerprint;
|
|
21773
|
+
try {
|
|
21774
|
+
// existsSync also returns false for permission errors. Let stat/read
|
|
21775
|
+
// report inaccessible stores instead of treating them as absent.
|
|
21776
|
+
fssync.statSync(dbPath);
|
|
21777
|
+
fingerprint = devinSqliteFingerprint(dbPath);
|
|
21778
|
+
if (sameSqliteFingerprint(fingerprint, databases[databaseKey])) continue;
|
|
21779
|
+
rows = await readUsageRows(dbPath, { env });
|
|
21780
|
+
finalFingerprint = devinSqliteFingerprint(dbPath);
|
|
21781
|
+
} catch (err) {
|
|
21782
|
+
// One install can be locked, corrupt, or use a different key while
|
|
21783
|
+
// another remains readable. Its prior fingerprint stays retryable;
|
|
21784
|
+
// successful stores still publish together after the queue append.
|
|
21785
|
+
errors.push({ database: dbPath, message: err?.message || String(err) });
|
|
21786
|
+
continue;
|
|
21787
|
+
}
|
|
21788
|
+
for (const row of rows) {
|
|
21789
|
+
recordsProcessed += 1;
|
|
21790
|
+
const totals = normalizeTraeUsage(row.usage, { model: row.model });
|
|
21791
|
+
const timestamp = traeTimestamp(row.created_at);
|
|
21792
|
+
const bucketStart = timestamp && toUtcHalfHourStart(timestamp);
|
|
21793
|
+
const rowId = row.turn_id || row.id;
|
|
21794
|
+
if (!totals || !bucketStart || rowId == null || rowId === "") {
|
|
21795
|
+
recordsSkipped += 1;
|
|
21796
|
+
continue;
|
|
21797
|
+
}
|
|
21798
|
+
const session = digest(String(row.session_id || `${databaseKey}:${rowId}`));
|
|
21799
|
+
// Stable turn ids deduplicate copies across the two international app
|
|
21800
|
+
// stores. Numeric SQLite ids are only unique within their database.
|
|
21801
|
+
const identity = digest(JSON.stringify(row.turn_id
|
|
21802
|
+
? [session, row.turn_id] : [databaseKey, rowId]));
|
|
21803
|
+
const previous = turns[identity];
|
|
21804
|
+
const modifiedAt = Date.parse(traeTimestamp(row.updated_at) || timestamp);
|
|
21805
|
+
// A copied turn in another install can lag behind the original. Never
|
|
21806
|
+
// let an unrelated rescan of that stale store retract newer usage.
|
|
21807
|
+
// Without a newer timestamp, conflicting copies retain their owner.
|
|
21808
|
+
if (previous && previous[T_STORE] !== store && modifiedAt <= previous[T_MODIFIED]) continue;
|
|
21809
|
+
if (!previous && totals.total_tokens === 0) continue;
|
|
21810
|
+
if (totals.usage_precision === "estimated") estimatedRecords += 1;
|
|
21811
|
+
if (totals.unpriced_input_tokens > 0) unpricedRecords += 1;
|
|
21812
|
+
totals.conversation_count = previous
|
|
21813
|
+
? previous[T_CONVERSATIONS]
|
|
21814
|
+
: conversations.has(session) ? 0 : 1;
|
|
21815
|
+
conversations.add(session);
|
|
21816
|
+
const model = normalizeTraeModel(row.model);
|
|
21817
|
+
const next = packTraeTurn({ session, model, bucketStart, totals, modifiedAt, store });
|
|
21818
|
+
if (previous && next.every((value, i) => i === T_MODIFIED || i === T_STORE || value === previous[i])) {
|
|
21819
|
+
turns[identity] = next;
|
|
21820
|
+
continue;
|
|
21821
|
+
}
|
|
21822
|
+
if (previous) {
|
|
21823
|
+
const previousBucket = traeTurnBucketStart(previous);
|
|
21824
|
+
const old = getHourlyBucket(hourlyState, "trae", previous[T_MODEL], previousBucket);
|
|
21825
|
+
subtractTotals(old.totals, unpackTraeTotals(previous));
|
|
21826
|
+
touchedBuckets.add(bucketKey("trae", previous[T_MODEL], previousBucket));
|
|
21827
|
+
}
|
|
21828
|
+
const bucket = getHourlyBucket(hourlyState, "trae", model, bucketStart);
|
|
21829
|
+
addTotals(bucket.totals, totals);
|
|
21830
|
+
touchedBuckets.add(bucketKey("trae", model, bucketStart));
|
|
21831
|
+
turns[identity] = next;
|
|
21832
|
+
eventsAggregated += 1;
|
|
21833
|
+
}
|
|
21834
|
+
// A read that races with a writer must be retried on the next sync.
|
|
21835
|
+
databases[databaseKey] = sameSqliteFingerprint(fingerprint, finalFingerprint)
|
|
21836
|
+
? fingerprint : null;
|
|
21837
|
+
if (typeof onProgress === "function") {
|
|
21838
|
+
onProgress({ recordsProcessed, eventsAggregated, bucketsQueued: touchedBuckets.size });
|
|
21839
|
+
}
|
|
21840
|
+
}
|
|
21841
|
+
// Rebuild precision from the retained contributions, including unchanged
|
|
21842
|
+
// stores. A correction can replace the last estimated turn with reported
|
|
21843
|
+
// usage without changing numeric totals, or move it to another bucket.
|
|
21844
|
+
const bucketPrecisions = new Map();
|
|
21845
|
+
for (const turn of Object.values(turns)) {
|
|
21846
|
+
const key = bucketKey("trae", turn[T_MODEL], traeTurnBucketStart(turn));
|
|
21847
|
+
if (!touchedBuckets.has(key) || unpackTraeTotals(turn).total_tokens === 0) continue;
|
|
21848
|
+
bucketPrecisions.set(key, mergeGrokUsagePrecision(
|
|
21849
|
+
bucketPrecisions.get(key), turn[T_ESTIMATED] ? "estimated" : "reported",
|
|
21850
|
+
));
|
|
21851
|
+
}
|
|
21852
|
+
for (const key of touchedBuckets) {
|
|
21853
|
+
const precision = bucketPrecisions.get(key);
|
|
21854
|
+
hourlyState.buckets[key].usage_precision = precision === "reported" ? null : precision || null;
|
|
21855
|
+
}
|
|
21856
|
+
// A rebuild is all or nothing. Buckets rebuilt from a subset of the stores
|
|
21857
|
+
// would replace shared buckets with partial totals, and each retry would
|
|
21858
|
+
// append every row again. Keep the old hourly state and ledger instead.
|
|
21859
|
+
if (rebuilding && errors.length) {
|
|
21860
|
+
return { recordsProcessed, eventsAggregated, bucketsQueued: 0, recordsSkipped, estimatedRecords, unpricedRecords, errors };
|
|
21861
|
+
}
|
|
21862
|
+
await ensureDir(path.dirname(queuePath));
|
|
21863
|
+
let bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
21864
|
+
cursors.hourly = hourlyState;
|
|
21865
|
+
if (rebuilding) {
|
|
21866
|
+
// The old ledger may have filed a turn under another bucket key (model or
|
|
21867
|
+
// half-hour); retract those rows so the dashboard does not count it twice.
|
|
21868
|
+
const keepKeys = new Set(Object.keys(hourlyState.buckets).filter((key) => key.startsWith("trae|")));
|
|
21869
|
+
bucketsQueued += await retractStaleSourceQueueRows(queuePath, "trae", keepKeys);
|
|
21870
|
+
}
|
|
21871
|
+
cursors.trae = { version: TRAE_CURSOR_VERSION, stores, databases, turns };
|
|
21872
|
+
return { recordsProcessed, eventsAggregated, bucketsQueued, recordsSkipped, estimatedRecords, unpricedRecords, errors };
|
|
21873
|
+
}
|
|
21874
|
+
|
|
21697
21875
|
// Ordered candidate app-dir names. The CN IDE build installs as "Trae CN" on
|
|
21698
21876
|
// Windows; the international build installs as "TRAE SOLO". Both expose the same
|
|
21699
21877
|
// iCubeServerData entitlement key, so either is a valid Trae IDE install.
|
|
@@ -24192,6 +24370,7 @@ async function parseCommandCodeIncremental({
|
|
|
24192
24370
|
}
|
|
24193
24371
|
|
|
24194
24372
|
module.exports = {
|
|
24373
|
+
parseTraeIncremental,
|
|
24195
24374
|
listRolloutFiles,
|
|
24196
24375
|
listRolloutFilesDeep,
|
|
24197
24376
|
codexSessionIdFromPath,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
const fs = require("node:fs");
|
|
2
|
+
const os = require("node:os");
|
|
3
|
+
const path = require("node:path");
|
|
4
|
+
|
|
5
|
+
const { openTraeSnapshot } = require("./trae-snapshot");
|
|
6
|
+
const { withTraeSqlite } = require("./trae-sqlite");
|
|
7
|
+
|
|
8
|
+
const TOKEN_FIELDS = [
|
|
9
|
+
"prompt_tokens", "prompt_tokens_total", "input_tokens",
|
|
10
|
+
"completion_tokens", "completion_tokens_total", "output_tokens",
|
|
11
|
+
"total_tokens", "cache_read_input_tokens", "cache_creation_input_tokens",
|
|
12
|
+
"reasoning_tokens", "reasoning_output_tokens",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function resolveTraeDbPaths(env = process.env, { platform = process.platform, home = os.homedir() } = {}) {
|
|
16
|
+
const explicit = String(env.TOKENTRACKER_TRAE_DB || "").trim();
|
|
17
|
+
if (explicit) return [path.resolve(explicit)];
|
|
18
|
+
const customHome = String(env.TOKENTRACKER_TRAE_HOME || "").trim();
|
|
19
|
+
const parent = platform === "win32"
|
|
20
|
+
? env.APPDATA || path.join(home, "AppData", "Roaming")
|
|
21
|
+
: platform === "darwin"
|
|
22
|
+
? path.join(home, "Library", "Application Support")
|
|
23
|
+
: env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
24
|
+
const roots = customHome ? [customHome] : [path.join(parent, "Trae"), path.join(parent, "TRAE SOLO")];
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const root of roots) {
|
|
27
|
+
// A decrypted sibling may be an old export. Only an explicit DB override
|
|
28
|
+
// opts into one; normal discovery always reads the current application DB.
|
|
29
|
+
for (const module of ["ai-agent", "ai-chat"]) {
|
|
30
|
+
const candidate = path.resolve(root, "ModularData", module, "database.db");
|
|
31
|
+
let present;
|
|
32
|
+
try { present = fs.statSync(candidate).isFile(); }
|
|
33
|
+
catch (err) {
|
|
34
|
+
// Permission failures must reach the reader's diagnostics, not masquerade
|
|
35
|
+
// as an absent installation and silently drop an entire usage history.
|
|
36
|
+
present = err.code !== "ENOENT" && err.code !== "ENOTDIR";
|
|
37
|
+
}
|
|
38
|
+
if (present) {
|
|
39
|
+
result.push(candidate);
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...new Set(result)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function error(message) {
|
|
48
|
+
return new Error(`Cannot read TRAE usage: ${message}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function projectUsage(queryRows) {
|
|
52
|
+
const tables = await queryRows("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chat_turn'");
|
|
53
|
+
// history_v2.token_usage can be a context-size snapshot rather than billed
|
|
54
|
+
// usage. Do not synthesize input tokens from those scalar values.
|
|
55
|
+
if (!tables.length) throw error("unsupported database schema: chat_turn table is missing.");
|
|
56
|
+
const columns = new Set((await queryRows("PRAGMA table_info(chat_turn)")).map((row) => row.name));
|
|
57
|
+
if (!columns.has("context") || (!columns.has("created_at") && !columns.has("updated_at"))) {
|
|
58
|
+
throw error("unsupported chat_turn schema.");
|
|
59
|
+
}
|
|
60
|
+
const column = (name, fallback = "NULL") => columns.has(name) ? `"${name}"` : fallback;
|
|
61
|
+
const date = columns.has("created_at") && columns.has("updated_at")
|
|
62
|
+
? 'COALESCE("created_at", "updated_at")' : column("created_at", column("updated_at"));
|
|
63
|
+
const modelPaths = [
|
|
64
|
+
"persist_user_message_context.model_info.display_model_name",
|
|
65
|
+
"persist_user_message_context.model_info.model_name",
|
|
66
|
+
"persist_user_message_context.model_info.config_name", "model_name", "model",
|
|
67
|
+
"agent_model", "selected_model.name", "selectedModel.name",
|
|
68
|
+
];
|
|
69
|
+
const model = modelPaths.map((field) => `CASE WHEN json_type(context, '$.${field}') = 'text'
|
|
70
|
+
THEN NULLIF(NULLIF(TRIM(json_extract(context, '$.${field}')), ''), '-') END`);
|
|
71
|
+
const fields = TOKEN_FIELDS.map((field) => `CASE
|
|
72
|
+
WHEN json_type(context, '$.token_usage.${field}') IN ('integer', 'real')
|
|
73
|
+
THEN json_extract(context, '$.token_usage.${field}') END AS "${field}"`);
|
|
74
|
+
const invalidFields = TOKEN_FIELDS.map((field) =>
|
|
75
|
+
`COALESCE(json_type(context, '$.token_usage.${field}') NOT IN ('integer', 'real', 'null'), 0)`);
|
|
76
|
+
// Project inside SQLite: prompts, messages, session titles, fee/account data,
|
|
77
|
+
// and the rest of the context never cross into JS results or cursor state.
|
|
78
|
+
const query = `SELECT CAST(${column("id", "rowid")} AS TEXT) AS id,
|
|
79
|
+
${column("session_id")} AS session_id, ${column("turn_id")} AS turn_id,
|
|
80
|
+
${date} AS created_at, ${column("updated_at")} AS updated_at,
|
|
81
|
+
COALESCE(${model.join(", ")}, 'trae-unknown') AS model,
|
|
82
|
+
${fields.join(", ")}, (${invalidFields.join(" OR ")}) AS invalid_usage
|
|
83
|
+
FROM chat_turn WHERE CASE WHEN json_valid(context)
|
|
84
|
+
THEN json_type(context, '$.token_usage') = 'object' ELSE 0 END`;
|
|
85
|
+
return (await queryRows(query)).map((row) => {
|
|
86
|
+
const usage = {};
|
|
87
|
+
let invalid = Boolean(row.invalid_usage);
|
|
88
|
+
for (const field of TOKEN_FIELDS) {
|
|
89
|
+
const value = row[field];
|
|
90
|
+
if (value == null) continue;
|
|
91
|
+
if (!Number.isSafeInteger(value) || value < 0) invalid = true;
|
|
92
|
+
else usage[field] = value;
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
id: row.id, session_id: row.session_id, turn_id: row.turn_id,
|
|
96
|
+
created_at: row.created_at, updated_at: row.updated_at,
|
|
97
|
+
model: row.model, usage: invalid ? null : usage,
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function readTraeUsageRows(dbPath, { env = process.env } = {}) {
|
|
103
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
104
|
+
let snapshot;
|
|
105
|
+
try { snapshot = openTraeSnapshot(dbPath, env); }
|
|
106
|
+
catch (err) {
|
|
107
|
+
// A writer can also change the files while the snapshot opens. Retry
|
|
108
|
+
// that too, and report the specific failure once attempts run out.
|
|
109
|
+
if (err?.transient && attempt < 2) continue;
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
let result;
|
|
114
|
+
try { result = await withTraeSqlite(snapshot, projectUsage); }
|
|
115
|
+
catch (err) {
|
|
116
|
+
// A writer may have changed pages while SQLite was reading. Discard
|
|
117
|
+
// every projected row and retry; never publish a mixed snapshot.
|
|
118
|
+
if (snapshot.isStable()) throw err;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (snapshot.isStable()) return result;
|
|
122
|
+
} finally { snapshot.close(); }
|
|
123
|
+
}
|
|
124
|
+
throw error("database changed during the read; retry after TRAE finishes its current turn.");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { resolveTraeDbPaths, readTraeUsageRows };
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const crypto = require("node:crypto");
|
|
5
|
+
|
|
6
|
+
const SQLITE_HEADER = Buffer.from("SQLite format 3\0");
|
|
7
|
+
const PAGE_SIZE = 4096;
|
|
8
|
+
const RESERVED = 80;
|
|
9
|
+
// TRAE's shared application key
|
|
10
|
+
const DEFAULT_SQLCIPHER_KEY = "3605f6691095a993f03d5009c918352ef5be31ae31e8f000212b81ff058da773";
|
|
11
|
+
const CACHE_PAGES = 64;
|
|
12
|
+
const MAX_WAL_INDEX_PAGES = 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function error(message) { return new Error(`Cannot read TRAE usage: ${message}`); }
|
|
15
|
+
// A writer race rather than a bad store: the caller may retry immediately.
|
|
16
|
+
function transientError(message) { return Object.assign(error(message), { transient: true }); }
|
|
17
|
+
|
|
18
|
+
function statOptional(file) {
|
|
19
|
+
try { return fs.statSync(file, { bigint: true }); }
|
|
20
|
+
catch (err) { if (err.code === "ENOENT" || err.code === "ENOTDIR") return null; throw err; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameStat(a, b) {
|
|
24
|
+
if (!a || !b) return a === b;
|
|
25
|
+
return a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs && a.ino === b.ino;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readExact(fd, target, offset) {
|
|
29
|
+
let read = 0;
|
|
30
|
+
while (read < target.length) {
|
|
31
|
+
const count = fs.readSync(fd, target, read, target.length - read, offset + read);
|
|
32
|
+
if (!count) throw transientError("database or WAL was truncated during the read; retry when TRAE is idle.");
|
|
33
|
+
read += count;
|
|
34
|
+
}
|
|
35
|
+
return target;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function pageDecryptor(firstPage, env) {
|
|
39
|
+
const hex = String(env.TOKENTRACKER_TRAE_SQLCIPHER_KEY || "").trim() || DEFAULT_SQLCIPHER_KEY;
|
|
40
|
+
if (!/^[0-9a-f]{64}$/i.test(hex)) throw error("TOKENTRACKER_TRAE_SQLCIPHER_KEY must contain 64 hex characters.");
|
|
41
|
+
const key = Buffer.from(hex, "hex");
|
|
42
|
+
const salt = Buffer.from(firstPage.subarray(0, 16).map((byte) => byte ^ 0x3a));
|
|
43
|
+
// SQLCipher 4 raw-key format: AES-256-CBC, 4096-byte pages, SHA-512 HMAC.
|
|
44
|
+
// https://github.com/sqlcipher/sqlcipher/blob/master/src/sqlcipher.c
|
|
45
|
+
const hmacKey = crypto.pbkdf2Sync(key, salt, 2, 32, "sha512");
|
|
46
|
+
salt.fill(0);
|
|
47
|
+
return {
|
|
48
|
+
decrypt(page, number) {
|
|
49
|
+
const start = number === 1 ? 16 : 0;
|
|
50
|
+
const ivOffset = PAGE_SIZE - RESERVED;
|
|
51
|
+
const pageId = Buffer.alloc(4);
|
|
52
|
+
pageId.writeUInt32LE(number);
|
|
53
|
+
const expected = crypto.createHmac("sha512", hmacKey)
|
|
54
|
+
.update(page.subarray(start, ivOffset + 16)).update(pageId).digest();
|
|
55
|
+
if (!crypto.timingSafeEqual(expected, page.subarray(ivOffset + 16))) {
|
|
56
|
+
throw error("SQLCipher authentication failed (incorrect key, unsupported format, or damaged database); set TOKENTRACKER_TRAE_SQLCIPHER_KEY to override the shared application key.");
|
|
57
|
+
}
|
|
58
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, page.subarray(ivOffset, ivOffset + 16));
|
|
59
|
+
decipher.setAutoPadding(false);
|
|
60
|
+
const decoded = decipher.update(page.subarray(start, ivOffset));
|
|
61
|
+
decipher.final();
|
|
62
|
+
page.fill(0);
|
|
63
|
+
if (number === 1) SQLITE_HEADER.copy(page);
|
|
64
|
+
decoded.copy(page, start);
|
|
65
|
+
decoded.fill(0);
|
|
66
|
+
if (number === 1 && (page.readUInt16BE(16) !== PAGE_SIZE || page[20] !== RESERVED)) {
|
|
67
|
+
throw error("unsupported SQLCipher page format.");
|
|
68
|
+
}
|
|
69
|
+
return page;
|
|
70
|
+
},
|
|
71
|
+
dispose() { key.fill(0); hmacKey.fill(0); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checksum(data, bigEndian, state = [0, 0]) {
|
|
76
|
+
let [a, b] = state;
|
|
77
|
+
for (let i = 0; i < data.length; i += 8) {
|
|
78
|
+
const x = bigEndian ? data.readUInt32BE(i) : data.readUInt32LE(i);
|
|
79
|
+
const y = bigEndian ? data.readUInt32BE(i + 4) : data.readUInt32LE(i + 4);
|
|
80
|
+
a = (a + x + b) >>> 0;
|
|
81
|
+
b = (b + y + a) >>> 0;
|
|
82
|
+
}
|
|
83
|
+
return [a, b];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function indexWal(fd, size, pageSize) {
|
|
87
|
+
const pages = new Map();
|
|
88
|
+
if (fd == null || size === 0) return { pages, pageCount: null };
|
|
89
|
+
if (size < 32) throw transientError("incomplete WAL header; retry when TRAE is idle.");
|
|
90
|
+
const header = readExact(fd, Buffer.alloc(32), 0);
|
|
91
|
+
const magic = header.readUInt32BE(0);
|
|
92
|
+
if ((magic !== 0x377f0682 && magic !== 0x377f0683) ||
|
|
93
|
+
header.readUInt32BE(4) !== 3007000 || header.readUInt32BE(8) !== pageSize) {
|
|
94
|
+
throw error("unsupported WAL format.");
|
|
95
|
+
}
|
|
96
|
+
const bigEndian = magic === 0x377f0683;
|
|
97
|
+
let sum = checksum(header.subarray(0, 24), bigEndian);
|
|
98
|
+
if (sum[0] !== header.readUInt32BE(24) || sum[1] !== header.readUInt32BE(28)) throw error("WAL header checksum failed.");
|
|
99
|
+
let pageCount = null;
|
|
100
|
+
let pending = new Map();
|
|
101
|
+
const frame = Buffer.alloc(24 + pageSize);
|
|
102
|
+
// Index offsets instead of buffering/decrypting an entire WAL. Uncommitted
|
|
103
|
+
// frames never enter the visible page map. SQLite checksums cover ciphertext.
|
|
104
|
+
// https://www.sqlite.org/fileformat2.html#walformat
|
|
105
|
+
for (let offset = 32; offset + frame.length <= size; offset += frame.length) {
|
|
106
|
+
readExact(fd, frame, offset);
|
|
107
|
+
if (!frame.subarray(8, 16).equals(header.subarray(16, 24))) break;
|
|
108
|
+
// Like a salt mismatch, the first checksum mismatch ends the valid log (a
|
|
109
|
+
// torn append or crash leftover); only committed frames before it count.
|
|
110
|
+
sum = checksum(frame.subarray(24), bigEndian, checksum(frame.subarray(0, 8), bigEndian, sum));
|
|
111
|
+
if (sum[0] !== frame.readUInt32BE(16) || sum[1] !== frame.readUInt32BE(20)) break;
|
|
112
|
+
const number = frame.readUInt32BE(0);
|
|
113
|
+
const commit = frame.readUInt32BE(4);
|
|
114
|
+
if (!number || number === 0xffffffff || commit === 0xffffffff) throw error("invalid WAL page number or database size.");
|
|
115
|
+
pending.set(number, offset + 24);
|
|
116
|
+
if (pending.size + pages.size > MAX_WAL_INDEX_PAGES) throw error("WAL index exceeds the bounded-memory limit; let TRAE checkpoint its WAL and retry.");
|
|
117
|
+
if (commit) {
|
|
118
|
+
for (const [page, position] of pending) pages.set(page, position);
|
|
119
|
+
pending = new Map();
|
|
120
|
+
pageCount = commit;
|
|
121
|
+
for (const page of pages.keys()) if (page > commit) pages.delete(page);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
frame.fill(0);
|
|
125
|
+
return { pages, pageCount };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A read-only, optimistic snapshot. SQLite reads authenticated pages on demand;
|
|
129
|
+
// no decrypted database is ever written to disk or materialized in memory.
|
|
130
|
+
// The caller must discard results if isStable() fails after the SQL query.
|
|
131
|
+
function openTraeSnapshot(dbPath, env = process.env) {
|
|
132
|
+
const paths = [dbPath, `${dbPath}-wal`, `${dbPath}-journal`];
|
|
133
|
+
const before = paths.map(statOptional);
|
|
134
|
+
if (!before[0]) throw error("database no longer exists.");
|
|
135
|
+
if (before.some((stat) => stat && (!stat.isFile() || stat.size > BigInt(Number.MAX_SAFE_INTEGER)))) {
|
|
136
|
+
throw error("unsupported database file or size.");
|
|
137
|
+
}
|
|
138
|
+
const handles = [];
|
|
139
|
+
const cache = new Map();
|
|
140
|
+
let decryptor;
|
|
141
|
+
const close = () => {
|
|
142
|
+
for (const page of cache.values()) page.fill(0);
|
|
143
|
+
cache.clear();
|
|
144
|
+
if (decryptor) { decryptor.dispose(); decryptor = null; }
|
|
145
|
+
let failure;
|
|
146
|
+
while (handles.length) {
|
|
147
|
+
try { fs.closeSync(handles.pop()); }
|
|
148
|
+
catch (err) { failure ||= err; }
|
|
149
|
+
}
|
|
150
|
+
if (failure) throw failure;
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
const dbFd = fs.openSync(dbPath, "r");
|
|
154
|
+
handles.push(dbFd);
|
|
155
|
+
if (!sameStat(before[0], fs.fstatSync(dbFd, { bigint: true }))) throw transientError("database changed while opening; retry when TRAE is idle.");
|
|
156
|
+
let walFd = null;
|
|
157
|
+
if (before[1]) {
|
|
158
|
+
walFd = fs.openSync(paths[1], "r");
|
|
159
|
+
handles.push(walFd);
|
|
160
|
+
if (!sameStat(before[1], fs.fstatSync(walFd, { bigint: true }))) throw transientError("WAL changed while opening; retry when TRAE is idle.");
|
|
161
|
+
}
|
|
162
|
+
if (before[2] && before[2].size >= 8n) {
|
|
163
|
+
const journalFd = fs.openSync(paths[2], "r");
|
|
164
|
+
handles.push(journalFd);
|
|
165
|
+
if (readExact(journalFd, Buffer.alloc(8), 0).some((byte) => byte !== 0)) throw error("a rollback transaction is active; retry when TRAE is idle.");
|
|
166
|
+
}
|
|
167
|
+
const size = Number(before[0].size);
|
|
168
|
+
if (size < 100) throw error("database is truncated.");
|
|
169
|
+
const header = readExact(dbFd, Buffer.alloc(100), 0);
|
|
170
|
+
const encrypted = !header.subarray(0, 16).equals(SQLITE_HEADER);
|
|
171
|
+
const encodedSize = encrypted ? PAGE_SIZE : header.readUInt16BE(16);
|
|
172
|
+
const pageSize = encodedSize === 1 ? 65536 : encodedSize;
|
|
173
|
+
if (pageSize < 512 || pageSize > 65536 || (pageSize & (pageSize - 1)) || size % pageSize) throw error("invalid database page size or truncated database.");
|
|
174
|
+
if (encrypted) decryptor = pageDecryptor(header, env);
|
|
175
|
+
const { pages, pageCount } = indexWal(walFd, Number(before[1]?.size || 0n), pageSize);
|
|
176
|
+
const finalPageCount = pageCount == null ? size / pageSize : pageCount;
|
|
177
|
+
if (!finalPageCount || finalPageCount > 0xfffffffe) throw error("invalid database size.");
|
|
178
|
+
const byteLength = finalPageCount * pageSize;
|
|
179
|
+
const getPage = (number) => {
|
|
180
|
+
if (cache.has(number)) {
|
|
181
|
+
const page = cache.get(number);
|
|
182
|
+
cache.delete(number);
|
|
183
|
+
cache.set(number, page);
|
|
184
|
+
return page;
|
|
185
|
+
}
|
|
186
|
+
const page = Buffer.alloc(pageSize);
|
|
187
|
+
try {
|
|
188
|
+
if (pages.has(number)) readExact(walFd, page, pages.get(number));
|
|
189
|
+
else readExact(dbFd, page, (number - 1) * pageSize);
|
|
190
|
+
if (decryptor) decryptor.decrypt(page, number);
|
|
191
|
+
if (number === 1) {
|
|
192
|
+
// Present the committed view as a standalone rollback-mode database.
|
|
193
|
+
page[18] = 1;
|
|
194
|
+
page[19] = 1;
|
|
195
|
+
page.writeUInt32BE(finalPageCount, 28);
|
|
196
|
+
page.writeUInt32BE(page.readUInt32BE(24), 92);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) { page.fill(0); throw err; }
|
|
199
|
+
cache.set(number, page);
|
|
200
|
+
if (cache.size > CACHE_PAGES) {
|
|
201
|
+
const oldest = cache.keys().next().value;
|
|
202
|
+
cache.get(oldest).fill(0);
|
|
203
|
+
cache.delete(oldest);
|
|
204
|
+
}
|
|
205
|
+
return page;
|
|
206
|
+
};
|
|
207
|
+
return {
|
|
208
|
+
byteLength,
|
|
209
|
+
read(target, offset) {
|
|
210
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw error("invalid read offset.");
|
|
211
|
+
target.fill(0);
|
|
212
|
+
const count = Math.min(target.length, Math.max(0, byteLength - offset));
|
|
213
|
+
let copied = 0;
|
|
214
|
+
while (copied < count) {
|
|
215
|
+
const position = offset + copied;
|
|
216
|
+
const pageOffset = position % pageSize;
|
|
217
|
+
const length = Math.min(count - copied, pageSize - pageOffset);
|
|
218
|
+
const page = getPage(Math.floor(position / pageSize) + 1);
|
|
219
|
+
target.set(page.subarray(pageOffset, pageOffset + length), copied);
|
|
220
|
+
copied += length;
|
|
221
|
+
}
|
|
222
|
+
return count;
|
|
223
|
+
},
|
|
224
|
+
isStable() { return paths.map(statOptional).every((stat, i) => sameStat(before[i], stat)); },
|
|
225
|
+
close,
|
|
226
|
+
};
|
|
227
|
+
} catch (err) { close(); throw err; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = { openTraeSnapshot };
|