From 7993ecc6d623aed8d7cdc0f5d2f7fddf1d3bbbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A2=A8?= Date: Mon, 20 Apr 2026 01:34:46 +0000 Subject: [PATCH] feat: extract @uncaged/pulse-local package, remove createStore from core exports Phase 2 of PulseDatabase abstraction: - Create @uncaged/pulse-local with bun:sqlite implementation - Core @uncaged/pulse now exports types only for store (no createStore/createScopedStore) - Update pulse-workflows, upulse to import factories from @uncaged/pulse-local - All tests passing (267 core, 39 pulse-workflows) --- bun.lock | 15 + packages/pulse-local/package.json | 22 + packages/pulse-local/src/index.ts | 11 + packages/pulse-local/src/store-local.ts | 752 ++++++++++++++++++ packages/pulse-local/tsconfig.json | 17 + packages/pulse-workflows/package.json | 3 +- .../src/roles/coder-cursor.test.ts | 2 +- .../src/roles/reviewer-cursor.test.ts | 2 +- .../src/workflows/coding-tdd.test.ts | 2 +- .../src/workflows/coding.test.ts | 2 +- .../src/workflows/report.test.ts | 2 +- packages/pulse/package.json | 22 + packages/pulse/src/index.d.ts | 2 - packages/pulse/src/index.ts | 16 +- packages/upulse/package.json | 1 + packages/upulse/src/commands/workflow.test.ts | 2 +- packages/upulse/src/init.ts | 3 +- packages/upulse/src/store.ts | 9 +- 18 files changed, 859 insertions(+), 26 deletions(-) create mode 100644 packages/pulse-local/package.json create mode 100644 packages/pulse-local/src/index.ts create mode 100644 packages/pulse-local/src/store-local.ts create mode 100644 packages/pulse-local/tsconfig.json diff --git a/bun.lock b/bun.lock index e828f3d..9cb4b4c 100644 --- a/bun.lock +++ b/bun.lock @@ -48,6 +48,17 @@ "@uncaged/pulse": ">=0.1.0", }, }, + "packages/pulse-local": { + "name": "@uncaged/pulse-local", + "version": "0.1.0", + "dependencies": { + "@uncaged/pulse": "workspace:*", + }, + "devDependencies": { + "bun-types": "latest", + "typescript": "^6.0.2", + }, + }, "packages/pulse-openclaw": { "name": "@uncaged/pulse-openclaw", "version": "0.1.0", @@ -66,6 +77,7 @@ "version": "0.1.0", "dependencies": { "@uncaged/pulse": "workspace:*", + "@uncaged/pulse-local": "workspace:*", }, "devDependencies": { "bun-types": "^1.3.12", @@ -90,6 +102,7 @@ }, "dependencies": { "@uncaged/pulse": "workspace:*", + "@uncaged/pulse-local": "workspace:*", "commander": "^12.0.0", }, "devDependencies": { @@ -264,6 +277,8 @@ "@uncaged/pulse-hermes": ["@uncaged/pulse-hermes@workspace:packages/pulse-hermes"], + "@uncaged/pulse-local": ["@uncaged/pulse-local@workspace:packages/pulse-local"], + "@uncaged/pulse-openclaw": ["@uncaged/pulse-openclaw@workspace:packages/pulse-openclaw"], "@uncaged/pulseflare": ["@uncaged/pulseflare@workspace:packages/pulseflare"], diff --git a/packages/pulse-local/package.json b/packages/pulse-local/package.json new file mode 100644 index 0000000..5280c00 --- /dev/null +++ b/packages/pulse-local/package.json @@ -0,0 +1,22 @@ +{ + "name": "@uncaged/pulse-local", + "version": "0.1.0", + "description": "Pulse local storage — bun:sqlite implementation of PulseStore", + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc", + "test": "bun test" + }, + "keywords": ["pulse", "sqlite", "local", "storage"], + "author": "oc-xiaoju", + "license": "MIT", + "dependencies": { + "@uncaged/pulse": "workspace:*" + }, + "devDependencies": { + "bun-types": "latest", + "typescript": "^6.0.2" + } +} diff --git a/packages/pulse-local/src/index.ts b/packages/pulse-local/src/index.ts new file mode 100644 index 0000000..467c0d7 --- /dev/null +++ b/packages/pulse-local/src/index.ts @@ -0,0 +1,11 @@ +/** + * @uncaged/pulse-local — Local (bun:sqlite + node:fs) implementation of Pulse storage. + * + * Re-exports all core types from @uncaged/pulse plus local factory functions. + */ + +// Re-export everything from core +export * from '@uncaged/pulse'; + +// Export local factories (override core's type-only createStore/createScopedStore) +export { createStore, createScopedStore } from './store-local.js'; diff --git a/packages/pulse-local/src/store-local.ts b/packages/pulse-local/src/store-local.ts new file mode 100644 index 0000000..1d3bf95 --- /dev/null +++ b/packages/pulse-local/src/store-local.ts @@ -0,0 +1,752 @@ +/** + * @uncaged/pulse-local — Local Storage Implementation (bun:sqlite + node:fs) + * + * Single `events` table with INTEGER AUTOINCREMENT primary keys + + * content-addressed object store (CAS) on disk via SHA-256 hashes. + */ + +import { Database } from 'bun:sqlite'; +import type { PulseDatabase } from '@uncaged/pulse'; +import { createHash } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { initDefsSchema } from '@uncaged/pulse/defs'; +import { + applyGuardUpdates, + checkGuards, + initGuardSchema, +} from '@uncaged/pulse/guard-projection'; +import { PROJECTIONS_SCHEMA } from '@uncaged/pulse/projection-engine'; +import type { + EventRecord, + ObjectInstance, + PulseStore, + CreateStoreOptions, + CreateScopedStoreOptions, + ScopedStore, +} from '@uncaged/pulse'; + +// ── CAS Hashing ──────────────────────────────────────────────── + +function hashObject(data: unknown): string { + return createHash('sha256') + .update(JSON.stringify(data)) + .digest('hex') + .slice(0, 32); // 32 hex chars = 128 bits, safe against birthday collisions +} + +// ── Schema ───────────────────────────────────────────────────── + +const EVENTS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + occurred_at INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT, + hash TEXT, + code_rev TEXT, + meta TEXT +); + +CREATE INDEX IF NOT EXISTS idx_occurred ON events(occurred_at); +CREATE INDEX IF NOT EXISTS idx_kind_key ON events(kind, key, occurred_at); +CREATE INDEX IF NOT EXISTS idx_code_rev ON events(code_rev, occurred_at); +`; + +const OBJECTS_SCHEMA = ` +CREATE TABLE IF NOT EXISTS objects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + object_type TEXT NOT NULL, + external_id TEXT, + created_at INTEGER NOT NULL, + code_rev TEXT NOT NULL, + UNIQUE(object_type, external_id) +); +`; + +/** Safe migration: add object_id column to events if it doesn't exist yet. */ +function migrateEventsObjectId(db: Database): void { + try { + db.run( + 'ALTER TABLE events ADD COLUMN object_id INTEGER REFERENCES objects(id)', + ); + } catch (_e) { + // Column already exists — ignore + } +} + +// ── Row mapping helper ───────────────────────────────────────── + +interface RawRow { + id: number; + occurred_at: number; + kind: string; + key: string | null; + hash: string | null; + code_rev: string | null; + meta: string | null; + object_id: number | null; +} + +function rowToRecord(row: RawRow): EventRecord { + const rec: EventRecord = { + id: Number(row.id), + occurredAt: row.occurred_at, + kind: row.kind, + }; + if (row.key != null) rec.key = row.key; + if (row.hash != null) rec.hash = row.hash; + if (row.code_rev != null) rec.codeRev = row.code_rev; + if (row.meta != null) rec.meta = row.meta; + if (row.object_id != null) rec.objectId = row.object_id; + return rec; +} + +interface RawObjectRow { + id: number; + object_type: string; + external_id: string | null; + created_at: number; + code_rev: string; +} + +function rowToObjectInstance(row: RawObjectRow): ObjectInstance { + return { + id: row.id, + objectType: row.object_type, + externalId: row.external_id, + createdAt: row.created_at, + codeRev: row.code_rev, + }; +} + +async function appendOneWithGuards( + db: Database, + insert: (event: Omit) => EventRecord, + event: Omit, +): Promise { + const nested = db.inTransaction; + if (!nested) db.exec('BEGIN IMMEDIATE'); + try { + const { updates } = await checkGuards(db, event, event.codeRev ?? ''); + const written = insert(event); + applyGuardUpdates( + db, + updates.map((u) => ({ ...u, lastEventId: written.id })), + ); + if (!nested) db.exec('COMMIT'); + return written; + } catch (e) { + if (!nested) db.exec('ROLLBACK'); + throw e; + } +} + +async function appendManyWithGuards( + db: Database, + insert: (event: Omit) => EventRecord, + events: Omit[], +): Promise { + if (events.length === 0) return []; + const nested = db.inTransaction; + if (!nested) db.exec('BEGIN IMMEDIATE'); + try { + const results: EventRecord[] = []; + for (const event of events) { + const { updates } = await checkGuards(db, event, event.codeRev ?? ''); + const written = insert(event); + applyGuardUpdates( + db, + updates.map((u) => ({ ...u, lastEventId: written.id })), + ); + results.push(written); + } + if (!nested) db.exec('COMMIT'); + return results; + } catch (e) { + if (!nested) db.exec('ROLLBACK'); + throw e; + } +} + +// ── Factory ──────────────────────────────────────────────────── + +export function createStore(options: CreateStoreOptions): PulseStore { + const { eventsDbPath, objectsDir } = options; + + mkdirSync(objectsDir, { recursive: true }); + + const eventsDb = new Database(eventsDbPath, { create: true }); + eventsDb.exec('PRAGMA journal_mode = WAL'); + eventsDb.exec('PRAGMA busy_timeout = 5000'); + eventsDb.exec(EVENTS_SCHEMA); + eventsDb.exec(OBJECTS_SCHEMA); + migrateEventsObjectId(eventsDb); + initGuardSchema(eventsDb); + + const insertEvent = eventsDb.prepare(` + INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta, object_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + const selectLatest = eventsDb.prepare(` + SELECT * FROM events + WHERE kind = ? AND (key = ? OR ? IS NULL) + ORDER BY occurred_at DESC, id DESC + LIMIT 1 + `); + + const selectHasEvents = eventsDb.prepare(` + SELECT 1 FROM events LIMIT 1 + `); + + function doAppendEvent(event: Omit): EventRecord { + const result = insertEvent.run( + event.occurredAt, + event.kind, + event.key ?? null, + event.hash ?? null, + event.codeRev ?? null, + event.meta ?? null, + event.objectId ?? null, + ); + const id = Number(result.lastInsertRowid); + return { id, ...event }; + } + + return { + async appendEvent(event: Omit): Promise { + return appendOneWithGuards(eventsDb, doAppendEvent, event); + }, + + async appendEvents( + events: Omit[], + ): Promise { + return appendManyWithGuards(eventsDb, doAppendEvent, events); + }, + + async getLatest(kind: string, key?: string): Promise { + const row = selectLatest.get( + kind, + key ?? null, + key ?? null, + ) as RawRow | null; + return row ? rowToRecord(row) : null; + }, + + async getLatestWhere(opts: { + kind: string; + key?: string; + codeRev?: string; + }): Promise { + 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 = eventsDb.prepare(sql).get(...params) as RawRow | null; + return row ? rowToRecord(row) : null; + }, + + async getRecent(limit: number = 20): Promise { + const sql = `SELECT * FROM events ORDER BY occurred_at DESC, id DESC LIMIT ?`; + return (eventsDb.prepare(sql).all(limit) as RawRow[]).map(rowToRecord); + }, + + async queryByKind( + kind: string, + opts?: { + key?: string; + since?: number; + codeRev?: string; + limit?: number; + }, + ): Promise { + 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 (eventsDb.prepare(sql).all(...params) as RawRow[]).map( + rowToRecord, + ); + }, + + async getAfter( + afterId: number, + opts?: { + kind?: string; + key?: string; + codeRev?: string; + }, + ): Promise { + 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 (eventsDb.prepare(sql).all(...params) as RawRow[]).map( + rowToRecord, + ); + }, + + async hasEvents(): Promise { + return selectHasEvents.get() !== null; + }, + + async createObject(opts: { + objectType: string; + externalId?: string; + codeRev: string; + }): Promise { + const now = Date.now(); + const extId = opts.externalId ?? null; + if (extId !== null) { + const existing = eventsDb + .prepare( + 'SELECT id FROM objects WHERE object_type = ? AND external_id = ?', + ) + .get(opts.objectType, extId) as { id: number } | null; + if (existing) return existing.id; + } + const result = eventsDb + .prepare( + 'INSERT INTO objects (object_type, external_id, created_at, code_rev) VALUES (?, ?, ?, ?)', + ) + .run(opts.objectType, extId, now, opts.codeRev); + return Number(result.lastInsertRowid); + }, + + async getObjectInstance(id: number): Promise { + const row = eventsDb + .prepare('SELECT * FROM objects WHERE id = ?') + .get(id) as RawObjectRow | null; + return row ? rowToObjectInstance(row) : null; + }, + + async queryObjectsByType(objectType: string): Promise { + return ( + eventsDb + .prepare('SELECT * FROM objects WHERE object_type = ?') + .all(objectType) as RawObjectRow[] + ).map(rowToObjectInstance); + }, + + async putObject(data: unknown): Promise { + const hash = hashObject(data); + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) { + mkdirSync(objectsDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } + return hash; + }, + + async getObject(hash: string): Promise { + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + }, + + async close(): Promise { + eventsDb.close(); + }, + + getDatabase(): Database { + return eventsDb; + }, + + async archiveEvents(olderThan: number): Promise { + const result = eventsDb + .prepare('DELETE FROM events WHERE occurred_at < ?') + .run(olderThan); + return result.changes; + }, + + async downsampleEvents( + kind: string, + key: string, + intervalMs: number, + olderThan: number, + ): Promise { + const safeInterval = Math.floor(Math.abs(intervalMs)); + if (safeInterval <= 0) return 0; + const stmt = eventsDb.prepare(` + DELETE FROM events WHERE kind = ? AND key = ? AND occurred_at < ? AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY (occurred_at / ${safeInterval}) ORDER BY occurred_at DESC + ) as rn FROM events WHERE kind = ? AND key = ? AND occurred_at < ? + ) WHERE rn = 1 + ) + `); + const result = stmt.run(kind, key, olderThan, kind, key, olderThan); + return result.changes; + }, + }; +} + +// ── Scoped Store ────────────────────────────────────────────── + +const SCOPE_NAME_RE = /^[a-z0-9_-]{1,64}$/; + +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 and projections table. + */ +function openScopeDb(path: string): Database { + mkdirSync(dirname(path), { recursive: true }); + const db = new Database(path, { create: true }); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec(EVENTS_SCHEMA); + db.exec(OBJECTS_SCHEMA); + migrateEventsObjectId(db); + + db.exec(PROJECTIONS_SCHEMA); + + void initDefsSchema(db); + initGuardSchema(db); + + return db; +} + +function createScopeStore(db: Database, objectsDir: string): PulseStore { + const insertEvent = db.prepare(` + INSERT INTO events (occurred_at, kind, key, hash, code_rev, meta, object_id) + 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 { + const result = insertEvent.run( + event.occurredAt, + event.kind, + event.key ?? null, + event.hash ?? null, + event.codeRev ?? null, + event.meta ?? null, + event.objectId ?? null, + ); + const id = Number(result.lastInsertRowid); + return { id, ...event }; + } + + return { + async appendEvent(event) { + return appendOneWithGuards(db, doAppendEvent, event); + }, + + async appendEvents(events) { + return appendManyWithGuards(db, doAppendEvent, events); + }, + + async getLatest(kind, key?) { + const row = selectLatest.get( + kind, + key ?? null, + key ?? null, + ) as RawRow | null; + return row ? rowToRecord(row) : null; + }, + + async 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; + }, + + async 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); + }, + + async 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); + }, + + async getAfter(afterId: number, 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); + }, + + async hasEvents() { + return selectHasEvents.get() !== null; + }, + + async createObject(opts: { + objectType: string; + externalId?: string; + codeRev: string; + }): Promise { + const now = Date.now(); + const extId = opts.externalId ?? null; + if (extId !== null) { + const existing = db + .prepare( + 'SELECT id FROM objects WHERE object_type = ? AND external_id = ?', + ) + .get(opts.objectType, extId) as { id: number } | null; + if (existing) return existing.id; + } + const result = db + .prepare( + 'INSERT INTO objects (object_type, external_id, created_at, code_rev) VALUES (?, ?, ?, ?)', + ) + .run(opts.objectType, extId, now, opts.codeRev); + return Number(result.lastInsertRowid); + }, + + async getObjectInstance(id: number): Promise { + const row = db + .prepare('SELECT * FROM objects WHERE id = ?') + .get(id) as RawObjectRow | null; + return row ? rowToObjectInstance(row) : null; + }, + + async queryObjectsByType(objectType: string): Promise { + return ( + db + .prepare('SELECT * FROM objects WHERE object_type = ?') + .all(objectType) as RawObjectRow[] + ).map(rowToObjectInstance); + }, + + async putObject(data) { + const hash = hashObject(data); + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) { + mkdirSync(objectsDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } + return hash; + }, + + async getObject(hash) { + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + }, + + async close() { + db.close(); + }, + + getDatabase(): Database { + return db; + }, + + async archiveEvents(olderThan: number): Promise { + const result = db + .prepare('DELETE FROM events WHERE occurred_at < ?') + .run(olderThan); + return result.changes; + }, + + async downsampleEvents( + kind: string, + key: string, + intervalMs: number, + olderThan: number, + ): Promise { + const safeInterval = Math.floor(Math.abs(intervalMs)); + if (safeInterval <= 0) return 0; + const stmt = db.prepare(` + DELETE FROM events WHERE kind = ? AND key = ? AND occurred_at < ? AND id NOT IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY (occurred_at / ${safeInterval}) ORDER BY occurred_at DESC + ) as rn FROM events WHERE kind = ? AND key = ? AND occurred_at < ? + ) WHERE rn = 1 + ) + `); + const result = stmt.run(kind, key, olderThan, kind, key, olderThan); + return result.changes; + }, + }; +} + +export function createScopedStore( + options: CreateScopedStoreOptions, +): ScopedStore { + const { basePath, objectsDir } = options; + + mkdirSync(basePath, { recursive: true }); + mkdirSync(objectsDir, { recursive: true }); + + const openStores = new Map(); + const openDatabases = new Map(); + + 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); + openDatabases.set(name, db); + return store; + }, + + scopeDatabase(name: string): Database { + validateScopeName(name); + + const existing = openDatabases.get(name); + if (existing) return existing; + + const dbPath = join(basePath, `${name}.db`); + const db = openScopeDb(dbPath); + openDatabases.set(name, db); + + if (!openStores.has(name)) { + const store = createScopeStore(db, objectsDir); + openStores.set(name, store); + } + + return db; + }, + + listScopes(): string[] { + if (!existsSync(basePath)) return []; + return readdirSync(basePath) + .filter((f) => f.endsWith('.db')) + .map((f) => f.slice(0, -3)) + .sort(); + }, + + async putObject(data: unknown): Promise { + const hash = hashObject(data); + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) { + mkdirSync(objectsDir, { recursive: true }); + writeFileSync(filePath, JSON.stringify(data), 'utf-8'); + } + return hash; + }, + + async getObject(hash: string): Promise { + const filePath = join(objectsDir, `${hash}.json`); + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, 'utf-8')); + }, + + async close(): Promise { + for (const store of openStores.values()) { + await store.close(); + } + for (const db of openDatabases.values()) { + db.close(); + } + openStores.clear(); + openDatabases.clear(); + }, + }; +} diff --git a/packages/pulse-local/tsconfig.json b/packages/pulse-local/tsconfig.json new file mode 100644 index 0000000..87e8d91 --- /dev/null +++ b/packages/pulse-local/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["bun-types"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/pulse-workflows/package.json b/packages/pulse-workflows/package.json index 7ad38d2..6035dbf 100644 --- a/packages/pulse-workflows/package.json +++ b/packages/pulse-workflows/package.json @@ -11,7 +11,8 @@ "test": "bun test" }, "dependencies": { - "@uncaged/pulse": "workspace:*" + "@uncaged/pulse": "workspace:*", + "@uncaged/pulse-local": "workspace:*" }, "devDependencies": { "bun-types": "^1.3.12" diff --git a/packages/pulse-workflows/src/roles/coder-cursor.test.ts b/packages/pulse-workflows/src/roles/coder-cursor.test.ts index 339322f..fffb1a7 100644 --- a/packages/pulse-workflows/src/roles/coder-cursor.test.ts +++ b/packages/pulse-workflows/src/roles/coder-cursor.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '@uncaged/pulse'; +import { createStore, type PulseStore } from '@uncaged/pulse-local'; import type { WorkflowMessage } from '@uncaged/pulse'; describe('coder-cursor role', () => { diff --git a/packages/pulse-workflows/src/roles/reviewer-cursor.test.ts b/packages/pulse-workflows/src/roles/reviewer-cursor.test.ts index 191d8d5..a232c7d 100644 --- a/packages/pulse-workflows/src/roles/reviewer-cursor.test.ts +++ b/packages/pulse-workflows/src/roles/reviewer-cursor.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '@uncaged/pulse'; +import { createStore, type PulseStore } from '@uncaged/pulse-local'; import type { WorkflowMessage } from '@uncaged/pulse'; describe('reviewer-cursor role', () => { diff --git a/packages/pulse-workflows/src/workflows/coding-tdd.test.ts b/packages/pulse-workflows/src/workflows/coding-tdd.test.ts index cb05fb8..df9ace3 100644 --- a/packages/pulse-workflows/src/workflows/coding-tdd.test.ts +++ b/packages/pulse-workflows/src/workflows/coding-tdd.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '@uncaged/pulse'; +import { createStore, type PulseStore } from '@uncaged/pulse-local'; import { createTddCodingWorkflow } from './coding-tdd.js'; import { createWorkflowRule } from '@uncaged/pulse'; import { END, START } from '@uncaged/pulse'; diff --git a/packages/pulse-workflows/src/workflows/coding.test.ts b/packages/pulse-workflows/src/workflows/coding.test.ts index 6b2ebe1..06204a6 100644 --- a/packages/pulse-workflows/src/workflows/coding.test.ts +++ b/packages/pulse-workflows/src/workflows/coding.test.ts @@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from 'bun:test'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '@uncaged/pulse'; +import { createStore, type PulseStore } from '@uncaged/pulse-local'; import { createCodingWorkflow } from './coding.js'; import { createWorkflowRule, END } from '@uncaged/pulse'; diff --git a/packages/pulse-workflows/src/workflows/report.test.ts b/packages/pulse-workflows/src/workflows/report.test.ts index 4b2d0dc..93af5fc 100644 --- a/packages/pulse-workflows/src/workflows/report.test.ts +++ b/packages/pulse-workflows/src/workflows/report.test.ts @@ -5,7 +5,7 @@ */ import { describe, expect, test } from 'bun:test'; -import { createStore, type PulseStore } from '@uncaged/pulse'; +import { createStore, type PulseStore } from '@uncaged/pulse-local'; import { createReportWorkflow } from './report.js'; import { createWorkflowRule } from '@uncaged/pulse'; diff --git a/packages/pulse/package.json b/packages/pulse/package.json index fe24f70..ba145ce 100644 --- a/packages/pulse/package.json +++ b/packages/pulse/package.json @@ -5,6 +5,28 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./defs": { + "types": "./dist/defs.d.ts", + "default": "./dist/defs.js" + }, + "./guard-projection": { + "types": "./dist/guard-projection.d.ts", + "default": "./dist/guard-projection.js" + }, + "./projection-engine": { + "types": "./dist/projection-engine.d.ts", + "default": "./dist/projection-engine.js" + }, + "./store": { + "types": "./dist/store.d.ts", + "default": "./dist/store.js" + } + }, "files": [ "dist" ], diff --git a/packages/pulse/src/index.d.ts b/packages/pulse/src/index.d.ts index e9dd589..e6f45b2 100644 --- a/packages/pulse/src/index.d.ts +++ b/packages/pulse/src/index.d.ts @@ -251,8 +251,6 @@ export declare function createRule( export { type CreateScopedStoreOptions, type CreateStoreOptions, - createScopedStore, - createStore, type EventRecord, type ObjectInstance, type PulseStore, diff --git a/packages/pulse/src/index.ts b/packages/pulse/src/index.ts index bc7cdca..87935d4 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -905,15 +905,13 @@ export type { PulseDatabase, PulseStatement } from './database.js'; // ── Storage ──────────────────────────────────────────────────── -export { - type CreateScopedStoreOptions, - type CreateStoreOptions, - createScopedStore, - createStore, - type EventRecord, - type ObjectInstance, - type PulseStore, - type ScopedStore, +export type { + CreateStoreOptions, + CreateScopedStoreOptions, + EventRecord, + ObjectInstance, + PulseStore, + ScopedStore, } from './store.js'; // ── Built-in Rules ───────────────────────────────────────────── diff --git a/packages/upulse/package.json b/packages/upulse/package.json index e064432..d181bb1 100644 --- a/packages/upulse/package.json +++ b/packages/upulse/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@uncaged/pulse": "workspace:*", + "@uncaged/pulse-local": "workspace:*", "commander": "^12.0.0" }, "devDependencies": { diff --git a/packages/upulse/src/commands/workflow.test.ts b/packages/upulse/src/commands/workflow.test.ts index 4b06fed..0dc706d 100644 --- a/packages/upulse/src/commands/workflow.test.ts +++ b/packages/upulse/src/commands/workflow.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { createStore, type PulseStore } from '@uncaged/pulse'; +import { createStore, type PulseStore } from '@uncaged/pulse-local'; function parseWorkflowKind(kind: string): { wf: string; role: string } | null { const i = kind.lastIndexOf('.'); diff --git a/packages/upulse/src/init.ts b/packages/upulse/src/init.ts index fc0583c..50a88bf 100644 --- a/packages/upulse/src/init.ts +++ b/packages/upulse/src/init.ts @@ -442,7 +442,8 @@ export default collectSystemRule; } function writePulseConfig(enginePath: string): void { - const content = `import { runPulse, createScopedStore } from '@uncaged/pulse'; + const content = `import { runPulse } from '@uncaged/pulse'; +import { createScopedStore } from '@uncaged/pulse-local'; import type { Snapshot, Effect } from './types.js'; import { collectSystem } from './executors/system.js'; import { join } from 'node:path'; diff --git a/packages/upulse/src/store.ts b/packages/upulse/src/store.ts index 853ac14..be4fa9f 100644 --- a/packages/upulse/src/store.ts +++ b/packages/upulse/src/store.ts @@ -7,13 +7,8 @@ import { existsSync, mkdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { - createScopedStore, - createStore, - type EventRecord, - type PulseStore, - type ScopedStore, -} from '@uncaged/pulse'; +import type { EventRecord, PulseStore, ScopedStore } from '@uncaged/pulse'; +import { createScopedStore, createStore } from '@uncaged/pulse-local'; import type { UpulseConfig } from './config.js'; export type { EventRecord, PulseStore, ScopedStore };