feat(pulse): scoped events store — Phase 1 (closes #53-p1) (#54)

Add ScopedStore interface and createScopedStore() factory that partitions
events into per-scope SQLite files while sharing a single CAS objects/
directory. Scope names are validated ([a-z0-9_-], max 64 chars) and
databases are lazily opened and cached. Existing createStore API remains
fully backward compatible. Includes 20 new tests.

Made-with: Cursor

Co-authored-by: 小橘 <xiaoju@shazhou.work>
This commit is contained in:
小橘 🍊
2026-04-15 08:55:58 +08:00
committed by GitHub
parent 39c7e7a443
commit 9a1da8f433
3 changed files with 610 additions and 1 deletions
+3
View File
@@ -407,10 +407,13 @@ export function createRule<S, E, T>(
// ── Storage ────────────────────────────────────────────────────
export {
type CreateScopedStoreOptions,
type CreateStoreOptions,
createScopedStore,
createStore,
type EventRecord,
type PulseStore,
type ScopedStore,
type VitalRecord,
} from './store.js';
+315
View File
@@ -0,0 +1,315 @@
/**
* Tests for ScopedStore — scope-partitioned SQLite event storage with shared CAS.
*
* Covers: GitHub Issue #53 (RFC Scoped Events) — Phase 1
*/
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 { createScopedStore, createStore, type ScopedStore } from './store.js';
describe('createScopedStore', () => {
let dir: string;
let basePath: string;
let objectsDir: string;
let scopedStore: ScopedStore;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pulse-scoped-'));
basePath = join(dir, 'scopes');
objectsDir = join(dir, 'objects');
scopedStore = createScopedStore({ basePath, objectsDir });
});
afterEach(() => {
scopedStore.close();
rmSync(dir, { recursive: true, force: true });
});
// ── 1. Directory structure ──────────────────────────────────
it('1. creates basePath and objectsDir directories', () => {
expect(existsSync(basePath)).toBe(true);
expect(existsSync(objectsDir)).toBe(true);
});
// ── 2. scope() returns a usable PulseStore ──────────────────
it('2. scope("_system") returns a PulseStore that can append/query events', () => {
const store = scopedStore.scope('_system');
const rec = store.appendEvent({
occurredAt: 1000,
kind: 'tick',
key: 'sys',
});
expect(rec.id.length).toBe(26);
expect(rec.kind).toBe('tick');
const latest = store.getLatest('tick');
expect(latest).toBeTruthy();
expect(latest?.occurredAt).toBe(1000);
});
// ── 3. scope() lazy-creates db file ─────────────────────────
it('3. scope("neko") lazy-creates a new .db file', () => {
const dbPath = join(basePath, 'neko.db');
expect(existsSync(dbPath)).toBe(false);
scopedStore.scope('neko');
expect(existsSync(dbPath)).toBe(true);
});
// ── 4. scope() caches open databases ────────────────────────
it('4. scope() returns the same PulseStore instance on repeated calls', () => {
const s1 = scopedStore.scope('_system');
const s2 = scopedStore.scope('_system');
expect(s1).toBe(s2);
});
// ── 5. scope isolation ──────────────────────────────────────
it('5. events in different scopes are isolated', () => {
const system = scopedStore.scope('_system');
const neko = scopedStore.scope('neko');
system.appendEvent({ occurredAt: 1000, kind: 'tick' });
system.appendEvent({ occurredAt: 2000, kind: 'tick' });
neko.appendEvent({ occurredAt: 3000, kind: 'tick' });
expect(system.getRecent(10).length).toBe(2);
expect(neko.getRecent(10).length).toBe(1);
expect(system.hasEvents()).toBe(true);
expect(neko.hasEvents()).toBe(true);
});
// ── 6. listScopes() ─────────────────────────────────────────
it('6. listScopes() returns sorted list of scope names from .db files', () => {
expect(scopedStore.listScopes()).toEqual([]);
scopedStore.scope('_system');
scopedStore.scope('neko');
scopedStore.scope('cursor');
const scopes = scopedStore.listScopes();
expect(scopes).toEqual(['_system', 'cursor', 'neko']);
});
// ── 7. listScopes() only includes .db files ─────────────────
it('7. listScopes() ignores non-.db files (WAL, SHM, etc.)', () => {
scopedStore.scope('_system');
// WAL/SHM files are created by SQLite alongside the .db
const scopes = scopedStore.listScopes();
expect(scopes.every((s) => !s.includes('.'))).toBe(true);
});
// ── 8. CAS shared across scopes ────────────────────────────
it('8. CAS objects/ directory is shared across scopes', () => {
const system = scopedStore.scope('_system');
const neko = scopedStore.scope('neko');
const data = { shared: true, value: 42 };
const hash = system.putObject(data);
const fromNeko = neko.getObject(hash);
expect(fromNeko).toEqual(data);
});
// ── 9. ScopedStore-level CAS ────────────────────────────────
it('9. ScopedStore.putObject/getObject work independently of scopes', () => {
const data = { top: 'level' };
const hash = scopedStore.putObject(data);
expect(scopedStore.getObject(hash)).toEqual(data);
// Also accessible from any scope
const store = scopedStore.scope('_system');
expect(store.getObject(hash)).toEqual(data);
});
// ── 10. scope name validation: valid names ──────────────────
it('10. scope() accepts valid names: lowercase, digits, underscore, hyphen', () => {
expect(() => scopedStore.scope('_system')).not.toThrow();
expect(() => scopedStore.scope('_vitals')).not.toThrow();
expect(() => scopedStore.scope('neko')).not.toThrow();
expect(() => scopedStore.scope('cursor-ai')).not.toThrow();
expect(() => scopedStore.scope('my_scope_123')).not.toThrow();
expect(() => scopedStore.scope('a')).not.toThrow();
});
// ── 11. scope name validation: rejects invalid chars ────────
it('11. scope() rejects names with invalid characters', () => {
expect(() => scopedStore.scope('Hello')).toThrow(/Invalid scope name/);
expect(() => scopedStore.scope('my scope')).toThrow(/Invalid scope name/);
expect(() => scopedStore.scope('neko.db')).toThrow(/Invalid scope name/);
expect(() => scopedStore.scope('../hack')).toThrow(/Invalid scope name/);
expect(() => scopedStore.scope('foo/bar')).toThrow(/Invalid scope name/);
});
// ── 12. scope name validation: rejects empty name ───────────
it('12. scope() rejects empty name', () => {
expect(() => scopedStore.scope('')).toThrow(/Invalid scope name/);
});
// ── 13. scope name validation: rejects too-long name ────────
it('13. scope() rejects names longer than 64 characters', () => {
const longName = 'a'.repeat(65);
expect(() => scopedStore.scope(longName)).toThrow(/Invalid scope name/);
// Exactly 64 chars is fine
const maxName = 'a'.repeat(64);
expect(() => scopedStore.scope(maxName)).not.toThrow();
});
// ── 14. close() closes all open scope dbs ───────────────────
it('14. close() closes all open scope databases', () => {
const system = scopedStore.scope('_system');
const neko = scopedStore.scope('neko');
system.appendEvent({ occurredAt: 1000, kind: 'tick' });
neko.appendEvent({ occurredAt: 2000, kind: 'tick' });
scopedStore.close();
// After close, accessing the db should throw
expect(() =>
system.appendEvent({ occurredAt: 3000, kind: 'tick' }),
).toThrow();
expect(() =>
neko.appendEvent({ occurredAt: 3000, kind: 'tick' }),
).toThrow();
});
// ── 15. full PulseStore API on scope ────────────────────────
it('15. scope PulseStore supports appendEvents (batch)', () => {
const store = scopedStore.scope('_system');
const results = store.appendEvents([
{ occurredAt: 1000, kind: 'tick' },
{ occurredAt: 2000, kind: 'collect', key: 'cpu' },
{ occurredAt: 3000, kind: 'effect' },
]);
expect(results.length).toBe(3);
expect(store.getRecent(10).length).toBe(3);
});
// ── 16. scope PulseStore supports queryByKind ───────────────
it('16. scope PulseStore supports queryByKind with filters', () => {
const store = scopedStore.scope('_system');
store.appendEvent({ occurredAt: 1000, kind: 'tick', codeRev: 'v1' });
store.appendEvent({ occurredAt: 2000, kind: 'collect', key: 'cpu' });
store.appendEvent({ occurredAt: 3000, kind: 'tick', codeRev: 'v2' });
const ticks = store.queryByKind('tick');
expect(ticks.length).toBe(2);
const v1Ticks = store.queryByKind('tick', { codeRev: 'v1' });
expect(v1Ticks.length).toBe(1);
expect(v1Ticks[0]?.occurredAt).toBe(1000);
});
// ── 17. scope PulseStore supports getAfter ──────────────────
it('17. scope PulseStore supports getAfter', () => {
const store = scopedStore.scope('_system');
const e1 = store.appendEvent({ occurredAt: 1000, kind: 'tick' });
store.appendEvent({ occurredAt: 2000, kind: 'tick' });
store.appendEvent({ occurredAt: 3000, kind: 'collect' });
const after = store.getAfter(e1.id);
expect(after.length).toBe(2);
expect(after[0]?.occurredAt).toBe(2000);
expect(after[1]?.occurredAt).toBe(3000);
});
// ── 18. scope PulseStore supports getLatestWhere ────────────
it('18. scope PulseStore supports getLatestWhere', () => {
const store = scopedStore.scope('_system');
store.appendEvent({
occurredAt: 1000,
kind: 'promote',
codeRev: 'v1',
});
store.appendEvent({
occurredAt: 2000,
kind: 'promote',
codeRev: 'v2',
});
const latest = store.getLatestWhere({ kind: 'promote', codeRev: 'v1' });
expect(latest).toBeTruthy();
expect(latest?.codeRev).toBe('v1');
expect(latest?.occurredAt).toBe(1000);
});
// ── 19. vitals throw on scope-level PulseStore ──────────────
it('19. vitals methods throw on scope-level PulseStore', () => {
const store = scopedStore.scope('_system');
expect(() => store.appendVital({ occurredAt: 1000, key: 'cpu' })).toThrow(
/not supported/i,
);
expect(() =>
store.appendVitals([{ occurredAt: 1000, key: 'cpu' }]),
).toThrow(/not supported/i);
expect(() => store.getLatestVital('cpu')).toThrow(/not supported/i);
expect(() => store.getVitalHistory('cpu')).toThrow(/not supported/i);
expect(() => store.archiveVitals(1000)).toThrow(/not supported/i);
expect(() => store.downsampleVitals('cpu', 1000, 5000)).toThrow(
/not supported/i,
);
});
});
// ── Backward compatibility ────────────────────────────────────
describe('createStore backward compatibility', () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'pulse-compat-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('20. createStore still works with eventsDbPath / vitalsDbPath / objectsDir', () => {
const store = createStore({
eventsDbPath: join(dir, 'events.db'),
vitalsDbPath: join(dir, 'vitals.db'),
objectsDir: join(dir, 'objects'),
});
const rec = store.appendEvent({ occurredAt: 1000, kind: 'tick' });
expect(rec.id.length).toBe(26);
store.appendVital({ occurredAt: 1000, key: 'cpu', hash: 'h1' });
const vital = store.getLatestVital('cpu');
expect(vital).toBeTruthy();
expect(vital?.hash).toBe('h1');
const data = { test: true };
const hash = store.putObject(data);
expect(store.getObject(hash)).toEqual(data);
store.close();
});
});
+292 -1
View File
@@ -7,7 +7,13 @@
import { Database } from 'bun:sqlite';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
// ── Types ──────────────────────────────────────────────────────
@@ -536,3 +542,288 @@ export function createStore(options: CreateStoreOptions): PulseStore {
},
};
}
// ── Scoped Store ──────────────────────────────────────────────
const SCOPE_NAME_RE = /^[a-z0-9_-]{1,64}$/;
export interface CreateScopedStoreOptions {
basePath: string; // e.g. ~/.upulse/scopes/
objectsDir: string; // e.g. ~/.upulse/objects/
}
export interface ScopedStore {
scope(name: string): PulseStore;
listScopes(): string[];
putObject(data: unknown): string;
getObject(hash: string): unknown | null;
close(): void;
}
function validateScopeName(name: string): void {
if (!SCOPE_NAME_RE.test(name)) {
throw new Error(
`Invalid scope name "${name}": must match [a-z0-9_-] and be 1-64 chars`,
);
}
}
/**
* Open (or create) a scope database at the given path.
* Sets WAL mode and creates the events table.
*/
function openScopeDb(path: string): Database {
const db = new Database(path, { create: true });
db.exec('PRAGMA journal_mode = WAL');
db.exec(EVENTS_SCHEMA);
return db;
}
function createScopeStore(db: Database, objectsDir: string): PulseStore {
const insertEvent = db.prepare(`
INSERT INTO events (id, occurred_at, kind, key, hash, code_rev, meta)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const selectLatest = db.prepare(`
SELECT * FROM events
WHERE kind = ? AND (key = ? OR ? IS NULL)
ORDER BY occurred_at DESC, id DESC
LIMIT 1
`);
const selectHasEvents = db.prepare(`
SELECT 1 FROM events LIMIT 1
`);
function doAppendEvent(event: Omit<EventRecord, 'id'>): EventRecord {
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,
);
return record;
}
const appendManyTx = db.transaction(
(events: Omit<EventRecord, 'id'>[]): 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) {
return doAppendEvent(event);
},
appendEvents(events) {
return appendManyTx(events);
},
getLatest(kind, key?) {
const row = selectLatest.get(
kind,
key ?? null,
key ?? null,
) as RawRow | null;
return row ? rowToRecord(row) : null;
},
getLatestWhere(opts) {
const conditions: string[] = ['kind = ?'];
const params: (string | number | null)[] = [opts.kind];
if (opts.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC LIMIT 1`;
const row = db.prepare(sql).get(...params) as RawRow | null;
return row ? rowToRecord(row) : null;
},
getRecent(limit = 20) {
const sql = `SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT ?`;
return (db.prepare(sql).all(limit) as RawRow[]).map(rowToRecord);
},
queryByKind(kind, opts?) {
const conditions: string[] = ['kind = ?'];
const params: (string | number | null)[] = [kind];
if (opts?.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts?.since !== undefined) {
conditions.push('occurred_at >= ?');
params.push(opts.since);
}
if (opts?.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
let sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY occurred_at DESC, id DESC`;
if (opts?.limit !== undefined) {
sql += ' LIMIT ?';
params.push(opts.limit);
}
return (db.prepare(sql).all(...params) as RawRow[]).map(rowToRecord);
},
getAfter(afterId, opts?) {
const conditions: string[] = ['id > ?'];
const params: (string | number | null)[] = [afterId];
if (opts?.kind !== undefined) {
conditions.push('kind = ?');
params.push(opts.kind);
}
if (opts?.key !== undefined) {
conditions.push('key = ?');
params.push(opts.key);
}
if (opts?.codeRev !== undefined) {
conditions.push('code_rev = ?');
params.push(opts.codeRev);
}
const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC`;
return (db.prepare(sql).all(...params) as RawRow[]).map(rowToRecord);
},
hasEvents() {
return selectHasEvents.get() !== null;
},
putObject(data) {
const hash = hashObject(data);
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) {
writeFileSync(filePath, JSON.stringify(data), 'utf-8');
}
return hash;
},
getObject(hash) {
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
},
close() {
db.close();
},
// Scope-level PulseStore does not support vitals — use _vitals scope via createStore
appendVital() {
throw new Error(
'Vitals are not supported on scope-level PulseStore. Use the _vitals scope via createStore().',
);
},
appendVitals() {
throw new Error(
'Vitals are not supported on scope-level PulseStore. Use the _vitals scope via createStore().',
);
},
getLatestVital() {
throw new Error(
'Vitals are not supported on scope-level PulseStore. Use the _vitals scope via createStore().',
);
},
getVitalHistory() {
throw new Error(
'Vitals are not supported on scope-level PulseStore. Use the _vitals scope via createStore().',
);
},
archiveVitals() {
throw new Error(
'Vitals are not supported on scope-level PulseStore. Use the _vitals scope via createStore().',
);
},
downsampleVitals() {
throw new Error(
'Vitals are not supported on scope-level PulseStore. Use the _vitals scope via createStore().',
);
},
};
}
export function createScopedStore(
options: CreateScopedStoreOptions,
): ScopedStore {
const { basePath, objectsDir } = options;
mkdirSync(basePath, { recursive: true });
mkdirSync(objectsDir, { recursive: true });
const openStores = new Map<string, PulseStore>();
return {
scope(name: string): PulseStore {
validateScopeName(name);
const existing = openStores.get(name);
if (existing) return existing;
const dbPath = join(basePath, `${name}.db`);
const db = openScopeDb(dbPath);
const store = createScopeStore(db, objectsDir);
openStores.set(name, store);
return store;
},
listScopes(): string[] {
if (!existsSync(basePath)) return [];
return readdirSync(basePath)
.filter((f) => f.endsWith('.db'))
.map((f) => f.slice(0, -3))
.sort();
},
putObject(data: unknown): string {
const hash = hashObject(data);
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) {
writeFileSync(filePath, JSON.stringify(data), 'utf-8');
}
return hash;
},
getObject(hash: string): unknown | null {
const filePath = join(objectsDir, `${hash}.json`);
if (!existsSync(filePath)) return null;
return JSON.parse(readFileSync(filePath, 'utf-8'));
},
close(): void {
for (const store of openStores.values()) {
store.close();
}
openStores.clear();
},
};
}