diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cfa5377..8ffcbd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,12 @@ jobs: with: bun-version: latest + - name: Install Biome + run: bun install + + - name: Lint (Biome) + run: bunx biome check packages/ + - name: Install dependencies (pulse) working-directory: packages/pulse run: bun install diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..2f16ea0 --- /dev/null +++ b/biome.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["packages/**/*.ts"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..383a15f --- /dev/null +++ b/bun.lock @@ -0,0 +1,30 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "devDependencies": { + "@biomejs/biome": "^2.4.11", + }, + }, + }, + "packages": { + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..aa6a9c1 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "private": true, + "scripts": { + "lint": "biome check packages/", + "lint:fix": "biome check --write packages/" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.11" + } +} diff --git a/packages/pulse/src/index.test.ts b/packages/pulse/src/index.test.ts index 038218d..c1a7dc2 100644 --- a/packages/pulse/src/index.test.ts +++ b/packages/pulse/src/index.test.ts @@ -1,11 +1,21 @@ /** * @uncaged/pulse — Tests */ -import { describe, it, beforeEach, afterEach, expect } from "bun:test"; -import { composeRules, createRule, rebuildSnapshot, runPulse, findEffectiveEpoch, createStore, type Rule, type Sensed, type PulseStore } from "./index.js"; -import { mkdtempSync, rmSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + composeRules, + createRule, + createStore, + findEffectiveEpoch, + type PulseStore, + type Rule, + rebuildSnapshot, + runPulse, + type Sensed, +} from './index.js'; // ── Test types ───────────────────────────────────────────────── @@ -24,8 +34,8 @@ interface TestEffect { // ── composeRules ─────────────────────────────────────────────── -describe("composeRules", () => { - it("dummy: no rules → empty effects, default tickMs", async () => { +describe('composeRules', () => { + it('dummy: no rules → empty effects, default tickMs', async () => { const pulse = composeRules([], 15000); const prev: TestSnapshot = { timestamp: 0, memory: 50, tasks: {} }; const curr: TestSnapshot = { timestamp: 1, memory: 50, tasks: {} }; @@ -36,37 +46,44 @@ describe("composeRules", () => { expect(tickMs).toBe(15000); }); - it("single rule appends effects", async () => { + it('single rule appends effects', async () => { const rule: Rule = async (prev, curr, inner) => { const [effects, tickMs] = await inner(prev, curr); - if (curr.tasks["42"] === "assigned") { - return [[...effects, { kind: "dispatch", target: "oc" }], tickMs]; + if (curr.tasks['42'] === 'assigned') { + return [[...effects, { kind: 'dispatch', target: 'oc' }], tickMs]; } return [effects, tickMs]; }; const pulse = composeRules([rule], 15000); const prev: TestSnapshot = { timestamp: 0, memory: 50, tasks: {} }; - const curr: TestSnapshot = { timestamp: 1, memory: 50, tasks: { "42": "assigned" } }; + const curr: TestSnapshot = { + timestamp: 1, + memory: 50, + tasks: { '42': 'assigned' }, + }; - const [effects, tickMs] = await pulse(prev, curr); + const [effects, _tickMs] = await pulse(prev, curr); expect(effects.length).toBe(1); - expect(effects[0]!.kind).toBe("dispatch"); + expect(effects[0]!.kind).toBe('dispatch'); }); - it("S combinator: outer rule can modify inner effects", async () => { + it('S combinator: outer rule can modify inner effects', async () => { // r1: dispatch task (innermost — produces effects) const r1: Rule = async (_prev, _curr, inner) => { const [effects, tickMs] = await inner(_prev, _curr); - return [[...effects, { kind: "dispatch", target: "oc" }], tickMs]; + return [[...effects, { kind: 'dispatch', target: 'oc' }], tickMs]; }; // r2: resource guard — wraps r1, removes dispatch if memory > 90% (outermost) const r2: Rule = async (_prev, curr, inner) => { const [effects, tickMs] = await inner(_prev, curr); if (curr.memory > 90) { - return [effects.filter((e) => e.kind !== "dispatch"), Math.min(tickMs, 5000)]; + return [ + effects.filter((e) => e.kind !== 'dispatch'), + Math.min(tickMs, 5000), + ]; } return [effects, tickMs]; }; @@ -90,7 +107,7 @@ describe("composeRules", () => { expect(tickMs2).toBe(5000); }); - it("rules can adjust tickMs", async () => { + it('rules can adjust tickMs', async () => { const rule: Rule = async (prev, curr, inner) => { const [effects, tickMs] = await inner(prev, curr); // Something changed → speed up @@ -105,7 +122,7 @@ describe("composeRules", () => { const [, tickMs1] = await pulse( { timestamp: 0, memory: 50, tasks: {} }, - { timestamp: 1, memory: 50, tasks: { "1": "new" } }, + { timestamp: 1, memory: 50, tasks: { '1': 'new' } }, ); expect(tickMs1).toBe(3000); @@ -116,11 +133,15 @@ describe("composeRules", () => { expect(tickMs2).toBe(30000); }); - it("async rules work", async () => { - const asyncRule: Rule = async (_prev, _curr, inner) => { + it('async rules work', async () => { + const asyncRule: Rule = async ( + _prev, + _curr, + inner, + ) => { const [effects, tickMs] = await inner(_prev, _curr); await new Promise((r) => setTimeout(r, 10)); - return [[...effects, { kind: "notify", message: "async!" }], tickMs]; + return [[...effects, { kind: 'notify', message: 'async!' }], tickMs]; }; const pulse = composeRules([asyncRule]); @@ -130,14 +151,14 @@ describe("composeRules", () => { ); expect(effects.length).toBe(1); - expect(effects[0]!.kind).toBe("notify"); + expect(effects[0]!.kind).toBe('notify'); }); }); // ── createRule ───────────────────────────────────────────────── -describe("createRule", () => { - it("accessor-based rule reads a slice of state", async () => { +describe('createRule', () => { + it('accessor-based rule reads a slice of state', async () => { const guard = createRule( (s) => s.memory, async (prevMem, currMem, inner) => { @@ -146,7 +167,7 @@ describe("createRule", () => { { timestamp: 0, memory: currMem, tasks: {} } as TestSnapshot, ); if (currMem > 90) { - return [effects.filter((e) => e.kind !== "dispatch"), 5000]; + return [effects.filter((e) => e.kind !== 'dispatch'), 5000]; } return [effects, tickMs]; }, @@ -166,13 +187,17 @@ describe("createRule", () => { // ── rebuildSnapshot ──────────────────────────────────────────── -describe("rebuildSnapshot", () => { +describe('rebuildSnapshot', () => { let tmpDir: string; let store: PulseStore; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "pulse-test-")); - store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + tmpDir = mkdtempSync(join(tmpdir(), 'pulse-test-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); }); afterEach(() => { @@ -180,48 +205,82 @@ describe("rebuildSnapshot", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("rebuilds snapshot from collect events", () => { + it('rebuilds snapshot from collect events', () => { const hash = store.putObject({ memoryPct: 72, cpuIdlePct: 85 }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "system", hash }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'system', + hash, + }); - interface SysSnapshot { timestamp: number; system: Sensed<{ memoryPct: number; cpuIdlePct: number }> } - const snapshot = rebuildSnapshot(store, ["system"]); + interface SysSnapshot { + timestamp: number; + system: Sensed<{ memoryPct: number; cpuIdlePct: number }>; + } + const snapshot = rebuildSnapshot(store, ['system']); expect(snapshot.system.data.memoryPct).toBe(72); expect(snapshot.system.data.cpuIdlePct).toBe(85); expect(snapshot.system.refreshedAt).toBe(1000); }); - it("returns latest collect for each key", () => { + it('returns latest collect for each key', () => { const h1 = store.putObject({ value: 1 }); const h2 = store.putObject({ value: 2 }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "system", hash: h1 }); - store.appendEvent({ occurredAt: 2000, kind: "collect", key: "system", hash: h2 }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'system', + hash: h1, + }); + store.appendEvent({ + occurredAt: 2000, + kind: 'collect', + key: 'system', + hash: h2, + }); - interface SysSnapshot { timestamp: number; system: Sensed<{ value: number }> } - const snapshot = rebuildSnapshot(store, ["system"]); + interface SysSnapshot { + timestamp: number; + system: Sensed<{ value: number }>; + } + const snapshot = rebuildSnapshot(store, ['system']); expect(snapshot.system.data.value).toBe(2); expect(snapshot.system.refreshedAt).toBe(2000); }); - it("missing sense key returns no field", () => { - const snapshot = rebuildSnapshot<{ timestamp: number; nonexistent?: unknown }>(store, ["nonexistent"]); + it('missing sense key returns no field', () => { + const snapshot = rebuildSnapshot<{ + timestamp: number; + nonexistent?: unknown; + }>(store, ['nonexistent']); expect(snapshot.nonexistent).toBe(undefined); }); - it("multiple sense keys each get their latest collect", () => { + it('multiple sense keys each get their latest collect', () => { const h1 = store.putObject({ mem: 50 }); const h2 = store.putObject({ cpu: 80 }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "memory", hash: h1 }); - store.appendEvent({ occurredAt: 1500, kind: "collect", key: "cpu", hash: h2 }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'memory', + hash: h1, + }); + store.appendEvent({ + occurredAt: 1500, + kind: 'collect', + key: 'cpu', + hash: h2, + }); interface MultiSnapshot { timestamp: number; memory: Sensed<{ mem: number }>; cpu: Sensed<{ cpu: number }>; } - const snapshot = rebuildSnapshot(store, ["memory", "cpu"]); + const snapshot = rebuildSnapshot(store, ['memory', 'cpu']); expect(snapshot.memory.data.mem).toBe(50); expect(snapshot.memory.refreshedAt).toBe(1000); @@ -229,22 +288,26 @@ describe("rebuildSnapshot", () => { expect(snapshot.cpu.refreshedAt).toBe(1500); }); - it("snapshot always has timestamp", () => { + it('snapshot always has timestamp', () => { const snapshot = rebuildSnapshot<{ timestamp: number }>(store, []); - expect(typeof snapshot.timestamp === "number").toBeTruthy(); + expect(typeof snapshot.timestamp === 'number').toBeTruthy(); expect(snapshot.timestamp > 0).toBeTruthy(); }); }); // ── runPulse: effects go to execute ──────────────────────────── -describe("runPulse effects", () => { +describe('runPulse effects', () => { let tmpDir: string; let store: PulseStore; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "pulse-test-")); - store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + tmpDir = mkdtempSync(join(tmpdir(), 'pulse-test-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); }); afterEach(() => { @@ -252,31 +315,44 @@ describe("runPulse effects", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("all effects (including collect) are passed to execute", async () => { + it('all effects (including collect) are passed to execute', async () => { // Pre-populate with data so snapshot has something const initHash = store.putObject({ v: 1 }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "sys", hash: initHash }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'sys', + hash: initHash, + }); const executedEffects: TestEffect[] = []; // Rule that emits both collect and non-collect effects - const mixedRule: Rule<{ timestamp: number; sys: Sensed<{ v: number }> }, TestEffect> = - async (_prev, _curr, inner) => { - const [effects, tickMs] = await inner(_prev, _curr); - return [[ + const mixedRule: Rule< + { timestamp: number; sys: Sensed<{ v: number }> }, + TestEffect + > = async (_prev, _curr, inner) => { + const [effects, tickMs] = await inner(_prev, _curr); + return [ + [ ...effects, - { kind: "collect", key: "sys" }, - { kind: "notify", message: "hello" }, - ], tickMs]; - }; + { kind: 'collect', key: 'sys' }, + { kind: 'notify', message: 'hello' }, + ], + tickMs, + ]; + }; - const promise = runPulse<{ timestamp: number; sys: Sensed<{ v: number }> }, TestEffect>({ + const _promise = runPulse< + { timestamp: number; sys: Sensed<{ v: number }> }, + TestEffect + >({ store, execute: async (effects) => { executedEffects.push(...effects); }, rules: [mixedRule], - senseKeys: ["sys"], + senseKeys: ['sys'], defaultTickMs: 30, }).catch(() => {}); // loop will error when store closes @@ -284,16 +360,16 @@ describe("runPulse effects", () => { // execute should get ALL effects — including collect expect(executedEffects.length >= 2).toBeTruthy(); - expect(executedEffects.some(e => e.kind === "collect")).toBeTruthy(); - expect(executedEffects.some(e => e.kind === "notify")).toBeTruthy(); + expect(executedEffects.some((e) => e.kind === 'collect')).toBeTruthy(); + expect(executedEffects.some((e) => e.kind === 'notify')).toBeTruthy(); // Verify effect events were recorded - const effectEvents = store.queryByKind("effect"); + const effectEvents = store.queryByKind('effect'); expect(effectEvents.length >= 1).toBeTruthy(); }); - it("tick events are recorded even with no effects", async () => { - const promise = runPulse<{ timestamp: number }, TestEffect>({ + it('tick events are recorded even with no effects', async () => { + const _promise = runPulse<{ timestamp: number }, TestEffect>({ store, execute: async () => {}, rules: [], @@ -303,25 +379,31 @@ describe("runPulse effects", () => { await new Promise((r) => setTimeout(r, 150)); - const tickEvents = store.queryByKind("tick"); + const tickEvents = store.queryByKind('tick'); expect(tickEvents.length >= 1).toBeTruthy(); }); }); // ── Moore machine property ───────────────────────────────────── -describe("Moore machine property", () => { - it("same (prev, curr) always produces same effects — deterministic", async () => { +describe('Moore machine property', () => { + it('same (prev, curr) always produces same effects — deterministic', async () => { const rule: Rule = async (prev, curr, inner) => { const [effects, tickMs] = await inner(prev, curr); - const newTasks = Object.keys(curr.tasks).filter((k) => !(k in prev.tasks)); - const newEffects = newTasks.map((t) => ({ kind: "dispatch", target: t })); + const newTasks = Object.keys(curr.tasks).filter( + (k) => !(k in prev.tasks), + ); + const newEffects = newTasks.map((t) => ({ kind: 'dispatch', target: t })); return [[...effects, ...newEffects], tickMs]; }; const pulse = composeRules([rule]); const prev: TestSnapshot = { timestamp: 0, memory: 50, tasks: {} }; - const curr: TestSnapshot = { timestamp: 1, memory: 50, tasks: { "42": "new" } }; + const curr: TestSnapshot = { + timestamp: 1, + memory: 50, + tasks: { '42': 'new' }, + }; const [effects1] = await pulse(prev, curr); const [effects2] = await pulse(prev, curr); @@ -329,29 +411,46 @@ describe("Moore machine property", () => { expect(effects1).toEqual(effects2); }); - it("collect result not visible until next tick (Moore semantics)", async () => { - const tmpDir = mkdtempSync(join(tmpdir(), "pulse-moore-")); - const store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + it('collect result not visible until next tick (Moore semantics)', async () => { + const tmpDir = mkdtempSync(join(tmpdir(), 'pulse-moore-')); + const store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); try { // Pre-populate with initial value const initHash = store.putObject({ v: 1 }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "sys", hash: initHash }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'sys', + hash: initHash, + }); // Take snapshot before collect - interface S { timestamp: number; sys: Sensed<{ v: number }> } - const before = rebuildSnapshot(store, ["sys"]); + interface S { + timestamp: number; + sys: Sensed<{ v: number }>; + } + const before = rebuildSnapshot(store, ['sys']); expect(before.sys.data.v).toBe(1); // Simulate what runPulse does: execute a collect effect const newHash = store.putObject({ v: 999 }); - store.appendEvent({ occurredAt: 2000, kind: "collect", key: "sys", hash: newHash }); + store.appendEvent({ + occurredAt: 2000, + kind: 'collect', + key: 'sys', + hash: newHash, + }); // The 'before' snapshot is still the same (Moore: output depends on state, not current input) expect(before.sys.data.v).toBe(1); // Only a new rebuildSnapshot call sees the updated value - const after = rebuildSnapshot(store, ["sys"]); + const after = rebuildSnapshot(store, ['sys']); expect(after.sys.data.v).toBe(999); } finally { store.close(); @@ -362,8 +461,8 @@ describe("Moore machine property", () => { // ── Sensed type ──────────────────────────────────────────────── -describe("Sensed type", () => { - it("has data and refreshedAt fields", () => { +describe('Sensed type', () => { + it('has data and refreshedAt fields', () => { const sensed: Sensed<{ x: number }> = { data: { x: 42 }, refreshedAt: 1000, @@ -375,13 +474,17 @@ describe("Sensed type", () => { // ── findEffectiveEpoch ───────────────────────────────────────── -describe("findEffectiveEpoch", () => { +describe('findEffectiveEpoch', () => { let tmpDir: string; let store: PulseStore; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "pulse-epoch-")); - store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + tmpDir = mkdtempSync(join(tmpdir(), 'pulse-epoch-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); }); afterEach(() => { @@ -389,62 +492,66 @@ describe("findEffectiveEpoch", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("no promote → null", () => { + it('no promote → null', () => { expect(findEffectiveEpoch(store)).toBe(null); }); - it("with promote → returns it", () => { - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); + it('with promote → returns it', () => { + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); const epoch = findEffectiveEpoch(store); expect(epoch).toBeTruthy(); - expect(epoch!.codeRev).toBe("v1"); + expect(epoch!.codeRev).toBe('v1'); }); - it("multiple promotes → returns latest", () => { - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 2000, kind: "promote", codeRev: "v2" }); + it('multiple promotes → returns latest', () => { + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ occurredAt: 2000, kind: 'promote', codeRev: 'v2' }); const epoch = findEffectiveEpoch(store); expect(epoch).toBeTruthy(); - expect(epoch!.codeRev).toBe("v2"); + expect(epoch!.codeRev).toBe('v2'); }); - it("with rollback → returns rolled-back-to promote", () => { - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 2000, kind: "promote", codeRev: "v2" }); + it('with rollback → returns rolled-back-to promote', () => { + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ occurredAt: 2000, kind: 'promote', codeRev: 'v2' }); store.appendEvent({ occurredAt: 3000, - kind: "rollback", - codeRev: "v1", - meta: JSON.stringify({ to: "v1", from: "v2" }), + kind: 'rollback', + codeRev: 'v1', + meta: JSON.stringify({ to: 'v1', from: 'v2' }), }); const epoch = findEffectiveEpoch(store); expect(epoch).toBeTruthy(); - expect(epoch!.codeRev).toBe("v1"); + expect(epoch!.codeRev).toBe('v1'); }); - it("rollback with only codeRev (no meta.to) → uses codeRev", () => { - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 2000, kind: "promote", codeRev: "v2" }); + it('rollback with only codeRev (no meta.to) → uses codeRev', () => { + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ occurredAt: 2000, kind: 'promote', codeRev: 'v2' }); store.appendEvent({ occurredAt: 3000, - kind: "rollback", - codeRev: "v1", + kind: 'rollback', + codeRev: 'v1', }); const epoch = findEffectiveEpoch(store); expect(epoch).toBeTruthy(); - expect(epoch!.codeRev).toBe("v1"); + expect(epoch!.codeRev).toBe('v1'); }); }); // ── version-aware rebuildSnapshot ────────────────────────────── -describe("rebuildSnapshot with epoch", () => { +describe('rebuildSnapshot with epoch', () => { let tmpDir: string; let store: PulseStore; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "pulse-epoch-snap-")); - store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + tmpDir = mkdtempSync(join(tmpdir(), 'pulse-epoch-snap-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); }); afterEach(() => { @@ -452,121 +559,225 @@ describe("rebuildSnapshot with epoch", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("only reads events after epoch with matching code_rev", () => { - const h1 = store.putObject({ value: "v1-data" }); - const h2 = store.putObject({ value: "v2-data" }); + it('only reads events after epoch with matching code_rev', () => { + const h1 = store.putObject({ value: 'v1-data' }); + const h2 = store.putObject({ value: 'v2-data' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 1100, kind: "collect", key: "system", hash: h1, codeRev: "v1" }); - store.appendEvent({ occurredAt: 2000, kind: "promote", codeRev: "v2" }); - store.appendEvent({ occurredAt: 2100, kind: "collect", key: "system", hash: h2, codeRev: "v2" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ + occurredAt: 1100, + kind: 'collect', + key: 'system', + hash: h1, + codeRev: 'v1', + }); + store.appendEvent({ occurredAt: 2000, kind: 'promote', codeRev: 'v2' }); + store.appendEvent({ + occurredAt: 2100, + kind: 'collect', + key: 'system', + hash: h2, + codeRev: 'v2', + }); // Using v1 epoch should get v1 data - const epochV1 = store.getLatestWhere({ kind: "promote", codeRev: "v1" }); - const snapshotV1 = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epochV1); - expect(snapshotV1.system.data.value).toBe("v1-data"); + const epochV1 = store.getLatestWhere({ kind: 'promote', codeRev: 'v1' }); + const snapshotV1 = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epochV1); + expect(snapshotV1.system.data.value).toBe('v1-data'); // Using v2 epoch should get v2 data - const epochV2 = store.getLatestWhere({ kind: "promote", codeRev: "v2" }); - const snapshotV2 = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epochV2); - expect(snapshotV2.system.data.value).toBe("v2-data"); + const epochV2 = store.getLatestWhere({ kind: 'promote', codeRev: 'v2' }); + const snapshotV2 = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epochV2); + expect(snapshotV2.system.data.value).toBe('v2-data'); }); - it("v2 events not visible in v1 epoch", () => { - const h1 = store.putObject({ value: "v1-data" }); - const h2 = store.putObject({ value: "v2-data" }); + it('v2 events not visible in v1 epoch', () => { + const h1 = store.putObject({ value: 'v1-data' }); + const h2 = store.putObject({ value: 'v2-data' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 1100, kind: "collect", key: "system", hash: h1, codeRev: "v1" }); - store.appendEvent({ occurredAt: 2000, kind: "promote", codeRev: "v2" }); - store.appendEvent({ occurredAt: 2100, kind: "collect", key: "system", hash: h2, codeRev: "v2" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ + occurredAt: 1100, + kind: 'collect', + key: 'system', + hash: h1, + codeRev: 'v1', + }); + store.appendEvent({ occurredAt: 2000, kind: 'promote', codeRev: 'v2' }); + store.appendEvent({ + occurredAt: 2100, + kind: 'collect', + key: 'system', + hash: h2, + codeRev: 'v2', + }); // v1 epoch should NOT see v2 collect (different code_rev) - const epochV1 = store.getLatestWhere({ kind: "promote", codeRev: "v1" }); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epochV1); - expect(snapshot.system.data.value).toBe("v1-data"); + const epochV1 = store.getLatestWhere({ kind: 'promote', codeRev: 'v1' }); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epochV1); + expect(snapshot.system.data.value).toBe('v1-data'); }); - it("migrate events used for initial snapshot after promote", () => { - const hMigrated = store.putObject({ value: "migrated" }); + it('migrate events used for initial snapshot after promote', () => { + const hMigrated = store.putObject({ value: 'migrated' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v2" }); - store.appendEvent({ occurredAt: 1001, kind: "migrate", key: "system", hash: hMigrated, codeRev: "v2" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v2' }); + store.appendEvent({ + occurredAt: 1001, + kind: 'migrate', + key: 'system', + hash: hMigrated, + codeRev: 'v2', + }); const epoch = findEffectiveEpoch(store); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epoch); - expect(snapshot.system.data.value).toBe("migrated"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epoch); + expect(snapshot.system.data.value).toBe('migrated'); }); - it("init events used when no collect or migrate exist", () => { - const hInit = store.putObject({ value: "initial" }); + it('init events used when no collect or migrate exist', () => { + const hInit = store.putObject({ value: 'initial' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 1001, kind: "init", key: "system", hash: hInit, codeRev: "v1" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ + occurredAt: 1001, + kind: 'init', + key: 'system', + hash: hInit, + codeRev: 'v1', + }); const epoch = findEffectiveEpoch(store); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epoch); - expect(snapshot.system.data.value).toBe("initial"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epoch); + expect(snapshot.system.data.value).toBe('initial'); }); - it("collect takes priority over migrate and init", () => { - const hInit = store.putObject({ value: "initial" }); - const hMigrate = store.putObject({ value: "migrated" }); - const hCollect = store.putObject({ value: "collected" }); + it('collect takes priority over migrate and init', () => { + const hInit = store.putObject({ value: 'initial' }); + const hMigrate = store.putObject({ value: 'migrated' }); + const hCollect = store.putObject({ value: 'collected' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v2" }); - store.appendEvent({ occurredAt: 1001, kind: "init", key: "system", hash: hInit, codeRev: "v2" }); - store.appendEvent({ occurredAt: 1002, kind: "migrate", key: "system", hash: hMigrate, codeRev: "v2" }); - store.appendEvent({ occurredAt: 1003, kind: "collect", key: "system", hash: hCollect, codeRev: "v2" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v2' }); + store.appendEvent({ + occurredAt: 1001, + kind: 'init', + key: 'system', + hash: hInit, + codeRev: 'v2', + }); + store.appendEvent({ + occurredAt: 1002, + kind: 'migrate', + key: 'system', + hash: hMigrate, + codeRev: 'v2', + }); + store.appendEvent({ + occurredAt: 1003, + kind: 'collect', + key: 'system', + hash: hCollect, + codeRev: 'v2', + }); const epoch = findEffectiveEpoch(store); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epoch); - expect(snapshot.system.data.value).toBe("collected"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epoch); + expect(snapshot.system.data.value).toBe('collected'); }); - it("no epoch → falls back to latest collect (backward compat)", () => { - const h1 = store.putObject({ value: "first" }); - const h2 = store.putObject({ value: "second" }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "system", hash: h1 }); - store.appendEvent({ occurredAt: 2000, kind: "collect", key: "system", hash: h2 }); + it('no epoch → falls back to latest collect (backward compat)', () => { + const h1 = store.putObject({ value: 'first' }); + const h2 = store.putObject({ value: 'second' }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'system', + hash: h1, + }); + store.appendEvent({ + occurredAt: 2000, + kind: 'collect', + key: 'system', + hash: h2, + }); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"]); - expect(snapshot.system.data.value).toBe("second"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system']); + expect(snapshot.system.data.value).toBe('second'); }); - it("rollback epoch reads correct version data", () => { - const h1 = store.putObject({ value: "v1-data" }); - const h2 = store.putObject({ value: "v2-data" }); + it('rollback epoch reads correct version data', () => { + const h1 = store.putObject({ value: 'v1-data' }); + const h2 = store.putObject({ value: 'v2-data' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 1100, kind: "collect", key: "system", hash: h1, codeRev: "v1" }); - store.appendEvent({ occurredAt: 2000, kind: "promote", codeRev: "v2" }); - store.appendEvent({ occurredAt: 2100, kind: "collect", key: "system", hash: h2, codeRev: "v2" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ + occurredAt: 1100, + kind: 'collect', + key: 'system', + hash: h1, + codeRev: 'v1', + }); + store.appendEvent({ occurredAt: 2000, kind: 'promote', codeRev: 'v2' }); + store.appendEvent({ + occurredAt: 2100, + kind: 'collect', + key: 'system', + hash: h2, + codeRev: 'v2', + }); store.appendEvent({ occurredAt: 3000, - kind: "rollback", - codeRev: "v1", - meta: JSON.stringify({ to: "v1", from: "v2" }), + kind: 'rollback', + codeRev: 'v1', + meta: JSON.stringify({ to: 'v1', from: 'v2' }), }); const epoch = findEffectiveEpoch(store); expect(epoch).toBeTruthy(); - expect(epoch!.codeRev).toBe("v1"); + expect(epoch!.codeRev).toBe('v1'); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epoch); - expect(snapshot.system.data.value).toBe("v1-data"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epoch); + expect(snapshot.system.data.value).toBe('v1-data'); }); }); // ── rebuildSnapshot: vitals priority ─────────────────────────── -describe("rebuildSnapshot vitals priority", () => { +describe('rebuildSnapshot vitals priority', () => { let tmpDir: string; let store: PulseStore; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "pulse-vitals-snap-")); - store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + tmpDir = mkdtempSync(join(tmpdir(), 'pulse-vitals-snap-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); }); afterEach(() => { @@ -574,60 +785,98 @@ describe("rebuildSnapshot vitals priority", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("reads latest from vitals table over events", () => { - const hEvent = store.putObject({ value: "from-event" }); - const hVital = store.putObject({ value: "from-vital" }); + it('reads latest from vitals table over events', () => { + const hEvent = store.putObject({ value: 'from-event' }); + const hVital = store.putObject({ value: 'from-vital' }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "system", hash: hEvent }); - store.appendVital({ occurredAt: 2000, key: "system", hash: hVital }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'system', + hash: hEvent, + }); + store.appendVital({ occurredAt: 2000, key: 'system', hash: hVital }); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"]); - expect(snapshot.system.data.value).toBe("from-vital"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system']); + expect(snapshot.system.data.value).toBe('from-vital'); expect(snapshot.system.refreshedAt).toBe(2000); }); - it("falls back to events when vitals empty", () => { - const h = store.putObject({ value: "event-only" }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "system", hash: h }); + it('falls back to events when vitals empty', () => { + const h = store.putObject({ value: 'event-only' }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'system', + hash: h, + }); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"]); - expect(snapshot.system.data.value).toBe("event-only"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system']); + expect(snapshot.system.data.value).toBe('event-only'); }); - it("vitals priority with epoch — still reads vitals first", () => { - const hVital = store.putObject({ value: "vital-data" }); - const hEvent = store.putObject({ value: "event-data" }); + it('vitals priority with epoch — still reads vitals first', () => { + const hVital = store.putObject({ value: 'vital-data' }); + const hEvent = store.putObject({ value: 'event-data' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v1" }); - store.appendEvent({ occurredAt: 1100, kind: "collect", key: "system", hash: hEvent, codeRev: "v1" }); - store.appendVital({ occurredAt: 1200, key: "system", hash: hVital }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v1' }); + store.appendEvent({ + occurredAt: 1100, + kind: 'collect', + key: 'system', + hash: hEvent, + codeRev: 'v1', + }); + store.appendVital({ occurredAt: 1200, key: 'system', hash: hVital }); const epoch = findEffectiveEpoch(store); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epoch); - expect(snapshot.system.data.value).toBe("vital-data"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epoch); + expect(snapshot.system.data.value).toBe('vital-data'); }); - it("falls back to migrate events when no vitals", () => { - const hMigrate = store.putObject({ value: "migrated" }); + it('falls back to migrate events when no vitals', () => { + const hMigrate = store.putObject({ value: 'migrated' }); - store.appendEvent({ occurredAt: 1000, kind: "promote", codeRev: "v2" }); - store.appendEvent({ occurredAt: 1001, kind: "migrate", key: "system", hash: hMigrate, codeRev: "v2" }); + store.appendEvent({ occurredAt: 1000, kind: 'promote', codeRev: 'v2' }); + store.appendEvent({ + occurredAt: 1001, + kind: 'migrate', + key: 'system', + hash: hMigrate, + codeRev: 'v2', + }); const epoch = findEffectiveEpoch(store); - const snapshot = rebuildSnapshot<{ timestamp: number; system: Sensed<{ value: string }> }>(store, ["system"], epoch); - expect(snapshot.system.data.value).toBe("migrated"); + const snapshot = rebuildSnapshot<{ + timestamp: number; + system: Sensed<{ value: string }>; + }>(store, ['system'], epoch); + expect(snapshot.system.data.value).toBe('migrated'); }); }); // ── runPulse: execute handles collect (dual-write by execute) ── -describe("runPulse execute-driven collect", () => { +describe('runPulse execute-driven collect', () => { let tmpDir: string; let store: PulseStore; beforeEach(() => { - tmpDir = mkdtempSync(join(tmpdir(), "pulse-dualwrite-")); - store = createStore({ eventsDbPath: join(tmpDir, "events.db"), vitalsDbPath: join(tmpDir, "vitals.db"), objectsDir: join(tmpDir, "objects") }); + tmpDir = mkdtempSync(join(tmpdir(), 'pulse-dualwrite-')); + store = createStore({ + eventsDbPath: join(tmpDir, 'events.db'), + vitalsDbPath: join(tmpDir, 'vitals.db'), + objectsDir: join(tmpDir, 'objects'), + }); }); afterEach(() => { @@ -635,44 +884,59 @@ describe("runPulse execute-driven collect", () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it("execute can write events + vitals for collect effects", async () => { + it('execute can write events + vitals for collect effects', async () => { // Pre-populate with initial data const initHash = store.putObject({ v: 1 }); - store.appendEvent({ occurredAt: 1000, kind: "collect", key: "sys", hash: initHash }); - store.appendVital({ occurredAt: 1000, key: "sys", hash: initHash }); + store.appendEvent({ + occurredAt: 1000, + kind: 'collect', + key: 'sys', + hash: initHash, + }); + store.appendVital({ occurredAt: 1000, key: 'sys', hash: initHash }); // Rule that emits a collect effect - const collectRule: Rule<{ timestamp: number; sys: Sensed<{ v: number }> }, TestEffect> = - async (_prev, _curr, inner) => { - const [effects, tickMs] = await inner(_prev, _curr); - return [[...effects, { kind: "collect", key: "sys" }], tickMs]; - }; + const collectRule: Rule< + { timestamp: number; sys: Sensed<{ v: number }> }, + TestEffect + > = async (_prev, _curr, inner) => { + const [effects, tickMs] = await inner(_prev, _curr); + return [[...effects, { kind: 'collect', key: 'sys' }], tickMs]; + }; // Execute function handles collect effects (like a real user would) - const promise = runPulse<{ timestamp: number; sys: Sensed<{ v: number }> }, TestEffect>({ + const _promise = runPulse< + { timestamp: number; sys: Sensed<{ v: number }> }, + TestEffect + >({ store, execute: async (effects) => { for (const effect of effects) { - if (effect.kind === "collect" && effect.key === "sys") { + if (effect.kind === 'collect' && effect.key === 'sys') { const data = { v: 99 }; const hash = store.putObject(data); - store.appendEvent({ occurredAt: Date.now(), kind: "collect", key: "sys", hash }); - store.appendVital({ occurredAt: Date.now(), key: "sys", hash }); + store.appendEvent({ + occurredAt: Date.now(), + kind: 'collect', + key: 'sys', + hash, + }); + store.appendVital({ occurredAt: Date.now(), key: 'sys', hash }); } } }, rules: [collectRule], - senseKeys: ["sys"], + senseKeys: ['sys'], defaultTickMs: 30, }).catch(() => {}); // loop will error when store closes await new Promise((r) => setTimeout(r, 150)); // Both events and vitals should have new records written by execute - const collectEvents = store.queryByKind("collect", { key: "sys" }); + const collectEvents = store.queryByKind('collect', { key: 'sys' }); expect(collectEvents.length >= 2).toBeTruthy(); - const vitalHistory = store.getVitalHistory("sys", 10); + const vitalHistory = store.getVitalHistory('sys', 10); expect(vitalHistory.length >= 2).toBeTruthy(); }); }); diff --git a/packages/pulse/src/index.ts b/packages/pulse/src/index.ts index cd813d4..4b4d196 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -12,7 +12,7 @@ * Composition: pulse = reduceRight rules base */ -import type { PulseStore, EventRecord } from './store.js'; +import type { EventRecord, PulseStore } from './store.js'; import { startWatcher, type WatcherDef } from './watcher.js'; // ── Sensed Types ─────────────────────────────────────────────── @@ -71,12 +71,20 @@ export function composeRules( defaultTickMs: number = 15000, ): (prev: S, curr: S) => Promise<[E[], number]> { const chain = rules.reduceRight< - (prev: S, curr: S, inner: (prev: S, curr: S) => Promise<[E[], number]>) => Promise<[E[], number]> + ( + prev: S, + curr: S, + inner: (prev: S, curr: S) => Promise<[E[], number]>, + ) => Promise<[E[], number]> >( - (next, rule) => (prev, curr, inner) => rule(prev, curr, (p, c) => next(p, c, inner)), + (next, rule) => (prev, curr, inner) => + rule(prev, curr, (p, c) => next(p, c, inner)), (prev, curr, inner) => inner(prev, curr), ); - const base: (prev: S, curr: S) => Promise<[E[], number]> = async () => [[], defaultTickMs]; + const base: (prev: S, curr: S) => Promise<[E[], number]> = async () => [ + [], + defaultTickMs, + ]; return (prev, curr) => chain(prev, curr, base); } @@ -135,11 +143,15 @@ export function rebuildSnapshot( key, codeRev: epoch.codeRev ?? undefined, }); - const latestCollect = events.length > 0 ? events[events.length - 1]! : null; + const latestCollect = + events.length > 0 ? events[events.length - 1]! : null; if (latestCollect?.hash) { const data = store.getObject(latestCollect.hash); if (data !== null) { - snapshot[key] = { data, refreshedAt: latestCollect.occurredAt } as Sensed; + snapshot[key] = { + data, + refreshedAt: latestCollect.occurredAt, + } as Sensed; continue; } } @@ -155,7 +167,10 @@ export function rebuildSnapshot( if (latestMigrate.hash) { const data = store.getObject(latestMigrate.hash); if (data !== null) { - snapshot[key] = { data, refreshedAt: latestMigrate.occurredAt } as Sensed; + snapshot[key] = { + data, + refreshedAt: latestMigrate.occurredAt, + } as Sensed; continue; } } @@ -172,7 +187,10 @@ export function rebuildSnapshot( if (latestInit.hash) { const data = store.getObject(latestInit.hash); if (data !== null) { - snapshot[key] = { data, refreshedAt: latestInit.occurredAt } as Sensed; + snapshot[key] = { + data, + refreshedAt: latestInit.occurredAt, + } as Sensed; } } } @@ -182,7 +200,10 @@ export function rebuildSnapshot( if (latest?.hash) { const data = store.getObject(latest.hash); if (data !== null) { - snapshot[key] = { data, refreshedAt: latest.occurredAt } as Sensed; + snapshot[key] = { + data, + refreshedAt: latest.occurredAt, + } as Sensed; } } } @@ -211,7 +232,14 @@ export async function runPulse(options: { codeRev?: string; watchers?: WatcherDef[]; }): Promise { - const { store, execute, rules, senseKeys, defaultTickMs = 15000, codeRev } = options; + const { + store, + execute, + rules, + senseKeys, + defaultTickMs = 15000, + codeRev, + } = options; const pulse = composeRules(rules, defaultTickMs); // Determine version epoch @@ -316,17 +344,22 @@ export function createRule( // ── Storage ──────────────────────────────────────────────────── export { - createStore, type CreateStoreOptions, - type PulseStore, + createStore, type EventRecord, + type PulseStore, type VitalRecord, } from './store.js'; // ── Built-in Rules ───────────────────────────────────────────── -export { clampTick, errorBackoff, adaptiveInterval, dedup } from './rules.js'; +export { adaptiveInterval, clampTick, dedup, errorBackoff } from './rules.js'; // ── Watcher ──────────────────────────────────────────────────── -export { startWatcher, type WatcherDef, type WatcherHandle, type WakeCondition } from './watcher.js'; +export { + startWatcher, + type WakeCondition, + type WatcherDef, + type WatcherHandle, +} from './watcher.js'; diff --git a/packages/pulse/src/rules.test.ts b/packages/pulse/src/rules.test.ts index 3e7796f..7a51630 100644 --- a/packages/pulse/src/rules.test.ts +++ b/packages/pulse/src/rules.test.ts @@ -2,9 +2,9 @@ * Tests for @uncaged/pulse built-in rules */ -import { describe, it, expect } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; -import { clampTick, errorBackoff, adaptiveInterval, dedup } from './rules.js'; +import { adaptiveInterval, clampTick, dedup, errorBackoff } from './rules.js'; // Helper to apply rule with new onion middleware signature async function apply( @@ -61,7 +61,13 @@ describe('errorBackoff', () => { it('backs off exponentially based on error count', async () => { const rule = errorBackoff((s) => s.errors); - const [effects, tickMs] = await apply(rule, { errors: 0 }, { errors: 3 }, [], 10000); + const [effects, tickMs] = await apply( + rule, + { errors: 0 }, + { errors: 3 }, + [], + 10000, + ); // 10000 * 2^3 = 80000 expect(tickMs).toBe(80000); expect(effects).toEqual([]); @@ -69,19 +75,37 @@ describe('errorBackoff', () => { it('caps at maxMs', async () => { const rule = errorBackoff((s) => s.errors, 50000); - const [, tickMs] = await apply(rule, { errors: 0 }, { errors: 10 }, [], 10000); + const [, tickMs] = await apply( + rule, + { errors: 0 }, + { errors: 10 }, + [], + 10000, + ); expect(tickMs).toBe(50000); }); it('passes through when zero errors', async () => { const rule = errorBackoff((s) => s.errors); - const [, tickMs] = await apply(rule, { errors: 0 }, { errors: 0 }, [], 15000); + const [, tickMs] = await apply( + rule, + { errors: 0 }, + { errors: 0 }, + [], + 15000, + ); expect(tickMs).toBe(15000); }); it('preserves effects', async () => { const rule = errorBackoff((s) => s.errors); - const [effects] = await apply(rule, { errors: 0 }, { errors: 2 }, ['alert'], 10000); + const [effects] = await apply( + rule, + { errors: 0 }, + { errors: 2 }, + ['alert'], + 10000, + ); expect(effects).toEqual(['alert']); }); }); @@ -108,14 +132,26 @@ describe('adaptiveInterval', () => { it('caps slowdown at slowMs', async () => { const rule = adaptiveInterval(changed, 3000, 120000, 2.0); - const [, tickMs] = await apply(rule, { value: 1 }, { value: 1 }, [], 100000); + const [, tickMs] = await apply( + rule, + { value: 1 }, + { value: 1 }, + [], + 100000, + ); // 100000 * 2.0 = 200000, capped at 120000 expect(tickMs).toBe(120000); }); it('preserves effects', async () => { const rule = adaptiveInterval(changed, 5000, 60000); - const [effects] = await apply(rule, { value: 1 }, { value: 2 }, ['notify'], 15000); + const [effects] = await apply( + rule, + { value: 1 }, + { value: 2 }, + ['notify'], + 15000, + ); expect(effects).toEqual(['notify']); }); }); @@ -129,7 +165,9 @@ describe('dedup', () => { it('removes duplicate effects by kind', async () => { const rule = dedup(); const [effects, tickMs] = await apply( - rule, {}, {}, + rule, + {}, + {}, [ { kind: 'alert', payload: 'first' }, { kind: 'notify', payload: 'hello' }, @@ -144,14 +182,16 @@ describe('dedup', () => { expect(kinds.includes('alert')).toBeTruthy(); expect(kinds.includes('notify')).toBeTruthy(); const alertEffect = effects.find((e) => e.kind === 'alert')!; - expect(alertEffect.payload).toBe('second'); // last wins + expect(alertEffect.payload).toBe('second'); // last wins expect(tickMs).toBe(15000); }); it('supports custom key function', async () => { const rule = dedup((e) => `${e.kind}:${e.payload}`); const [effects] = await apply( - rule, {}, {}, + rule, + {}, + {}, [ { kind: 'alert', payload: 'a' }, { kind: 'alert', payload: 'b' }, @@ -172,7 +212,13 @@ describe('dedup', () => { it('preserves tickMs', async () => { const rule = dedup(); - const [, tickMs] = await apply(rule, {}, {}, [{ kind: 'x', payload: '' }], 42000); + const [, tickMs] = await apply( + rule, + {}, + {}, + [{ kind: 'x', payload: '' }], + 42000, + ); expect(tickMs).toBe(42000); }); }); diff --git a/packages/pulse/src/rules.ts b/packages/pulse/src/rules.ts index 0e1a57e..fc0f740 100644 --- a/packages/pulse/src/rules.ts +++ b/packages/pulse/src/rules.ts @@ -36,7 +36,7 @@ export function errorBackoff( const [effects, tickMs] = await inner(_prev, curr); const errors = getErrors(curr); if (errors <= 0) return [effects, tickMs]; - const backed = Math.min(tickMs * Math.pow(2, errors), maxMs); + const backed = Math.min(tickMs * 2 ** errors, maxMs); return [effects, backed]; }; } diff --git a/packages/pulse/src/store.test.ts b/packages/pulse/src/store.test.ts index a5820fa..b9a0911 100644 --- a/packages/pulse/src/store.test.ts +++ b/packages/pulse/src/store.test.ts @@ -4,14 +4,12 @@ * Covers: https://github.com/oc-xiaoju/pulse/issues/6 */ -import { describe, it, beforeEach, afterEach, expect } from 'bun:test'; -import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { createStore } from './store.js'; -import type { EventRecord, VitalRecord } from './store.js'; import { Database } from 'bun:sqlite'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStore } from './store.js'; describe('createStore (events table + CAS)', () => { let dir: string; @@ -147,16 +145,39 @@ describe('createStore (events table + CAS)', () => { it('7. getLatestWhere filters by codeRev', () => { const store = createStore({ eventsDbPath, vitalsDbPath, objectsDir }); - store.appendEvent({ occurredAt: 1000, kind: 'tick', key: 'cpu', codeRev: 'v1' }); - store.appendEvent({ occurredAt: 2000, kind: 'tick', key: 'cpu', codeRev: 'v2' }); - store.appendEvent({ occurredAt: 3000, kind: 'tick', key: 'cpu', codeRev: 'v1' }); + store.appendEvent({ + occurredAt: 1000, + kind: 'tick', + key: 'cpu', + codeRev: 'v1', + }); + store.appendEvent({ + occurredAt: 2000, + kind: 'tick', + key: 'cpu', + codeRev: 'v2', + }); + store.appendEvent({ + occurredAt: 3000, + kind: 'tick', + key: 'cpu', + codeRev: 'v1', + }); - const latestV2 = store.getLatestWhere({ kind: 'tick', key: 'cpu', codeRev: 'v2' }); + const latestV2 = store.getLatestWhere({ + kind: 'tick', + key: 'cpu', + codeRev: 'v2', + }); expect(latestV2).toBeTruthy(); expect(latestV2!.occurredAt).toBe(2000); expect(latestV2!.codeRev).toBe('v2'); - const latestV1 = store.getLatestWhere({ kind: 'tick', key: 'cpu', codeRev: 'v1' }); + const latestV1 = store.getLatestWhere({ + kind: 'tick', + key: 'cpu', + codeRev: 'v1', + }); expect(latestV1).toBeTruthy(); expect(latestV1!.occurredAt).toBe(3000); expect(latestV1!.codeRev).toBe('v1'); @@ -192,7 +213,7 @@ describe('createStore (events table + CAS)', () => { const ticks = store.queryByKind('tick'); expect(ticks.length).toBe(2); - expect(ticks.every(e => e.kind === 'tick')).toBeTruthy(); + expect(ticks.every((e) => e.kind === 'tick')).toBeTruthy(); // Newest first expect(ticks[0]!.occurredAt).toBe(3000); expect(ticks[1]!.occurredAt).toBe(1000); @@ -227,7 +248,7 @@ describe('createStore (events table + CAS)', () => { const result = store.queryByKind('tick', { key: 'cpu' }); expect(result.length).toBe(2); - expect(result.every(e => e.key === 'cpu')).toBeTruthy(); + expect(result.every((e) => e.key === 'cpu')).toBeTruthy(); store.close(); }); @@ -242,7 +263,7 @@ describe('createStore (events table + CAS)', () => { const result = store.queryByKind('tick', { codeRev: 'abc123' }); expect(result.length).toBe(2); - expect(result.every(e => e.codeRev === 'abc123')).toBeTruthy(); + expect(result.every((e) => e.codeRev === 'abc123')).toBeTruthy(); store.close(); }); @@ -253,8 +274,8 @@ describe('createStore (events table + CAS)', () => { const e1 = store.appendEvent({ occurredAt: 1000, kind: 'tick' }); const e2 = store.appendEvent({ occurredAt: 2000, kind: 'tick' }); - const e3 = store.appendEvent({ occurredAt: 3000, kind: 'tick' }); - const e4 = store.appendEvent({ occurredAt: 4000, kind: 'collect' }); + const _e3 = store.appendEvent({ occurredAt: 3000, kind: 'tick' }); + const _e4 = store.appendEvent({ occurredAt: 4000, kind: 'collect' }); const after = store.getAfter(e2.id); expect(after.length).toBe(2); @@ -265,7 +286,7 @@ describe('createStore (events table + CAS)', () => { // With kind filter const afterTick = store.getAfter(e1.id, { kind: 'tick' }); expect(afterTick.length).toBe(2); - expect(afterTick.every(e => e.kind === 'tick')).toBeTruthy(); + expect(afterTick.every((e) => e.kind === 'tick')).toBeTruthy(); store.close(); }); @@ -352,7 +373,7 @@ describe('createStore (events table + CAS)', () => { expect(results.length).toBe(3); // Each result should have a unique ULID id - const ids = new Set(results.map(r => r.id)); + const ids = new Set(results.map((r) => r.id)); expect(ids.size).toBe(3); // All should be retrievable @@ -502,7 +523,7 @@ describe('Vitals (vitals table + housekeeping)', () => { const results = store.appendVitals(vitals); expect(results.length).toBe(3); - const ids = new Set(results.map(r => r.id)); + const ids = new Set(results.map((r) => r.id)); expect(ids.size).toBe(3); // Verify retrievable @@ -552,7 +573,7 @@ describe('Vitals (vitals table + housekeeping)', () => { // Should keep: w0_3 (900, latest in window 0), w1_3 (1900, latest in window 1), recent (5000) const history = store.getVitalHistory('cpu', 10); expect(history.length).toBe(3); - const hashes = history.map(v => v.hash); + const hashes = history.map((v) => v.hash); expect(hashes.includes('w0_3')).toBeTruthy(); expect(hashes.includes('w1_3')).toBeTruthy(); expect(hashes.includes('recent')).toBeTruthy(); @@ -620,13 +641,21 @@ describe('Split-database isolation', () => { // events.db should have events table but NOT vitals expect(eDb.prepare('SELECT * FROM events').all().length > 0).toBeTruthy(); expect( - eDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='vitals'").get(), + eDb + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='vitals'", + ) + .get(), ).toBeNull(); // vitals.db should have vitals table but NOT events expect(vDb.prepare('SELECT * FROM vitals').all().length > 0).toBeTruthy(); expect( - vDb.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='events'").get(), + vDb + .prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name='events'", + ) + .get(), ).toBeNull(); eDb.close(); diff --git a/packages/pulse/src/store.ts b/packages/pulse/src/store.ts index 7d1230f..37686db 100644 --- a/packages/pulse/src/store.ts +++ b/packages/pulse/src/store.ts @@ -7,23 +7,23 @@ import { Database } from 'bun:sqlite'; import { createHash } from 'node:crypto'; -import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; // ── Types ────────────────────────────────────────────────────── export interface EventRecord { - id: string; // ULID + id: string; // ULID occurredAt: number; kind: string; key?: string; hash?: string; codeRev?: string; - meta?: string; // JSON string + meta?: string; // JSON string } export interface VitalRecord { - id: string; // ULID + id: string; // ULID occurredAt: number; key: string; hash?: string; @@ -41,25 +41,35 @@ export interface PulseStore { getLatest(kind: string, key?: string): EventRecord | null; /** Get latest event with additional filters */ - getLatestWhere(opts: { kind: string; key?: string; codeRev?: string }): EventRecord | null; + getLatestWhere(opts: { + kind: string; + key?: string; + codeRev?: string; + }): EventRecord | null; /** Get recent events (newest first) */ getRecent(limit?: number): EventRecord[]; /** Query events by kind with optional filters */ - queryByKind(kind: string, opts?: { - key?: string; - since?: number; // occurred_at >= since - codeRev?: string; - limit?: number; - }): EventRecord[]; + queryByKind( + kind: string, + opts?: { + key?: string; + since?: number; // occurred_at >= since + codeRev?: string; + limit?: number; + }, + ): EventRecord[]; /** Get all events after a specific event id */ - getAfter(afterId: string, opts?: { - kind?: string; - key?: string; - codeRev?: string; - }): EventRecord[]; + getAfter( + afterId: string, + opts?: { + kind?: string; + key?: string; + codeRev?: string; + }, + ): EventRecord[]; /** Check if any events exist */ hasEvents(): boolean; @@ -252,24 +262,38 @@ export function createStore(options: CreateStoreOptions): PulseStore { SELECT * FROM vitals WHERE key = ? ORDER BY occurred_at DESC, id DESC LIMIT 1 `); - const appendVitalsManyTx = vitalsDb.transaction((vitals: Omit[]): VitalRecord[] => { - const results: VitalRecord[] = []; - for (const v of vitals) { - const id = makeUlid(v.occurredAt); - insertVital.run(id, v.occurredAt, v.key, v.hash ?? null, v.meta ?? null); - results.push({ id, ...v }); - } - return results; - }); + const appendVitalsManyTx = vitalsDb.transaction( + (vitals: Omit[]): VitalRecord[] => { + const results: VitalRecord[] = []; + for (const v of vitals) { + const id = makeUlid(v.occurredAt); + insertVital.run( + id, + v.occurredAt, + v.key, + v.hash ?? null, + v.meta ?? null, + ); + results.push({ id, ...v }); + } + return results; + }, + ); const archiveVitalsTx = vitalsDb.transaction((olderThan: number): number => { - vitalsDb.prepare(`INSERT INTO vitals_archive SELECT * FROM vitals WHERE occurred_at < ?`).run(olderThan); - const result = vitalsDb.prepare(`DELETE FROM vitals WHERE occurred_at < ?`).run(olderThan); + vitalsDb + .prepare( + `INSERT INTO vitals_archive SELECT * FROM vitals WHERE occurred_at < ?`, + ) + .run(olderThan); + const result = vitalsDb + .prepare(`DELETE FROM vitals WHERE occurred_at < ?`) + .run(olderThan); return result.changes; }); // Batch insert transaction — events - const insertMany = eventsDb.transaction((records: EventRecord[]) => { + const _insertMany = eventsDb.transaction((records: EventRecord[]) => { for (const r of records) { insertEvent.run( r.id, @@ -298,24 +322,26 @@ export function createStore(options: CreateStoreOptions): PulseStore { return record; } - const appendManyTx = eventsDb.transaction((events: Omit[]): EventRecord[] => { - const results: EventRecord[] = []; - for (const event of events) { - const id = makeUlid(event.occurredAt); - const record: EventRecord = { id, ...event }; - insertEvent.run( - id, - event.occurredAt, - event.kind, - event.key ?? null, - event.hash ?? null, - event.codeRev ?? null, - event.meta ?? null, - ); - results.push(record); - } - return results; - }); + const appendManyTx = eventsDb.transaction( + (events: Omit[]): EventRecord[] => { + const results: EventRecord[] = []; + for (const event of events) { + const id = makeUlid(event.occurredAt); + const record: EventRecord = { id, ...event }; + insertEvent.run( + id, + event.occurredAt, + event.kind, + event.key ?? null, + event.hash ?? null, + event.codeRev ?? null, + event.meta ?? null, + ); + results.push(record); + } + return results; + }, + ); return { appendEvent(event: Omit): EventRecord { @@ -327,11 +353,19 @@ export function createStore(options: CreateStoreOptions): PulseStore { }, getLatest(kind: string, key?: string): EventRecord | null { - const row = selectLatest.get(kind, key ?? null, key ?? null) as RawRow | null; + const row = selectLatest.get( + kind, + key ?? null, + key ?? null, + ) as RawRow | null; return row ? rowToRecord(row) : null; }, - getLatestWhere(opts: { kind: string; key?: string; codeRev?: string }): EventRecord | null { + getLatestWhere(opts: { + kind: string; + key?: string; + codeRev?: string; + }): EventRecord | null { const conditions: string[] = ['kind = ?']; const params: (string | number | null)[] = [opts.kind]; @@ -354,12 +388,15 @@ export function createStore(options: CreateStoreOptions): PulseStore { return (eventsDb.prepare(sql).all(limit) as RawRow[]).map(rowToRecord); }, - queryByKind(kind: string, opts?: { - key?: string; - since?: number; - codeRev?: string; - limit?: number; - }): EventRecord[] { + queryByKind( + kind: string, + opts?: { + key?: string; + since?: number; + codeRev?: string; + limit?: number; + }, + ): EventRecord[] { const conditions: string[] = ['kind = ?']; const params: (string | number | null)[] = [kind]; @@ -382,14 +419,19 @@ export function createStore(options: CreateStoreOptions): PulseStore { params.push(opts.limit); } - return (eventsDb.prepare(sql).all(...params) as RawRow[]).map(rowToRecord); + return (eventsDb.prepare(sql).all(...params) as RawRow[]).map( + rowToRecord, + ); }, - getAfter(afterId: string, opts?: { - kind?: string; - key?: string; - codeRev?: string; - }): EventRecord[] { + getAfter( + afterId: string, + opts?: { + kind?: string; + key?: string; + codeRev?: string; + }, + ): EventRecord[] { const conditions: string[] = ['id > ?']; const params: (string | number | null)[] = [afterId]; @@ -407,7 +449,9 @@ export function createStore(options: CreateStoreOptions): PulseStore { } const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`; - return (eventsDb.prepare(sql).all(...params) as RawRow[]).map(rowToRecord); + return (eventsDb.prepare(sql).all(...params) as RawRow[]).map( + rowToRecord, + ); }, hasEvents(): boolean { @@ -438,7 +482,13 @@ export function createStore(options: CreateStoreOptions): PulseStore { appendVital(vital: Omit): VitalRecord { const id = makeUlid(vital.occurredAt); - insertVital.run(id, vital.occurredAt, vital.key, vital.hash ?? null, vital.meta ?? null); + insertVital.run( + id, + vital.occurredAt, + vital.key, + vital.hash ?? null, + vital.meta ?? null, + ); const record: VitalRecord = { id, ...vital }; return record; }, @@ -454,14 +504,20 @@ export function createStore(options: CreateStoreOptions): PulseStore { getVitalHistory(key: string, limit: number = 20): VitalRecord[] { const sql = `SELECT * FROM vitals WHERE key = ? ORDER BY occurred_at DESC, id DESC LIMIT ?`; - return (vitalsDb.prepare(sql).all(key, limit) as RawVitalRow[]).map(vitalRowToRecord); + return (vitalsDb.prepare(sql).all(key, limit) as RawVitalRow[]).map( + vitalRowToRecord, + ); }, archiveVitals(olderThan: number): number { return archiveVitalsTx(olderThan); }, - downsampleVitals(key: string, intervalMs: number, olderThan: number): number { + downsampleVitals( + key: string, + intervalMs: number, + olderThan: number, + ): number { // Note: SQLite does not support parameter binding inside PARTITION BY expressions. // We safely cast intervalMs to integer and embed it directly in the SQL. const safeInterval = Math.floor(Math.abs(intervalMs)); diff --git a/packages/pulse/src/watcher.test.ts b/packages/pulse/src/watcher.test.ts index 8fc4e42..f68cc06 100644 --- a/packages/pulse/src/watcher.test.ts +++ b/packages/pulse/src/watcher.test.ts @@ -2,9 +2,13 @@ * @uncaged/pulse — Watcher Tests */ -import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest'; -import { startWatcher, type WatcherDef, type WatcherHandle } from './watcher.js'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { PulseStore, VitalRecord } from './store.js'; +import { + startWatcher, + type WatcherDef, + type WatcherHandle, +} from './watcher.js'; // Mock store implementation const createMockStore = (): PulseStore => ({ @@ -106,7 +110,8 @@ describe('watcher', () => { }); test('error handling continues loop', async () => { - const mockCollect = vi.fn() + const mockCollect = vi + .fn() .mockRejectedValueOnce(new Error('First error')) .mockResolvedValue({ data: 'success' }); const mockShouldWake = vi.fn().mockReturnValue(false); @@ -125,7 +130,7 @@ describe('watcher', () => { vi.advanceTimersByTime(1000); expect(console.error).toHaveBeenCalledWith( '[watcher:error-watcher] error during tick:', - expect.any(Error) + expect.any(Error), ); // Second tick - success @@ -186,12 +191,12 @@ describe('watcher', () => { test('getVitalHistory called with correct parameters', async () => { const mockCollect = vi.fn().mockResolvedValue({ data: 'history-test' }); const mockShouldWake = vi.fn().mockReturnValue(false); - + const mockVitals: VitalRecord[] = [ { occurredAt: Date.now(), key: 'test-key', hash: 'hash-1' }, { occurredAt: Date.now() - 5000, key: 'test-key', hash: 'hash-2' }, ]; - + (mockStore.getVitalHistory as any).mockReturnValue(mockVitals); const def: WatcherDef = { @@ -208,4 +213,4 @@ describe('watcher', () => { expect(mockStore.getVitalHistory).toHaveBeenCalledWith('history-key', 12); expect(mockShouldWake).toHaveBeenCalledWith(mockVitals); }); -}); \ No newline at end of file +}); diff --git a/packages/upulse/src/cli.ts b/packages/upulse/src/cli.ts index c2fa1a5..e050d88 100644 --- a/packages/upulse/src/cli.ts +++ b/packages/upulse/src/cli.ts @@ -7,14 +7,14 @@ */ import { Command } from 'commander'; -import { registerInitCommand } from './commands/init.js'; import { registerDaemonCommand } from './commands/daemon.js'; -import { registerTickCommand } from './commands/tick.js'; -import { registerListCommand } from './commands/list.js'; -import { registerInspectCommand } from './commands/inspect.js'; -import { registerDevCommand } from './commands/dev.js'; import { registerDeployCommand } from './commands/deploy.js'; +import { registerDevCommand } from './commands/dev.js'; import { registerGcCommand } from './commands/gc.js'; +import { registerInitCommand } from './commands/init.js'; +import { registerInspectCommand } from './commands/inspect.js'; +import { registerListCommand } from './commands/list.js'; +import { registerTickCommand } from './commands/tick.js'; const program = new Command(); @@ -22,7 +22,10 @@ program .name('upulse') .description('Pulse CLI — Agent 的自主神经系统管理工具') .version('0.1.0') - .option('-d, --dir ', 'Pulse base directory (overrides PULSE_BASE_DIR)'); + .option( + '-d, --dir ', + 'Pulse base directory (overrides PULSE_BASE_DIR)', + ); registerInitCommand(program); registerDaemonCommand(program); diff --git a/packages/upulse/src/commands/daemon.ts b/packages/upulse/src/commands/daemon.ts index 9fae66c..27abbc4 100644 --- a/packages/upulse/src/commands/daemon.ts +++ b/packages/upulse/src/commands/daemon.ts @@ -4,7 +4,7 @@ import type { Command } from 'commander'; import { loadConfig, resolveDir } from '../config.js'; -import { daemonStart, daemonStop, daemonStatus } from '../daemon.js'; +import { daemonStart, daemonStatus, daemonStop } from '../daemon.js'; export function registerDaemonCommand(program: Command): void { const daemon = program diff --git a/packages/upulse/src/commands/deploy.ts b/packages/upulse/src/commands/deploy.ts index a7a3621..150d1c7 100644 --- a/packages/upulse/src/commands/deploy.ts +++ b/packages/upulse/src/commands/deploy.ts @@ -5,24 +5,36 @@ * All state changes are append-only events. */ -import type { Command } from 'commander'; -import { loadConfig, resolveDir, type UpulseConfig } from '../config.js'; import { execSync } from 'node:child_process'; -import { join } from 'node:path'; import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Command } from 'commander'; +import { loadConfig, resolveDir } from '../config.js'; +import { daemonStart, daemonStop, isDaemonRunning } from '../daemon.js'; import { gitExec, gitMerge, gitResetHard, gitRevert } from '../git.js'; -import { daemonStop, daemonStart, isDaemonRunning } from '../daemon.js'; -import { openStore, openOrCreateStore, type PulseStore, type EventRecord } from '../store.js'; +import { + type EventRecord, + openOrCreateStore, + openStore, + type PulseStore, +} from '../store.js'; // ── Helpers ──────────────────────────────────────────────────── function typeCheck(cwd: string): boolean { try { // Pure type checking — no emit, no dist/ needed - execSync('bunx tsc --noEmit', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); + execSync('bunx tsc --noEmit', { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); return true; } catch (err: unknown) { - const msg = err instanceof Error ? (err as { stderr?: string }).stderr ?? err.message : String(err); + const msg = + err instanceof Error + ? ((err as { stderr?: string }).stderr ?? err.message) + : String(err); console.error(`Type check failed:\n${msg}`); return false; } @@ -41,7 +53,10 @@ function getCurrentCodeRev(store: PulseStore): string | undefined { * Find the previous promote event with a different code_rev than the current one. * queryByKind returns newest-first, so find the first with a different codeRev. */ -function findPreviousPromote(store: PulseStore, currentCodeRev: string | undefined): EventRecord | null { +function findPreviousPromote( + store: PulseStore, + currentCodeRev: string | undefined, +): EventRecord | null { const allPromotes = store.queryByKind('promote'); for (const promote of allPromotes) { if (promote.codeRev !== currentCodeRev) { @@ -64,18 +79,22 @@ async function tryWriteMigrateEvents( if (!existsSync(migrateFile)) return; try { - const mod = await import(migrateFile) as Record; + const mod = (await import(migrateFile)) as Record; const migrateFn = (mod.default ?? mod.migrate) as | ((snapshot: Record) => Record) | undefined; if (!migrateFn) return; // Build a minimal current snapshot from store for migration - const { findEffectiveEpoch, rebuildSnapshot } = await import('@uncaged/pulse'); + const { findEffectiveEpoch, rebuildSnapshot } = await import( + '@uncaged/pulse' + ); const epoch = findEffectiveEpoch(store); // Discover sense keys from existing collect events const collects = store.queryByKind('collect', { limit: 100 }); - const senseKeys = [...new Set(collects.map(e => e.key).filter(Boolean) as string[])]; + const senseKeys = [ + ...new Set(collects.map((e) => e.key).filter(Boolean) as string[]), + ]; const currentSnapshot = rebuildSnapshot(store, senseKeys, epoch); const migrated = migrateFn(currentSnapshot); @@ -92,7 +111,9 @@ async function tryWriteMigrateEvents( } console.log(' ✓ migrate events written'); } catch (err: unknown) { - console.error(` ✗ migrate failed: ${err instanceof Error ? err.message : String(err)}`); + console.error( + ` ✗ migrate failed: ${err instanceof Error ? err.message : String(err)}`, + ); console.error(' Promote aborted. Fix migrate.ts and try again.'); process.exit(1); } @@ -107,8 +128,10 @@ async function tryWriteInitEvents( if (!existsSync(initFile)) return; try { - const mod = await import(initFile) as Record; - const initSenses = (mod.default ?? mod.initSenses) as Record | undefined; + const mod = (await import(initFile)) as Record; + const initSenses = (mod.default ?? mod.initSenses) as + | Record + | undefined; if (!initSenses || typeof initSenses !== 'object') return; for (const [key, data] of Object.entries(initSenses)) { @@ -122,7 +145,9 @@ async function tryWriteInitEvents( } console.log(' ✓ init events written'); } catch (err: unknown) { - console.warn(` ⚠ initSenses skipped: ${err instanceof Error ? err.message : String(err)}`); + console.warn( + ` ⚠ initSenses skipped: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -135,7 +160,9 @@ export function registerDeployCommand(program: Command): void { deploy .command('promote') - .description('Merge staging → engine (tsc verify + version events + daemon reload)') + .description( + 'Merge staging → engine (tsc verify + version events + daemon reload)', + ) .action(async () => { const config = loadConfig(resolveDir(program.opts().dir)); const running = isDaemonRunning(config); @@ -157,7 +184,9 @@ export function registerDeployCommand(program: Command): void { gitMerge(config.engine.path, 'staging'); console.log(' ✓ merge successful'); } catch (err: unknown) { - console.error(`Error: merge failed: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Error: merge failed: ${err instanceof Error ? err.message : String(err)}`, + ); console.error('Resolve conflicts manually in engine/ and retry.'); process.exit(1); } @@ -165,7 +194,9 @@ export function registerDeployCommand(program: Command): void { // Step 3: Type-check engine (second verification) console.log('Step 3: Type-checking engine...'); if (!typeCheck(config.engine.path)) { - console.error('Error: engine type check failed after merge. Rolling back...'); + console.error( + 'Error: engine type check failed after merge. Rolling back...', + ); gitResetHard(config.engine.path, 'HEAD~1'); console.error('Rolled back to previous state.'); process.exit(1); @@ -174,7 +205,10 @@ export function registerDeployCommand(program: Command): void { // Step 4: Write version events console.log('Step 4: Writing version events...'); - const store = openOrCreateStore(config.store.eventsDbFile, config.store.vitalsDbFile); + const store = openOrCreateStore( + config.store.eventsDbFile, + config.store.vitalsDbFile, + ); { const currentCodeRev = getCurrentCodeRev(store); const newCodeRev = getGitHash(config.engine.path); @@ -192,7 +226,9 @@ export function registerDeployCommand(program: Command): void { codeRev: newCodeRev, meta: JSON.stringify({ from: currentCodeRev ?? null }), }); - console.log(` ✓ promote event: ${currentCodeRev ?? '(cold start)'} → ${newCodeRev}`); + console.log( + ` ✓ promote event: ${currentCodeRev ?? '(cold start)'} → ${newCodeRev}`, + ); store.close(); } @@ -205,7 +241,9 @@ export function registerDeployCommand(program: Command): void { daemonStart(config, false); console.log(' ✓ daemon reloaded'); } catch (err: unknown) { - console.error(`Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`, + ); } } @@ -214,16 +252,23 @@ export function registerDeployCommand(program: Command): void { deploy .command('rollback') - .description('Roll back to previous version (append-only: writes rollback event, reverts engine)') + .description( + 'Roll back to previous version (append-only: writes rollback event, reverts engine)', + ) .action(() => { const config = loadConfig(resolveDir(program.opts().dir)); const running = isDaemonRunning(config); // Step 1: Open store and find versions console.log('Step 1: Finding versions...'); - const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile); + const store = openStore( + config.store.eventsDbFile, + config.store.vitalsDbFile, + ); if (!store) { - console.error('Error: no events.db found. Cannot determine versions for rollback.'); + console.error( + 'Error: no events.db found. Cannot determine versions for rollback.', + ); process.exit(1); } @@ -234,7 +279,10 @@ export function registerDeployCommand(program: Command): void { process.exit(1); } - const previousPromote = findPreviousPromote(store, currentPromote.codeRev); + const previousPromote = findPreviousPromote( + store, + currentPromote.codeRev, + ); if (!previousPromote) { console.error('Error: no previous version to roll back to.'); store.close(); @@ -264,7 +312,9 @@ export function registerDeployCommand(program: Command): void { gitRevert(config.engine.path, 'HEAD'); console.log(' ✓ reverted'); } catch (err: unknown) { - console.error(`Error: revert failed: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Error: revert failed: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } @@ -284,7 +334,9 @@ export function registerDeployCommand(program: Command): void { daemonStart(config, false); console.log(' ✓ daemon reloaded'); } catch (err: unknown) { - console.error(`Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Warning: daemon reload failed: ${err instanceof Error ? err.message : String(err)}`, + ); } } diff --git a/packages/upulse/src/commands/dev.ts b/packages/upulse/src/commands/dev.ts index bf10cae..91513e2 100644 --- a/packages/upulse/src/commands/dev.ts +++ b/packages/upulse/src/commands/dev.ts @@ -2,10 +2,10 @@ * commands/dev.ts — upulse dev path/build */ -import type { Command } from 'commander'; -import { loadConfig, resolveDir } from '../config.js'; import { execSync } from 'node:child_process'; import { join } from 'node:path'; +import type { Command } from 'commander'; +import { loadConfig, resolveDir } from '../config.js'; export function registerDevCommand(program: Command): void { const dev = program @@ -19,9 +19,9 @@ export function registerDevCommand(program: Command): void { .action((opts: { engine?: boolean }) => { const config = loadConfig(resolveDir(program.opts().dir)); if (opts.engine) { - process.stdout.write(config.engine.path + '\n'); + process.stdout.write(`${config.engine.path}\n`); } else { - process.stdout.write(config.staging.path + '\n'); + process.stdout.write(`${config.staging.path}\n`); } }); @@ -30,10 +30,19 @@ export function registerDevCommand(program: Command): void { .description('tsc compile staging/') .action(() => { const config = loadConfig(resolveDir(program.opts().dir)); - const tscBin = join(config.engine.path, 'node_modules', 'typescript', 'bin', 'tsc'); + const tscBin = join( + config.engine.path, + 'node_modules', + 'typescript', + 'bin', + 'tsc', + ); console.log(`Compiling staging (${config.staging.path})...`); try { - execSync(`bun ${tscBin}`, { cwd: config.staging.path, stdio: 'inherit' }); + execSync(`bun ${tscBin}`, { + cwd: config.staging.path, + stdio: 'inherit', + }); console.log('✓ Build successful'); } catch { console.error('Error: tsc compilation failed.'); diff --git a/packages/upulse/src/commands/gc.ts b/packages/upulse/src/commands/gc.ts index 7ecc0b2..e2bcd59 100644 --- a/packages/upulse/src/commands/gc.ts +++ b/packages/upulse/src/commands/gc.ts @@ -11,7 +11,9 @@ import { openStore } from '../store.js'; function parseDuration(s: string): number { const match = s.match(/^(\d+)(ms|s|m|h|d)?$/); if (!match) { - console.error(`Error: invalid duration "${s}". Use format: 7d, 24h, 30m, 60s, 1000ms`); + console.error( + `Error: invalid duration "${s}". Use format: 7d, 24h, 30m, 60s, 1000ms`, + ); process.exit(1); } const val = parseInt(match[1], 10); @@ -33,12 +35,22 @@ export function registerGcCommand(program: Command): void { gc.command('vitals') .description('Archive old vitals and optionally downsample') - .option('--keep ', 'Move vitals older than this to archive (default: 7d)', '7d') - .option('--downsample ', 'Downsample interval (e.g., 1m, 5m). If omitted, no downsampling.') + .option( + '--keep ', + 'Move vitals older than this to archive (default: 7d)', + '7d', + ) + .option( + '--downsample ', + 'Downsample interval (e.g., 1m, 5m). If omitted, no downsampling.', + ) .option('--dry-run', 'Show what would happen without making changes', false) .action((opts: { keep: string; downsample?: string; dryRun: boolean }) => { const config = loadConfig(resolveDir(program.opts().dir)); - const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile); + const store = openStore( + config.store.eventsDbFile, + config.store.vitalsDbFile, + ); if (!store) { console.log('No events.db found. Daemon may not have run yet.'); @@ -57,12 +69,14 @@ export function registerGcCommand(program: Command): void { if (opts.downsample) { const intervalMs = parseDuration(opts.downsample); - console.log(`Downsample interval: ${opts.downsample} (${intervalMs}ms)`); + console.log( + `Downsample interval: ${opts.downsample} (${intervalMs}ms)`, + ); if (!opts.dryRun) { // Get unique keys from vitals for downsampling // We downsample per-key before archiving - const keys = (store as any).__db + const _keys = (store as any).__db ? [] // fallback: skip key enumeration in dry-run : []; @@ -81,12 +95,18 @@ export function registerGcCommand(program: Command): void { // Actually, store has no listKeys — let's just run the SQL directly via a workaround. // The cleanest solution: archive includes downsampling semantics. // For now, we skip key enumeration and inform the user. - console.log(' Note: use --downsample with specific key names (not yet supported in batch mode).'); - console.log(' Downsampling skipped. Use the programmatic API for per-key downsampling.'); + console.log( + ' Note: use --downsample with specific key names (not yet supported in batch mode).', + ); + console.log( + ' Downsampling skipped. Use the programmatic API for per-key downsampling.', + ); console.log('\n[2/2] Archiving vitals...'); const archived = store.archiveVitals(olderThan); - console.log(` Archived ${archived} vital(s) older than ${olderThanStr}.`); + console.log( + ` Archived ${archived} vital(s) older than ${olderThanStr}.`, + ); } else { console.log('\n(dry-run) Would downsample and archive vitals.'); } @@ -94,7 +114,9 @@ export function registerGcCommand(program: Command): void { // Archive only if (!opts.dryRun) { const archived = store.archiveVitals(olderThan); - console.log(`\nArchived ${archived} vital(s) older than ${olderThanStr}.`); + console.log( + `\nArchived ${archived} vital(s) older than ${olderThanStr}.`, + ); } else { console.log('\n(dry-run) Would archive vitals.'); } diff --git a/packages/upulse/src/commands/init.ts b/packages/upulse/src/commands/init.ts index 15d3f9f..2843d57 100644 --- a/packages/upulse/src/commands/init.ts +++ b/packages/upulse/src/commands/init.ts @@ -3,8 +3,8 @@ */ import type { Command } from 'commander'; -import { initUpulse } from '../init.js'; import { resolveDir } from '../config.js'; +import { initUpulse } from '../init.js'; export function registerInitCommand(program: Command): void { program diff --git a/packages/upulse/src/commands/inspect.ts b/packages/upulse/src/commands/inspect.ts index 60e4a56..e049ccb 100644 --- a/packages/upulse/src/commands/inspect.ts +++ b/packages/upulse/src/commands/inspect.ts @@ -11,12 +11,19 @@ import { openStore } from '../store.js'; function parseDuration(s: string): number { const match = s.match(/^(\d+)(m|h|d|s)?$/); if (!match) { - console.error(`Error: invalid duration "${s}". Use format: 1h, 30m, 7d, 3600s`); + console.error( + `Error: invalid duration "${s}". Use format: 1h, 30m, 7d, 3600s`, + ); process.exit(1); } const val = parseInt(match[1], 10); const unit = match[2] ?? 'm'; - const multiplier: Record = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + const multiplier: Record = { + s: 1000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, + }; return val * (multiplier[unit] ?? 60_000); } @@ -31,7 +38,10 @@ export function registerInspectCommand(program: Command): void { .option('--since ', 'Time window (e.g., 1h, 30m)', '1h') .action((opts: { since: string }) => { const config = loadConfig(resolveDir(program.opts().dir)); - const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile); + const store = openStore( + config.store.eventsDbFile, + config.store.vitalsDbFile, + ); if (!store) { console.log('No events.db found. Daemon may not have run yet.'); @@ -64,7 +74,10 @@ export function registerInspectCommand(program: Command): void { .option('--limit ', 'Number of ticks to show', '10') .action((opts: { limit: string }) => { const config = loadConfig(resolveDir(program.opts().dir)); - const store = openStore(config.store.eventsDbFile, config.store.vitalsDbFile); + const store = openStore( + config.store.eventsDbFile, + config.store.vitalsDbFile, + ); if (!store) { console.log('No events.db found. Daemon may not have run yet.'); @@ -79,8 +92,12 @@ export function registerInspectCommand(program: Command): void { } else { console.log(`Recent ticks (last ${limit}):`); console.log(''); - console.log(' Timestamp | tickMs | Effects | Duration'); - console.log(' ------------------------------------------------------------------'); + console.log( + ' Timestamp | tickMs | Effects | Duration', + ); + console.log( + ' ------------------------------------------------------------------', + ); for (const tick of ticks) { const ts = new Date(tick.occurredAt).toISOString(); let tickMs = '-'; @@ -90,13 +107,17 @@ export function registerInspectCommand(program: Command): void { try { const meta = JSON.parse(tick.meta); if (meta.tick_ms !== undefined) tickMs = String(meta.tick_ms); - if (meta.effect_count !== undefined) effectCount = String(meta.effect_count); - if (meta.duration_ms !== undefined) durationMs = `${meta.duration_ms}ms`; + if (meta.effect_count !== undefined) + effectCount = String(meta.effect_count); + if (meta.duration_ms !== undefined) + durationMs = `${meta.duration_ms}ms`; } catch { // ignore parse errors } } - console.log(` ${ts} | ${tickMs.padStart(6)} | ${effectCount.padStart(7)} | ${durationMs}`); + console.log( + ` ${ts} | ${tickMs.padStart(6)} | ${effectCount.padStart(7)} | ${durationMs}`, + ); } console.log(`\n Total: ${ticks.length} tick(s)`); } diff --git a/packages/upulse/src/commands/list.ts b/packages/upulse/src/commands/list.ts index d9248b5..cc232ae 100644 --- a/packages/upulse/src/commands/list.ts +++ b/packages/upulse/src/commands/list.ts @@ -2,10 +2,10 @@ * commands/list.ts — upulse list (show current rule chain) */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; import type { Command } from 'commander'; import { loadConfig, resolveDir } from '../config.js'; -import { readFileSync, existsSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; export function registerListCommand(program: Command): void { program @@ -22,7 +22,7 @@ export function registerListCommand(program: Command): void { // List rule files sorted by name (number prefix = order) const files = readdirSync(rulesDir) - .filter(f => f.endsWith('.ts')) + .filter((f) => f.endsWith('.ts')) .sort(); if (files.length === 0) { @@ -50,13 +50,15 @@ export function registerListCommand(program: Command): void { const configFile = join(config.engine.path, 'pulse.config.ts'); if (existsSync(configFile)) { const configContent = readFileSync(configFile, 'utf-8'); - const rulesMatch = configContent.match(/const rules\s*=\s*\[([\s\S]*?)\]/); + const rulesMatch = configContent.match( + /const rules\s*=\s*\[([\s\S]*?)\]/, + ); if (rulesMatch) { console.log(`\n Inline rules in pulse.config.ts:`); const inlineRules = rulesMatch[1] .split('\n') - .map(l => l.trim()) - .filter(l => l && !l.startsWith('//') && !l.startsWith(']')); + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('//') && !l.startsWith(']')); for (const rule of inlineRules) { console.log(` ${rule.replace(/,\s*$/, '')}`); } diff --git a/packages/upulse/src/commands/tick.ts b/packages/upulse/src/commands/tick.ts index 1c7d768..a164e5b 100644 --- a/packages/upulse/src/commands/tick.ts +++ b/packages/upulse/src/commands/tick.ts @@ -4,11 +4,11 @@ * Manually trigger one tick using rebuildSnapshot + collectors. */ -import type { Command } from 'commander'; -import { loadConfig, resolveDir } from '../config.js'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; +import type { Command } from 'commander'; +import { loadConfig, resolveDir } from '../config.js'; export function registerTickCommand(program: Command): void { program @@ -22,10 +22,24 @@ export function registerTickCommand(program: Command): void { try { // Import @uncaged/pulse from engine's node_modules - const pulseModulePath = join(enginePath, 'node_modules', '@uncaged', 'pulse', 'dist', 'index.js'); + const pulseModulePath = join( + enginePath, + 'node_modules', + '@uncaged', + 'pulse', + 'dist', + 'index.js', + ); let rebuildSnapshot: (store: unknown, senseKeys: string[]) => unknown; - let createStore: (options: { eventsDbPath: string; vitalsDbPath: string; objectsDir: string }) => unknown; - let composeRules: (rules: unknown[], defaultTickMs?: number) => (prev: unknown, curr: unknown) => Promise<[unknown[], number]>; + let createStore: (options: { + eventsDbPath: string; + vitalsDbPath: string; + objectsDir: string; + }) => unknown; + let composeRules: ( + rules: unknown[], + defaultTickMs?: number, + ) => (prev: unknown, curr: unknown) => Promise<[unknown[], number]>; if (existsSync(pulseModulePath)) { const pulseModule = await import(pathToFileURL(pulseModulePath).href); @@ -33,15 +47,20 @@ export function registerTickCommand(program: Command): void { createStore = pulseModule.createStore; composeRules = pulseModule.composeRules; } else { - console.error('Error: @uncaged/pulse not found in engine node_modules.'); + console.error( + 'Error: @uncaged/pulse not found in engine node_modules.', + ); console.error('Run: cd ~/.upulse/engine && bun install'); process.exit(1); } // Import collector (.ts source — Bun runs TS natively) - const collectSystemPath = pathToFileURL(join(enginePath, 'executors', 'system.ts')).href; + const collectSystemPath = pathToFileURL( + join(enginePath, 'executors', 'system.ts'), + ).href; const collectorModule = await import(collectSystemPath); - const collectSystem = collectorModule.collectSystem ?? collectorModule.collect; + const collectSystem = + collectorModule.collectSystem ?? collectorModule.collect; // Import rules (dynamically discover .ts rule files) const rulesDir = join(enginePath, 'rules'); @@ -71,7 +90,11 @@ export function registerTickCommand(program: Command): void { const eventsDbPath = config.store.eventsDbFile; const vitalsDbPath = config.store.vitalsDbFile; const objectsDir = config.store.objectsDir; - const store = createStore({ eventsDbPath, vitalsDbPath, objectsDir }) as { + const store = createStore({ + eventsDbPath, + vitalsDbPath, + objectsDir, + }) as { hasEvents(): boolean; appendEvent(e: Record): unknown; putObject(d: unknown): string; @@ -92,7 +115,10 @@ export function registerTickCommand(program: Command): void { hash, }); } catch (err) { - console.error(`Warning: collector "${key}" failed:`, err instanceof Error ? err.message : String(err)); + console.error( + `Warning: collector "${key}" failed:`, + err instanceof Error ? err.message : String(err), + ); } } } @@ -160,7 +186,9 @@ export function registerTickCommand(program: Command): void { }); console.log(` ✓ Collected: ${e.key}`); } catch (err) { - console.error(` ✗ Collection failed for ${e.key}: ${err instanceof Error ? err.message : String(err)}`); + console.error( + ` ✗ Collection failed for ${e.key}: ${err instanceof Error ? err.message : String(err)}`, + ); } } } @@ -181,7 +209,7 @@ export function registerTickCommand(program: Command): void { } console.log('\n✓ Effects handled (tick mode)'); } - + // Record events like runPulse does if (effects.length > 0) { const effectsHash = store.putObject(effects); @@ -192,7 +220,7 @@ export function registerTickCommand(program: Command): void { meta: JSON.stringify({ count: effects.length }), }); } - + // Record tick event store.appendEvent({ occurredAt: Date.now(), @@ -204,7 +232,9 @@ export function registerTickCommand(program: Command): void { }); } } catch (err: unknown) { - console.error(`Error during tick: ${err instanceof Error ? err.message : String(err)}`); + console.error( + `Error during tick: ${err instanceof Error ? err.message : String(err)}`, + ); process.exit(1); } }); diff --git a/packages/upulse/src/config.test.ts b/packages/upulse/src/config.test.ts index 121e515..8381420 100644 --- a/packages/upulse/src/config.test.ts +++ b/packages/upulse/src/config.test.ts @@ -2,11 +2,11 @@ * config.test.ts — resolveDir + loadConfig tests */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; -import { tmpdir, homedir } from 'node:os'; -import { resolveDir, defaultConfig, saveConfig, loadConfig } from './config.js'; +import { defaultConfig, loadConfig, resolveDir, saveConfig } from './config.js'; describe('resolveDir', () => { const origEnv = process.env.PULSE_BASE_DIR; diff --git a/packages/upulse/src/config.ts b/packages/upulse/src/config.ts index ff68947..13c0fe6 100644 --- a/packages/upulse/src/config.ts +++ b/packages/upulse/src/config.ts @@ -2,9 +2,9 @@ * config.ts — ~/.upulse/config.json management */ -import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; +import { join } from 'node:path'; export interface UpulseConfig { dir: string; @@ -77,5 +77,9 @@ export function loadConfig(dir?: string): UpulseConfig { } export function saveConfig(config: UpulseConfig): void { - writeFileSync(configPath(config.dir), JSON.stringify(config, null, 2), 'utf-8'); + writeFileSync( + configPath(config.dir), + JSON.stringify(config, null, 2), + 'utf-8', + ); } diff --git a/packages/upulse/src/daemon.ts b/packages/upulse/src/daemon.ts index 8847de4..37ff8b6 100644 --- a/packages/upulse/src/daemon.ts +++ b/packages/upulse/src/daemon.ts @@ -2,12 +2,22 @@ * daemon.ts — daemon lifecycle management (start/stop/status) */ -import { spawn, execSync } from 'node:child_process'; -import { readFileSync, writeFileSync, existsSync, unlinkSync, openSync, closeSync } from 'node:fs'; +import { execSync, spawn } from 'node:child_process'; +import { + closeSync, + existsSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import type { UpulseConfig } from './config.js'; import { openStore } from './store.js'; -export function daemonStart(config: UpulseConfig, foreground: boolean = false): void { +export function daemonStart( + config: UpulseConfig, + foreground: boolean = false, +): void { const enginePath = config.engine.path; const tsEntry = `${enginePath}/pulse.config.ts`; @@ -37,7 +47,9 @@ export function daemonStart(config: UpulseConfig, foreground: boolean = false): // Check if already running if (isDaemonRunning(config)) { - console.error('Error: daemon is already running. Stop it first with "upulse daemon stop".'); + console.error( + 'Error: daemon is already running. Stop it first with "upulse daemon stop".', + ); process.exit(1); } @@ -124,7 +136,9 @@ export function daemonStatus(config: UpulseConfig): void { try { const meta = JSON.parse(lastTick.meta); detail = ` (${meta.effect_count ?? 0} effects, ${meta.tick_ms ?? '-'}ms interval)`; - } catch { /* ignore */ } + } catch { + /* ignore */ + } } console.log(`Last tick: ${ago}s ago${detail}`); } else { @@ -138,7 +152,7 @@ function readPid(config: UpulseConfig): number | null { if (!existsSync(config.daemon.pidFile)) return null; const raw = readFileSync(config.daemon.pidFile, 'utf-8').trim(); const pid = parseInt(raw, 10); - return isNaN(pid) ? null : pid; + return Number.isNaN(pid) ? null : pid; } function isProcessAlive(pid: number): boolean { diff --git a/packages/upulse/src/e2e/helper.ts b/packages/upulse/src/e2e/helper.ts index 7a69097..714eaf6 100644 --- a/packages/upulse/src/e2e/helper.ts +++ b/packages/upulse/src/e2e/helper.ts @@ -4,12 +4,12 @@ * Provides isolated temp directories, CLI runner, and DB query helpers. */ -import { mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { tmpdir } from 'node:os'; -import { execSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; import { Database } from 'bun:sqlite'; +import { execSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -76,10 +76,14 @@ export interface RunOptions { * - Sets PULSE_BASE_DIR so engine's pulse.config.ts uses the right paths. * - Uses `bun` to run the TypeScript CLI directly. */ -export function runUpulse(ctx: E2EContext, args: string, options?: RunOptions): string { +export function runUpulse( + ctx: E2EContext, + args: string, + options?: RunOptions, +): string { const timeout = options?.timeout ?? 120_000; const env: Record = { - ...process.env as Record, + ...(process.env as Record), HOME: ctx.baseDir, PULSE_BASE_DIR: ctx.upulseDir, }; @@ -94,7 +98,9 @@ export function runUpulse(ctx: E2EContext, args: string, options?: RunOptions): }); if (options?.expectFail) { - throw new Error(`Expected command to fail but it succeeded: upulse ${args}\nstdout: ${result}`); + throw new Error( + `Expected command to fail but it succeeded: upulse ${args}\nstdout: ${result}`, + ); } return result; } catch (err: unknown) { @@ -140,5 +146,5 @@ export function queryVitalsDb(ctx: E2EContext, sql: string): unknown[] { */ export function listObjects(ctx: E2EContext): string[] { if (!existsSync(ctx.objectsDir)) return []; - return readdirSync(ctx.objectsDir).filter(f => f.endsWith('.json')); + return readdirSync(ctx.objectsDir).filter((f) => f.endsWith('.json')); } diff --git a/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts b/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts index 9a003fc..a490169 100644 --- a/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts +++ b/packages/upulse/src/e2e/t1-init-daemon-tick.test.ts @@ -9,18 +9,17 @@ * Run: bun test src/e2e/t1-init-daemon-tick.test.ts */ -import { describe, it, beforeAll, afterAll, expect } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { execSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { execSync } from 'node:child_process'; import { - createE2EContext, cleanupE2EContext, - runUpulse, - queryEventsDb, - queryVitalsDb, - listObjects, + createE2EContext, type E2EContext, + listObjects, + queryEventsDb, + runUpulse, } from './helper.js'; describe('E2E T1: Init → Daemon → Tick', () => { @@ -81,9 +80,7 @@ describe('E2E T1: Init → Daemon → Tick', () => { }); it('init installs node_modules in engine', () => { - expect( - existsSync(join(ctx.engineDir, 'node_modules')), - ).toBeTruthy(); + expect(existsSync(join(ctx.engineDir, 'node_modules'))).toBeTruthy(); expect( existsSync(join(ctx.engineDir, 'node_modules', '@uncaged', 'pulse')), ).toBeTruthy(); @@ -107,7 +104,9 @@ describe('E2E T1: Init → Daemon → Tick', () => { const output = runUpulse(ctx, 'tick'); // tick should produce snapshot output - expect(output.includes('Snapshot') || output.includes('Result')).toBeTruthy(); + expect( + output.includes('Snapshot') || output.includes('Result'), + ).toBeTruthy(); // Verify events.db has entries expect(existsSync(ctx.eventsDbPath)).toBeTruthy(); @@ -122,14 +121,17 @@ describe('E2E T1: Init → Daemon → Tick', () => { }); it('tick records collect events', () => { - const collectEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'collect'") as Array<{ + const collectEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'collect'", + ) as Array<{ kind: string; key: string; hash: string; }>; expect(collectEvents.length > 0).toBeTruthy(); // Should have collected 'system' sense - const systemCollects = collectEvents.filter(e => e.key === 'system'); + const systemCollects = collectEvents.filter((e) => e.key === 'system'); expect(systemCollects.length > 0).toBeTruthy(); // Each collect event should have a hash pointing to an object for (const evt of systemCollects) { @@ -141,8 +143,6 @@ describe('E2E T1: Init → Daemon → Tick', () => { it('upulse daemon status shows STOPPED when daemon is not running', () => { const output = runUpulse(ctx, 'daemon status'); - expect( - output.includes('STOPPED'), - ).toBeTruthy(); + expect(output.includes('STOPPED')).toBeTruthy(); }); }); diff --git a/packages/upulse/src/e2e/t2-staging-promote.test.ts b/packages/upulse/src/e2e/t2-staging-promote.test.ts index 3162a84..e3cc43d 100644 --- a/packages/upulse/src/e2e/t2-staging-promote.test.ts +++ b/packages/upulse/src/e2e/t2-staging-promote.test.ts @@ -11,16 +11,16 @@ * Run: bun test src/e2e/t2-staging-promote.test.ts */ -import { describe, it, beforeAll, afterAll, expect } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { execSync } from 'node:child_process'; import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { execSync } from 'node:child_process'; import { - createE2EContext, cleanupE2EContext, - runUpulse, - queryEventsDb, + createE2EContext, type E2EContext, + queryEventsDb, + runUpulse, } from './helper.js'; describe('E2E T2: Staging → Promote', () => { @@ -54,7 +54,7 @@ export default rule;`; const ruleFilePath = join(ctx.stagingDir, 'rules', '02-log-hello.ts'); writeFileSync(ruleFilePath, newRuleContent); - + expect(existsSync(ruleFilePath)).toBeTruthy(); }); @@ -70,20 +70,21 @@ export default rule;`; expect(log.includes('02-log-hello')).toBeTruthy(); }); - it('upulse deploy promote succeeds', async () => { + it('upulse deploy promote succeeds', async () => { const output = runUpulse(ctx, 'deploy promote', { timeout: 30_000 }); - expect( - !output.includes('error') && !output.includes('Error'), - ).toBeTruthy(); + expect(!output.includes('error') && !output.includes('Error')).toBeTruthy(); }, 30_000); it('promote creates promote event in events.db', () => { - const promoteEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{ + const promoteEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'promote'", + ) as Array<{ kind: string; code_rev: string; }>; expect(promoteEvents.length > 0).toBeTruthy(); - + const latestPromote = promoteEvents[promoteEvents.length - 1]; expect(latestPromote.code_rev).toBeTruthy(); }); @@ -104,4 +105,4 @@ export default rule;`; output.includes('log') || output.includes('hello from v2'), ).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/packages/upulse/src/e2e/t3-promote-guard.test.ts b/packages/upulse/src/e2e/t3-promote-guard.test.ts index 2d55793..6dc637c 100644 --- a/packages/upulse/src/e2e/t3-promote-guard.test.ts +++ b/packages/upulse/src/e2e/t3-promote-guard.test.ts @@ -11,16 +11,16 @@ * Run: bun test src/e2e/t3-promote-guard.test.ts */ -import { describe, it, beforeAll, afterAll, expect } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; +import { execSync } from 'node:child_process'; import { existsSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { execSync } from 'node:child_process'; import { - createE2EContext, cleanupE2EContext, - runUpulse, - queryEventsDb, + createE2EContext, type E2EContext, + queryEventsDb, + runUpulse, } from './helper.js'; describe('E2E T3: Promote Guard (Compilation Failed)', () => { @@ -36,7 +36,10 @@ describe('E2E T3: Promote Guard (Compilation Failed)', () => { it('upulse init initializes project', () => { const output = runUpulse(ctx, 'init'); - expect(output.includes('initialized'), `init output should mention initialized, got:\n${output}`).toBeTruthy(); + expect( + output.includes('initialized'), + `init output should mention initialized, got:\n${output}`, + ).toBeTruthy(); expect(existsSync(ctx.upulseDir), '.upulse/ should exist').toBeTruthy(); expect(existsSync(ctx.stagingDir), 'staging/ should exist').toBeTruthy(); expect(existsSync(ctx.engineDir), 'engine/ should exist').toBeTruthy(); @@ -49,48 +52,74 @@ export default bad;`; const badRuleFilePath = join(ctx.stagingDir, 'rules', '99-bad-rule.ts'); writeFileSync(badRuleFilePath, badRuleContent); - - expect(existsSync(badRuleFilePath), '99-bad-rule.ts should be written to staging/rules/').toBeTruthy(); + + expect( + existsSync(badRuleFilePath), + '99-bad-rule.ts should be written to staging/rules/', + ).toBeTruthy(); }); it('commit bad rule in staging', () => { execSync('git add .', { cwd: ctx.stagingDir }); - execSync('git commit -m "Add bad rule with compilation error"', { cwd: ctx.stagingDir }); + execSync('git commit -m "Add bad rule with compilation error"', { + cwd: ctx.stagingDir, + }); // Verify commit exists const log = execSync('git log --oneline -n 1', { cwd: ctx.stagingDir, encoding: 'utf-8', }).trim(); - expect(log.includes('bad rule'), 'commit should mention the bad rule').toBeTruthy(); + expect( + log.includes('bad rule'), + 'commit should mention the bad rule', + ).toBeTruthy(); }); it('upulse deploy promote fails due to compilation error', () => { const output = runUpulse(ctx, 'deploy promote', { expectFail: true }); - + // Check that output contains type-check-related error messages expect( - output.includes('Type check') || output.includes('tsc') || output.includes('type check failed'), + output.includes('Type check') || + output.includes('tsc') || + output.includes('type check failed'), ).toBeTruthy(); }); it('no promote event created in events.db after failed promote', () => { - const promoteEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'"); + const promoteEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'promote'", + ); expect(promoteEvents.length).toBe(0); }); it('bad rule file not merged to engine directory', () => { const badRulePath = join(ctx.engineDir, 'rules', '99-bad-rule.ts'); - expect(!existsSync(badRulePath), 'bad rule should not exist in engine/rules/ after failed promote').toBeTruthy(); + expect( + !existsSync(badRulePath), + 'bad rule should not exist in engine/rules/ after failed promote', + ).toBeTruthy(); }); it('engine directory remains clean after failed promote', () => { // Verify engine still has only the original files (no contamination from failed promote) - const engineRulesPath = join(ctx.engineDir, 'rules', '01-collect-system.ts'); - expect(existsSync(engineRulesPath), 'original rules should still exist in engine').toBeTruthy(); - + const engineRulesPath = join( + ctx.engineDir, + 'rules', + '01-collect-system.ts', + ); + expect( + existsSync(engineRulesPath), + 'original rules should still exist in engine', + ).toBeTruthy(); + // But the bad rule should not be there const badRulePath = join(ctx.engineDir, 'rules', '99-bad-rule.ts'); - expect(!existsSync(badRulePath), 'bad rule should not contaminate engine directory').toBeTruthy(); + expect( + !existsSync(badRulePath), + 'bad rule should not contaminate engine directory', + ).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/packages/upulse/src/e2e/t4-rollback.test.ts b/packages/upulse/src/e2e/t4-rollback.test.ts index 5a1fd80..500726e 100644 --- a/packages/upulse/src/e2e/t4-rollback.test.ts +++ b/packages/upulse/src/e2e/t4-rollback.test.ts @@ -14,23 +14,26 @@ * Run: bun test src/e2e/t4-rollback.test.ts */ -import { describe, it, beforeAll, afterAll, expect } from 'bun:test'; -import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { - createE2EContext, cleanupE2EContext, - runUpulse, - queryEventsDb, + createE2EContext, type E2EContext, + queryEventsDb, + runUpulse, } from './helper.js'; // ── Helpers ──────────────────────────────────────────────────── /** Count events in the DB. */ function eventCount(ctx: E2EContext): number { - const rows = queryEventsDb(ctx, 'SELECT COUNT(*) as cnt FROM events') as Array<{ cnt: number }>; + const rows = queryEventsDb( + ctx, + 'SELECT COUNT(*) as cnt FROM events', + ) as Array<{ cnt: number }>; return rows.length > 0 ? rows[0]!.cnt : 0; } @@ -39,7 +42,11 @@ function eventCount(ctx: E2EContext): number { * Uses targeted `git add` to avoid committing the node_modules symlink * (git .gitignore pattern `node_modules/` does not match symlinks). */ -function addRuleToStaging(ctx: E2EContext, filename: string, commitMsg: string): void { +function addRuleToStaging( + ctx: E2EContext, + filename: string, + commitMsg: string, +): void { const rulesDir = join(ctx.stagingDir, 'rules'); if (!existsSync(rulesDir)) mkdirSync(rulesDir, { recursive: true }); @@ -131,15 +138,24 @@ describe('E2E T4: Rollback', () => { it('v2 rule (99-v2.ts) exists in engine before rollback', () => { const v2RulePath = join(ctx.engineDir, 'rules', '99-v2.ts'); - expect(existsSync(v2RulePath), 'engine should have 99-v2.ts after v2 promote').toBeTruthy(); + expect( + existsSync(v2RulePath), + 'engine should have 99-v2.ts after v2 promote', + ).toBeTruthy(); }); it('both promote events are in the database', () => { - const promotes = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{ + const promotes = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'promote'", + ) as Array<{ code_rev: string; meta: string; }>; - expect(promotes.length >= 2, `should have at least 2 promote events, found ${promotes.length}`).toBeTruthy(); + expect( + promotes.length >= 2, + `should have at least 2 promote events, found ${promotes.length}`, + ).toBeTruthy(); }); // ── Rollback ───────────────────────────────────────────────── @@ -149,14 +165,13 @@ describe('E2E T4: Rollback', () => { const output = runUpulse(ctx, 'deploy rollback'); expect( - output.includes('Rollback complete') || output.includes('rollback event written'), + output.includes('Rollback complete') || + output.includes('rollback event written'), ).toBeTruthy(); // Event count must increase (append-only) const countAfter = eventCount(ctx); - expect( - countAfter > countBefore, - ).toBeTruthy(); + expect(countAfter > countBefore).toBeTruthy(); eventCounts.push(countAfter); }); @@ -172,9 +187,7 @@ describe('E2E T4: Rollback', () => { expect(rollbackEvent).toBeTruthy(); // code_rev should point to the target (v1) - expect( - rollbackEvent.code_rev, - ).toBe(v1CodeRev); + expect(rollbackEvent.code_rev).toBe(v1CodeRev); // meta should have from + to const meta = JSON.parse(rollbackEvent.meta) as { from: string; to: string }; @@ -188,19 +201,18 @@ describe('E2E T4: Rollback', () => { it('append-only: event count is monotonically increasing', () => { expect(eventCounts.length >= 5).toBeTruthy(); for (let i = 1; i < eventCounts.length; i++) { - expect( - eventCounts[i]! >= eventCounts[i - 1]!, - ).toBeTruthy(); + expect(eventCounts[i]! >= eventCounts[i - 1]!).toBeTruthy(); } }); it('all event ULIDs are ordered', () => { - const allEvents = queryEventsDb(ctx, 'SELECT id FROM events ORDER BY id ASC') as Array<{ id: string }>; + const allEvents = queryEventsDb( + ctx, + 'SELECT id FROM events ORDER BY id ASC', + ) as Array<{ id: string }>; expect(allEvents.length > 0).toBeTruthy(); for (let i = 1; i < allEvents.length; i++) { - expect( - allEvents[i]!.id > allEvents[i - 1]!.id, - ).toBeTruthy(); + expect(allEvents[i]!.id > allEvents[i - 1]!.id).toBeTruthy(); } }); @@ -217,10 +229,13 @@ describe('E2E T4: Rollback', () => { it('v2 events are not deleted (append-only)', () => { // All events that were in the DB before rollback should still be there. // We check that at least the promote events for both versions exist. - const allPromotes = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{ + const allPromotes = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'promote'", + ) as Array<{ code_rev: string; }>; - const codeRevs = new Set(allPromotes.map(p => p.code_rev)); + const codeRevs = new Set(allPromotes.map((p) => p.code_rev)); expect(codeRevs.has(v1CodeRev)).toBeTruthy(); expect(codeRevs.has(v2CodeRev)).toBeTruthy(); }); @@ -229,16 +244,12 @@ describe('E2E T4: Rollback', () => { it('engine code reverts after rollback (99-v2.ts removed)', () => { const v2RulePath = join(ctx.engineDir, 'rules', '99-v2.ts'); - expect( - !existsSync(v2RulePath), - ).toBeTruthy(); + expect(!existsSync(v2RulePath)).toBeTruthy(); }); it('v1 rule (98-v1.ts) still exists in engine after rollback', () => { const v1RulePath = join(ctx.engineDir, 'rules', '98-v1.ts'); - expect( - existsSync(v1RulePath), - ).toBeTruthy(); + expect(existsSync(v1RulePath)).toBeTruthy(); }); it('engine git log shows revert commit', () => { @@ -246,9 +257,7 @@ describe('E2E T4: Rollback', () => { cwd: ctx.engineDir, encoding: 'utf-8', }).trim(); - expect( - log.toLowerCase().includes('revert'), - ).toBeTruthy(); + expect(log.toLowerCase().includes('revert')).toBeTruthy(); }); }); @@ -270,7 +279,9 @@ describe('E2E T4: Rollback boundary — no previous version', () => { it('rollback with no promote events fails gracefully', () => { const output = runUpulse(ctx, 'deploy rollback', { expectFail: true }); expect( - output.includes('no promote event') || output.includes('Nothing to roll back') || output.includes('Error'), + output.includes('no promote event') || + output.includes('Nothing to roll back') || + output.includes('Error'), ).toBeTruthy(); }); diff --git a/packages/upulse/src/e2e/t5-migrate.test.ts b/packages/upulse/src/e2e/t5-migrate.test.ts index 87c8bc4..8479f91 100644 --- a/packages/upulse/src/e2e/t5-migrate.test.ts +++ b/packages/upulse/src/e2e/t5-migrate.test.ts @@ -13,17 +13,16 @@ * Run: bun test src/e2e/t5-migrate.test.ts */ -import { describe, it, beforeAll, afterAll, expect } from 'bun:test'; -import { existsSync, writeFileSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { execSync } from 'node:child_process'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import { - createE2EContext, cleanupE2EContext, - runUpulse, - queryEventsDb, - listObjects, + createE2EContext, type E2EContext, + queryEventsDb, + runUpulse, } from './helper.js'; describe('E2E T5: Migrate Chain Migration', () => { @@ -52,7 +51,10 @@ describe('E2E T5: Migrate Chain Migration', () => { ).toBeTruthy(); // Verify v1 collect events exist - const collectEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'collect' AND key = 'system'") as Array<{ + const collectEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'collect' AND key = 'system'", + ) as Array<{ kind: string; key: string; hash: string; @@ -95,7 +97,7 @@ export type Effect = const typesFilePath = join(ctx.stagingDir, 'types.ts'); writeFileSync(typesFilePath, newTypesContent); - + expect(existsSync(typesFilePath)).toBeTruthy(); }); @@ -119,7 +121,7 @@ export type Effect = const migrateFilePath = join(ctx.stagingDir, 'rules', 'migrate.ts'); writeFileSync(migrateFilePath, migrateContent); - + expect(existsSync(migrateFilePath)).toBeTruthy(); }); @@ -142,9 +144,13 @@ export default createRule( }, );`; - const systemRuleFilePath = join(ctx.stagingDir, 'rules', '01-collect-system.ts'); + const systemRuleFilePath = join( + ctx.stagingDir, + 'rules', + '01-collect-system.ts', + ); writeFileSync(systemRuleFilePath, newSystemRuleContent); - + expect(existsSync(systemRuleFilePath)).toBeTruthy(); }); @@ -181,13 +187,15 @@ export async function collectSystem(): Promise { const executorFilePath = join(ctx.stagingDir, 'executors', 'system.ts'); writeFileSync(executorFilePath, newExecutorContent); - + expect(existsSync(executorFilePath)).toBeTruthy(); }); it('commit changes in staging', () => { execSync('git add .', { cwd: ctx.stagingDir }); - execSync('git commit -m "Migrate to v2 SystemSense format"', { cwd: ctx.stagingDir }); + execSync('git commit -m "Migrate to v2 SystemSense format"', { + cwd: ctx.stagingDir, + }); // Verify commit exists const log = execSync('git log --oneline -n 1', { @@ -199,34 +207,38 @@ export async function collectSystem(): Promise { it('upulse deploy promote succeeds', () => { const output = runUpulse(ctx, 'deploy promote'); - expect( - !output.includes('error') && !output.includes('Error'), - ).toBeTruthy(); + expect(!output.includes('error') && !output.includes('Error')).toBeTruthy(); }); it('migrate event created in events.db', () => { - const migrateEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'") as Array<{ + const migrateEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'", + ) as Array<{ kind: string; key: string; hash: string; }>; expect(migrateEvents.length > 0).toBeTruthy(); - + const latestMigrate = migrateEvents[migrateEvents.length - 1]; expect(latestMigrate.hash).toBeTruthy(); }); it('migrated object contains new format data', () => { - const migrateEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'") as Array<{ + const migrateEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'migrate' AND key = 'system'", + ) as Array<{ hash: string; }>; expect(migrateEvents.length > 0).toBeTruthy(); - + const migrateHash = migrateEvents[migrateEvents.length - 1].hash; const objectPath = join(ctx.objectsDir, `${migrateHash}.json`); - + expect(existsSync(objectPath)).toBeTruthy(); - + const objectData = JSON.parse(readFileSync(objectPath, 'utf-8')); // The stored object is the sense value (data.data), not the full snapshot wrapper expect(objectData.memory?.usedPct !== undefined).toBeTruthy(); @@ -234,13 +246,16 @@ export async function collectSystem(): Promise { }); it('promote event created after migration', () => { - const promoteEvents = queryEventsDb(ctx, "SELECT * FROM events WHERE kind = 'promote'") as Array<{ + const promoteEvents = queryEventsDb( + ctx, + "SELECT * FROM events WHERE kind = 'promote'", + ) as Array<{ kind: string; code_rev: string; }>; expect(promoteEvents.length > 0).toBeTruthy(); - + const latestPromote = promoteEvents[promoteEvents.length - 1]; expect(latestPromote.code_rev).toBeTruthy(); }); -}); \ No newline at end of file +}); diff --git a/packages/upulse/src/e2e/t6-dir-flag.test.ts b/packages/upulse/src/e2e/t6-dir-flag.test.ts index 371f1e6..a306cea 100644 --- a/packages/upulse/src/e2e/t6-dir-flag.test.ts +++ b/packages/upulse/src/e2e/t6-dir-flag.test.ts @@ -5,17 +5,16 @@ * Run: bun test src/e2e/t6-dir-flag.test.ts */ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import { execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { - createE2EContext, cleanupE2EContext, + createE2EContext, type E2EContext, } from './helper.js'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI_PATH = join(__dirname, '..', 'cli.ts'); diff --git a/packages/upulse/src/git.ts b/packages/upulse/src/git.ts index 30554a5..d6211fc 100644 --- a/packages/upulse/src/git.ts +++ b/packages/upulse/src/git.ts @@ -6,9 +6,16 @@ import { execSync } from 'node:child_process'; export function gitExec(cwd: string, args: string): string { try { - return execSync(`git ${args}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + return execSync(`git ${args}`, { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); } catch (err: unknown) { - const msg = err instanceof Error ? (err as { stderr?: string }).stderr ?? err.message : String(err); + const msg = + err instanceof Error + ? ((err as { stderr?: string }).stderr ?? err.message) + : String(err); throw new Error(`git ${args} failed in ${cwd}: ${msg}`); } } @@ -25,7 +32,11 @@ export function gitCommit(cwd: string, message: string): void { gitExec(cwd, `commit -m "${message}"`); } -export function gitWorktreeAdd(repoDir: string, worktreePath: string, branch: string): void { +export function gitWorktreeAdd( + repoDir: string, + worktreePath: string, + branch: string, +): void { gitExec(repoDir, `worktree add ${worktreePath} -b ${branch}`); } diff --git a/packages/upulse/src/init.ts b/packages/upulse/src/init.ts index a4d243c..5730cdb 100644 --- a/packages/upulse/src/init.ts +++ b/packages/upulse/src/init.ts @@ -4,12 +4,12 @@ * Creates ~/.upulse/ with engine scaffold, git init, worktree, bun install */ -import { mkdirSync, writeFileSync, existsSync, symlinkSync } from 'node:fs'; -import { join } from 'node:path'; import { execSync } from 'node:child_process'; +import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; -import { defaultConfig, saveConfig, type UpulseConfig } from './config.js'; -import { gitInit, gitAddAll, gitCommit, gitWorktreeAdd } from './git.js'; +import { join } from 'node:path'; +import { defaultConfig, saveConfig } from './config.js'; +import { gitAddAll, gitCommit, gitInit, gitWorktreeAdd } from './git.js'; export function initUpulse(dir?: string): void { const baseDir = dir ?? join(homedir(), '.upulse'); @@ -83,7 +83,9 @@ export function initUpulse(dir?: string): void { execSync('bun install', { cwd: config.engine.path, stdio: 'inherit' }); console.log(' ✓ bun install complete'); } catch { - console.error('Warning: bun install failed. Run manually: cd ~/.upulse/engine && bun install'); + console.error( + 'Warning: bun install failed. Run manually: cd ~/.upulse/engine && bun install', + ); } // Symlink node_modules to staging (staging shares engine's deps) @@ -91,7 +93,7 @@ export function initUpulse(dir?: string): void { symlinkSync( join(config.engine.path, 'node_modules'), join(config.staging.path, 'node_modules'), - 'dir' + 'dir', ); console.log(' ✓ node_modules symlinked to staging'); } catch { @@ -136,7 +138,10 @@ function findLocalPulsePath(): string | null { // ── File writers ─────────────────────────────────────────────── -function writeEnginePackageJson(enginePath: string, localPulsePath: string | null): void { +function writeEnginePackageJson( + enginePath: string, + localPulsePath: string | null, +): void { const pulseDep = localPulsePath ? `file:${localPulsePath}` : 'latest'; const pkg = { name: 'pulse-engine', @@ -149,11 +154,15 @@ function writeEnginePackageJson(enginePath: string, localPulsePath: string | nul '@uncaged/pulse': pulseDep, }, devDependencies: { - 'typescript': '^6.0.2', + typescript: '^6.0.2', 'bun-types': 'latest', }, }; - writeFileSync(join(enginePath, 'package.json'), JSON.stringify(pkg, null, 2), 'utf-8'); + writeFileSync( + join(enginePath, 'package.json'), + JSON.stringify(pkg, null, 2), + 'utf-8', + ); } function writeEngineTsConfig(enginePath: string): void { @@ -171,7 +180,11 @@ function writeEngineTsConfig(enginePath: string): void { }, include: ['*.ts', 'executors/**/*.ts', 'rules/**/*.ts'], }; - writeFileSync(join(enginePath, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2), 'utf-8'); + writeFileSync( + join(enginePath, 'tsconfig.json'), + JSON.stringify(tsconfig, null, 2), + 'utf-8', + ); } function writeAgentsMd(enginePath: string): void { @@ -329,7 +342,11 @@ export default createRule( }, ); `; - writeFileSync(join(enginePath, 'rules', '01-collect-system.ts'), content, 'utf-8'); + writeFileSync( + join(enginePath, 'rules', '01-collect-system.ts'), + content, + 'utf-8', + ); } function writePulseConfig(enginePath: string): void { diff --git a/packages/upulse/src/store.ts b/packages/upulse/src/store.ts index 7ce6921..95788b3 100644 --- a/packages/upulse/src/store.ts +++ b/packages/upulse/src/store.ts @@ -5,17 +5,25 @@ * The CLI uses queryByKind / getRecent to inspect events. */ -import { createStore, type PulseStore, type EventRecord, type VitalRecord } from '@uncaged/pulse'; import { existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; +import { dirname, join } from 'node:path'; +import { + createStore, + type EventRecord, + type PulseStore, + type VitalRecord, +} from '@uncaged/pulse'; -export type { PulseStore, EventRecord, VitalRecord }; +export type { EventRecord, PulseStore, VitalRecord }; /** * Open existing events.db + vitals.db (+ sibling objects/ dir) for read/write. * Returns null when the events DB does not exist yet. */ -export function openStore(eventsDbPath: string, vitalsDbPath: string): PulseStore | null { +export function openStore( + eventsDbPath: string, + vitalsDbPath: string, +): PulseStore | null { if (!existsSync(eventsDbPath)) { return null; } @@ -26,7 +34,10 @@ export function openStore(eventsDbPath: string, vitalsDbPath: string): PulseStor /** * Open or create events.db + vitals.db. Always returns a store (creates DB if needed). */ -export function openOrCreateStore(eventsDbPath: string, vitalsDbPath: string): PulseStore { +export function openOrCreateStore( + eventsDbPath: string, + vitalsDbPath: string, +): PulseStore { const objectsDir = join(dirname(eventsDbPath), 'objects'); return createStore({ eventsDbPath, vitalsDbPath, objectsDir }); }