@jhizzard/termdeck 0.2.0 → 0.2.1

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 (23) hide show
  1. package/README.md +191 -169
  2. package/package.json +7 -6
  3. package/packages/cli/src/index.js +39 -1
  4. package/packages/cli/src/init-engram.js +344 -0
  5. package/packages/cli/src/init-rumen.js +425 -0
  6. package/packages/client/public/index.html +2 -2
  7. package/packages/server/src/setup/dotenv-io.js +116 -0
  8. package/packages/server/src/setup/engram-migrations/001_engram_tables.sql +116 -0
  9. package/packages/server/src/setup/engram-migrations/002_engram_search_function.sql +141 -0
  10. package/packages/server/src/setup/engram-migrations/003_engram_event_webhook.sql +28 -0
  11. package/packages/server/src/setup/engram-migrations/004_engram_match_count_cap_and_explain.sql +176 -0
  12. package/packages/server/src/setup/engram-migrations/005_v0_1_to_v0_2_upgrade.sql +23 -0
  13. package/packages/server/src/setup/engram-migrations/006_memory_status_rpc.sql +58 -0
  14. package/packages/server/src/setup/index.js +14 -0
  15. package/packages/server/src/setup/migrations.js +80 -0
  16. package/packages/server/src/setup/pg-runner.js +113 -0
  17. package/packages/server/src/setup/prompts.js +177 -0
  18. package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +85 -0
  19. package/packages/server/src/setup/rumen/functions/rumen-tick/tsconfig.json +14 -0
  20. package/packages/server/src/setup/rumen/migrations/001_rumen_tables.sql +91 -0
  21. package/packages/server/src/setup/rumen/migrations/002_pg_cron_schedule.sql +40 -0
  22. package/packages/server/src/setup/supabase-url.js +114 -0
  23. package/packages/server/src/setup/yaml-io.js +99 -0
@@ -0,0 +1,141 @@
1
+ -- Engram v0.1 — memory_hybrid_search
2
+ --
3
+ -- Reciprocal rank fusion over full-text and semantic search, with the
4
+ -- three SQL-side fixes from RAG-MEMORY-IMPROVEMENTS-AND-TERMDECK-STRATEGY.md:
5
+ --
6
+ -- Fix 1 — Tiered recency decay by source_type. Architectural decisions
7
+ -- decay on a one-year half-life; bug fixes on a 30-day half-life;
8
+ -- session summaries and document chunks on a 14-day half-life.
9
+ --
10
+ -- Fix 3 — Source_type weighting. Decisions and architecture outrank
11
+ -- raw document chunks in the final fused score.
12
+ --
13
+ -- Fix 5 — Project affinity scoring. Exact project match multiplies the
14
+ -- score by 1.5x; mismatches are penalised 0.7x.
15
+
16
+ create or replace function memory_hybrid_search (
17
+ query_text text,
18
+ query_embedding vector(1536),
19
+ match_count int default 20,
20
+ full_text_weight float default 1.0,
21
+ semantic_weight float default 1.0,
22
+ rrf_k int default 60,
23
+ filter_project text default null,
24
+ filter_source_type text default null
25
+ )
26
+ returns table (
27
+ id uuid,
28
+ content text,
29
+ source_type text,
30
+ category text,
31
+ project text,
32
+ metadata jsonb,
33
+ score float,
34
+ created_at timestamptz
35
+ )
36
+ language sql stable
37
+ as $$
38
+ with candidates as (
39
+ select
40
+ m.id,
41
+ m.content,
42
+ m.source_type,
43
+ m.category,
44
+ m.project,
45
+ m.metadata,
46
+ m.created_at,
47
+ m.embedding,
48
+ ts_rank_cd(to_tsvector('english', m.content), plainto_tsquery('english', query_text))
49
+ as ft_rank,
50
+ 1 - (m.embedding <=> query_embedding) as sem_rank,
51
+ extract(epoch from (now() - m.created_at))::float as age_seconds
52
+ from memory_items m
53
+ where m.is_active = true
54
+ and m.archived = false
55
+ and m.superseded_by is null
56
+ and m.embedding is not null
57
+ and (filter_project is null or m.project = filter_project)
58
+ and (filter_source_type is null or m.source_type = filter_source_type)
59
+ ),
60
+ ft_ranked as (
61
+ select id, row_number() over (order by ft_rank desc nulls last) as rank
62
+ from candidates where ft_rank > 0
63
+ ),
64
+ sem_ranked as (
65
+ select id, row_number() over (order by sem_rank desc nulls last) as rank
66
+ from candidates
67
+ ),
68
+ fused as (
69
+ select
70
+ c.id,
71
+ c.content,
72
+ c.source_type,
73
+ c.category,
74
+ c.project,
75
+ c.metadata,
76
+ c.created_at,
77
+ c.age_seconds,
78
+ -- RRF base score
79
+ coalesce(full_text_weight / (rrf_k + ft.rank), 0.0) +
80
+ coalesce(semantic_weight / (rrf_k + sr.rank), 0.0) as base_score
81
+ from candidates c
82
+ left join ft_ranked ft on ft.id = c.id
83
+ left join sem_ranked sr on sr.id = c.id
84
+ ),
85
+ scored as (
86
+ select
87
+ f.id,
88
+ f.content,
89
+ f.source_type,
90
+ f.category,
91
+ f.project,
92
+ f.metadata,
93
+ f.created_at,
94
+ f.base_score
95
+ -- Fix 1: tiered recency decay by source_type
96
+ * case f.source_type
97
+ when 'decision' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
98
+ when 'architecture' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
99
+ when 'preference' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
100
+ when 'fact' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
101
+ when 'convention' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
102
+ when 'bug_fix' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
103
+ when 'debugging' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
104
+ when 'session_summary' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
105
+ when 'document_chunk' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
106
+ when 'code_context' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
107
+ else 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
108
+ end
109
+ -- Fix 3: source_type weighting
110
+ * case f.source_type
111
+ when 'decision' then 1.5
112
+ when 'architecture' then 1.4
113
+ when 'bug_fix' then 1.3
114
+ when 'preference' then 1.2
115
+ when 'fact' then 1.0
116
+ when 'document_chunk' then 0.6
117
+ else 1.0
118
+ end
119
+ -- Fix 5: project affinity scoring
120
+ * case
121
+ when filter_project is null then 1.0
122
+ when f.project = filter_project then 1.5
123
+ when f.project = 'global' then 1.0
124
+ else 0.7
125
+ end
126
+ as score
127
+ from fused f
128
+ )
129
+ select
130
+ s.id,
131
+ s.content,
132
+ s.source_type,
133
+ s.category,
134
+ s.project,
135
+ s.metadata,
136
+ s.score,
137
+ s.created_at
138
+ from scored s
139
+ order by s.score desc
140
+ limit match_count;
141
+ $$;
@@ -0,0 +1,28 @@
1
+ -- Engram v0.1 — real-time event webhook (Fix 6)
2
+ --
3
+ -- Fix 6 from RAG-MEMORY-IMPROVEMENTS-AND-TERMDECK-STRATEGY.md is a
4
+ -- real-time event intake path so TermDeck (or any other client) can
5
+ -- POST terminal events — "server started on :8080", "tests failing",
6
+ -- "error detected" — and have them land in memory immediately.
7
+ --
8
+ -- That intake is implemented as an HTTP endpoint inside the Engram MCP
9
+ -- server process, not as a SQL trigger. This file exists as a placeholder
10
+ -- so the migration history is explicit and future database-side changes
11
+ -- (e.g. an events queue table for async ingestion) have a home.
12
+ --
13
+ -- If you want to add a durable event queue later, add it below this line.
14
+
15
+ -- Example future shape (commented out — not applied):
16
+ --
17
+ -- create table if not exists memory_events (
18
+ -- id uuid primary key default gen_random_uuid(),
19
+ -- project text not null,
20
+ -- source text not null,
21
+ -- event_type text not null,
22
+ -- payload jsonb not null default '{}'::jsonb,
23
+ -- processed boolean not null default false,
24
+ -- created_at timestamptz not null default now()
25
+ -- );
26
+ --
27
+ -- create index if not exists memory_events_unprocessed_idx
28
+ -- on memory_events(created_at) where processed = false;
@@ -0,0 +1,176 @@
1
+ -- Engram v0.2 — match_count cap + EXPLAIN variant
2
+ --
3
+ -- Two changes to the search surface:
4
+ --
5
+ -- 1. memory_hybrid_search gains a configurable cap on `match_count`.
6
+ -- Default cap: 200. The original function was unbounded, which risks
7
+ -- runaway queries at scale (10k+ rows pulled per call).
8
+ --
9
+ -- Override per-database: ALTER DATABASE your_db SET engram.max_match_count = 500;
10
+ -- Override per-session: SET engram.max_match_count = 500;
11
+ -- Leave unset: cap defaults to 200.
12
+ --
13
+ -- 2. A new function `memory_hybrid_search_explain` that returns
14
+ -- EXPLAIN (ANALYZE, BUFFERS) output for an equivalent call. Used by
15
+ -- `engram diagnose` to troubleshoot slow recall queries.
16
+ --
17
+ -- Rerun-safe: CREATE OR REPLACE on both.
18
+
19
+ -- ── memory_hybrid_search ─────────────────────────────────────────────────
20
+
21
+ create or replace function memory_hybrid_search (
22
+ query_text text,
23
+ query_embedding vector(1536),
24
+ match_count int default 20,
25
+ full_text_weight float default 1.0,
26
+ semantic_weight float default 1.0,
27
+ rrf_k int default 60,
28
+ filter_project text default null,
29
+ filter_source_type text default null
30
+ )
31
+ returns table (
32
+ id uuid,
33
+ content text,
34
+ source_type text,
35
+ category text,
36
+ project text,
37
+ metadata jsonb,
38
+ score float,
39
+ created_at timestamptz
40
+ )
41
+ language sql stable
42
+ as $$
43
+ with candidates as (
44
+ select
45
+ m.id,
46
+ m.content,
47
+ m.source_type,
48
+ m.category,
49
+ m.project,
50
+ m.metadata,
51
+ m.created_at,
52
+ m.embedding,
53
+ ts_rank_cd(to_tsvector('english', m.content), plainto_tsquery('english', query_text))
54
+ as ft_rank,
55
+ 1 - (m.embedding <=> query_embedding) as sem_rank,
56
+ extract(epoch from (now() - m.created_at))::float as age_seconds
57
+ from memory_items m
58
+ where m.is_active = true
59
+ and m.archived = false
60
+ and m.superseded_by is null
61
+ and m.embedding is not null
62
+ and (filter_project is null or m.project = filter_project)
63
+ and (filter_source_type is null or m.source_type = filter_source_type)
64
+ ),
65
+ ft_ranked as (
66
+ select id, row_number() over (order by ft_rank desc nulls last) as rank
67
+ from candidates where ft_rank > 0
68
+ ),
69
+ sem_ranked as (
70
+ select id, row_number() over (order by sem_rank desc nulls last) as rank
71
+ from candidates
72
+ ),
73
+ fused as (
74
+ select
75
+ c.id,
76
+ c.content,
77
+ c.source_type,
78
+ c.category,
79
+ c.project,
80
+ c.metadata,
81
+ c.created_at,
82
+ c.age_seconds,
83
+ coalesce(full_text_weight / (rrf_k + ft.rank), 0.0) +
84
+ coalesce(semantic_weight / (rrf_k + sr.rank), 0.0) as base_score
85
+ from candidates c
86
+ left join ft_ranked ft on ft.id = c.id
87
+ left join sem_ranked sr on sr.id = c.id
88
+ ),
89
+ scored as (
90
+ select
91
+ f.id,
92
+ f.content,
93
+ f.source_type,
94
+ f.category,
95
+ f.project,
96
+ f.metadata,
97
+ f.created_at,
98
+ f.base_score
99
+ * case f.source_type
100
+ when 'decision' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
101
+ when 'architecture' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
102
+ when 'preference' then 1.0 / (1.0 + f.age_seconds / (365.0 * 86400.0))
103
+ when 'fact' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
104
+ when 'convention' then 1.0 / (1.0 + f.age_seconds / ( 90.0 * 86400.0))
105
+ when 'bug_fix' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
106
+ when 'debugging' then 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
107
+ when 'session_summary' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
108
+ when 'document_chunk' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
109
+ when 'code_context' then 1.0 / (1.0 + f.age_seconds / ( 14.0 * 86400.0))
110
+ else 1.0 / (1.0 + f.age_seconds / ( 30.0 * 86400.0))
111
+ end
112
+ * case f.source_type
113
+ when 'decision' then 1.5
114
+ when 'architecture' then 1.4
115
+ when 'bug_fix' then 1.3
116
+ when 'preference' then 1.2
117
+ when 'fact' then 1.0
118
+ when 'document_chunk' then 0.6
119
+ else 1.0
120
+ end
121
+ * case
122
+ when filter_project is null then 1.0
123
+ when f.project = filter_project then 1.5
124
+ when f.project = 'global' then 1.0
125
+ else 0.7
126
+ end
127
+ as score
128
+ from fused f
129
+ )
130
+ select
131
+ s.id,
132
+ s.content,
133
+ s.source_type,
134
+ s.category,
135
+ s.project,
136
+ s.metadata,
137
+ s.score,
138
+ s.created_at
139
+ from scored s
140
+ order by s.score desc
141
+ limit least(
142
+ greatest(match_count, 1),
143
+ coalesce(nullif(current_setting('engram.max_match_count', true), '')::int, 200)
144
+ );
145
+ $$;
146
+
147
+ -- ── memory_hybrid_search_explain ─────────────────────────────────────────
148
+
149
+ create or replace function memory_hybrid_search_explain (
150
+ query_text text,
151
+ query_embedding vector(1536),
152
+ match_count int default 20,
153
+ full_text_weight float default 1.0,
154
+ semantic_weight float default 1.0,
155
+ rrf_k int default 60,
156
+ filter_project text default null,
157
+ filter_source_type text default null
158
+ )
159
+ returns setof text
160
+ language plpgsql
161
+ as $$
162
+ begin
163
+ return query execute
164
+ 'explain (analyze, buffers, format text) '
165
+ || 'select * from memory_hybrid_search($1, $2, $3, $4, $5, $6, $7, $8)'
166
+ using
167
+ query_text,
168
+ query_embedding,
169
+ match_count,
170
+ full_text_weight,
171
+ semantic_weight,
172
+ rrf_k,
173
+ filter_project,
174
+ filter_source_type;
175
+ end;
176
+ $$;
@@ -0,0 +1,23 @@
1
+ -- Engram v0.2 — minimal additive delta for stores provisioned against the
2
+ -- original rag-system schema.
3
+ --
4
+ -- This migration is idempotent and non-destructive:
5
+ -- • adds only the single column (`archived`) that Engram v0.2 reads/writes
6
+ -- but which is absent from the original rag-system `memory_items` table;
7
+ -- • re-creates the two partial indexes under v2 names so they cannot
8
+ -- collide with any pre-existing same-named index that uses a different
9
+ -- `where` predicate;
10
+ -- • does NOT replace `memory_hybrid_search`, `match_memories`, or any
11
+ -- other SQL function — those remain on their production versions.
12
+ --
13
+ -- Apply once, in the Supabase SQL editor, against any existing store that
14
+ -- pre-dates Engram v0.2's `archived` soft-delete column.
15
+
16
+ alter table memory_items
17
+ add column if not exists archived boolean not null default false;
18
+
19
+ create index if not exists memory_items_project_idx_v2
20
+ on memory_items(project) where is_active = true and archived = false;
21
+
22
+ create index if not exists memory_items_source_type_idx_v2
23
+ on memory_items(source_type) where is_active = true and archived = false;
@@ -0,0 +1,58 @@
1
+ -- Engram migration 006 — memory_status_aggregation RPC
2
+ --
3
+ -- Why: `memoryStatus()` previously did a plain
4
+ -- supabase.from('memory_items').select('project, source_type, category')
5
+ -- which hits PostgREST's default 1000-row cap. On a store with 3,397 active
6
+ -- rows the `by_project` / `by_source_type` / `by_category` histograms only
7
+ -- summed to ~1000 even though `total_active` was correct. Pushing the GROUP
8
+ -- BY server-side eliminates the cap and saves the round-trip of streaming
9
+ -- every row to the client just to count them.
10
+ --
11
+ -- Safe to re-run — CREATE OR REPLACE.
12
+
13
+ create or replace function memory_status_aggregation()
14
+ returns table (
15
+ total_active bigint,
16
+ sessions bigint,
17
+ by_project jsonb,
18
+ by_source_type jsonb,
19
+ by_category jsonb
20
+ )
21
+ language sql
22
+ stable
23
+ as $$
24
+ select
25
+ (select count(*)::bigint from memory_items
26
+ where is_active = true and archived = false) as total_active,
27
+ (select count(*)::bigint from memory_sessions) as sessions,
28
+ coalesce(
29
+ (select jsonb_object_agg(project, c) from (
30
+ select project, count(*)::bigint as c
31
+ from memory_items
32
+ where is_active = true and archived = false
33
+ group by project
34
+ ) p),
35
+ '{}'::jsonb
36
+ ) as by_project,
37
+ coalesce(
38
+ (select jsonb_object_agg(source_type, c) from (
39
+ select source_type, count(*)::bigint as c
40
+ from memory_items
41
+ where is_active = true and archived = false
42
+ group by source_type
43
+ ) s),
44
+ '{}'::jsonb
45
+ ) as by_source_type,
46
+ coalesce(
47
+ (select jsonb_object_agg(coalesce(category, 'uncategorized'), c) from (
48
+ select category, count(*)::bigint as c
49
+ from memory_items
50
+ where is_active = true and archived = false
51
+ group by category
52
+ ) cat),
53
+ '{}'::jsonb
54
+ ) as by_category;
55
+ $$;
56
+
57
+ -- Ensure the service role (and anon, if you allow it) can call the RPC.
58
+ grant execute on function memory_status_aggregation() to anon, authenticated, service_role;
@@ -0,0 +1,14 @@
1
+ // Aggregate export for the `termdeck init` setup helpers.
2
+ //
3
+ // The init wizards live in packages/cli/src/ but all the heavy lifting
4
+ // (prompting, reading/writing config, applying migrations) lives here so
5
+ // the CLI files can stay short, linear, and easy to audit.
6
+
7
+ module.exports = {
8
+ prompts: require('./prompts'),
9
+ dotenv: require('./dotenv-io'),
10
+ yaml: require('./yaml-io'),
11
+ supabaseUrl: require('./supabase-url'),
12
+ migrations: require('./migrations'),
13
+ pgRunner: require('./pg-runner')
14
+ };
@@ -0,0 +1,80 @@
1
+ // Discover the SQL migration files that ship bundled inside the TermDeck
2
+ // package. Both init wizards call this — init-engram for the six Engram
3
+ // migrations, init-rumen for the two Rumen migrations.
4
+ //
5
+ // The wizards intentionally do NOT fall back to a sibling `../../engram`
6
+ // working copy. Resolution order:
7
+ //
8
+ // 1. Files bundled at `packages/server/src/setup/engram-migrations/*.sql`
9
+ // (this directory is covered by the root package.json `files` glob).
10
+ // 2. Files at `node_modules/@jhizzard/engram/migrations/*.sql` if that
11
+ // package is installed alongside TermDeck (future-proof path — shipping
12
+ // `@jhizzard/engram` as an optional peer would let us drop the bundled
13
+ // copy).
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const SETUP_DIR = __dirname;
19
+
20
+ function listBundled(subdir) {
21
+ const dir = path.join(SETUP_DIR, subdir);
22
+ if (!fs.existsSync(dir)) return [];
23
+ return fs.readdirSync(dir)
24
+ .filter((f) => f.toLowerCase().endsWith('.sql'))
25
+ .sort()
26
+ .map((f) => path.join(dir, f));
27
+ }
28
+
29
+ function tryNodeModules(packageName, migrationSubdir = 'migrations') {
30
+ try {
31
+ // Resolve the package's main file, then look for a migrations sibling dir.
32
+ const pkgJsonPath = require.resolve(`${packageName}/package.json`, {
33
+ paths: [process.cwd(), SETUP_DIR]
34
+ });
35
+ const pkgDir = path.dirname(pkgJsonPath);
36
+ const migrationDir = path.join(pkgDir, migrationSubdir);
37
+ if (!fs.existsSync(migrationDir)) return [];
38
+ return fs.readdirSync(migrationDir)
39
+ .filter((f) => f.toLowerCase().endsWith('.sql'))
40
+ .sort()
41
+ .map((f) => path.join(migrationDir, f));
42
+ } catch (_err) {
43
+ return [];
44
+ }
45
+ }
46
+
47
+ function listEngramMigrations() {
48
+ const fromNm = tryNodeModules('@jhizzard/engram');
49
+ if (fromNm.length > 0) return fromNm;
50
+ return listBundled('engram-migrations');
51
+ }
52
+
53
+ function listRumenMigrations() {
54
+ const fromNm = tryNodeModules('@jhizzard/rumen');
55
+ if (fromNm.length > 0) return fromNm;
56
+ return listBundled(path.join('rumen', 'migrations'));
57
+ }
58
+
59
+ function rumenFunctionDir() {
60
+ // Same resolution order.
61
+ try {
62
+ const pkgJsonPath = require.resolve('@jhizzard/rumen/package.json', {
63
+ paths: [process.cwd(), SETUP_DIR]
64
+ });
65
+ const candidate = path.join(path.dirname(pkgJsonPath), 'supabase', 'functions', 'rumen-tick');
66
+ if (fs.existsSync(candidate)) return candidate;
67
+ } catch (_err) { /* fallthrough */ }
68
+ return path.join(SETUP_DIR, 'rumen', 'functions', 'rumen-tick');
69
+ }
70
+
71
+ function readFile(filepath) {
72
+ return fs.readFileSync(filepath, 'utf-8');
73
+ }
74
+
75
+ module.exports = {
76
+ listEngramMigrations,
77
+ listRumenMigrations,
78
+ rumenFunctionDir,
79
+ readFile
80
+ };
@@ -0,0 +1,113 @@
1
+ // Thin wrapper around node-postgres for applying SQL migration files.
2
+ //
3
+ // `pg` is a runtime dep of the top-level package and must be present when the
4
+ // init wizards run. If `require('pg')` fails we throw an actionable error
5
+ // pointing the user at `npm install` — TermDeck's normal install path pulls
6
+ // `pg` in automatically, so this only fires on partial / broken installs.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ function loadPg() {
12
+ try {
13
+ return require('pg');
14
+ } catch (err) {
15
+ const e = new Error(
16
+ "Could not load node-postgres ('pg'). TermDeck's init wizards need it to " +
17
+ 'apply migrations. Run `npm install` (or reinstall TermDeck with `npm install -g @jhizzard/termdeck`) and try again.'
18
+ );
19
+ e.cause = err;
20
+ throw e;
21
+ }
22
+ }
23
+
24
+ // Connect to the given postgres URL and return a live Client. The caller is
25
+ // responsible for `await client.end()`.
26
+ async function connect(databaseUrl) {
27
+ const { Client } = loadPg();
28
+ const client = new Client({
29
+ connectionString: databaseUrl,
30
+ // Supabase pooler endpoints present a TLS cert that Node's default CA
31
+ // store accepts, but some users hit verification issues behind corporate
32
+ // proxies. Enable TLS with rejectUnauthorized=false so the wizard works
33
+ // in those environments — we're talking to a Supabase host we already
34
+ // trust by project ref, and the connection is still encrypted.
35
+ ssl: { rejectUnauthorized: false },
36
+ // Keep connection attempts bounded so a misconfigured URL fails fast.
37
+ connectionTimeoutMillis: 15000
38
+ });
39
+ try {
40
+ await client.connect();
41
+ } catch (err) {
42
+ const friendly = mapConnectError(err);
43
+ const e = new Error(`Could not connect to Postgres: ${friendly}`);
44
+ e.cause = err;
45
+ throw e;
46
+ }
47
+ return client;
48
+ }
49
+
50
+ function mapConnectError(err) {
51
+ const msg = err && err.message ? err.message : String(err);
52
+ if (/ENOTFOUND/.test(msg)) return 'host not found (check the project URL)';
53
+ if (/ECONNREFUSED/.test(msg)) return 'connection refused (wrong port?)';
54
+ if (/password authentication failed/i.test(msg)) return 'password authentication failed (check the service_role / db password)';
55
+ if (/SASL/i.test(msg)) return 'authentication failed (check username, it should be `postgres` or `postgres.<project-ref>`)';
56
+ if (/timeout/i.test(msg)) return 'connect timed out after 15s (network issue or wrong host)';
57
+ return msg;
58
+ }
59
+
60
+ // Read a migration file and execute it as a single batched query. Returns
61
+ // `{ ok, file, elapsedMs, rowCount, skipped?, error? }`.
62
+ //
63
+ // A migration "skipped" means the file body included a marker the runner
64
+ // detected as already-applied (reserved for future use — currently not wired).
65
+ async function applyFile(client, filepath) {
66
+ const started = Date.now();
67
+ const sql = fs.readFileSync(filepath, 'utf-8');
68
+ const base = path.basename(filepath);
69
+ try {
70
+ const result = await client.query(sql);
71
+ const rowCount = Array.isArray(result)
72
+ ? result.reduce((sum, r) => sum + (r && r.rowCount ? r.rowCount : 0), 0)
73
+ : (result && result.rowCount) || 0;
74
+ return {
75
+ ok: true,
76
+ file: base,
77
+ elapsedMs: Date.now() - started,
78
+ rowCount
79
+ };
80
+ } catch (err) {
81
+ return {
82
+ ok: false,
83
+ file: base,
84
+ elapsedMs: Date.now() - started,
85
+ error: err && err.message ? err.message : String(err)
86
+ };
87
+ }
88
+ }
89
+
90
+ // Apply a list of migration files in order. Returns an array of per-file
91
+ // results. Stops on first failure unless `continueOnError: true` is passed.
92
+ async function applyAll(client, files, { continueOnError = false } = {}) {
93
+ const results = [];
94
+ for (const file of files) {
95
+ const result = await applyFile(client, file);
96
+ results.push(result);
97
+ if (!result.ok && !continueOnError) break;
98
+ }
99
+ return results;
100
+ }
101
+
102
+ // Run a single SQL string (not a file). Useful for parameterized checks like
103
+ // `SELECT COUNT(*) FROM memory_items`.
104
+ async function run(client, sql, params = []) {
105
+ return client.query(sql, params);
106
+ }
107
+
108
+ module.exports = {
109
+ connect,
110
+ applyFile,
111
+ applyAll,
112
+ run
113
+ };