@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.
Files changed (134) hide show
  1. package/dist/brain/knowledge-synthesizer.d.ts.map +1 -1
  2. package/dist/brain/knowledge-synthesizer.js +0 -2
  3. package/dist/brain/knowledge-synthesizer.js.map +1 -1
  4. package/dist/curator/classifier.d.ts.map +1 -1
  5. package/dist/curator/classifier.js +0 -2
  6. package/dist/curator/classifier.js.map +1 -1
  7. package/dist/curator/quality-gate.d.ts.map +1 -1
  8. package/dist/curator/quality-gate.js +0 -2
  9. package/dist/curator/quality-gate.js.map +1 -1
  10. package/dist/domain-packs/index.d.ts +0 -3
  11. package/dist/domain-packs/index.d.ts.map +1 -1
  12. package/dist/domain-packs/index.js +0 -3
  13. package/dist/domain-packs/index.js.map +1 -1
  14. package/dist/domain-packs/loader.d.ts.map +1 -1
  15. package/dist/domain-packs/loader.js +20 -4
  16. package/dist/domain-packs/loader.js.map +1 -1
  17. package/dist/domain-packs/pack-runtime.d.ts +5 -5
  18. package/dist/domain-packs/pack-runtime.d.ts.map +1 -1
  19. package/dist/domain-packs/pack-runtime.js +2 -2
  20. package/dist/domain-packs/pack-runtime.js.map +1 -1
  21. package/dist/domain-packs/types.d.ts +8 -2
  22. package/dist/domain-packs/types.d.ts.map +1 -1
  23. package/dist/domain-packs/types.js.map +1 -1
  24. package/dist/engine/bin/soleri-engine.js +12 -2
  25. package/dist/engine/bin/soleri-engine.js.map +1 -1
  26. package/dist/engine/index.d.ts +2 -0
  27. package/dist/engine/index.d.ts.map +1 -1
  28. package/dist/engine/index.js +1 -0
  29. package/dist/engine/index.js.map +1 -1
  30. package/dist/engine/module-manifest.d.ts +28 -0
  31. package/dist/engine/module-manifest.d.ts.map +1 -0
  32. package/dist/engine/module-manifest.js +85 -0
  33. package/dist/engine/module-manifest.js.map +1 -0
  34. package/dist/engine/register-engine.d.ts +19 -0
  35. package/dist/engine/register-engine.d.ts.map +1 -1
  36. package/dist/engine/register-engine.js +15 -2
  37. package/dist/engine/register-engine.js.map +1 -1
  38. package/dist/index.d.ts +0 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +0 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/intake/content-classifier.d.ts.map +1 -1
  43. package/dist/intake/content-classifier.js +0 -2
  44. package/dist/intake/content-classifier.js.map +1 -1
  45. package/dist/llm/llm-client.d.ts.map +1 -1
  46. package/dist/llm/llm-client.js +8 -4
  47. package/dist/llm/llm-client.js.map +1 -1
  48. package/dist/llm/oauth-discovery.d.ts +0 -8
  49. package/dist/llm/oauth-discovery.d.ts.map +1 -1
  50. package/dist/llm/oauth-discovery.js +0 -19
  51. package/dist/llm/oauth-discovery.js.map +1 -1
  52. package/dist/llm/types.d.ts +4 -2
  53. package/dist/llm/types.d.ts.map +1 -1
  54. package/dist/packs/pack-installer.d.ts +2 -1
  55. package/dist/packs/pack-installer.d.ts.map +1 -1
  56. package/dist/packs/pack-installer.js +10 -1
  57. package/dist/packs/pack-installer.js.map +1 -1
  58. package/dist/persistence/index.d.ts +0 -1
  59. package/dist/persistence/index.d.ts.map +1 -1
  60. package/dist/persistence/index.js +0 -1
  61. package/dist/persistence/index.js.map +1 -1
  62. package/dist/persistence/types.d.ts +2 -6
  63. package/dist/persistence/types.d.ts.map +1 -1
  64. package/dist/plugins/index.d.ts +4 -0
  65. package/dist/plugins/index.d.ts.map +1 -1
  66. package/dist/plugins/index.js +4 -0
  67. package/dist/plugins/index.js.map +1 -1
  68. package/dist/plugins/plugin-registry.d.ts +4 -0
  69. package/dist/plugins/plugin-registry.d.ts.map +1 -1
  70. package/dist/plugins/plugin-registry.js +4 -0
  71. package/dist/plugins/plugin-registry.js.map +1 -1
  72. package/dist/plugins/types.d.ts +32 -27
  73. package/dist/plugins/types.d.ts.map +1 -1
  74. package/dist/plugins/types.js +6 -3
  75. package/dist/plugins/types.js.map +1 -1
  76. package/dist/runtime/claude-md-helpers.d.ts +0 -9
  77. package/dist/runtime/claude-md-helpers.d.ts.map +1 -1
  78. package/dist/runtime/claude-md-helpers.js +1 -14
  79. package/dist/runtime/claude-md-helpers.js.map +1 -1
  80. package/dist/runtime/facades/admin-facade.d.ts.map +1 -1
  81. package/dist/runtime/facades/admin-facade.js +1 -2
  82. package/dist/runtime/facades/admin-facade.js.map +1 -1
  83. package/dist/runtime/pack-ops.d.ts +3 -0
  84. package/dist/runtime/pack-ops.d.ts.map +1 -1
  85. package/dist/runtime/pack-ops.js +18 -1
  86. package/dist/runtime/pack-ops.js.map +1 -1
  87. package/dist/runtime/plugin-ops.d.ts.map +1 -1
  88. package/dist/runtime/plugin-ops.js +3 -0
  89. package/dist/runtime/plugin-ops.js.map +1 -1
  90. package/dist/runtime/session-briefing.d.ts.map +1 -1
  91. package/dist/runtime/session-briefing.js +14 -0
  92. package/dist/runtime/session-briefing.js.map +1 -1
  93. package/dist/runtime/vault-linking-ops.d.ts.map +1 -1
  94. package/dist/runtime/vault-linking-ops.js +2 -4
  95. package/dist/runtime/vault-linking-ops.js.map +1 -1
  96. package/dist/vault/vault.d.ts +9 -0
  97. package/dist/vault/vault.d.ts.map +1 -1
  98. package/dist/vault/vault.js +22 -0
  99. package/dist/vault/vault.js.map +1 -1
  100. package/package.json +6 -4
  101. package/src/__tests__/curator-pipeline-e2e.test.ts +187 -0
  102. package/src/__tests__/module-manifest-drift.test.ts +59 -0
  103. package/src/brain/knowledge-synthesizer.ts +0 -2
  104. package/src/curator/classifier.ts +0 -2
  105. package/src/curator/quality-gate.ts +0 -2
  106. package/src/domain-packs/index.ts +0 -6
  107. package/src/domain-packs/loader.ts +25 -5
  108. package/src/domain-packs/pack-runtime.ts +6 -6
  109. package/src/domain-packs/types.ts +8 -2
  110. package/src/engine/bin/soleri-engine.ts +17 -2
  111. package/src/engine/index.ts +2 -0
  112. package/src/engine/module-manifest.ts +99 -0
  113. package/src/engine/register-engine.ts +21 -2
  114. package/src/index.ts +0 -1
  115. package/src/intake/content-classifier.ts +0 -2
  116. package/src/llm/llm-client.ts +12 -6
  117. package/src/llm/oauth-discovery.ts +0 -18
  118. package/src/llm/types.ts +4 -2
  119. package/src/packs/pack-installer.ts +16 -1
  120. package/src/persistence/index.ts +0 -1
  121. package/src/persistence/types.ts +2 -6
  122. package/src/plugins/index.ts +4 -0
  123. package/src/plugins/plugin-registry.ts +6 -1
  124. package/src/plugins/types.ts +10 -5
  125. package/src/runtime/claude-md-helpers.ts +1 -19
  126. package/src/runtime/facades/admin-facade.ts +1 -2
  127. package/src/runtime/pack-ops.ts +26 -1
  128. package/src/runtime/plugin-ops.ts +3 -0
  129. package/src/runtime/session-briefing.ts +14 -0
  130. package/src/runtime/vault-linking-ops.ts +2 -4
  131. package/src/vault/vault.ts +26 -0
  132. package/src/__tests__/postgres-provider.test.ts +0 -116
  133. package/src/health/doctor-checks.ts +0 -115
  134. package/src/persistence/postgres-provider.ts +0 -310
@@ -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
- }