@richhaase/c2 0.3.2 → 0.4.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,236 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { initStore, inspectDataDir, moveStore, storeSummary } from "./data.ts";
6
+ import { pathsFor } from "./paths.ts";
7
+ import { readMeta, readWorkouts } from "./storage.ts";
8
+
9
+ const NOW = new Date("2026-07-05T12:00:00");
10
+
11
+ async function tempRoot(): Promise<string> {
12
+ return mkdtemp(join(tmpdir(), "c2-data-test-"));
13
+ }
14
+
15
+ const WORKOUT_LINE = JSON.stringify({
16
+ id: 1,
17
+ user_id: 1,
18
+ date: "2026-07-01 08:00:00",
19
+ distance: 8000,
20
+ type: "rower",
21
+ time: 12000,
22
+ time_formatted: "20:00.0",
23
+ });
24
+
25
+ test("inspect reports missing directory", async () => {
26
+ const base = await tempRoot();
27
+ const paths = pathsFor(join(base, "nope"));
28
+ const insp = await inspectDataDir(paths);
29
+ expect(insp.state).toBe("missing");
30
+ expect(insp.writable).toBe(true);
31
+ });
32
+
33
+ test("inspect reports empty, store, legacy store, and foreign", async () => {
34
+ const base = await tempRoot();
35
+
36
+ const empty = pathsFor(join(base, "empty"));
37
+ await mkdir(empty.root);
38
+ expect((await inspectDataDir(empty)).state).toBe("empty");
39
+
40
+ const store = pathsFor(join(base, "store"));
41
+ await mkdir(store.root);
42
+ await initStore(store, NOW);
43
+ expect((await inspectDataDir(store)).state).toBe("store");
44
+
45
+ const legacy = pathsFor(join(base, "legacy"));
46
+ await mkdir(join(legacy.root, "strokes"), { recursive: true });
47
+ await writeFile(legacy.workouts, `${WORKOUT_LINE}\n`, "utf-8");
48
+ expect((await inspectDataDir(legacy)).state).toBe("store");
49
+
50
+ const foreign = pathsFor(join(base, "foreign"));
51
+ await mkdir(foreign.root);
52
+ await writeFile(join(foreign.root, "novel.docx"), "chapter one", "utf-8");
53
+ expect((await inspectDataDir(foreign)).state).toBe("foreign");
54
+ });
55
+
56
+ test("initStore creates directories and meta once", async () => {
57
+ const base = await tempRoot();
58
+ const paths = pathsFor(join(base, "init"));
59
+ await mkdir(paths.root);
60
+ await initStore(paths, NOW);
61
+
62
+ const meta = await readMeta(paths);
63
+ expect(meta?.schema_version).toBe(1);
64
+ expect(meta?.created).toBe(NOW.toISOString());
65
+
66
+ await initStore(paths, new Date("2027-01-01T00:00:00"));
67
+ const again = await readMeta(paths);
68
+ expect(again?.created).toBe(NOW.toISOString());
69
+ });
70
+
71
+ test("storeSummary counts contents", async () => {
72
+ const base = await tempRoot();
73
+ const paths = pathsFor(join(base, "sum"));
74
+ await mkdir(paths.root);
75
+ await initStore(paths, NOW);
76
+ await writeFile(paths.workouts, `${WORKOUT_LINE}\n`, "utf-8");
77
+ await writeFile(paths.strokeFile(1), '{"t":1}\n', "utf-8");
78
+ await writeFile(
79
+ join(paths.notesDir, "01A.json"),
80
+ JSON.stringify({
81
+ id: "01A",
82
+ date: "2026-07-01T08:00:00-06:00",
83
+ type: "observation",
84
+ body: "counted",
85
+ author: "athlete",
86
+ }),
87
+ "utf-8",
88
+ );
89
+
90
+ const summary = await storeSummary(paths);
91
+ expect(summary.workouts).toBe(1);
92
+ expect(summary.firstDate).toBe("2026-07-01");
93
+ expect(summary.strokeFiles).toBe(1);
94
+ expect(summary.notes).toBe(1);
95
+ expect(summary.schemaVersion).toBe(1);
96
+ });
97
+
98
+ test("missing nested paths are creatable, not rejected", async () => {
99
+ const base = await tempRoot();
100
+ const nested = pathsFor(join(base, "a", "b", "c"));
101
+ const insp = await inspectDataDir(nested);
102
+ expect(insp.state).toBe("missing");
103
+ expect(insp.writable).toBe(true);
104
+
105
+ await initStore(nested, NOW);
106
+ expect((await inspectDataDir(nested)).state).toBe("store");
107
+ });
108
+
109
+ test("moveStore tolerates pre-existing dotfiles in the target", async () => {
110
+ const base = await tempRoot();
111
+ const from = pathsFor(join(base, "src"));
112
+ await mkdir(from.root);
113
+ await initStore(from, NOW);
114
+ await writeFile(from.workouts, `${WORKOUT_LINE}\n`, "utf-8");
115
+
116
+ const to = pathsFor(join(base, "gitrepo"));
117
+ await mkdir(join(to.root, ".git"), { recursive: true });
118
+ await writeFile(join(to.root, ".git", "HEAD"), "ref: refs/heads/main\n", "utf-8");
119
+ await writeFile(join(to.root, ".DS_Store"), "junk", "utf-8");
120
+
121
+ const src = await treeStatsPublic(from.root);
122
+ const copied = await moveStore(from, to);
123
+ expect(copied.files).toBe(src.files);
124
+ expect((await readWorkouts(to)).length).toBe(1);
125
+ });
126
+
127
+ async function treeStatsPublic(dir: string): Promise<{ files: number }> {
128
+ const { readdir: rd, stat: st } = await import("node:fs/promises");
129
+ let files = 0;
130
+ for (const e of await rd(dir, { withFileTypes: true })) {
131
+ const full = join(dir, e.name);
132
+ if (e.isDirectory()) files += (await treeStatsPublic(full)).files;
133
+ else {
134
+ await st(full);
135
+ files++;
136
+ }
137
+ }
138
+ return { files };
139
+ }
140
+
141
+ test("moveStore never copies dotfiles and preserves target VCS metadata", async () => {
142
+ const base = await tempRoot();
143
+ const from = pathsFor(join(base, "src"));
144
+ await mkdir(from.root);
145
+ await initStore(from, NOW);
146
+ await writeFile(from.workouts, `${WORKOUT_LINE}\n`, "utf-8");
147
+ await writeFile(join(from.root, ".DS_Store"), "source junk", "utf-8");
148
+ await mkdir(join(from.root, ".git"));
149
+ await writeFile(join(from.root, ".git", "HEAD"), "ref: refs/heads/source\n", "utf-8");
150
+
151
+ const to = pathsFor(join(base, "synced"));
152
+ await mkdir(join(to.root, ".git"), { recursive: true });
153
+ await writeFile(join(to.root, ".git", "HEAD"), "ref: refs/heads/target\n", "utf-8");
154
+ await writeFile(join(to.root, ".DS_Store"), "different pre-existing junk!", "utf-8");
155
+
156
+ const copied = await moveStore(from, to);
157
+ expect(copied.files).toBeGreaterThanOrEqual(2);
158
+ expect((await readWorkouts(to)).length).toBe(1);
159
+ expect(await readFile(join(to.root, ".git", "HEAD"), "utf-8")).toBe("ref: refs/heads/target\n");
160
+ expect(await readFile(join(to.root, ".DS_Store"), "utf-8")).toBe("different pre-existing junk!");
161
+ });
162
+
163
+ test("generic folder names alone are not adopted as stores", async () => {
164
+ const base = await tempRoot();
165
+
166
+ const notesOnly = pathsFor(join(base, "notes-only"));
167
+ await mkdir(join(notesOnly.root, "notes"), { recursive: true });
168
+ expect((await inspectDataDir(notesOnly)).state).toBe("foreign");
169
+
170
+ const planOnly = pathsFor(join(base, "plan-only"));
171
+ await mkdir(planOnly.root);
172
+ await writeFile(planOnly.plan, "# someone else's plan\n", "utf-8");
173
+ expect((await inspectDataDir(planOnly)).state).toBe("foreign");
174
+ });
175
+
176
+ test("a directory with only a foreign meta.json is not adopted", async () => {
177
+ const base = await tempRoot();
178
+ const paths = pathsFor(join(base, "other-tool"));
179
+ await mkdir(paths.root);
180
+ await writeFile(paths.meta, JSON.stringify({ name: "some other tool" }), "utf-8");
181
+ expect((await inspectDataDir(paths)).state).toBe("foreign");
182
+ });
183
+
184
+ test("a store missing meta.json but with current-layout dirs is recognized", async () => {
185
+ const base = await tempRoot();
186
+ const paths = pathsFor(join(base, "meta-lost"));
187
+ await mkdir(join(paths.root, "strokes"), { recursive: true });
188
+ await mkdir(join(paths.root, "reports"), { recursive: true });
189
+ await writeFile(paths.workouts, `${WORKOUT_LINE}\n`, "utf-8");
190
+ await writeFile(paths.plan, "# plan\n", "utf-8");
191
+ expect((await inspectDataDir(paths)).state).toBe("store");
192
+ });
193
+
194
+ test("a file in the path yields foreign, not a raw ENOTDIR", async () => {
195
+ const base = await tempRoot();
196
+ const filePath = join(base, "regular-file");
197
+ await writeFile(filePath, "hi", "utf-8");
198
+ const nested = pathsFor(join(filePath, "sub"));
199
+ const insp = await inspectDataDir(nested);
200
+ expect(insp.state).toBe("foreign");
201
+ expect(insp.writable).toBe(false);
202
+ });
203
+
204
+ test("corrupt meta.json is tolerated, not fatal", async () => {
205
+ const base = await tempRoot();
206
+ const paths = pathsFor(join(base, "corrupt"));
207
+ await mkdir(paths.root);
208
+ await initStore(paths, NOW);
209
+ await writeFile(paths.meta, "{ truncated", "utf-8");
210
+
211
+ const insp = await inspectDataDir(paths);
212
+ expect(insp.state).toBe("store");
213
+ expect(await readMeta(paths)).toBeNull();
214
+ const summary = await storeSummary(paths);
215
+ expect(summary.schemaVersion).toBeNull();
216
+ });
217
+
218
+ test("moveStore copies, verifies, and refuses non-empty targets", async () => {
219
+ const base = await tempRoot();
220
+ const from = pathsFor(join(base, "src"));
221
+ await mkdir(from.root);
222
+ await initStore(from, NOW);
223
+ await writeFile(from.workouts, `${WORKOUT_LINE}\n`, "utf-8");
224
+ await writeFile(from.strokeFile(1), '{"t":1}\n', "utf-8");
225
+
226
+ const to = pathsFor(join(base, "dst"));
227
+ const copied = await moveStore(from, to);
228
+ expect(copied.files).toBeGreaterThanOrEqual(3);
229
+ expect((await readWorkouts(to)).length).toBe(1);
230
+ expect((await readdir(to.strokesDir)).length).toBe(1);
231
+
232
+ const occupied = pathsFor(join(base, "occupied"));
233
+ await mkdir(occupied.root);
234
+ await writeFile(join(occupied.root, "file.txt"), "x", "utf-8");
235
+ await expect(moveStore(from, occupied)).rejects.toThrow("not empty");
236
+ });
package/src/data.ts ADDED
@@ -0,0 +1,200 @@
1
+ import { cp, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { calendarDay } from "./models.ts";
4
+ import { readAllNotes } from "./notes.ts";
5
+ import type { DataPaths } from "./paths.ts";
6
+ import { readMeta, readWorkouts, SCHEMA_VERSION, writeMeta } from "./storage.ts";
7
+
8
+ export type DirState = "missing" | "store" | "empty" | "foreign";
9
+
10
+ export interface DirInspection {
11
+ path: string;
12
+ state: DirState;
13
+ writable: boolean;
14
+ }
15
+
16
+ export interface StoreSummary {
17
+ workouts: number;
18
+ firstDate: string;
19
+ lastDate: string;
20
+ strokeFiles: number;
21
+ notes: number;
22
+ schemaVersion: number | null;
23
+ lastSync: string | null;
24
+ }
25
+
26
+ const STORE_MARKERS = new Set([
27
+ "workouts.jsonl",
28
+ "strokes",
29
+ "notes",
30
+ "reports",
31
+ "plan.md",
32
+ "playbook.md",
33
+ ]);
34
+
35
+ export async function inspectDataDir(paths: DataPaths): Promise<DirInspection> {
36
+ let state: DirState;
37
+ try {
38
+ const s = await stat(paths.root);
39
+ if (!s.isDirectory()) {
40
+ return { path: paths.root, state: "foreign", writable: false };
41
+ }
42
+ const meta = await readMeta(paths);
43
+ const entries = (await readdir(paths.root)).filter((e) => !e.startsWith("."));
44
+ if (meta != null && typeof meta.schema_version === "number") {
45
+ state = "store";
46
+ } else if (entries.length === 0) {
47
+ state = "empty";
48
+ } else {
49
+ const hasStrongMarker = entries.includes("workouts.jsonl") || entries.includes("strokes");
50
+ const allKnown = entries.every((e) => STORE_MARKERS.has(e) || e === "meta.json");
51
+ state = hasStrongMarker && allKnown ? "store" : "foreign";
52
+ }
53
+ } catch (err: unknown) {
54
+ const code = (err as NodeJS.ErrnoException).code;
55
+ if (code === "ENOTDIR") {
56
+ return { path: paths.root, state: "foreign", writable: false };
57
+ }
58
+ if (code !== "ENOENT") throw err;
59
+ return { path: paths.root, state: "missing", writable: await parentWritable(paths.root) };
60
+ }
61
+ return { path: paths.root, state, writable: await probeWritable(paths.root) };
62
+ }
63
+
64
+ async function parentWritable(path: string): Promise<boolean> {
65
+ let current = path;
66
+ for (;;) {
67
+ const parent = dirname(current);
68
+ if (parent === current) return false;
69
+ try {
70
+ await stat(parent);
71
+ return probeWritable(parent);
72
+ } catch (err: unknown) {
73
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") return false;
74
+ current = parent;
75
+ }
76
+ }
77
+ }
78
+
79
+ async function probeWritable(dir: string): Promise<boolean> {
80
+ const probe = join(dir, `.c2-probe-${process.pid}`);
81
+ try {
82
+ await writeFile(probe, "", "utf-8");
83
+ await rm(probe);
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ export async function rejectForeignStore(paths: DataPaths): Promise<string | null> {
91
+ const inspection = await inspectDataDir(paths);
92
+ if (inspection.state === "foreign") {
93
+ return `${paths.root} exists but is not a c2 data store. Fix data_dir via \`c2 setup\`.`;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ export async function ensureStoreForWrite(paths: DataPaths, now: Date): Promise<string | null> {
99
+ const inspection = await inspectDataDir(paths);
100
+ if (inspection.state === "foreign") {
101
+ return `${paths.root} exists but is not a c2 data store. Fix data_dir via \`c2 setup\`.`;
102
+ }
103
+ if (!inspection.writable) {
104
+ return `Cannot write to ${paths.root}.`;
105
+ }
106
+ await initStore(paths, now);
107
+ return null;
108
+ }
109
+
110
+ export async function initStore(paths: DataPaths, now: Date): Promise<void> {
111
+ await mkdir(paths.strokesDir, { recursive: true });
112
+ await mkdir(paths.archiveDir, { recursive: true });
113
+ await mkdir(paths.reportsDir, { recursive: true });
114
+ const meta = await readMeta(paths);
115
+ if (meta == null) {
116
+ await writeMeta(paths, { schema_version: SCHEMA_VERSION, created: now.toISOString() });
117
+ }
118
+ }
119
+
120
+ async function listIfPresent(dir: string): Promise<string[]> {
121
+ try {
122
+ return await readdir(dir);
123
+ } catch (err: unknown) {
124
+ const code = (err as NodeJS.ErrnoException).code;
125
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
126
+ throw err;
127
+ }
128
+ }
129
+
130
+ export async function storeSummary(paths: DataPaths): Promise<StoreSummary> {
131
+ const workouts = await readWorkouts(paths);
132
+ const days = workouts.map(calendarDay).sort();
133
+ const strokeFiles = (await listIfPresent(paths.strokesDir)).filter((f) =>
134
+ f.endsWith(".jsonl"),
135
+ ).length;
136
+ const notes = (await readAllNotes(paths)).length;
137
+ const meta = await readMeta(paths);
138
+ return {
139
+ workouts: workouts.length,
140
+ firstDate: days[0] ?? "",
141
+ lastDate: days[days.length - 1] ?? "",
142
+ strokeFiles,
143
+ notes,
144
+ schemaVersion: meta?.schema_version ?? null,
145
+ lastSync: meta?.last_sync ?? null,
146
+ };
147
+ }
148
+
149
+ export async function moveStore(
150
+ from: DataPaths,
151
+ to: DataPaths,
152
+ ): Promise<{ files: number; bytes: number }> {
153
+ const target = await inspectDataDir(to);
154
+ if (target.state === "store" || target.state === "foreign") {
155
+ throw new Error(`target ${to.root} is not empty`);
156
+ }
157
+ if (!target.writable) {
158
+ throw new Error(`target ${to.root} is not writable`);
159
+ }
160
+ await mkdir(to.root, { recursive: true });
161
+ await cp(from.root, to.root, {
162
+ recursive: true,
163
+ filter: (src) => src === from.root || !basename(src).startsWith("."),
164
+ });
165
+ return verifyCopy(from.root, to.root);
166
+ }
167
+
168
+ async function verifyCopy(
169
+ fromDir: string,
170
+ toDir: string,
171
+ ): Promise<{ files: number; bytes: number }> {
172
+ let files = 0;
173
+ let bytes = 0;
174
+ for (const e of await readdir(fromDir, { withFileTypes: true })) {
175
+ if (e.name.startsWith(".")) continue;
176
+ const src = join(fromDir, e.name);
177
+ const dst = join(toDir, e.name);
178
+ if (e.isDirectory()) {
179
+ const sub = await verifyCopy(src, dst);
180
+ files += sub.files;
181
+ bytes += sub.bytes;
182
+ } else {
183
+ const srcStat = await stat(src);
184
+ let dstStat: Awaited<ReturnType<typeof stat>>;
185
+ try {
186
+ dstStat = await stat(dst);
187
+ } catch {
188
+ throw new Error(`copy verification failed: ${dst} is missing`);
189
+ }
190
+ if (dstStat.size !== srcStat.size) {
191
+ throw new Error(
192
+ `copy verification failed: ${dst} has ${dstStat.size} bytes, expected ${srcStat.size}`,
193
+ );
194
+ }
195
+ files++;
196
+ bytes += srcStat.size;
197
+ }
198
+ }
199
+ return { files, bytes };
200
+ }
@@ -155,7 +155,6 @@ describe("formatWorkoutLine", () => {
155
155
  });
156
156
 
157
157
  test("appends [IVL rest M:SS.S] tag for interval workouts", () => {
158
- // Real 2026-04-11 session: 6x500m, 14:22.6 work, 6:00 rest.
159
158
  const w = makeWorkout({
160
159
  date: "2026-04-11 09:14:00",
161
160
  distance: 3000,
@@ -170,7 +169,6 @@ describe("formatWorkoutLine", () => {
170
169
  });
171
170
  const line = formatWorkoutLine(w, "01/02");
172
171
  expect(line).toContain("[IVL rest 6:00.0]");
173
- // Pace comes from work time, not elapsed — must be 2:23.8, not 3:23.8.
174
172
  expect(line).toContain("2:23.8/500m");
175
173
  });
176
174
 
package/src/display.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import type { Workout } from "./models.ts";
2
- import { formatSeconds, isIntervalWorkout, pace500m, parsedDate, restSeconds } from "./models.ts";
2
+ import {
3
+ formatSeconds,
4
+ isIntervalWorkout,
5
+ pace500m,
6
+ pace500mSeconds,
7
+ parsedDate,
8
+ restSeconds,
9
+ } from "./models.ts";
3
10
 
4
11
  export function formatMeters(m: number): string {
5
12
  return m.toLocaleString("en-US");
@@ -51,6 +58,28 @@ export function formatWorkoutLine(w: Workout, dateFormat: string): string {
51
58
  return `${dateStr} ${distance.padStart(7)} ${w.time_formatted.padStart(8)} ${pace.padStart(7)}/500m ${spm.padStart(5)} ${hr.padStart(6)} ${df.padStart(4)}${tagSuffix}`;
52
59
  }
53
60
 
61
+ export function workoutJSON(w: Workout) {
62
+ const paceSecs = pace500mSeconds(w);
63
+ const rest = restSeconds(w);
64
+ return {
65
+ id: w.id,
66
+ date: w.date,
67
+ distance: w.distance,
68
+ time_tenths: w.time,
69
+ time_formatted: w.time_formatted,
70
+ pace_500m: paceSecs > 0 ? pace500m(w) : null,
71
+ pace_500m_seconds: paceSecs > 0 ? Math.round(paceSecs * 10) / 10 : null,
72
+ stroke_rate: w.stroke_rate ?? null,
73
+ hr_avg: w.heart_rate?.average ?? null,
74
+ drag_factor: w.drag_factor ?? null,
75
+ workout_type: w.workout_type ?? null,
76
+ interval: isIntervalWorkout(w),
77
+ rest_seconds: rest > 0 ? rest : null,
78
+ comments: w.comments ?? null,
79
+ has_stroke_data: w.stroke_data ?? false,
80
+ };
81
+ }
82
+
54
83
  export function sparkBar(value: number, max: number): string {
55
84
  if (max === 0) return "";
56
85
  const BAR_WIDTH = 20;
@@ -70,5 +99,5 @@ export function trendArrow(prev: number, curr: number): string {
70
99
 
71
100
  export function paceArrow(prev: number, curr: number): string {
72
101
  if (prev === 0) return " ";
73
- return trendArrow(curr, prev); // reversed: lower pace is better
102
+ return trendArrow(curr, prev);
74
103
  }
package/src/doctor.ts ADDED
@@ -0,0 +1,174 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { isNoteShaped, serializeNote } from "./notes.ts";
4
+ import type { DataPaths } from "./paths.ts";
5
+
6
+ export interface DoctorReport {
7
+ issues: string[];
8
+ checkedFiles: number;
9
+ }
10
+
11
+ async function readOrNull(path: string, label: string, issues: string[]): Promise<string | null> {
12
+ try {
13
+ return await readFile(path, "utf-8");
14
+ } catch (err: unknown) {
15
+ const code = (err as NodeJS.ErrnoException).code;
16
+ if (code !== "ENOENT") {
17
+ issues.push(`${label}: unreadable (${code ?? (err as Error).message})`);
18
+ }
19
+ return null;
20
+ }
21
+ }
22
+
23
+ async function listDir(dir: string, label: string, issues: string[]): Promise<string[]> {
24
+ try {
25
+ return await readdir(dir);
26
+ } catch (err: unknown) {
27
+ const code = (err as NodeJS.ErrnoException).code;
28
+ if (code !== "ENOENT") {
29
+ issues.push(`${label}: unreadable (${code ?? (err as Error).message})`);
30
+ }
31
+ return [];
32
+ }
33
+ }
34
+
35
+ const STROKE_FIELDS = ["t", "d", "p", "spm", "hr"] as const;
36
+
37
+ function isStrokeShaped(parsed: unknown): boolean {
38
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) return false;
39
+ const row = parsed as Record<string, unknown>;
40
+ return STROKE_FIELDS.every((k) => row[k] === undefined || typeof row[k] === "number");
41
+ }
42
+
43
+ export async function runDoctor(paths: DataPaths): Promise<DoctorReport> {
44
+ const issues: string[] = [];
45
+ let checkedFiles = 0;
46
+
47
+ const meta = await readOrNull(paths.meta, "meta.json", issues);
48
+ if (meta != null) {
49
+ checkedFiles++;
50
+ try {
51
+ const parsed = JSON.parse(meta);
52
+ if (typeof parsed?.schema_version !== "number") {
53
+ issues.push("meta.json: missing numeric schema_version");
54
+ }
55
+ } catch {
56
+ issues.push("meta.json: not valid JSON");
57
+ }
58
+ }
59
+
60
+ const workouts = await readOrNull(paths.workouts, "workouts.jsonl", issues);
61
+ if (workouts != null) {
62
+ checkedFiles++;
63
+ let lineNo = 0;
64
+ for (const line of workouts.split("\n")) {
65
+ lineNo++;
66
+ if (line.trim() === "") continue;
67
+ let parsed: unknown;
68
+ try {
69
+ parsed = JSON.parse(line);
70
+ } catch {
71
+ issues.push(`workouts.jsonl: line ${lineNo} is not valid JSON`);
72
+ continue;
73
+ }
74
+ const w = parsed as { id?: unknown; date?: unknown; distance?: unknown; time?: unknown };
75
+ if (
76
+ parsed == null ||
77
+ typeof parsed !== "object" ||
78
+ typeof w.id !== "number" ||
79
+ typeof w.date !== "string" ||
80
+ typeof w.distance !== "number" ||
81
+ typeof w.time !== "number"
82
+ ) {
83
+ issues.push(`workouts.jsonl: line ${lineNo} malformed workout record`);
84
+ }
85
+ }
86
+ }
87
+
88
+ for (const f of await listDir(paths.strokesDir, "strokes/", issues)) {
89
+ if (!f.endsWith(".jsonl")) continue;
90
+ const text = await readOrNull(join(paths.strokesDir, f), `strokes/${f}`, issues);
91
+ if (text == null) continue;
92
+ checkedFiles++;
93
+ let lineNo = 0;
94
+ for (const line of text.split("\n")) {
95
+ lineNo++;
96
+ if (line.trim() === "") continue;
97
+ let parsed: unknown;
98
+ try {
99
+ parsed = JSON.parse(line);
100
+ } catch {
101
+ issues.push(`strokes/${f}: line ${lineNo} is not valid JSON`);
102
+ continue;
103
+ }
104
+ if (!isStrokeShaped(parsed)) {
105
+ issues.push(`strokes/${f}: line ${lineNo} malformed stroke record`);
106
+ }
107
+ }
108
+ }
109
+
110
+ const looseContent = new Map<string, { content: string; file: string }>();
111
+ const divergentIds = new Set<string>();
112
+ for (const f of await listDir(paths.notesDir, "notes/", issues)) {
113
+ if (!f.endsWith(".json")) continue;
114
+ const text = await readOrNull(join(paths.notesDir, f), `notes/${f}`, issues);
115
+ if (text == null) continue;
116
+ checkedFiles++;
117
+ try {
118
+ const parsed = JSON.parse(text) as unknown;
119
+ if (!isNoteShaped(parsed)) {
120
+ issues.push(`notes/${f}: malformed note record`);
121
+ } else {
122
+ const content = serializeNote(parsed);
123
+ const prior = looseContent.get(parsed.id);
124
+ if (prior != null && prior.content !== content && !divergentIds.has(parsed.id)) {
125
+ divergentIds.add(parsed.id);
126
+ issues.push(
127
+ `notes: divergent copies of note ${parsed.id} (${prior.file}, ${f}); reconcile before they compact`,
128
+ );
129
+ }
130
+ looseContent.set(parsed.id, { content, file: f });
131
+ }
132
+ } catch {
133
+ issues.push(`notes/${f}: not valid JSON`);
134
+ }
135
+ }
136
+
137
+ const archiveIds = new Set<string>();
138
+ for (const f of await listDir(paths.archiveDir, "notes/archive/", issues)) {
139
+ if (!f.endsWith(".jsonl")) continue;
140
+ const text = await readOrNull(join(paths.archiveDir, f), `notes/archive/${f}`, issues);
141
+ if (text == null) continue;
142
+ checkedFiles++;
143
+ let prevMs = Number.NEGATIVE_INFINITY;
144
+ let prevId = "";
145
+ let lineNo = 0;
146
+ for (const line of text.split("\n")) {
147
+ lineNo++;
148
+ if (line.trim() === "") continue;
149
+ let parsed: unknown;
150
+ try {
151
+ parsed = JSON.parse(line);
152
+ } catch {
153
+ issues.push(`notes/archive/${f}: line ${lineNo} is not valid JSON`);
154
+ continue;
155
+ }
156
+ if (!isNoteShaped(parsed)) {
157
+ issues.push(`notes/archive/${f}: line ${lineNo} malformed note record`);
158
+ continue;
159
+ }
160
+ const ms = new Date(parsed.date).getTime();
161
+ if (ms < prevMs || (ms === prevMs && parsed.id < prevId)) {
162
+ issues.push(`notes/archive/${f}: line ${lineNo} out of (date, id) order`);
163
+ }
164
+ prevMs = ms;
165
+ prevId = parsed.id;
166
+ if (archiveIds.has(parsed.id)) {
167
+ issues.push(`notes/archive/${f}: duplicate note id ${parsed.id}`);
168
+ }
169
+ archiveIds.add(parsed.id);
170
+ }
171
+ }
172
+
173
+ return { issues, checkedFiles };
174
+ }
@@ -0,0 +1,17 @@
1
+ export interface Envelope<T> {
2
+ schema: string;
3
+ generated_at: string;
4
+ data: T;
5
+ }
6
+
7
+ export function envelope<T>(schema: string, data: T, now?: Date): Envelope<T> {
8
+ return {
9
+ schema,
10
+ generated_at: (now ?? new Date()).toISOString(),
11
+ data,
12
+ };
13
+ }
14
+
15
+ export function printJSON<T>(schema: string, data: T): void {
16
+ console.log(JSON.stringify(envelope(schema, data), null, 2));
17
+ }