@trazum/cli 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Where the store actually lives.
3
+ *
4
+ * The core decides what a record is and when two are the same; this decides
5
+ * where the bytes go. Split that way for the reason every module here is:
6
+ * `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, one buffer per write.** A pull appends a single block and
9
+ * never rewrites what is already there. Two consequences worth stating: a
10
+ * crash during a write loses the tail of one block rather than a year of
11
+ * measurements, and two runs writing at once interleave whole blocks rather
12
+ * than half-lines. Compaction is a separate, explicit errand — `store
13
+ * --prune` — because collapsing a log is the one operation that destroys
14
+ * something, and it should never happen as a side effect of a pull.
15
+ *
16
+ * **A line that will not parse is kept, counted and skipped.** The store is a
17
+ * file a human may open, a backup may truncate and a merge may mangle. Losing
18
+ * the whole month because one line is broken would be the worst possible
19
+ * response; so would silently pretending the month is complete.
20
+ */
21
+
22
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+ import { resolveStore } from '@trazum/core';
25
+ import type { ResolvedStore, StoreRecord } from '@trazum/core';
26
+
27
+ /** The directory name, relative to wherever the caller roots the store. */
28
+ export const STORE_DIR = '.trazum/store';
29
+
30
+ /** Records are filed by the UTC month their window starts in. */
31
+ function monthOf(record: StoreRecord): string {
32
+ return new Date(record.fromMs).toISOString().slice(0, 7);
33
+ }
34
+
35
+ export interface StoreReadResult {
36
+ resolved: ResolvedStore;
37
+ /** Lines that would not parse: counted and named by file, never dropped quietly. */
38
+ unreadable: { file: string; line: number }[];
39
+ /** Files read, so an empty store can be told from an unread one. */
40
+ files: string[];
41
+ }
42
+
43
+ /**
44
+ * Reads every record in the store.
45
+ *
46
+ * Returns an empty result rather than throwing when the store does not exist:
47
+ * "you have not stored anything yet" is a state, not an error, and the caller
48
+ * says so in a sentence that names `trazum connect`.
49
+ */
50
+ export async function readStore(root: string): Promise<StoreReadResult> {
51
+ const dir = join(root, STORE_DIR);
52
+ const records: StoreRecord[] = [];
53
+ const unreadable: { file: string; line: number }[] = [];
54
+ const files: string[] = [];
55
+
56
+ let providers: string[];
57
+ try {
58
+ const entries = await readdir(dir, { withFileTypes: true });
59
+ providers = entries.filter((e) => e.isDirectory()).map((e) => e.name);
60
+ } catch {
61
+ return { resolved: resolveStore([]), unreadable, files };
62
+ }
63
+
64
+ for (const provider of providers.sort()) {
65
+ const providerDir = join(dir, provider);
66
+ let months: string[];
67
+ try {
68
+ months = (await readdir(providerDir)).filter((name) => name.endsWith('.jsonl')).sort();
69
+ } catch {
70
+ continue;
71
+ }
72
+ for (const month of months) {
73
+ const path = join(providerDir, month);
74
+ files.push(join(STORE_DIR, provider, month));
75
+ const text = await readFile(path, 'utf8');
76
+ for (const [index, line] of text.split('\n').entries()) {
77
+ if (line.trim() === '') continue;
78
+ try {
79
+ const parsed = JSON.parse(line) as StoreRecord;
80
+ if (typeof parsed?.provider === 'string' && typeof parsed?.fromMs === 'number') {
81
+ records.push(parsed);
82
+ } else {
83
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
84
+ }
85
+ } catch {
86
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ return { resolved: resolveStore(records), unreadable, files };
93
+ }
94
+
95
+ /**
96
+ * Appends records, grouped into one write per month file.
97
+ *
98
+ * Nothing already on disk is read, rewritten or resolved here: convergence
99
+ * happens when the store is *read*, which is what keeps a write cheap enough
100
+ * to run on a schedule and impossible to corrupt by racing.
101
+ */
102
+ export async function appendRecords(root: string, records: readonly StoreRecord[]): Promise<number> {
103
+ if (records.length === 0) return 0;
104
+ const byFile = new Map<string, StoreRecord[]>();
105
+ for (const record of records) {
106
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
107
+ const list = byFile.get(key) ?? [];
108
+ list.push(record);
109
+ byFile.set(key, list);
110
+ }
111
+
112
+ for (const [relative, list] of byFile) {
113
+ const path = join(root, STORE_DIR, relative);
114
+ await mkdir(join(path, '..'), { recursive: true });
115
+ const block = `${list.map((record) => JSON.stringify(record)).join('\n')}\n`;
116
+ await writeFile(path, block, { flag: 'a', mode: 0o600 });
117
+ }
118
+ return records.length;
119
+ }
120
+
121
+ /**
122
+ * Rewrites the store with exactly the records given.
123
+ *
124
+ * The one operation that destroys something, so it is only ever reached from
125
+ * an explicit `--prune`. Each month file is written whole, and a month left
126
+ * with nothing is written empty rather than removed — a missing file and an
127
+ * empty one say different things to whoever looks next.
128
+ */
129
+ export async function rewriteStore(root: string, records: readonly StoreRecord[]): Promise<void> {
130
+ const dir = join(root, STORE_DIR);
131
+ const existing = new Set<string>();
132
+ try {
133
+ for (const provider of await readdir(dir)) {
134
+ for (const month of await readdir(join(dir, provider)).catch(() => [])) {
135
+ if (month.endsWith('.jsonl')) existing.add(join(provider, month));
136
+ }
137
+ }
138
+ } catch {
139
+ // Nothing stored yet: the writes below create what is needed.
140
+ }
141
+
142
+ const byFile = new Map<string, StoreRecord[]>();
143
+ for (const record of records) {
144
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
145
+ const list = byFile.get(key) ?? [];
146
+ list.push(record);
147
+ byFile.set(key, list);
148
+ }
149
+
150
+ for (const relative of new Set([...existing, ...byFile.keys()])) {
151
+ const list = byFile.get(relative) ?? [];
152
+ const path = join(dir, relative);
153
+ await mkdir(join(path, '..'), { recursive: true });
154
+ const block = list.length === 0 ? '' : `${list.map((r) => JSON.stringify(r)).join('\n')}\n`;
155
+ await writeFile(path, block, { mode: 0o600 });
156
+ }
157
+ }