@soleri/core 8.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/brain/knowledge-synthesizer.d.ts.map +1 -1
- package/dist/brain/knowledge-synthesizer.js +0 -2
- package/dist/brain/knowledge-synthesizer.js.map +1 -1
- package/dist/curator/classifier.d.ts.map +1 -1
- package/dist/curator/classifier.js +0 -2
- package/dist/curator/classifier.js.map +1 -1
- package/dist/curator/quality-gate.d.ts.map +1 -1
- package/dist/curator/quality-gate.js +0 -2
- package/dist/curator/quality-gate.js.map +1 -1
- package/dist/domain-packs/index.d.ts +0 -3
- package/dist/domain-packs/index.d.ts.map +1 -1
- package/dist/domain-packs/index.js +0 -3
- package/dist/domain-packs/index.js.map +1 -1
- package/dist/domain-packs/loader.d.ts.map +1 -1
- package/dist/domain-packs/loader.js +20 -4
- package/dist/domain-packs/loader.js.map +1 -1
- package/dist/domain-packs/pack-runtime.d.ts +5 -5
- package/dist/domain-packs/pack-runtime.d.ts.map +1 -1
- package/dist/domain-packs/pack-runtime.js +2 -2
- package/dist/domain-packs/pack-runtime.js.map +1 -1
- package/dist/domain-packs/types.d.ts +8 -2
- package/dist/domain-packs/types.d.ts.map +1 -1
- package/dist/domain-packs/types.js.map +1 -1
- package/dist/engine/bin/soleri-engine.js +12 -2
- package/dist/engine/bin/soleri-engine.js.map +1 -1
- package/dist/engine/index.d.ts +2 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/module-manifest.d.ts +28 -0
- package/dist/engine/module-manifest.d.ts.map +1 -0
- package/dist/engine/module-manifest.js +85 -0
- package/dist/engine/module-manifest.js.map +1 -0
- package/dist/engine/register-engine.d.ts +19 -0
- package/dist/engine/register-engine.d.ts.map +1 -1
- package/dist/engine/register-engine.js +15 -2
- package/dist/engine/register-engine.js.map +1 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/intake/content-classifier.d.ts.map +1 -1
- package/dist/intake/content-classifier.js +0 -2
- package/dist/intake/content-classifier.js.map +1 -1
- package/dist/llm/llm-client.d.ts.map +1 -1
- package/dist/llm/llm-client.js +8 -4
- package/dist/llm/llm-client.js.map +1 -1
- package/dist/llm/oauth-discovery.d.ts +0 -8
- package/dist/llm/oauth-discovery.d.ts.map +1 -1
- package/dist/llm/oauth-discovery.js +0 -19
- package/dist/llm/oauth-discovery.js.map +1 -1
- package/dist/llm/types.d.ts +4 -2
- package/dist/llm/types.d.ts.map +1 -1
- package/dist/packs/pack-installer.d.ts +2 -1
- package/dist/packs/pack-installer.d.ts.map +1 -1
- package/dist/packs/pack-installer.js +10 -1
- package/dist/packs/pack-installer.js.map +1 -1
- package/dist/persistence/index.d.ts +0 -1
- package/dist/persistence/index.d.ts.map +1 -1
- package/dist/persistence/index.js +0 -1
- package/dist/persistence/index.js.map +1 -1
- package/dist/persistence/types.d.ts +2 -6
- package/dist/persistence/types.d.ts.map +1 -1
- package/dist/plugins/index.d.ts +4 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/plugin-registry.d.ts +4 -0
- package/dist/plugins/plugin-registry.d.ts.map +1 -1
- package/dist/plugins/plugin-registry.js +4 -0
- package/dist/plugins/plugin-registry.js.map +1 -1
- package/dist/plugins/types.d.ts +32 -27
- package/dist/plugins/types.d.ts.map +1 -1
- package/dist/plugins/types.js +6 -3
- package/dist/plugins/types.js.map +1 -1
- package/dist/runtime/claude-md-helpers.d.ts +0 -9
- package/dist/runtime/claude-md-helpers.d.ts.map +1 -1
- package/dist/runtime/claude-md-helpers.js +1 -14
- package/dist/runtime/claude-md-helpers.js.map +1 -1
- package/dist/runtime/facades/admin-facade.d.ts.map +1 -1
- package/dist/runtime/facades/admin-facade.js +1 -2
- package/dist/runtime/facades/admin-facade.js.map +1 -1
- package/dist/runtime/pack-ops.d.ts +3 -0
- package/dist/runtime/pack-ops.d.ts.map +1 -1
- package/dist/runtime/pack-ops.js +18 -1
- package/dist/runtime/pack-ops.js.map +1 -1
- package/dist/runtime/plugin-ops.d.ts.map +1 -1
- package/dist/runtime/plugin-ops.js +3 -0
- package/dist/runtime/plugin-ops.js.map +1 -1
- package/dist/runtime/session-briefing.d.ts.map +1 -1
- package/dist/runtime/session-briefing.js +14 -0
- package/dist/runtime/session-briefing.js.map +1 -1
- package/dist/runtime/vault-linking-ops.d.ts.map +1 -1
- package/dist/runtime/vault-linking-ops.js +2 -4
- package/dist/runtime/vault-linking-ops.js.map +1 -1
- package/dist/vault/vault.d.ts +9 -0
- package/dist/vault/vault.d.ts.map +1 -1
- package/dist/vault/vault.js +22 -0
- package/dist/vault/vault.js.map +1 -1
- package/package.json +6 -4
- package/src/__tests__/curator-pipeline-e2e.test.ts +187 -0
- package/src/__tests__/module-manifest-drift.test.ts +59 -0
- package/src/brain/knowledge-synthesizer.ts +0 -2
- package/src/curator/classifier.ts +0 -2
- package/src/curator/quality-gate.ts +0 -2
- package/src/domain-packs/index.ts +0 -6
- package/src/domain-packs/loader.ts +25 -5
- package/src/domain-packs/pack-runtime.ts +6 -6
- package/src/domain-packs/types.ts +8 -2
- package/src/engine/bin/soleri-engine.ts +17 -2
- package/src/engine/index.ts +2 -0
- package/src/engine/module-manifest.ts +99 -0
- package/src/engine/register-engine.ts +21 -2
- package/src/index.ts +0 -1
- package/src/intake/content-classifier.ts +0 -2
- package/src/llm/llm-client.ts +12 -6
- package/src/llm/oauth-discovery.ts +0 -18
- package/src/llm/types.ts +4 -2
- package/src/packs/pack-installer.ts +16 -1
- package/src/persistence/index.ts +0 -1
- package/src/persistence/types.ts +2 -6
- package/src/plugins/index.ts +4 -0
- package/src/plugins/plugin-registry.ts +6 -1
- package/src/plugins/types.ts +10 -5
- package/src/runtime/claude-md-helpers.ts +1 -19
- package/src/runtime/facades/admin-facade.ts +1 -2
- package/src/runtime/pack-ops.ts +26 -1
- package/src/runtime/plugin-ops.ts +3 -0
- package/src/runtime/session-briefing.ts +14 -0
- package/src/runtime/vault-linking-ops.ts +2 -4
- package/src/vault/vault.ts +26 -0
- package/src/__tests__/postgres-provider.test.ts +0 -116
- package/src/health/doctor-checks.ts +0 -115
- package/src/persistence/postgres-provider.ts +0 -310
package/src/vault/vault.ts
CHANGED
|
@@ -76,6 +76,32 @@ export class Vault {
|
|
|
76
76
|
providerOrPath instanceof SQLitePersistenceProvider ? providerOrPath : null;
|
|
77
77
|
}
|
|
78
78
|
this.initialize();
|
|
79
|
+
this.checkFormatVersion();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Vault format version — tracks schema changes for migration safety.
|
|
84
|
+
* Increment when schema changes in a way that requires migration.
|
|
85
|
+
*
|
|
86
|
+
* History:
|
|
87
|
+
* 1 — Initial schema (v8.0.0): entries, memories, brain_feedback, FTS5
|
|
88
|
+
*/
|
|
89
|
+
static readonly FORMAT_VERSION = 1;
|
|
90
|
+
|
|
91
|
+
private checkFormatVersion(): void {
|
|
92
|
+
const row = this.provider.get<{ user_version: number }>('PRAGMA user_version');
|
|
93
|
+
const current = row?.user_version ?? 0;
|
|
94
|
+
|
|
95
|
+
if (current === 0) {
|
|
96
|
+
// Fresh database — stamp with current version
|
|
97
|
+
this.provider.run(`PRAGMA user_version = ${Vault.FORMAT_VERSION}`);
|
|
98
|
+
} else if (current > Vault.FORMAT_VERSION) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Vault format version ${current} is newer than engine supports (${Vault.FORMAT_VERSION}). ` +
|
|
101
|
+
`Upgrade @soleri/core to a compatible version.`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// current < FORMAT_VERSION → future: run migration scripts here
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
setSyncManager(mgr: import('../cognee/sync-manager.js').CogneeSyncManager): void {
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { translateSql, PostgresPersistenceProvider } from '../persistence/postgres-provider.js';
|
|
3
|
-
|
|
4
|
-
describe('translateSql', () => {
|
|
5
|
-
it('converts positional ? to $N', () => {
|
|
6
|
-
const result = translateSql('SELECT * FROM t WHERE a = ? AND b = ?', ['x', 'y']);
|
|
7
|
-
expect(result.sql).toBe('SELECT * FROM t WHERE a = $1 AND b = $2');
|
|
8
|
-
expect(result.values).toEqual(['x', 'y']);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('converts named @params to $N', () => {
|
|
12
|
-
const result = translateSql('INSERT INTO t (a, b) VALUES (@name, @age)', {
|
|
13
|
-
name: 'Alice',
|
|
14
|
-
age: 30,
|
|
15
|
-
});
|
|
16
|
-
expect(result.sql).toBe('INSERT INTO t (a, b) VALUES ($1, $2)');
|
|
17
|
-
expect(result.values).toEqual(['Alice', 30]);
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('deduplicates repeated named params', () => {
|
|
21
|
-
const result = translateSql('SELECT * FROM t WHERE a = @x OR b = @x', { x: 42 });
|
|
22
|
-
expect(result.sql).toBe('SELECT * FROM t WHERE a = $1 OR b = $1');
|
|
23
|
-
expect(result.values).toEqual([42]);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('replaces unixepoch()', () => {
|
|
27
|
-
const result = translateSql('INSERT INTO t (ts) VALUES (unixepoch())');
|
|
28
|
-
expect(result.sql).toBe('INSERT INTO t (ts) VALUES (EXTRACT(EPOCH FROM NOW())::integer)');
|
|
29
|
-
expect(result.values).toEqual([]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('handles no params', () => {
|
|
33
|
-
const result = translateSql('SELECT 1');
|
|
34
|
-
expect(result.sql).toBe('SELECT 1');
|
|
35
|
-
expect(result.values).toEqual([]);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('handles mixed: ? params + unixepoch()', () => {
|
|
39
|
-
const result = translateSql('UPDATE t SET name = ?, updated_at = unixepoch() WHERE id = ?', [
|
|
40
|
-
'Bob',
|
|
41
|
-
'id-1',
|
|
42
|
-
]);
|
|
43
|
-
expect(result.sql).toBe(
|
|
44
|
-
'UPDATE t SET name = $1, updated_at = EXTRACT(EPOCH FROM NOW())::integer WHERE id = $2',
|
|
45
|
-
);
|
|
46
|
-
expect(result.values).toEqual(['Bob', 'id-1']);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('replaces INTEGER PRIMARY KEY AUTOINCREMENT with SERIAL PRIMARY KEY', () => {
|
|
50
|
-
const result = translateSql('CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)');
|
|
51
|
-
expect(result.sql).toBe('CREATE TABLE t (id SERIAL PRIMARY KEY, name TEXT)');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('strips PRAGMA statements', () => {
|
|
55
|
-
const result = translateSql('PRAGMA journal_mode = WAL');
|
|
56
|
-
expect(result.sql).toBe('-- pragma removed');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('replaces INSERT OR IGNORE with INSERT', () => {
|
|
60
|
-
const result = translateSql('INSERT OR IGNORE INTO t (a) VALUES (?)', ['x']);
|
|
61
|
-
expect(result.sql).toBe('INSERT INTO t (a) VALUES ($1)');
|
|
62
|
-
expect(result.values).toEqual(['x']);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it('replaces INSERT OR REPLACE with INSERT', () => {
|
|
66
|
-
const result = translateSql('INSERT OR REPLACE INTO t (a) VALUES (?)', ['x']);
|
|
67
|
-
expect(result.sql).toBe('INSERT INTO t (a) VALUES ($1)');
|
|
68
|
-
expect(result.values).toEqual(['x']);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('handles complex vault-style query with multiple named params', () => {
|
|
72
|
-
const result = translateSql(
|
|
73
|
-
'SELECT e.* FROM entries e WHERE e.domain = @domain AND e.type = @type ORDER BY e.title LIMIT @limit OFFSET @offset',
|
|
74
|
-
{ domain: 'design', type: 'pattern', limit: 10, offset: 0 },
|
|
75
|
-
);
|
|
76
|
-
expect(result.sql).toBe(
|
|
77
|
-
'SELECT e.* FROM entries e WHERE e.domain = $1 AND e.type = $2 ORDER BY e.title LIMIT $3 OFFSET $4',
|
|
78
|
-
);
|
|
79
|
-
expect(result.values).toEqual(['design', 'pattern', 10, 0]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('handles vault seed query with many named params', () => {
|
|
83
|
-
const result = translateSql(
|
|
84
|
-
'INSERT INTO entries (id, type, domain) VALUES (@id, @type, @domain) ON CONFLICT(id) DO UPDATE SET type=excluded.type',
|
|
85
|
-
{ id: 'e1', type: 'pattern', domain: 'general' },
|
|
86
|
-
);
|
|
87
|
-
expect(result.sql).toContain('VALUES ($1, $2, $3)');
|
|
88
|
-
expect(result.values).toEqual(['e1', 'pattern', 'general']);
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('PostgresPersistenceProvider', () => {
|
|
93
|
-
it('class exists with correct backend', () => {
|
|
94
|
-
expect(PostgresPersistenceProvider).toBeDefined();
|
|
95
|
-
expect(typeof PostgresPersistenceProvider.create).toBe('function');
|
|
96
|
-
expect(typeof PostgresPersistenceProvider.createSync).toBe('function');
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('createSync returns a provider instance', () => {
|
|
100
|
-
const provider = PostgresPersistenceProvider.createSync('postgresql://localhost/test');
|
|
101
|
-
expect(provider.backend).toBe('postgres');
|
|
102
|
-
expect(provider.getConnectionString()).toBe('postgresql://localhost/test');
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('ftsRebuild is a no-op (PostgreSQL GIN auto-maintains)', () => {
|
|
106
|
-
const provider = PostgresPersistenceProvider.createSync('postgresql://localhost/test');
|
|
107
|
-
// Should not throw
|
|
108
|
-
provider.ftsRebuild('entries');
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('close is safe to call multiple times', () => {
|
|
112
|
-
const provider = PostgresPersistenceProvider.createSync('postgresql://localhost/test');
|
|
113
|
-
provider.close();
|
|
114
|
-
provider.close(); // should not throw
|
|
115
|
-
});
|
|
116
|
-
});
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Doctor Checks — 8 specialized health checks for comprehensive diagnostics.
|
|
3
|
-
*
|
|
4
|
-
* Each check validates a subsystem and reports pass/warn/fail.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { HealthRegistry } from './health-registry.js';
|
|
8
|
-
import type { AgentRuntime } from '../runtime/types.js';
|
|
9
|
-
|
|
10
|
-
export interface DoctorCheckResult {
|
|
11
|
-
check: string;
|
|
12
|
-
status: 'pass' | 'warn' | 'fail';
|
|
13
|
-
message: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function runDoctorChecks(runtime: AgentRuntime): DoctorCheckResult[] {
|
|
17
|
-
const results: DoctorCheckResult[] = [];
|
|
18
|
-
|
|
19
|
-
// 1. Config
|
|
20
|
-
try {
|
|
21
|
-
const id = runtime.config.agentId;
|
|
22
|
-
results.push({
|
|
23
|
-
check: 'config',
|
|
24
|
-
status: id ? 'pass' : 'fail',
|
|
25
|
-
message: id ? `Agent "${id}" configured` : 'No agent ID',
|
|
26
|
-
});
|
|
27
|
-
} catch (e) {
|
|
28
|
-
results.push({ check: 'config', status: 'fail', message: (e as Error).message });
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// 2. Database
|
|
32
|
-
try {
|
|
33
|
-
runtime.vault.getProvider().get<{ v: number }>('PRAGMA user_version');
|
|
34
|
-
results.push({ check: 'database', status: 'pass', message: 'SQLite healthy' });
|
|
35
|
-
} catch (e) {
|
|
36
|
-
results.push({ check: 'database', status: 'fail', message: (e as Error).message });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// 3. Vault
|
|
40
|
-
try {
|
|
41
|
-
const stats = runtime.vault.stats();
|
|
42
|
-
results.push({
|
|
43
|
-
check: 'vault',
|
|
44
|
-
status: stats.totalEntries > 0 ? 'pass' : 'warn',
|
|
45
|
-
message: stats.totalEntries > 0 ? `${stats.totalEntries} entries` : 'Vault empty',
|
|
46
|
-
});
|
|
47
|
-
} catch (e) {
|
|
48
|
-
results.push({ check: 'vault', status: 'fail', message: (e as Error).message });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 4. LLM
|
|
52
|
-
try {
|
|
53
|
-
const pool = runtime.keyPool;
|
|
54
|
-
const total = (pool.openai?.getActiveKey() ? 1 : 0) + (pool.anthropic?.getActiveKey() ? 1 : 0);
|
|
55
|
-
results.push({
|
|
56
|
-
check: 'llm',
|
|
57
|
-
status: total > 0 ? 'pass' : 'warn',
|
|
58
|
-
message: total > 0 ? `${total} provider(s) available` : 'No API keys — LLM features disabled',
|
|
59
|
-
});
|
|
60
|
-
} catch {
|
|
61
|
-
results.push({ check: 'llm', status: 'warn', message: 'LLM check failed' });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// 5. Auth
|
|
65
|
-
results.push({
|
|
66
|
-
check: 'auth',
|
|
67
|
-
status: 'pass',
|
|
68
|
-
message: `Mode: ${runtime.authPolicy.mode}`,
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
// 6. Plugins
|
|
72
|
-
try {
|
|
73
|
-
const count = runtime.pluginRegistry.list().length;
|
|
74
|
-
results.push({ check: 'plugins', status: 'pass', message: `${count} plugin(s)` });
|
|
75
|
-
} catch {
|
|
76
|
-
results.push({ check: 'plugins', status: 'warn', message: 'Plugin check failed' });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 7. Embeddings (Cognee)
|
|
80
|
-
try {
|
|
81
|
-
const available = runtime.cognee?.isAvailable ?? false;
|
|
82
|
-
results.push({
|
|
83
|
-
check: 'embeddings',
|
|
84
|
-
status: available ? 'pass' : 'warn',
|
|
85
|
-
message: available ? 'Cognee available' : 'Cognee not available — vector search disabled',
|
|
86
|
-
});
|
|
87
|
-
} catch {
|
|
88
|
-
results.push({ check: 'embeddings', status: 'warn', message: 'Embedding check failed' });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// 8. Security
|
|
92
|
-
results.push({
|
|
93
|
-
check: 'security',
|
|
94
|
-
status: runtime.authPolicy.mode === 'permissive' ? 'warn' : 'pass',
|
|
95
|
-
message:
|
|
96
|
-
runtime.authPolicy.mode === 'permissive'
|
|
97
|
-
? 'Permissive mode — all ops allowed'
|
|
98
|
-
: `Enforcement: ${runtime.authPolicy.mode}`,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
return results;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function registerDoctorChecks(health: HealthRegistry, runtime: AgentRuntime): void {
|
|
105
|
-
const results = runDoctorChecks(runtime);
|
|
106
|
-
for (const r of results) {
|
|
107
|
-
health.register(
|
|
108
|
-
r.check,
|
|
109
|
-
r.status === 'pass' ? 'healthy' : r.status === 'warn' ? 'degraded' : 'down',
|
|
110
|
-
);
|
|
111
|
-
if (r.status !== 'pass') {
|
|
112
|
-
health.update(r.check, r.status === 'warn' ? 'degraded' : 'down', r.message);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
}
|
|
@@ -1,310 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PostgreSQL persistence provider.
|
|
3
|
-
*
|
|
4
|
-
* Implements PersistenceProvider with pg.Pool. The translateSql() function
|
|
5
|
-
* converts SQLite-style queries to PostgreSQL-compatible syntax.
|
|
6
|
-
*
|
|
7
|
-
* Architecture: Dual interface — sync methods implement PersistenceProvider
|
|
8
|
-
* for drop-in compatibility, async methods provide the real implementation.
|
|
9
|
-
*
|
|
10
|
-
* Sync methods use execFileSync (safe, no shell injection) to run queries
|
|
11
|
-
* in a subprocess. This is slower than native async but maintains interface
|
|
12
|
-
* compliance with zero additional dependencies.
|
|
13
|
-
*
|
|
14
|
-
* For high-performance use, prefer the async methods directly:
|
|
15
|
-
* await provider.queryAsync(sql, params)
|
|
16
|
-
* await provider.runAsync(sql, params)
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { execFileSync } from 'node:child_process';
|
|
20
|
-
import type {
|
|
21
|
-
PersistenceProvider,
|
|
22
|
-
PersistenceParams,
|
|
23
|
-
RunResult,
|
|
24
|
-
FtsSearchOptions,
|
|
25
|
-
} from './types.js';
|
|
26
|
-
|
|
27
|
-
// =============================================================================
|
|
28
|
-
// SQL TRANSLATION
|
|
29
|
-
// =============================================================================
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Translate SQLite-style SQL to PostgreSQL-compatible SQL.
|
|
33
|
-
*
|
|
34
|
-
* - Converts positional `?` params to `$1, $2, ...`
|
|
35
|
-
* - Converts named `@name` params to `$N` positional, returns ordered values
|
|
36
|
-
* - Replaces `unixepoch()` with `EXTRACT(EPOCH FROM NOW())::integer`
|
|
37
|
-
* - Replaces `INTEGER PRIMARY KEY AUTOINCREMENT` with `SERIAL PRIMARY KEY`
|
|
38
|
-
* - Strips SQLite `PRAGMA` statements
|
|
39
|
-
* - Converts `INSERT OR IGNORE` to `INSERT ... ON CONFLICT DO NOTHING`
|
|
40
|
-
* - Converts `INSERT OR REPLACE` to PostgreSQL upsert syntax
|
|
41
|
-
*/
|
|
42
|
-
export function translateSql(
|
|
43
|
-
sql: string,
|
|
44
|
-
params?: PersistenceParams,
|
|
45
|
-
): { sql: string; values: unknown[] } {
|
|
46
|
-
let translated = sql
|
|
47
|
-
.replace(/unixepoch\(\)/gi, 'EXTRACT(EPOCH FROM NOW())::integer')
|
|
48
|
-
.replace(/INTEGER\s+PRIMARY\s+KEY\s+AUTOINCREMENT/gi, 'SERIAL PRIMARY KEY')
|
|
49
|
-
.replace(/PRAGMA\s+[^;]+;?/gi, '-- pragma removed')
|
|
50
|
-
.replace(/INSERT\s+OR\s+IGNORE/gi, 'INSERT')
|
|
51
|
-
.replace(/INSERT\s+OR\s+REPLACE/gi, 'INSERT');
|
|
52
|
-
|
|
53
|
-
if (!params) return { sql: translated, values: [] };
|
|
54
|
-
|
|
55
|
-
if (Array.isArray(params)) {
|
|
56
|
-
let idx = 0;
|
|
57
|
-
translated = translated.replace(/\?/g, () => `$${++idx}`);
|
|
58
|
-
return { sql: translated, values: params };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Named params: @name -> $N
|
|
62
|
-
const values: unknown[] = [];
|
|
63
|
-
const nameMap = new Map<string, number>();
|
|
64
|
-
translated = translated.replace(/@(\w+)/g, (_match, name: string) => {
|
|
65
|
-
if (!nameMap.has(name)) {
|
|
66
|
-
nameMap.set(name, values.length + 1);
|
|
67
|
-
values.push(params[name]);
|
|
68
|
-
}
|
|
69
|
-
return `$${nameMap.get(name)}`;
|
|
70
|
-
});
|
|
71
|
-
return { sql: translated, values };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// =============================================================================
|
|
75
|
-
// POSTGRESQL PROVIDER
|
|
76
|
-
// =============================================================================
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* PostgreSQL persistence provider.
|
|
80
|
-
*
|
|
81
|
-
* Uses `pg` (optional peer dependency) via subprocess for sync interface compliance.
|
|
82
|
-
* Created via async factory `PostgresPersistenceProvider.create()`.
|
|
83
|
-
*
|
|
84
|
-
* For production use, prefer the async methods directly:
|
|
85
|
-
* ```ts
|
|
86
|
-
* const rows = await provider.queryAsync('SELECT * FROM entries WHERE domain = $1', ['design']);
|
|
87
|
-
* ```
|
|
88
|
-
*/
|
|
89
|
-
export class PostgresPersistenceProvider implements PersistenceProvider {
|
|
90
|
-
readonly backend = 'postgres' as const;
|
|
91
|
-
private connectionString: string;
|
|
92
|
-
private pool: unknown = null;
|
|
93
|
-
private inTransaction = false;
|
|
94
|
-
|
|
95
|
-
private constructor(connectionString: string) {
|
|
96
|
-
this.connectionString = connectionString;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Async factory. Dynamically imports `pg` (optional dependency).
|
|
101
|
-
* Verifies connection before returning.
|
|
102
|
-
*/
|
|
103
|
-
static async create(
|
|
104
|
-
connectionString: string,
|
|
105
|
-
poolSize = 10,
|
|
106
|
-
): Promise<PostgresPersistenceProvider> {
|
|
107
|
-
const provider = new PostgresPersistenceProvider(connectionString);
|
|
108
|
-
|
|
109
|
-
// Dynamically import pg and create pool
|
|
110
|
-
const { default: pg } = await import('pg');
|
|
111
|
-
provider.pool = new pg.Pool({
|
|
112
|
-
connectionString,
|
|
113
|
-
max: poolSize,
|
|
114
|
-
idleTimeoutMillis: 30_000,
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Verify connection
|
|
118
|
-
const client = await (
|
|
119
|
-
provider.pool as {
|
|
120
|
-
connect(): Promise<{ release(): void; query(s: string): Promise<unknown> }>;
|
|
121
|
-
}
|
|
122
|
-
).connect();
|
|
123
|
-
await client.query('SELECT 1');
|
|
124
|
-
client.release();
|
|
125
|
-
|
|
126
|
-
return provider;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Create a provider for sync-only use (no pg pool needed).
|
|
131
|
-
* Uses subprocess execution for all queries.
|
|
132
|
-
*/
|
|
133
|
-
static createSync(connectionString: string): PostgresPersistenceProvider {
|
|
134
|
-
return new PostgresPersistenceProvider(connectionString);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// ─── Async methods (preferred for performance) ─────────
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Execute a query asynchronously. Returns rows.
|
|
141
|
-
*/
|
|
142
|
-
async queryAsync<T = Record<string, unknown>>(sql: string, values?: unknown[]): Promise<T[]> {
|
|
143
|
-
if (!this.pool)
|
|
144
|
-
throw new Error('Pool not initialized. Use PostgresPersistenceProvider.create()');
|
|
145
|
-
const result = await (
|
|
146
|
-
this.pool as { query(s: string, v?: unknown[]): Promise<{ rows: T[] }> }
|
|
147
|
-
).query(sql, values);
|
|
148
|
-
return result.rows;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Execute a command asynchronously. Returns row count.
|
|
153
|
-
*/
|
|
154
|
-
async runAsync(sql: string, values?: unknown[]): Promise<number> {
|
|
155
|
-
if (!this.pool)
|
|
156
|
-
throw new Error('Pool not initialized. Use PostgresPersistenceProvider.create()');
|
|
157
|
-
const result = await (
|
|
158
|
-
this.pool as { query(s: string, v?: unknown[]): Promise<{ rowCount: number }> }
|
|
159
|
-
).query(sql, values);
|
|
160
|
-
return result.rowCount ?? 0;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// ─── Sync PersistenceProvider interface ─────────────────
|
|
164
|
-
// Uses execFileSync subprocess bridge for sync compliance.
|
|
165
|
-
// Safe: execFileSync does not use shell (no injection risk).
|
|
166
|
-
|
|
167
|
-
execSql(sql: string): void {
|
|
168
|
-
const statements = sql
|
|
169
|
-
.split(';')
|
|
170
|
-
.map((s) => s.trim())
|
|
171
|
-
.filter((s) => s.length > 0 && !s.startsWith('--'));
|
|
172
|
-
|
|
173
|
-
for (const stmt of statements) {
|
|
174
|
-
const { sql: pgSql } = translateSql(stmt);
|
|
175
|
-
if (pgSql.includes('CREATE VIRTUAL TABLE') || pgSql.includes('CREATE TRIGGER')) continue;
|
|
176
|
-
if (pgSql.trim().startsWith('--')) continue;
|
|
177
|
-
this.execSyncQuery(pgSql);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
run(sql: string, params?: PersistenceParams): RunResult {
|
|
182
|
-
const { sql: pgSql, values } = translateSql(sql, params);
|
|
183
|
-
const result = this.execSyncQuery(pgSql, values);
|
|
184
|
-
return {
|
|
185
|
-
changes: result.rowCount ?? 0,
|
|
186
|
-
lastInsertRowid: 0,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
get<T = Record<string, unknown>>(sql: string, params?: PersistenceParams): T | undefined {
|
|
191
|
-
const { sql: pgSql, values } = translateSql(sql, params);
|
|
192
|
-
const result = this.execSyncQuery(pgSql, values);
|
|
193
|
-
return result.rows?.[0] as T | undefined;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
all<T = Record<string, unknown>>(sql: string, params?: PersistenceParams): T[] {
|
|
197
|
-
const { sql: pgSql, values } = translateSql(sql, params);
|
|
198
|
-
const result = this.execSyncQuery(pgSql, values);
|
|
199
|
-
return (result.rows ?? []) as T[];
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
transaction<T>(fn: () => T): T {
|
|
203
|
-
this.execSyncQuery('BEGIN');
|
|
204
|
-
this.inTransaction = true;
|
|
205
|
-
try {
|
|
206
|
-
const result = fn();
|
|
207
|
-
this.execSyncQuery('COMMIT');
|
|
208
|
-
this.inTransaction = false;
|
|
209
|
-
return result;
|
|
210
|
-
} catch (err) {
|
|
211
|
-
this.execSyncQuery('ROLLBACK');
|
|
212
|
-
this.inTransaction = false;
|
|
213
|
-
throw err;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
ftsSearch<T = Record<string, unknown>>(
|
|
218
|
-
table: string,
|
|
219
|
-
query: string,
|
|
220
|
-
options?: FtsSearchOptions,
|
|
221
|
-
): T[] {
|
|
222
|
-
const limit = options?.limit ?? 50;
|
|
223
|
-
const offset = options?.offset ?? 0;
|
|
224
|
-
const filters = options?.filters ?? {};
|
|
225
|
-
const values: unknown[] = [query];
|
|
226
|
-
let paramIdx = 2;
|
|
227
|
-
|
|
228
|
-
const filterClauses: string[] = [];
|
|
229
|
-
for (const [key, value] of Object.entries(filters)) {
|
|
230
|
-
filterClauses.push(`${key} = $${paramIdx}`);
|
|
231
|
-
values.push(value);
|
|
232
|
-
paramIdx++;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const whereExtra = filterClauses.length > 0 ? `AND ${filterClauses.join(' AND ')}` : '';
|
|
236
|
-
const orderClause =
|
|
237
|
-
options?.orderByRank !== false
|
|
238
|
-
? "ORDER BY ts_rank(to_tsvector('english', COALESCE(title,'') || ' ' || COALESCE(description,'')), plainto_tsquery('english', $1)) DESC"
|
|
239
|
-
: '';
|
|
240
|
-
|
|
241
|
-
const sql = `SELECT * FROM ${table} WHERE to_tsvector('english', COALESCE(title,'') || ' ' || COALESCE(description,'')) @@ plainto_tsquery('english', $1) ${whereExtra} ${orderClause} LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
|
|
242
|
-
values.push(limit, offset);
|
|
243
|
-
|
|
244
|
-
const result = this.execSyncQuery(sql, values);
|
|
245
|
-
return (result.rows ?? []) as T[];
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
ftsRebuild(_table: string): void {
|
|
249
|
-
// PostgreSQL GIN indexes are maintained automatically
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
close(): void {
|
|
253
|
-
if (this.pool) {
|
|
254
|
-
void (this.pool as { end(): Promise<void> }).end();
|
|
255
|
-
this.pool = null;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** Get the connection string (for diagnostics). */
|
|
260
|
-
getConnectionString(): string {
|
|
261
|
-
return this.connectionString;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ─── Sync subprocess bridge ────────────────────────────
|
|
265
|
-
// Uses execFileSync (no shell, safe from injection).
|
|
266
|
-
// SQL and values are passed via environment variables, not shell arguments.
|
|
267
|
-
|
|
268
|
-
private execSyncQuery(sql: string, values?: unknown[]): { rows: unknown[]; rowCount: number } {
|
|
269
|
-
// Build a Node.js script that connects, queries, and outputs JSON
|
|
270
|
-
const script = [
|
|
271
|
-
"const pg = require('pg');",
|
|
272
|
-
'const client = new pg.Client({ connectionString: process.env.PG_CONN });',
|
|
273
|
-
'(async () => {',
|
|
274
|
-
' await client.connect();',
|
|
275
|
-
' try {',
|
|
276
|
-
' const result = await client.query(',
|
|
277
|
-
` ${JSON.stringify(sql)},`,
|
|
278
|
-
" JSON.parse(process.env.PG_VALUES || '[]')",
|
|
279
|
-
' );',
|
|
280
|
-
' process.stdout.write(JSON.stringify({',
|
|
281
|
-
' rows: result.rows || [],',
|
|
282
|
-
' rowCount: result.rowCount || 0',
|
|
283
|
-
' }));',
|
|
284
|
-
' } finally {',
|
|
285
|
-
' await client.end();',
|
|
286
|
-
' }',
|
|
287
|
-
'})().catch(err => {',
|
|
288
|
-
' process.stderr.write(err.message);',
|
|
289
|
-
' process.exit(1);',
|
|
290
|
-
'});',
|
|
291
|
-
].join('\n');
|
|
292
|
-
|
|
293
|
-
try {
|
|
294
|
-
// execFileSync with array args — no shell, safe from injection
|
|
295
|
-
const output = execFileSync('node', ['-e', script], {
|
|
296
|
-
encoding: 'utf-8',
|
|
297
|
-
timeout: 30_000,
|
|
298
|
-
env: {
|
|
299
|
-
...process.env,
|
|
300
|
-
PG_CONN: this.connectionString,
|
|
301
|
-
PG_VALUES: JSON.stringify(values ?? []),
|
|
302
|
-
},
|
|
303
|
-
});
|
|
304
|
-
return JSON.parse(output || '{"rows":[],"rowCount":0}');
|
|
305
|
-
} catch (err) {
|
|
306
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
307
|
-
throw new Error(`PostgreSQL query failed: ${msg}`, { cause: err });
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
}
|