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:
@@ -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';
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user