From b4c85c65c150553a58dcc84f9df77ec7a80e98f5 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 24 Apr 2026 11:01:08 +0200 Subject: [PATCH] feat(cli): zod-validate JSONL rows in colony import The import command used to JSON.parse each line and feed the fields through String() / Number() into Storage. Malformed exports became silent corruption: sessions with string timestamps were stored as NaN, missing fields became the literal "undefined" string, and an unrecognized row type was silently skipped. Parse each row with a discriminated-union zod schema and fail with `:: : ` on any mismatch. Export schema is now exported from the command module for direct testing. --- .changeset/export-import-validate.md | 9 +++ apps/cli/src/commands/export.ts | 87 +++++++++++++++++++++------- apps/cli/test/export.test.ts | 59 +++++++++++++++++++ 3 files changed, 133 insertions(+), 22 deletions(-) create mode 100644 .changeset/export-import-validate.md create mode 100644 apps/cli/test/export.test.ts diff --git a/.changeset/export-import-validate.md b/.changeset/export-import-validate.md new file mode 100644 index 0000000..706cf91 --- /dev/null +++ b/.changeset/export-import-validate.md @@ -0,0 +1,9 @@ +--- +'@imdeadpool/colony': patch +--- + +Validate each JSONL row in `colony import` with a zod discriminated +union. Previously malformed rows were coerced with `String()` / +`Number()` and silently inserted as `NaN` timestamps or `"undefined"` +strings. Now the command fails fast with `:: : +` the moment a row does not match the export schema. diff --git a/apps/cli/src/commands/export.ts b/apps/cli/src/commands/export.ts index 5873927..cd26d82 100644 --- a/apps/cli/src/commands/export.ts +++ b/apps/cli/src/commands/export.ts @@ -3,6 +3,30 @@ import { join } from 'node:path'; import { loadSettings, resolveDataDir } from '@colony/config'; import { Storage } from '@colony/storage'; import type { Command } from 'commander'; +import { z } from 'zod'; + +const SessionRecord = z.object({ + type: z.literal('session'), + id: z.string(), + ide: z.string(), + cwd: z.string().nullable().optional(), + started_at: z.number(), + metadata: z.string().nullable().optional(), +}); + +const ObservationRecord = z.object({ + type: z.literal('observation'), + session_id: z.string(), + kind: z.string(), + content: z.string(), + compressed: z + .union([z.boolean(), z.literal(0), z.literal(1)]) + .transform((v) => v === true || v === 1), + intensity: z.string().nullable().optional(), + ts: z.number().optional(), +}); + +export const ImportRecord = z.discriminatedUnion('type', [SessionRecord, ObservationRecord]); export function registerExportCommand(program: Command): void { program @@ -31,32 +55,51 @@ export function registerExportCommand(program: Command): void { .action(async (file: string) => { const settings = loadSettings(); const s = new Storage(join(resolveDataDir(settings.dataDir), 'data.db')); - const lines = readFileSync(file, 'utf8').split(/\n+/).filter(Boolean); + const lines = readFileSync(file, 'utf8').split(/\n+/); let n = 0; - for (const line of lines) { - const rec = JSON.parse(line) as { type: string } & Record; - if (rec.type === 'session') { - s.createSession({ - id: String(rec.id), - ide: String(rec.ide), - cwd: (rec.cwd as string | null) ?? null, - started_at: Number(rec.started_at), - metadata: (rec.metadata as string | null) ?? null, - }); - n++; - } else if (rec.type === 'observation') { - s.insertObservation({ - session_id: String(rec.session_id), - kind: String(rec.kind), - content: String(rec.content), - compressed: rec.compressed === 1 || rec.compressed === true, - intensity: (rec.intensity as string | null) ?? null, - ts: Number(rec.ts), - }); + try { + for (let i = 0; i < lines.length; i++) { + const raw = lines[i]; + if (!raw) continue; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error( + `${file}:${i + 1}: invalid JSON — ${(err as Error).message}`, + ); + } + const result = ImportRecord.safeParse(parsed); + if (!result.success) { + const msg = result.error.issues + .map((iss) => `${iss.path.join('.') || ''}: ${iss.message}`) + .join('; '); + throw new Error(`${file}:${i + 1}: ${msg}`); + } + const rec = result.data; + if (rec.type === 'session') { + s.createSession({ + id: rec.id, + ide: rec.ide, + cwd: rec.cwd ?? null, + started_at: rec.started_at, + metadata: rec.metadata ?? null, + }); + } else { + s.insertObservation({ + session_id: rec.session_id, + kind: rec.kind, + content: rec.content, + compressed: rec.compressed, + intensity: rec.intensity ?? null, + ...(rec.ts !== undefined ? { ts: rec.ts } : {}), + }); + } n++; } + } finally { + s.close(); } - s.close(); process.stdout.write(`imported ${n} records\n`); }); } diff --git a/apps/cli/test/export.test.ts b/apps/cli/test/export.test.ts new file mode 100644 index 0000000..9f1a54a --- /dev/null +++ b/apps/cli/test/export.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { ImportRecord } from '../src/commands/export.js'; + +describe('ImportRecord schema', () => { + it('accepts a well-formed session row', () => { + const ok = ImportRecord.safeParse({ + type: 'session', + id: 'claude@abc', + ide: 'claude-code', + cwd: '/repo', + started_at: 123, + metadata: null, + }); + expect(ok.success).toBe(true); + }); + + it('accepts a well-formed observation row with numeric compressed flag', () => { + const ok = ImportRecord.safeParse({ + type: 'observation', + session_id: 's1', + kind: 'note', + content: 'hello', + compressed: 1, + intensity: 'full', + ts: 1000, + }); + expect(ok.success).toBe(true); + if (ok.success && ok.data.type === 'observation') { + expect(ok.data.compressed).toBe(true); + } + }); + + it('rejects rows with an unknown discriminator', () => { + const res = ImportRecord.safeParse({ type: 'garbage', id: 'x' }); + expect(res.success).toBe(false); + }); + + it('rejects a session with a non-numeric started_at', () => { + const res = ImportRecord.safeParse({ + type: 'session', + id: 'a', + ide: 'claude-code', + cwd: null, + started_at: 'yesterday', + metadata: null, + }); + expect(res.success).toBe(false); + }); + + it('rejects an observation missing required content', () => { + const res = ImportRecord.safeParse({ + type: 'observation', + session_id: 's', + kind: 'note', + compressed: true, + }); + expect(res.success).toBe(false); + }); +});