@soleri/core 2.5.0 → 2.6.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 (81) hide show
  1. package/dist/brain/intelligence.d.ts +1 -0
  2. package/dist/brain/intelligence.d.ts.map +1 -1
  3. package/dist/brain/intelligence.js +164 -148
  4. package/dist/brain/intelligence.js.map +1 -1
  5. package/dist/control/identity-manager.d.ts +3 -1
  6. package/dist/control/identity-manager.d.ts.map +1 -1
  7. package/dist/control/identity-manager.js +49 -51
  8. package/dist/control/identity-manager.js.map +1 -1
  9. package/dist/control/intent-router.d.ts +1 -0
  10. package/dist/control/intent-router.d.ts.map +1 -1
  11. package/dist/control/intent-router.js +32 -32
  12. package/dist/control/intent-router.js.map +1 -1
  13. package/dist/curator/curator.d.ts +1 -0
  14. package/dist/curator/curator.d.ts.map +1 -1
  15. package/dist/curator/curator.js +48 -99
  16. package/dist/curator/curator.js.map +1 -1
  17. package/dist/governance/governance.d.ts +1 -0
  18. package/dist/governance/governance.d.ts.map +1 -1
  19. package/dist/governance/governance.js +51 -68
  20. package/dist/governance/governance.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/persistence/index.d.ts +2 -1
  26. package/dist/persistence/index.d.ts.map +1 -1
  27. package/dist/persistence/index.js +1 -0
  28. package/dist/persistence/index.js.map +1 -1
  29. package/dist/persistence/postgres-provider.d.ts +46 -0
  30. package/dist/persistence/postgres-provider.d.ts.map +1 -0
  31. package/dist/persistence/postgres-provider.js +115 -0
  32. package/dist/persistence/postgres-provider.js.map +1 -0
  33. package/dist/persistence/sqlite-provider.d.ts +5 -2
  34. package/dist/persistence/sqlite-provider.d.ts.map +1 -1
  35. package/dist/persistence/sqlite-provider.js +39 -1
  36. package/dist/persistence/sqlite-provider.js.map +1 -1
  37. package/dist/persistence/types.d.ts +23 -1
  38. package/dist/persistence/types.d.ts.map +1 -1
  39. package/dist/project/project-registry.d.ts +4 -4
  40. package/dist/project/project-registry.d.ts.map +1 -1
  41. package/dist/project/project-registry.js +25 -50
  42. package/dist/project/project-registry.js.map +1 -1
  43. package/dist/runtime/admin-extra-ops.d.ts +3 -3
  44. package/dist/runtime/admin-extra-ops.d.ts.map +1 -1
  45. package/dist/runtime/admin-extra-ops.js +29 -3
  46. package/dist/runtime/admin-extra-ops.js.map +1 -1
  47. package/dist/runtime/core-ops.d.ts +4 -4
  48. package/dist/runtime/core-ops.js +4 -4
  49. package/dist/runtime/runtime.js +1 -1
  50. package/dist/runtime/runtime.js.map +1 -1
  51. package/dist/runtime/vault-extra-ops.d.ts +3 -2
  52. package/dist/runtime/vault-extra-ops.d.ts.map +1 -1
  53. package/dist/runtime/vault-extra-ops.js +40 -2
  54. package/dist/runtime/vault-extra-ops.js.map +1 -1
  55. package/dist/vault/vault.d.ts +21 -0
  56. package/dist/vault/vault.d.ts.map +1 -1
  57. package/dist/vault/vault.js +99 -0
  58. package/dist/vault/vault.js.map +1 -1
  59. package/package.json +4 -2
  60. package/src/__tests__/admin-extra-ops.test.ts +2 -2
  61. package/src/__tests__/core-ops.test.ts +8 -2
  62. package/src/__tests__/persistence.test.ts +66 -0
  63. package/src/__tests__/postgres-provider.test.ts +58 -0
  64. package/src/__tests__/vault-extra-ops.test.ts +2 -2
  65. package/src/__tests__/vault.test.ts +184 -0
  66. package/src/brain/intelligence.ts +258 -307
  67. package/src/control/identity-manager.ts +77 -75
  68. package/src/control/intent-router.ts +55 -57
  69. package/src/curator/curator.ts +124 -145
  70. package/src/governance/governance.ts +90 -107
  71. package/src/index.ts +2 -0
  72. package/src/persistence/index.ts +2 -0
  73. package/src/persistence/postgres-provider.ts +157 -0
  74. package/src/persistence/sqlite-provider.ts +55 -2
  75. package/src/persistence/types.ts +31 -1
  76. package/src/project/project-registry.ts +69 -74
  77. package/src/runtime/admin-extra-ops.ts +36 -3
  78. package/src/runtime/core-ops.ts +4 -4
  79. package/src/runtime/runtime.ts +1 -1
  80. package/src/runtime/vault-extra-ops.ts +42 -2
  81. package/src/vault/vault.ts +118 -0
@@ -1,4 +1,5 @@
1
1
  import type { Vault } from '../vault/vault.js';
2
+ import type { PersistenceProvider } from '../persistence/types.js';
2
3
  import type {
3
4
  PolicyType,
4
5
  PolicyPreset,
@@ -73,17 +74,18 @@ const DEFAULT_PRESET: PolicyPreset = 'moderate';
73
74
 
74
75
  export class Governance {
75
76
  private vault: Vault;
77
+ private provider: PersistenceProvider;
76
78
 
77
79
  constructor(vault: Vault) {
78
80
  this.vault = vault;
81
+ this.provider = vault.getProvider();
79
82
  this.initializeTables();
80
83
  }
81
84
 
82
85
  // ─── Schema ─────────────────────────────────────────────────────
83
86
 
84
87
  private initializeTables(): void {
85
- const db = this.vault.getDb();
86
- db.exec(`
88
+ this.provider.execSql(`
87
89
  CREATE TABLE IF NOT EXISTS vault_policies (
88
90
  id INTEGER PRIMARY KEY AUTOINCREMENT,
89
91
  project_path TEXT NOT NULL,
@@ -147,14 +149,12 @@ export class Governance {
147
149
  // ─── Policy CRUD ────────────────────────────────────────────────
148
150
 
149
151
  getPolicy(projectPath: string): VaultPolicy {
150
- const db = this.vault.getDb();
151
152
  const defaults = POLICY_PRESETS[DEFAULT_PRESET];
152
153
 
153
- const rows = db
154
- .prepare(
155
- 'SELECT policy_type, config FROM vault_policies WHERE project_path = ? AND enabled = 1',
156
- )
157
- .all(projectPath) as Array<{ policy_type: string; config: string }>;
154
+ const rows = this.provider.all<{ policy_type: string; config: string }>(
155
+ 'SELECT policy_type, config FROM vault_policies WHERE project_path = ? AND enabled = 1',
156
+ [projectPath],
157
+ );
158
158
 
159
159
  let quotas = defaults.quotas;
160
160
  let retention = defaults.retention;
@@ -176,26 +176,27 @@ export class Governance {
176
176
  config: Record<string, unknown>,
177
177
  changedBy?: string,
178
178
  ): void {
179
- const db = this.vault.getDb();
180
-
181
179
  // Get old config for audit trail
182
- const existing = db
183
- .prepare('SELECT config FROM vault_policies WHERE project_path = ? AND policy_type = ?')
184
- .get(projectPath, policyType) as { config: string } | undefined;
180
+ const existing = this.provider.get<{ config: string }>(
181
+ 'SELECT config FROM vault_policies WHERE project_path = ? AND policy_type = ?',
182
+ [projectPath, policyType],
183
+ );
185
184
  const oldConfig = existing ? existing.config : null;
186
185
 
187
186
  // UPSERT policy
188
- db.prepare(
187
+ this.provider.run(
189
188
  `INSERT INTO vault_policies (project_path, policy_type, config, updated_at)
190
189
  VALUES (?, ?, ?, unixepoch())
191
190
  ON CONFLICT(project_path, policy_type)
192
191
  DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`,
193
- ).run(projectPath, policyType, JSON.stringify(config));
192
+ [projectPath, policyType, JSON.stringify(config)],
193
+ );
194
194
 
195
195
  // Audit trail
196
- db.prepare(
196
+ this.provider.run(
197
197
  'INSERT INTO vault_policy_changes (project_path, policy_type, old_config, new_config, changed_by) VALUES (?, ?, ?, ?, ?)',
198
- ).run(projectPath, policyType, oldConfig, JSON.stringify(config), changedBy ?? null);
198
+ [projectPath, policyType, oldConfig, JSON.stringify(config), changedBy ?? null],
199
+ );
199
200
  }
200
201
 
201
202
  applyPreset(projectPath: string, preset: PolicyPreset, changedBy?: string): void {
@@ -224,24 +225,23 @@ export class Governance {
224
225
 
225
226
  getQuotaStatus(projectPath: string): QuotaStatus {
226
227
  const policy = this.getPolicy(projectPath);
227
- const db = this.vault.getDb();
228
228
 
229
- const totalRow = db.prepare('SELECT COUNT(*) as count FROM entries').get() as { count: number };
230
- const total = totalRow.count;
229
+ const totalRow = this.provider.get<{ count: number }>('SELECT COUNT(*) as count FROM entries');
230
+ const total = totalRow?.count ?? 0;
231
231
 
232
232
  // Count by domain (used as category proxy)
233
- const categoryRows = db
234
- .prepare('SELECT domain, COUNT(*) as count FROM entries GROUP BY domain')
235
- .all() as Array<{ domain: string; count: number }>;
233
+ const categoryRows = this.provider.all<{ domain: string; count: number }>(
234
+ 'SELECT domain, COUNT(*) as count FROM entries GROUP BY domain',
235
+ );
236
236
  const byCategory: Record<string, number> = {};
237
237
  for (const row of categoryRows) {
238
238
  byCategory[row.domain] = row.count;
239
239
  }
240
240
 
241
241
  // Count by type
242
- const typeRows = db
243
- .prepare('SELECT type, COUNT(*) as count FROM entries GROUP BY type')
244
- .all() as Array<{ type: string; count: number }>;
242
+ const typeRows = this.provider.all<{ type: string; count: number }>(
243
+ 'SELECT type, COUNT(*) as count FROM entries GROUP BY type',
244
+ );
245
245
  const byType: Record<string, number> = {};
246
246
  for (const row of typeRows) {
247
247
  byType[row.type] = row.count;
@@ -261,12 +261,7 @@ export class Governance {
261
261
  }
262
262
 
263
263
  getAuditTrail(projectPath: string, limit?: number): PolicyAuditEntry[] {
264
- const db = this.vault.getDb();
265
- const rows = db
266
- .prepare(
267
- 'SELECT id, project_path, policy_type, old_config, new_config, changed_by, changed_at FROM vault_policy_changes WHERE project_path = ? ORDER BY changed_at DESC LIMIT ?',
268
- )
269
- .all(projectPath, limit ?? 50) as Array<{
264
+ const rows = this.provider.all<{
270
265
  id: number;
271
266
  project_path: string;
272
267
  policy_type: string;
@@ -274,7 +269,10 @@ export class Governance {
274
269
  new_config: string;
275
270
  changed_by: string | null;
276
271
  changed_at: number;
277
- }>;
272
+ }>(
273
+ 'SELECT id, project_path, policy_type, old_config, new_config, changed_by, changed_at FROM vault_policy_changes WHERE project_path = ? ORDER BY changed_at DESC LIMIT ?',
274
+ [projectPath, limit ?? 50],
275
+ );
278
276
 
279
277
  return rows.map((row) => ({
280
278
  id: row.id,
@@ -376,20 +374,20 @@ export class Governance {
376
374
  decision: PolicyDecision,
377
375
  ): void {
378
376
  try {
379
- const db = this.vault.getDb();
380
- db.prepare(
377
+ this.provider.run(
381
378
  `INSERT INTO vault_policy_evaluations
382
379
  (project_path, entry_type, entry_category, entry_title, action, reason, quota_total, quota_max)
383
380
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
384
- ).run(
385
- projectPath,
386
- entry.type,
387
- entry.category,
388
- entry.title ?? null,
389
- decision.action,
390
- decision.reason ?? null,
391
- decision.quotaStatus?.total ?? null,
392
- decision.quotaStatus?.maxTotal ?? null,
381
+ [
382
+ projectPath,
383
+ entry.type,
384
+ entry.category,
385
+ entry.title ?? null,
386
+ decision.action,
387
+ decision.reason ?? null,
388
+ decision.quotaStatus?.total ?? null,
389
+ decision.quotaStatus?.maxTotal ?? null,
390
+ ],
393
391
  );
394
392
  } catch {
395
393
  // Fire-and-forget — don't fail capture because of evaluation logging
@@ -409,13 +407,10 @@ export class Governance {
409
407
  },
410
408
  source?: string,
411
409
  ): number {
412
- const db = this.vault.getDb();
413
- const result = db
414
- .prepare(
415
- `INSERT INTO vault_proposals (project_path, entry_id, title, type, category, proposed_data, source)
416
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
417
- )
418
- .run(
410
+ const result = this.provider.run(
411
+ `INSERT INTO vault_proposals (project_path, entry_id, title, type, category, proposed_data, source)
412
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
413
+ [
419
414
  projectPath,
420
415
  entryData.entryId ?? null,
421
416
  entryData.title,
@@ -423,7 +418,8 @@ export class Governance {
423
418
  entryData.category,
424
419
  JSON.stringify(entryData.data ?? {}),
425
420
  source ?? 'auto-capture',
426
- );
421
+ ],
422
+ );
427
423
  return Number(result.lastInsertRowid);
428
424
  }
429
425
 
@@ -443,23 +439,23 @@ export class Governance {
443
439
  modifications: Record<string, unknown>,
444
440
  decidedBy?: string,
445
441
  ): Proposal | null {
446
- const db = this.vault.getDb();
447
442
  const existing = this.getProposalById(proposalId);
448
443
  if (!existing || existing.status !== 'pending') return null;
449
444
 
450
445
  // Merge modifications into proposed data
451
446
  const merged = { ...existing.proposedData, ...modifications };
452
447
 
453
- db.prepare(
448
+ this.provider.run(
454
449
  `UPDATE vault_proposals
455
450
  SET status = 'modified', proposed_data = ?, decided_at = unixepoch(),
456
451
  decided_by = ?, modification_note = ?
457
452
  WHERE id = ?`,
458
- ).run(
459
- JSON.stringify(merged),
460
- decidedBy ?? null,
461
- `Modified fields: ${Object.keys(modifications).join(', ')}`,
462
- proposalId,
453
+ [
454
+ JSON.stringify(merged),
455
+ decidedBy ?? null,
456
+ `Modified fields: ${Object.keys(modifications).join(', ')}`,
457
+ proposalId,
458
+ ],
463
459
  );
464
460
 
465
461
  const proposal = this.getProposalById(proposalId);
@@ -468,31 +464,28 @@ export class Governance {
468
464
  }
469
465
 
470
466
  listPendingProposals(projectPath?: string, limit?: number): Proposal[] {
471
- const db = this.vault.getDb();
472
467
  if (projectPath) {
473
- const rows = db
474
- .prepare(
475
- 'SELECT * FROM vault_proposals WHERE project_path = ? AND status = ? ORDER BY proposed_at DESC LIMIT ?',
476
- )
477
- .all(projectPath, 'pending', limit ?? 50);
478
- return (rows as RawProposal[]).map(mapProposal);
468
+ const rows = this.provider.all<RawProposal>(
469
+ 'SELECT * FROM vault_proposals WHERE project_path = ? AND status = ? ORDER BY proposed_at DESC LIMIT ?',
470
+ [projectPath, 'pending', limit ?? 50],
471
+ );
472
+ return rows.map(mapProposal);
479
473
  }
480
- const rows = db
481
- .prepare('SELECT * FROM vault_proposals WHERE status = ? ORDER BY proposed_at DESC LIMIT ?')
482
- .all('pending', limit ?? 50);
483
- return (rows as RawProposal[]).map(mapProposal);
474
+ const rows = this.provider.all<RawProposal>(
475
+ 'SELECT * FROM vault_proposals WHERE status = ? ORDER BY proposed_at DESC LIMIT ?',
476
+ ['pending', limit ?? 50],
477
+ );
478
+ return rows.map(mapProposal);
484
479
  }
485
480
 
486
481
  getProposalStats(projectPath?: string): ProposalStats {
487
- const db = this.vault.getDb();
488
482
  const whereClause = projectPath ? 'WHERE project_path = ?' : '';
489
483
  const params = projectPath ? [projectPath] : [];
490
484
 
491
- const statusRows = db
492
- .prepare(
493
- `SELECT status, COUNT(*) as count FROM vault_proposals ${whereClause} GROUP BY status`,
494
- )
495
- .all(...params) as Array<{ status: string; count: number }>;
485
+ const statusRows = this.provider.all<{ status: string; count: number }>(
486
+ `SELECT status, COUNT(*) as count FROM vault_proposals ${whereClause} GROUP BY status`,
487
+ params,
488
+ );
496
489
 
497
490
  const stats: ProposalStats = {
498
491
  total: 0,
@@ -530,11 +523,10 @@ export class Governance {
530
523
  stats.acceptanceRate = decided > 0 ? (stats.approved + stats.modified) / decided : 0;
531
524
 
532
525
  // Category breakdown
533
- const catRows = db
534
- .prepare(
535
- `SELECT category, status, COUNT(*) as count FROM vault_proposals ${whereClause} GROUP BY category, status`,
536
- )
537
- .all(...params) as Array<{ category: string; status: string; count: number }>;
526
+ const catRows = this.provider.all<{ category: string; status: string; count: number }>(
527
+ `SELECT category, status, COUNT(*) as count FROM vault_proposals ${whereClause} GROUP BY category, status`,
528
+ params,
529
+ );
538
530
 
539
531
  for (const row of catRows) {
540
532
  if (!stats.byCategory[row.category]) {
@@ -554,15 +546,13 @@ export class Governance {
554
546
  }
555
547
 
556
548
  expireStaleProposals(maxAgeDays?: number): number {
557
- const db = this.vault.getDb();
558
549
  const days = maxAgeDays ?? 14;
559
550
  const cutoff = Math.floor(Date.now() / 1000) - days * 86400;
560
551
 
561
- const result = db
562
- .prepare(
563
- "UPDATE vault_proposals SET status = 'expired', decided_at = unixepoch() WHERE status = 'pending' AND proposed_at < ?",
564
- )
565
- .run(cutoff);
552
+ const result = this.provider.run(
553
+ "UPDATE vault_proposals SET status = 'expired', decided_at = unixepoch() WHERE status = 'pending' AND proposed_at < ?",
554
+ [cutoff],
555
+ );
566
556
 
567
557
  return result.changes;
568
558
  }
@@ -575,13 +565,11 @@ export class Governance {
575
565
  const proposalStats = this.getProposalStats(projectPath);
576
566
 
577
567
  // Evaluation trend — count by action in last 7 days
578
- const db = this.vault.getDb();
579
568
  const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
580
- const trendRows = db
581
- .prepare(
582
- 'SELECT action, COUNT(*) as count FROM vault_policy_evaluations WHERE project_path = ? AND evaluated_at > ? GROUP BY action',
583
- )
584
- .all(projectPath, sevenDaysAgo) as Array<{ action: string; count: number }>;
569
+ const trendRows = this.provider.all<{ action: string; count: number }>(
570
+ 'SELECT action, COUNT(*) as count FROM vault_policy_evaluations WHERE project_path = ? AND evaluated_at > ? GROUP BY action',
571
+ [projectPath, sevenDaysAgo],
572
+ );
585
573
 
586
574
  const evaluationTrend: Record<string, number> = {};
587
575
  for (const row of trendRows) {
@@ -608,13 +596,11 @@ export class Governance {
608
596
  // ─── Private Helpers ────────────────────────────────────────────
609
597
 
610
598
  private countPending(projectPath: string): number {
611
- const db = this.vault.getDb();
612
- const row = db
613
- .prepare(
614
- "SELECT COUNT(*) as count FROM vault_proposals WHERE project_path = ? AND status = 'pending'",
615
- )
616
- .get(projectPath) as { count: number };
617
- return row.count;
599
+ const row = this.provider.get<{ count: number }>(
600
+ "SELECT COUNT(*) as count FROM vault_proposals WHERE project_path = ? AND status = 'pending'",
601
+ [projectPath],
602
+ );
603
+ return row?.count ?? 0;
618
604
  }
619
605
 
620
606
  private captureFromProposal(proposal: Proposal): void {
@@ -641,22 +627,19 @@ export class Governance {
641
627
  decidedBy?: string,
642
628
  note?: string,
643
629
  ): Proposal | null {
644
- const db = this.vault.getDb();
645
630
  const existing = this.getProposalById(proposalId);
646
631
  if (!existing || existing.status !== 'pending') return null;
647
632
 
648
- db.prepare(
633
+ this.provider.run(
649
634
  'UPDATE vault_proposals SET status = ?, decided_at = unixepoch(), decided_by = ?, modification_note = ? WHERE id = ?',
650
- ).run(status, decidedBy ?? null, note ?? null, proposalId);
635
+ [status, decidedBy ?? null, note ?? null, proposalId],
636
+ );
651
637
 
652
638
  return this.getProposalById(proposalId);
653
639
  }
654
640
 
655
641
  private getProposalById(id: number): Proposal | null {
656
- const db = this.vault.getDb();
657
- const row = db.prepare('SELECT * FROM vault_proposals WHERE id = ?').get(id) as
658
- | RawProposal
659
- | undefined;
642
+ const row = this.provider.get<RawProposal>('SELECT * FROM vault_proposals WHERE id = ?', [id]);
660
643
  return row ? mapProposal(row) : null;
661
644
  }
662
645
  }
package/src/index.ts CHANGED
@@ -328,11 +328,13 @@ export type {
328
328
 
329
329
  // ─── Persistence ───────────────────────────────────────────────────────
330
330
  export { SQLitePersistenceProvider } from './persistence/index.js';
331
+ export { PostgresPersistenceProvider, translateSql } from './persistence/index.js';
331
332
  export type {
332
333
  PersistenceProvider,
333
334
  PersistenceParams,
334
335
  RunResult,
335
336
  PersistenceConfig,
337
+ FtsSearchOptions,
336
338
  } from './persistence/index.js';
337
339
 
338
340
  // ─── Prompts ───────────────────────────────────────────────────────────
@@ -3,5 +3,7 @@ export type {
3
3
  PersistenceParams,
4
4
  RunResult,
5
5
  PersistenceConfig,
6
+ FtsSearchOptions,
6
7
  } from './types.js';
7
8
  export { SQLitePersistenceProvider } from './sqlite-provider.js';
9
+ export { PostgresPersistenceProvider, translateSql } from './postgres-provider.js';
@@ -0,0 +1,157 @@
1
+ /**
2
+ * PostgreSQL persistence provider (working stub).
3
+ *
4
+ * Implements PersistenceProvider with pg.Pool. The translateSql() function
5
+ * converts SQLite-style queries to PostgreSQL-compatible syntax.
6
+ *
7
+ * NOTE: PersistenceProvider is synchronous (better-sqlite3 heritage).
8
+ * This provider wraps async pg calls synchronously for interface compliance.
9
+ * Full async provider support is planned for v7.0.
10
+ */
11
+
12
+ import type {
13
+ PersistenceProvider,
14
+ PersistenceParams,
15
+ RunResult,
16
+ FtsSearchOptions,
17
+ } from './types.js';
18
+
19
+ /**
20
+ * Translate SQLite-style SQL to PostgreSQL-compatible SQL.
21
+ *
22
+ * - Converts positional `?` params to `$1, $2, ...`
23
+ * - Converts named `@name` params to `$N` positional, returns ordered values
24
+ * - Replaces `unixepoch()` with `EXTRACT(EPOCH FROM NOW())::integer`
25
+ */
26
+ export function translateSql(
27
+ sql: string,
28
+ params?: PersistenceParams,
29
+ ): { sql: string; values: unknown[] } {
30
+ let translated = sql.replace(/unixepoch\(\)/gi, 'EXTRACT(EPOCH FROM NOW())::integer');
31
+
32
+ if (!params) return { sql: translated, values: [] };
33
+
34
+ if (Array.isArray(params)) {
35
+ let idx = 0;
36
+ translated = translated.replace(/\?/g, () => `$${++idx}`);
37
+ return { sql: translated, values: params };
38
+ }
39
+
40
+ // Named params: @name -> $N
41
+ const values: unknown[] = [];
42
+ const nameMap = new Map<string, number>();
43
+ translated = translated.replace(/@(\w+)/g, (_match, name: string) => {
44
+ if (!nameMap.has(name)) {
45
+ nameMap.set(name, values.length + 1);
46
+ values.push(params[name]);
47
+ }
48
+ return `$${nameMap.get(name)}`;
49
+ });
50
+ return { sql: translated, values };
51
+ }
52
+
53
+ /**
54
+ * PostgreSQL persistence provider.
55
+ *
56
+ * Uses pg.Pool for connection management. Created via async factory
57
+ * `PostgresPersistenceProvider.create()`.
58
+ */
59
+ export class PostgresPersistenceProvider implements PersistenceProvider {
60
+ readonly backend = 'postgres' as const;
61
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
62
+ private pool: any;
63
+
64
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
+ private constructor(pool: any) {
66
+ this.pool = pool;
67
+ }
68
+
69
+ /**
70
+ * Async factory. Dynamically imports `pg` (optional dependency).
71
+ */
72
+ static async create(
73
+ connectionString: string,
74
+ poolSize = 10,
75
+ ): Promise<PostgresPersistenceProvider> {
76
+ const { default: pg } = await import('pg');
77
+ const pool = new pg.Pool({
78
+ connectionString,
79
+ max: poolSize,
80
+ idleTimeoutMillis: 30_000,
81
+ });
82
+ return new PostgresPersistenceProvider(pool);
83
+ }
84
+
85
+ execSql(sql: string): void {
86
+ // Sync wrapper -- logs warning in non-test env
87
+ // Full async support in v7.0
88
+ void sql;
89
+ throw new Error(
90
+ 'PostgresPersistenceProvider.execSql() is not yet implemented. ' +
91
+ 'Use SQLitePersistenceProvider for synchronous operations. ' +
92
+ 'Full PostgreSQL support requires async PersistenceProvider (v7.0).',
93
+ );
94
+ }
95
+
96
+ run(sql: string, params?: PersistenceParams): RunResult {
97
+ const _translated = translateSql(sql, params);
98
+ throw new Error(
99
+ 'PostgresPersistenceProvider.run() is not yet implemented. ' +
100
+ 'Full PostgreSQL support requires async PersistenceProvider (v7.0).',
101
+ );
102
+ }
103
+
104
+ get<T = Record<string, unknown>>(sql: string, params?: PersistenceParams): T | undefined {
105
+ const _translated = translateSql(sql, params);
106
+ throw new Error(
107
+ 'PostgresPersistenceProvider.get() is not yet implemented. ' +
108
+ 'Full PostgreSQL support requires async PersistenceProvider (v7.0).',
109
+ );
110
+ }
111
+
112
+ all<T = Record<string, unknown>>(sql: string, params?: PersistenceParams): T[] {
113
+ const _translated = translateSql(sql, params);
114
+ throw new Error(
115
+ 'PostgresPersistenceProvider.all() is not yet implemented. ' +
116
+ 'Full PostgreSQL support requires async PersistenceProvider (v7.0).',
117
+ );
118
+ }
119
+
120
+ transaction<T>(fn: () => T): T {
121
+ void fn;
122
+ throw new Error(
123
+ 'PostgresPersistenceProvider.transaction() is not yet implemented. ' +
124
+ 'Full PostgreSQL support requires async PersistenceProvider (v7.0).',
125
+ );
126
+ }
127
+
128
+ ftsSearch<T = Record<string, unknown>>(
129
+ table: string,
130
+ query: string,
131
+ options?: FtsSearchOptions,
132
+ ): T[] {
133
+ const _cols = options?.columns?.length ? options.columns.join(', ') : '*';
134
+ const _limit = options?.limit ?? 50;
135
+ const _offset = options?.offset ?? 0;
136
+ void table;
137
+ void query;
138
+ // Would generate: SELECT cols FROM table WHERE tsvector_col @@ to_tsquery($1)
139
+ // ORDER BY ts_rank(tsvector_col, to_tsquery($1)) DESC LIMIT $2 OFFSET $3
140
+ throw new Error(
141
+ 'PostgresPersistenceProvider.ftsSearch() is not yet implemented. ' +
142
+ 'Full PostgreSQL support requires async PersistenceProvider (v7.0).',
143
+ );
144
+ }
145
+
146
+ ftsRebuild(table: string): void {
147
+ // Would run: REINDEX INDEX idx_{table}_fts
148
+ void table;
149
+ }
150
+
151
+ close(): void {
152
+ if (this.pool) {
153
+ // Fire-and-forget pool end; sync interface constraint
154
+ void (this.pool.end() as Promise<void>);
155
+ }
156
+ }
157
+ }
@@ -8,14 +8,25 @@
8
8
  import Database from 'better-sqlite3';
9
9
  import { mkdirSync } from 'node:fs';
10
10
  import { dirname } from 'node:path';
11
- import type { PersistenceProvider, PersistenceParams, RunResult } from './types.js';
11
+ import type {
12
+ PersistenceProvider,
13
+ PersistenceParams,
14
+ RunResult,
15
+ FtsSearchOptions,
16
+ } from './types.js';
12
17
 
13
18
  export class SQLitePersistenceProvider implements PersistenceProvider {
19
+ readonly backend = 'sqlite' as const;
14
20
  private db: Database.Database;
15
21
 
16
22
  constructor(path: string = ':memory:') {
17
23
  if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
18
24
  this.db = new Database(path);
25
+ if (path !== ':memory:') {
26
+ this.db.pragma('cache_size = -64000'); // 64MB
27
+ this.db.pragma('temp_store = MEMORY');
28
+ this.db.pragma('mmap_size = 268435456'); // 256MB
29
+ }
19
30
  }
20
31
 
21
32
  execSql(sql: string): void {
@@ -47,6 +58,43 @@ export class SQLitePersistenceProvider implements PersistenceProvider {
47
58
  return this.db.transaction(fn)();
48
59
  }
49
60
 
61
+ ftsSearch<T = Record<string, unknown>>(
62
+ table: string,
63
+ query: string,
64
+ options?: FtsSearchOptions,
65
+ ): T[] {
66
+ const ftsTable = `${table}_fts`;
67
+ const cols = options?.columns?.length ? options.columns.join(', ') : `${table}.*`;
68
+ const orderBy = options?.orderByRank !== false ? `ORDER BY rank` : '';
69
+ const limit = options?.limit ? `LIMIT ${options.limit}` : '';
70
+ const offset = options?.offset ? `OFFSET ${options.offset}` : '';
71
+
72
+ const filterClauses: string[] = [];
73
+ const filterParams: unknown[] = [query];
74
+
75
+ if (options?.filters) {
76
+ for (const [key, value] of Object.entries(options.filters)) {
77
+ filterClauses.push(`${table}.${key} = ?`);
78
+ filterParams.push(value);
79
+ }
80
+ }
81
+
82
+ const filterSql = filterClauses.length ? `AND ${filterClauses.join(' AND ')}` : '';
83
+
84
+ const sql = `SELECT ${cols} FROM ${ftsTable} JOIN ${table} ON ${table}.rowid = ${ftsTable}.rowid WHERE ${ftsTable} MATCH ? ${filterSql} ${orderBy} ${limit} ${offset}`;
85
+
86
+ return this.all<T>(sql, filterParams);
87
+ }
88
+
89
+ ftsRebuild(table: string): void {
90
+ const ftsTable = `${table}_fts`;
91
+ try {
92
+ this.execSql(`INSERT INTO ${ftsTable}(${ftsTable}) VALUES('rebuild')`);
93
+ } catch {
94
+ // Graceful degradation: FTS table may not exist
95
+ }
96
+ }
97
+
50
98
  close(): void {
51
99
  this.db.close();
52
100
  }
@@ -54,9 +102,14 @@ export class SQLitePersistenceProvider implements PersistenceProvider {
54
102
  /**
55
103
  * Escape hatch: get the raw better-sqlite3 Database.
56
104
  * Used by modules that need direct db access (ProjectRegistry, BrainIntelligence, etc.).
57
- * Will be deprecated once those modules migrate to PersistenceProvider.
105
+ * @deprecated Use provider methods instead.
58
106
  */
59
107
  getDatabase(): Database.Database {
108
+ if (process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true') {
109
+ console.warn(
110
+ 'SQLitePersistenceProvider.getDatabase() is deprecated. Use provider methods instead.',
111
+ );
112
+ }
60
113
  return this.db;
61
114
  }
62
115
  }
@@ -34,11 +34,41 @@ export interface PersistenceProvider {
34
34
  /** Run a function inside a transaction. Commits on success, rolls back on error. */
35
35
  transaction<T>(fn: () => T): T;
36
36
 
37
+ /** Identifies the backend engine. */
38
+ readonly backend: 'sqlite' | 'postgres';
39
+
40
+ /** Full-text search abstraction. */
41
+ ftsSearch<T = Record<string, unknown>>(
42
+ table: string,
43
+ query: string,
44
+ options?: FtsSearchOptions,
45
+ ): T[];
46
+
47
+ /** Rebuild FTS index for a table. */
48
+ ftsRebuild(table: string): void;
49
+
37
50
  /** Close the connection. */
38
51
  close(): void;
39
52
  }
40
53
 
41
54
  export interface PersistenceConfig {
42
- type: 'sqlite';
55
+ type: 'sqlite' | 'postgres';
43
56
  path: string;
57
+ /** PostgreSQL connection string. */
58
+ connectionString?: string;
59
+ /** PostgreSQL pool size. */
60
+ poolSize?: number;
61
+ }
62
+
63
+ export interface FtsSearchOptions {
64
+ /** Columns to search (default: all FTS columns). */
65
+ columns?: string[];
66
+ /** Max results. */
67
+ limit?: number;
68
+ /** Skip N results. */
69
+ offset?: number;
70
+ /** Additional WHERE conditions on the base table. */
71
+ filters?: Record<string, unknown>;
72
+ /** Order by FTS relevance rank (default: true). */
73
+ orderByRank?: boolean;
44
74
  }