@jhizzard/termdeck 1.0.14 → 1.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/package.json +1 -1
- package/packages/cli/src/init-mnestra.js +50 -10
- package/packages/server/src/setup/migrations.js +488 -1
- package/packages/server/src/setup/mnestra-migrations/001_mnestra_tables.sql +2 -2
- package/packages/server/src/setup/mnestra-migrations/002_mnestra_search_function.sql +1 -1
- package/packages/server/src/setup/mnestra-migrations/009_memory_relationship_metadata.sql +1 -1
- package/packages/server/src/setup/mnestra-migrations/011_project_tag_backfill.sql +7 -3
- package/packages/server/src/setup/mnestra-migrations/012_project_tag_re_taxonomy.sql +2 -2
- package/packages/server/src/setup/mnestra-migrations/014_explicit_grants.sql +3 -3
- package/packages/server/src/setup/mnestra-migrations/016_mnestra_doctor_probes.sql +3 -3
- package/packages/server/src/setup/mnestra-migrations/017_memory_sessions_session_metadata.sql +5 -5
- package/packages/server/src/setup/mnestra-migrations/018_rumen_processed_at.sql +1 -1
- package/packages/server/src/setup/mnestra-migrations/019_security_hardening.sql +190 -0
- package/packages/server/src/setup/mnestra-migrations/020_migration_tracking.sql +57 -0
- package/packages/server/src/setup/rumen/functions/rumen-tick/index.ts +0 -30
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jhizzard/termdeck",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Browser-based terminal multiplexer with metadata overlays, panel flashback memory recall, and AI-aware session management",
|
|
5
5
|
"bin": {
|
|
6
6
|
"termdeck": "./packages/cli/src/index.js"
|
|
@@ -332,23 +332,63 @@ async function promptSecretWithValidation(validator) {
|
|
|
332
332
|
throw new Error('Too many invalid attempts — cancelling.');
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// Sprint 61 T2 — collapsed fresh-install / upgrade paths. Pre-Sprint-61, the
|
|
336
|
+
// wizard re-applied every bundled mnestra migration on every invocation,
|
|
337
|
+
// relying on per-file IF NOT EXISTS / CREATE OR REPLACE idempotency. That
|
|
338
|
+
// works for fresh installs but doesn't tell the wizard which migrations the
|
|
339
|
+
// live database has actually received — so a user running
|
|
340
|
+
// `npm install -g @latest` against an existing project lands in Class A
|
|
341
|
+
// (schema drift on package upgrade): the npm package files upgrade, the
|
|
342
|
+
// database stays at first-kickstart state. Brad reported this 2026-05-02.
|
|
343
|
+
//
|
|
344
|
+
// applyPendingMigrations (migrations.js) replaces the loop with a tracker-
|
|
345
|
+
// aware diff: SELECT applied filenames from public.mnestra_migrations, run
|
|
346
|
+
// only the bundled-but-unapplied ones, INSERT a tracker row per apply.
|
|
347
|
+
// Pre-020 installs trigger a one-time backfill probe pass that seeds the
|
|
348
|
+
// tracker for migrations whose schema artifacts are already present.
|
|
335
349
|
async function applyMigrations(client, dryRun) {
|
|
336
350
|
const files = migrations.listMnestraMigrations();
|
|
337
351
|
if (files.length === 0) {
|
|
338
352
|
throw new Error('No Mnestra migrations found. TermDeck install looks corrupted.');
|
|
339
353
|
}
|
|
340
354
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
ok(
|
|
348
|
-
} else {
|
|
349
|
-
fail(result.error);
|
|
350
|
-
throw new Error(`Migration failed: ${base}`);
|
|
355
|
+
if (dryRun) {
|
|
356
|
+
// Preserve the per-file dry-run banner so the user sees the planned
|
|
357
|
+
// sequence without touching the database.
|
|
358
|
+
for (const file of files) {
|
|
359
|
+
const base = path.basename(file);
|
|
360
|
+
step(`Applying migration ${base}...`);
|
|
361
|
+
ok('(dry-run)');
|
|
351
362
|
}
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
step('Running tracker-aware diff-and-apply (skips already-applied migrations)...');
|
|
367
|
+
const summary = await migrations.applyPendingMigrations(client);
|
|
368
|
+
|
|
369
|
+
if (summary.errored) {
|
|
370
|
+
fail(`${summary.errored.file}: ${summary.errored.error}`);
|
|
371
|
+
throw new Error(`Migration failed: ${summary.errored.file}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
ok(
|
|
375
|
+
`(applied ${summary.applied.length}, backfilled ${summary.backfilled.length}, ` +
|
|
376
|
+
`skipped ${summary.skipped.length})`
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
for (const f of summary.applied) {
|
|
380
|
+
process.stdout.write(` ✓ applied ${f}\n`);
|
|
381
|
+
}
|
|
382
|
+
for (const f of summary.backfilled) {
|
|
383
|
+
process.stdout.write(` ◇ backfilled ${f} (schema already present, recorded in tracker)\n`);
|
|
384
|
+
}
|
|
385
|
+
for (const w of summary.warnings) {
|
|
386
|
+
const tracked = (w.trackedChecksum || '').slice(0, 12) || '<empty>';
|
|
387
|
+
const bundled = (w.bundledChecksum || '').slice(0, 12) || '<empty>';
|
|
388
|
+
process.stdout.write(
|
|
389
|
+
` ! checksum drift on ${w.file}: tracked=${tracked}, bundled=${bundled} ` +
|
|
390
|
+
`(no auto-overwrite — investigate before re-running)\n`
|
|
391
|
+
);
|
|
352
392
|
}
|
|
353
393
|
}
|
|
354
394
|
|
|
@@ -26,9 +26,120 @@
|
|
|
26
26
|
|
|
27
27
|
const fs = require('fs');
|
|
28
28
|
const path = require('path');
|
|
29
|
+
const crypto = require('crypto');
|
|
29
30
|
|
|
30
31
|
const SETUP_DIR = __dirname;
|
|
31
32
|
|
|
33
|
+
// Sprint 61 T2 — durable migration tracking table + filename + table name.
|
|
34
|
+
// `mnestra_migrations` is created by bundled migration 020 (RLS-on,
|
|
35
|
+
// service_role-only, no policies). The applyPendingMigrations diff loop and
|
|
36
|
+
// the backfill probe both target this table.
|
|
37
|
+
const TRACKER_TABLE = 'public.mnestra_migrations';
|
|
38
|
+
const TRACKER_FILE = '020_migration_tracking.sql';
|
|
39
|
+
|
|
40
|
+
// Sprint 61 T2 — declarative probe set.
|
|
41
|
+
//
|
|
42
|
+
// One row per bundled mnestra migration 001-019 (020 itself is the tracker
|
|
43
|
+
// and is bootstrap-special-cased; not probed). Each probe is a single
|
|
44
|
+
// presence-style SQL statement: returns ≥1 row when the migration's schema
|
|
45
|
+
// artifact is in place, 0 rows otherwise.
|
|
46
|
+
//
|
|
47
|
+
// Used by applyPendingMigrations() during the backfill pass: when an install
|
|
48
|
+
// is pre-020 (no tracker table yet) and a bundled migration is not in the
|
|
49
|
+
// applied-set, the probe decides whether the migration's effects are already
|
|
50
|
+
// present (→ INSERT a backfill tracker row, skip apply) or genuinely missing
|
|
51
|
+
// (→ run the migration via the normal apply path, INSERT a real tracker row).
|
|
52
|
+
//
|
|
53
|
+
// Probe values:
|
|
54
|
+
// - string: SQL fragment to run via client.query(). Probe is "present"
|
|
55
|
+
// when the result has ≥1 row.
|
|
56
|
+
// - null: no schema artifact to introspect (DML migrations, comments-only
|
|
57
|
+
// placeholders). The first apply runs the migration; the tracker
|
|
58
|
+
// row prevents re-application on subsequent passes. The brief
|
|
59
|
+
// notes 003 (event_webhook placeholder), 011 (project_tag_backfill
|
|
60
|
+
// DML), and 012 (project_tag_re_taxonomy DML) fall here.
|
|
61
|
+
const MIGRATION_PROBES = Object.freeze({
|
|
62
|
+
'001_mnestra_tables.sql':
|
|
63
|
+
"select 1 from information_schema.tables where table_schema='public' and table_name='memory_items'",
|
|
64
|
+
'002_mnestra_search_function.sql':
|
|
65
|
+
"select 1 from pg_proc where proname='memory_hybrid_search'",
|
|
66
|
+
// 003 is a comments-only placeholder migration with no DDL/DML body. The
|
|
67
|
+
// apply path is a no-op on every install. Always-present probe is the
|
|
68
|
+
// honest schema fingerprint — every install for which 001 has run is
|
|
69
|
+
// also "compatible with 003." Post-Sprint-61-T2-audit refinement.
|
|
70
|
+
'003_mnestra_event_webhook.sql':
|
|
71
|
+
"select 1",
|
|
72
|
+
'004_mnestra_match_count_cap_and_explain.sql':
|
|
73
|
+
"select 1 from pg_proc where proname='memory_hybrid_search_explain'",
|
|
74
|
+
'005_v0_1_to_v0_2_upgrade.sql':
|
|
75
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='archived'",
|
|
76
|
+
'006_memory_status_rpc.sql':
|
|
77
|
+
"select 1 from pg_proc where proname='memory_status_aggregation'",
|
|
78
|
+
'007_add_source_session_id.sql':
|
|
79
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='source_session_id'",
|
|
80
|
+
'008_legacy_rag_tables.sql':
|
|
81
|
+
"select 1 from information_schema.tables where table_schema='public' and table_name='mnestra_session_memory'",
|
|
82
|
+
'009_memory_relationship_metadata.sql':
|
|
83
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_relationships' and column_name='weight'",
|
|
84
|
+
'010_memory_recall_graph.sql':
|
|
85
|
+
"select 1 from pg_proc where proname='memory_recall_graph'",
|
|
86
|
+
// 011 retags chopin-nashville rows into post-Sprint-41 buckets (termdeck,
|
|
87
|
+
// rumen, podium, pvb, dor). Probe present iff any row carries one of those
|
|
88
|
+
// tags — meaning either 011 has run, or the install legitimately has rows
|
|
89
|
+
// tagged that way through other means. Either way the apply is a no-op
|
|
90
|
+
// (the UPDATEs are gated on `project = 'chopin-nashville'`), so a false-
|
|
91
|
+
// positive backfill costs nothing. Post-Sprint-61-T2-audit refinement.
|
|
92
|
+
'011_project_tag_backfill.sql':
|
|
93
|
+
"select 1 from memory_items where project in ('termdeck', 'rumen', 'podium', 'pvb', 'dor') limit 1",
|
|
94
|
+
// 012 expands 011's taxonomy with chopin-in-bohemia, chopin-scheduler, and
|
|
95
|
+
// claimguard buckets. Probe present iff any row is in those expanded
|
|
96
|
+
// buckets. Same false-positive-is-harmless reasoning as 011.
|
|
97
|
+
// Post-Sprint-61-T2-audit refinement.
|
|
98
|
+
'012_project_tag_re_taxonomy.sql':
|
|
99
|
+
"select 1 from memory_items where project in ('chopin-in-bohemia', 'chopin-scheduler', 'claimguard') limit 1",
|
|
100
|
+
'013_reclassify_uncertain.sql':
|
|
101
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='reclassified_by'",
|
|
102
|
+
'014_explicit_grants.sql':
|
|
103
|
+
"select 1 where has_table_privilege('service_role', 'public.memory_items', 'INSERT')",
|
|
104
|
+
'015_source_agent.sql':
|
|
105
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_items' and column_name='source_agent'",
|
|
106
|
+
'016_mnestra_doctor_probes.sql':
|
|
107
|
+
"select 1 from pg_proc where proname='mnestra_doctor_vault_secret_exists'",
|
|
108
|
+
'017_memory_sessions_session_metadata.sql':
|
|
109
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='session_id'",
|
|
110
|
+
'018_rumen_processed_at.sql':
|
|
111
|
+
"select 1 from information_schema.columns where table_schema='public' and table_name='memory_sessions' and column_name='rumen_processed_at'",
|
|
112
|
+
'019_security_hardening.sql':
|
|
113
|
+
"select 1 from pg_proc p, unnest(coalesce(p.proconfig,'{}'::text[])) c where p.proname='memory_hybrid_search' and c like 'search_path=%' and c like '%extensions%'"
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Sprint 61 T2 — self-transactional detection.
|
|
117
|
+
//
|
|
118
|
+
// Bundled migrations 011 + 012 contain top-level `BEGIN;` and `COMMIT;`
|
|
119
|
+
// statements (011:75/217, 012:76/353). When the diff-apply loop wrapped
|
|
120
|
+
// these in its own outer BEGIN/COMMIT, the inner COMMIT closed the outer
|
|
121
|
+
// transaction prematurely and the subsequent `recordApplied` INSERT ran
|
|
122
|
+
// auto-committed — defeating the per-file atomicity contract. T4-CODEX
|
|
123
|
+
// audit-concern 2026-05-07 18:51 ET surfaced this.
|
|
124
|
+
//
|
|
125
|
+
// Detection: case-sensitive match for a line that is exactly `BEGIN;` or
|
|
126
|
+
// `COMMIT;` (top-level). PL/pgSQL anonymous block delimiters (`begin ... end`
|
|
127
|
+
// inside `do $$ ... $$`) use lowercase without trailing semicolon-on-its-
|
|
128
|
+
// own-line, so they don't match.
|
|
129
|
+
//
|
|
130
|
+
// Behavior for self-transactional migrations: SKIP the outer wrapper.
|
|
131
|
+
// Apply via pgRunner.applyFile (which sends the file as a single batched
|
|
132
|
+
// query, inner BEGIN/COMMIT handled by Postgres). Then INSERT the tracker
|
|
133
|
+
// row in a separate auto-commit. The tracker INSERT is recoverable on
|
|
134
|
+
// failure: 011/012 are idempotent (`WHERE project = 'chopin-nashville'`
|
|
135
|
+
// gates every UPDATE; re-running on an already-retagged install is a no-op),
|
|
136
|
+
// so a missing tracker row from a failed INSERT will be re-applied on the
|
|
137
|
+
// next pass and the INSERT retried. The brief explicitly notes this
|
|
138
|
+
// recovery shape under "out-of-T2 scope: rumen migration tracker."
|
|
139
|
+
function isSelfTransactional(sql) {
|
|
140
|
+
return /^[ \t]*(BEGIN|COMMIT)[ \t]*;[ \t]*$/m.test(sql);
|
|
141
|
+
}
|
|
142
|
+
|
|
32
143
|
function listBundled(subdir) {
|
|
33
144
|
const dir = path.join(SETUP_DIR, subdir);
|
|
34
145
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -127,11 +238,387 @@ function readFile(filepath) {
|
|
|
127
238
|
return fs.readFileSync(filepath, 'utf-8');
|
|
128
239
|
}
|
|
129
240
|
|
|
241
|
+
// ── Sprint 61 T2 — durable migration tracker + diff-and-apply ──────────────
|
|
242
|
+
//
|
|
243
|
+
// applyPendingMigrations(client, opts) replaces the per-wizard
|
|
244
|
+
// "apply every bundled migration" loop with a tracker-aware diff loop:
|
|
245
|
+
//
|
|
246
|
+
// 1. Try `SELECT filename, checksum FROM public.mnestra_migrations`.
|
|
247
|
+
// On 42P01 (relation does not exist), the project is pre-020 — bootstrap
|
|
248
|
+
// by applying 020 directly, then INSERT 020's own tracker row, then
|
|
249
|
+
// re-query.
|
|
250
|
+
// 2. Iterate bundled migrations 001..N in lex-filename order. For each
|
|
251
|
+
// bundled file:
|
|
252
|
+
// - Already in tracker: skip; if tracked checksum != bundled checksum,
|
|
253
|
+
// push to warnings[] (do NOT auto-overwrite).
|
|
254
|
+
// - Not in tracker AND probe says present: INSERT backfill row
|
|
255
|
+
// (applied_at = '1970-01-01T00:00:00Z', schema_version = 'backfill'),
|
|
256
|
+
// skip apply. (As of Sprint 61 T2 audit refinement, every bundled
|
|
257
|
+
// migration 001-019 has a non-null probe in MIGRATION_PROBES; the
|
|
258
|
+
// null-probe branch is preserved for forward-compatibility.)
|
|
259
|
+
// - Not in tracker AND probe absent (or null probe):
|
|
260
|
+
// * Self-transactional file (top-level BEGIN; / COMMIT;, currently
|
|
261
|
+
// 011/012): SKIP the outer wrapper. apply via pgRunner.applyFile
|
|
262
|
+
// (the file's own transaction control runs through Postgres).
|
|
263
|
+
// INSERT tracker row in a separate auto-commit. Tracker INSERT
|
|
264
|
+
// failure is recoverable: re-running applyPendingMigrations
|
|
265
|
+
// re-applies the migration (idempotent — the bundled self-tx
|
|
266
|
+
// files are gated on chopin-nashville rows, no-op on subsequent
|
|
267
|
+
// runs) and retries the tracker INSERT.
|
|
268
|
+
// * Non-self-transactional file: BEGIN, apply via
|
|
269
|
+
// pgRunner.applyFile, INSERT real tracker row, COMMIT. On
|
|
270
|
+
// error, ROLLBACK and halt — record errored summary, do not
|
|
271
|
+
// attempt subsequent migrations.
|
|
272
|
+
//
|
|
273
|
+
// Returns:
|
|
274
|
+
// {
|
|
275
|
+
// applied: string[] // filenames applied this pass
|
|
276
|
+
// skipped: string[] // filenames already in tracker
|
|
277
|
+
// backfilled: string[] // filenames probe-seeded this pass
|
|
278
|
+
// warnings: Array<{ file, trackedChecksum, bundledChecksum }>
|
|
279
|
+
// errored: null | { file, error }
|
|
280
|
+
// }
|
|
281
|
+
//
|
|
282
|
+
// Idempotent: a second invocation against an up-to-date project reports
|
|
283
|
+
// applied=[], backfilled=[], errored=null, and skipped=[...all bundled].
|
|
284
|
+
//
|
|
285
|
+
// Test injection (every dependency is replaceable):
|
|
286
|
+
// opts._migrations — module override for listMnestraMigrations / readFile
|
|
287
|
+
// (defaults to this module's own exports).
|
|
288
|
+
// opts._readFile — file-read shim (defaults to fs.readFileSync utf-8).
|
|
289
|
+
// opts._applyFile — pgRunner.applyFile shim (defaults to lazy-required
|
|
290
|
+
// ./pg-runner.applyFile to avoid pulling node-postgres
|
|
291
|
+
// at module-load time).
|
|
292
|
+
// opts._probes — MIGRATION_PROBES override (defaults to the constant).
|
|
293
|
+
|
|
294
|
+
function computeChecksum(content) {
|
|
295
|
+
return crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Lazy-resolve pgRunner.applyFile so callers in test environments can avoid
|
|
299
|
+
// the `require('pg')` cost. Tests override via opts._applyFile.
|
|
300
|
+
function defaultApplyFile() {
|
|
301
|
+
const pgRunner = require('./pg-runner');
|
|
302
|
+
return (client, file) => pgRunner.applyFile(client, file);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Returns Map<filename, checksum> when the tracker exists, null when the
|
|
306
|
+
// table is missing (PG error code 42P01 — relation does not exist). Any
|
|
307
|
+
// other error propagates.
|
|
308
|
+
async function loadAppliedSet(client) {
|
|
309
|
+
let res;
|
|
310
|
+
try {
|
|
311
|
+
res = await client.query(
|
|
312
|
+
`SELECT filename, checksum FROM ${TRACKER_TABLE}`
|
|
313
|
+
);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err && err.code === '42P01') return null;
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
const map = new Map();
|
|
319
|
+
for (const row of (res && res.rows) || []) {
|
|
320
|
+
map.set(row.filename, row.checksum);
|
|
321
|
+
}
|
|
322
|
+
return map;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Pre-020 bootstrap: the tracker doesn't exist yet, so apply 020 to create
|
|
326
|
+
// it, then INSERT 020's own tracker row. Caller re-queries the tracker
|
|
327
|
+
// afterwards to pick up the seeded row.
|
|
328
|
+
async function bootstrapTracker(client, files, applyFile, readFileImpl) {
|
|
329
|
+
const trackerPath = files.find(
|
|
330
|
+
(f) => path.basename(f) === TRACKER_FILE
|
|
331
|
+
);
|
|
332
|
+
if (!trackerPath) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`applyPendingMigrations: bundled ${TRACKER_FILE} not found in migration set — ` +
|
|
335
|
+
`the migration tracker cannot bootstrap. Re-publish the package or sync ` +
|
|
336
|
+
`the engram migrations directory into packages/server/src/setup/mnestra-migrations/.`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const result = await applyFile(client, trackerPath);
|
|
340
|
+
if (!result || result.ok !== true) {
|
|
341
|
+
const detail = (result && result.error) || 'apply returned not-ok with no error message';
|
|
342
|
+
throw new Error(
|
|
343
|
+
`applyPendingMigrations: failed to bootstrap tracker via ${TRACKER_FILE}: ${detail}`
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const sql = readFileImpl(trackerPath);
|
|
347
|
+
const checksum = computeChecksum(sql);
|
|
348
|
+
await client.query(
|
|
349
|
+
`INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
|
|
350
|
+
`VALUES ($1, now(), $2, $3) ` +
|
|
351
|
+
`ON CONFLICT (filename) DO UPDATE SET applied_at = EXCLUDED.applied_at, ` +
|
|
352
|
+
`checksum = EXCLUDED.checksum, schema_version = EXCLUDED.schema_version`,
|
|
353
|
+
[TRACKER_FILE, checksum, null]
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Run a probe SQL string and return whether the schema artifact is present.
|
|
358
|
+
// Null probes always return false. Probe-side errors (e.g. relation does
|
|
359
|
+
// not exist when probing into the live schema) are swallowed and degrade
|
|
360
|
+
// to "absent" — same posture as audit-upgrade.js::probeOne. The artifact's
|
|
361
|
+
// real apply path will surface any underlying error with full context.
|
|
362
|
+
async function probePresent(client, probeSql) {
|
|
363
|
+
if (!probeSql) return false;
|
|
364
|
+
try {
|
|
365
|
+
const res = await client.query(probeSql);
|
|
366
|
+
return Array.isArray(res && res.rows) && res.rows.length > 0;
|
|
367
|
+
} catch (_err) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Insert a backfill row for a migration whose probe came back present on
|
|
373
|
+
// a pre-020 install. applied_at is the epoch sentinel so audit queries
|
|
374
|
+
// can distinguish "applied at install" from "tracked from day one." The
|
|
375
|
+
// checksum is recorded too so future bundle drift can still be detected
|
|
376
|
+
// against backfilled rows.
|
|
377
|
+
async function recordBackfill(client, filename, checksum) {
|
|
378
|
+
await client.query(
|
|
379
|
+
`INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
|
|
380
|
+
`VALUES ($1, $2::timestamptz, $3, $4) ` +
|
|
381
|
+
`ON CONFLICT (filename) DO NOTHING`,
|
|
382
|
+
[filename, '1970-01-01T00:00:00Z', checksum, 'backfill']
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Insert a real tracker row after a successful apply.
|
|
387
|
+
async function recordApplied(client, filename, checksum) {
|
|
388
|
+
await client.query(
|
|
389
|
+
`INSERT INTO ${TRACKER_TABLE} (filename, applied_at, checksum, schema_version) ` +
|
|
390
|
+
`VALUES ($1, now(), $2, $3) ` +
|
|
391
|
+
`ON CONFLICT (filename) DO UPDATE SET applied_at = EXCLUDED.applied_at, ` +
|
|
392
|
+
`checksum = EXCLUDED.checksum, schema_version = EXCLUDED.schema_version`,
|
|
393
|
+
[filename, checksum, null]
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function applyPendingMigrations(client, opts = {}) {
|
|
398
|
+
if (!client || typeof client.query !== 'function') {
|
|
399
|
+
throw new Error('applyPendingMigrations: client with .query() is required');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const _migrations = opts._migrations || module.exports;
|
|
403
|
+
const _readFile = opts._readFile || ((p) => fs.readFileSync(p, 'utf-8'));
|
|
404
|
+
const _applyFile = opts._applyFile || defaultApplyFile();
|
|
405
|
+
const _probes = opts._probes || MIGRATION_PROBES;
|
|
406
|
+
|
|
407
|
+
const files = _migrations.listMnestraMigrations();
|
|
408
|
+
if (!files || files.length === 0) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
'applyPendingMigrations: no Mnestra migrations bundled — TermDeck install looks corrupted.'
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const summary = {
|
|
415
|
+
applied: [],
|
|
416
|
+
skipped: [],
|
|
417
|
+
backfilled: [],
|
|
418
|
+
warnings: [],
|
|
419
|
+
errored: null
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Step 1: load applied-set (or bootstrap if missing).
|
|
423
|
+
let applied = await loadAppliedSet(client);
|
|
424
|
+
let bootstrapped = false;
|
|
425
|
+
if (applied === null) {
|
|
426
|
+
// Pre-020 — apply 020 + INSERT row.
|
|
427
|
+
await bootstrapTracker(client, files, _applyFile, _readFile);
|
|
428
|
+
bootstrapped = true;
|
|
429
|
+
summary.applied.push(TRACKER_FILE);
|
|
430
|
+
applied = await loadAppliedSet(client);
|
|
431
|
+
if (applied === null) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
'applyPendingMigrations: tracker still missing after bootstrap — ' +
|
|
434
|
+
'check that 020_migration_tracking.sql actually created public.mnestra_migrations.'
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Step 2: iterate bundled files in lex order.
|
|
440
|
+
for (const file of files) {
|
|
441
|
+
const base = path.basename(file);
|
|
442
|
+
|
|
443
|
+
// Tracker file: bootstrap already accounted for it in summary.applied.
|
|
444
|
+
// On a non-bootstrap pass (post-020 install where 020 is in the tracker),
|
|
445
|
+
// record as skipped. On any other state (tracker present but somehow
|
|
446
|
+
// missing 020), fall through to the normal apply path so the diff loop
|
|
447
|
+
// can re-record it.
|
|
448
|
+
if (base === TRACKER_FILE) {
|
|
449
|
+
if (bootstrapped) {
|
|
450
|
+
// Already in summary.applied via bootstrap; do not duplicate.
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (applied.has(TRACKER_FILE)) {
|
|
454
|
+
summary.skipped.push(base);
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
// Defensive fall-through: tracker exists (loadAppliedSet succeeded) but
|
|
458
|
+
// doesn't have 020's row. Apply path below will re-record it.
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
let sql;
|
|
462
|
+
try {
|
|
463
|
+
sql = _readFile(file);
|
|
464
|
+
} catch (err) {
|
|
465
|
+
summary.errored = {
|
|
466
|
+
file: base,
|
|
467
|
+
error: err && err.message ? err.message : String(err)
|
|
468
|
+
};
|
|
469
|
+
return summary;
|
|
470
|
+
}
|
|
471
|
+
const checksum = computeChecksum(sql);
|
|
472
|
+
|
|
473
|
+
if (applied.has(base)) {
|
|
474
|
+
// Already applied — checksum-drift guard.
|
|
475
|
+
const trackedChecksum = applied.get(base);
|
|
476
|
+
if (trackedChecksum && trackedChecksum !== checksum) {
|
|
477
|
+
summary.warnings.push({
|
|
478
|
+
file: base,
|
|
479
|
+
trackedChecksum,
|
|
480
|
+
bundledChecksum: checksum
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
summary.skipped.push(base);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Not applied. Try probe-backfill (only meaningful on pre-020 installs
|
|
488
|
+
// that just bootstrapped, but cheap to evaluate on every pass; an
|
|
489
|
+
// already-tracked migration short-circuits above before reaching here).
|
|
490
|
+
const probeSql = Object.prototype.hasOwnProperty.call(_probes, base)
|
|
491
|
+
? _probes[base]
|
|
492
|
+
: null;
|
|
493
|
+
if (probeSql) {
|
|
494
|
+
const present = await probePresent(client, probeSql);
|
|
495
|
+
if (present) {
|
|
496
|
+
try {
|
|
497
|
+
await recordBackfill(client, base, checksum);
|
|
498
|
+
} catch (err) {
|
|
499
|
+
summary.errored = {
|
|
500
|
+
file: base,
|
|
501
|
+
error: `backfill INSERT failed: ${err && err.message ? err.message : String(err)}`
|
|
502
|
+
};
|
|
503
|
+
return summary;
|
|
504
|
+
}
|
|
505
|
+
summary.backfilled.push(base);
|
|
506
|
+
applied.set(base, checksum);
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Genuinely needs apply. Self-transactional migrations (011, 012) ship
|
|
512
|
+
// top-level BEGIN/COMMIT in their bodies — see isSelfTransactional + the
|
|
513
|
+
// header comment near its definition. For those, skip the outer wrapper
|
|
514
|
+
// and rely on the file's own transaction control + idempotency for
|
|
515
|
+
// recovery on tracker INSERT failure. For everything else, wrap in
|
|
516
|
+
// outer BEGIN/COMMIT for per-file atomicity (apply + tracker row commit
|
|
517
|
+
// or roll back together).
|
|
518
|
+
const selfTx = isSelfTransactional(sql);
|
|
519
|
+
|
|
520
|
+
if (selfTx) {
|
|
521
|
+
let applyResult;
|
|
522
|
+
try {
|
|
523
|
+
applyResult = await _applyFile(client, file);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
summary.errored = {
|
|
526
|
+
file: base,
|
|
527
|
+
error: err && err.message ? err.message : String(err)
|
|
528
|
+
};
|
|
529
|
+
return summary;
|
|
530
|
+
}
|
|
531
|
+
if (!applyResult || applyResult.ok !== true) {
|
|
532
|
+
summary.errored = {
|
|
533
|
+
file: base,
|
|
534
|
+
error: (applyResult && applyResult.error) || 'apply returned not-ok with no error message'
|
|
535
|
+
};
|
|
536
|
+
return summary;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
await recordApplied(client, base, checksum);
|
|
540
|
+
} catch (err) {
|
|
541
|
+
summary.errored = {
|
|
542
|
+
file: base,
|
|
543
|
+
error: `tracker INSERT failed (self-transactional ${base} applied; re-run will replay it idempotently and retry the tracker insert): ${err && err.message ? err.message : String(err)}`
|
|
544
|
+
};
|
|
545
|
+
return summary;
|
|
546
|
+
}
|
|
547
|
+
summary.applied.push(base);
|
|
548
|
+
applied.set(base, checksum);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Non-self-transactional path: outer BEGIN/COMMIT wrapper.
|
|
553
|
+
try {
|
|
554
|
+
await client.query('BEGIN');
|
|
555
|
+
} catch (err) {
|
|
556
|
+
summary.errored = {
|
|
557
|
+
file: base,
|
|
558
|
+
error: `BEGIN failed: ${err && err.message ? err.message : String(err)}`
|
|
559
|
+
};
|
|
560
|
+
return summary;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
let applyResult;
|
|
564
|
+
try {
|
|
565
|
+
applyResult = await _applyFile(client, file);
|
|
566
|
+
} catch (err) {
|
|
567
|
+
try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
|
|
568
|
+
summary.errored = {
|
|
569
|
+
file: base,
|
|
570
|
+
error: err && err.message ? err.message : String(err)
|
|
571
|
+
};
|
|
572
|
+
return summary;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
if (!applyResult || applyResult.ok !== true) {
|
|
576
|
+
try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
|
|
577
|
+
summary.errored = {
|
|
578
|
+
file: base,
|
|
579
|
+
error: (applyResult && applyResult.error) || 'apply returned not-ok with no error message'
|
|
580
|
+
};
|
|
581
|
+
return summary;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
await recordApplied(client, base, checksum);
|
|
586
|
+
await client.query('COMMIT');
|
|
587
|
+
} catch (err) {
|
|
588
|
+
try { await client.query('ROLLBACK'); } catch (_e) { /* best-effort */ }
|
|
589
|
+
summary.errored = {
|
|
590
|
+
file: base,
|
|
591
|
+
error: `tracker INSERT failed: ${err && err.message ? err.message : String(err)}`
|
|
592
|
+
};
|
|
593
|
+
return summary;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
summary.applied.push(base);
|
|
597
|
+
applied.set(base, checksum);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return summary;
|
|
601
|
+
}
|
|
602
|
+
|
|
130
603
|
module.exports = {
|
|
131
604
|
listMnestraMigrations,
|
|
132
605
|
listRumenMigrations,
|
|
133
606
|
rumenFunctionsRoot,
|
|
134
607
|
listRumenFunctions,
|
|
135
608
|
rumenFunctionDir,
|
|
136
|
-
readFile
|
|
609
|
+
readFile,
|
|
610
|
+
// Sprint 61 T2 — migration tracker.
|
|
611
|
+
applyPendingMigrations,
|
|
612
|
+
MIGRATION_PROBES,
|
|
613
|
+
TRACKER_TABLE,
|
|
614
|
+
TRACKER_FILE,
|
|
615
|
+
// Test surface — kept exported so tests/migration-tracker.test.js can pin
|
|
616
|
+
// each helper without a live pg client.
|
|
617
|
+
_computeChecksum: computeChecksum,
|
|
618
|
+
_loadAppliedSet: loadAppliedSet,
|
|
619
|
+
_bootstrapTracker: bootstrapTracker,
|
|
620
|
+
_probePresent: probePresent,
|
|
621
|
+
_recordBackfill: recordBackfill,
|
|
622
|
+
_recordApplied: recordApplied,
|
|
623
|
+
_isSelfTransactional: isSelfTransactional
|
|
137
624
|
};
|
|
@@ -79,8 +79,8 @@ create index if not exists memory_relationships_target_idx on memory_relationshi
|
|
|
79
79
|
-- ── match_memories helper RPC ─────────────────────────────────────────────
|
|
80
80
|
-- Used by remember.ts (dedup) and consolidate.ts (cluster seeding).
|
|
81
81
|
--
|
|
82
|
-
-- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs
|
|
83
|
-
--
|
|
82
|
+
-- Sprint 52.1 — signature-drift guard. On long-lived v0.6.x-era installs,
|
|
83
|
+
-- match_memories was created by
|
|
84
84
|
-- a prior Mnestra version with a different RETURN-table column shape:
|
|
85
85
|
-- (id, content, metadata, source_type, category, project, created_at, similarity)
|
|
86
86
|
-- vs the canonical:
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
-- Sprint 51.9 — signature-drift guard. Same Class A pattern Sprint 52.1
|
|
17
17
|
-- closed for `match_memories` (mig 001:81-95). Codex T4 surfaced the cousin
|
|
18
18
|
-- 2026-05-04 14:42 ET during Sprint 51.5b dogfood: long-lived v0.6.x-era
|
|
19
|
-
-- installs
|
|
19
|
+
-- installs ALSO have a
|
|
20
20
|
-- 10-arg drift overload of `memory_hybrid_search` coexisting with the
|
|
21
21
|
-- canonical 8-arg signature. The drift overload carries the never-shipped
|
|
22
22
|
-- `recency_weight`/`decay_days` parameters from a pre-canonical Mnestra
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
--
|
|
9
9
|
-- Idempotent: safe to re-run.
|
|
10
10
|
--
|
|
11
|
-
-- Pre-existing state (verified against
|
|
11
|
+
-- Pre-existing state (verified against the reference Mnestra project 2026-04-27 17:25 ET):
|
|
12
12
|
-- memory_relationships has 749 live edges. The migration adds nullable
|
|
13
13
|
-- columns and a wider CHECK; no existing row violates the new constraint.
|
|
14
14
|
--
|
|
@@ -36,7 +36,10 @@
|
|
|
36
36
|
-- 1. termdeck / mnestra — keywords: termdeck, mnestra, "4+1 sprint"
|
|
37
37
|
-- 2. rumen — keyword: rumen
|
|
38
38
|
-- 3. podium — keyword: podium
|
|
39
|
-
-- 4. pvb — keywords: PVB,
|
|
39
|
+
-- 4. pvb — keywords: PVB, pet vet bid (and the
|
|
40
|
+
-- legacy single-word identifier matched
|
|
41
|
+
-- by the load-bearing classifier on
|
|
42
|
+
-- line 156)
|
|
40
43
|
-- 5. dor / openclaw — TIGHTENED:
|
|
41
44
|
-- word-boundary uppercase DOR (rules out
|
|
42
45
|
-- "dormant", "vendored", "indoor", etc.),
|
|
@@ -49,7 +52,8 @@
|
|
|
49
52
|
-- rumen: 92 rows, all 6 sampled were true positives.
|
|
50
53
|
-- podium: 58 rows, all 6 sampled were true positives.
|
|
51
54
|
-- pvb: 7 rows, 1 of those overlaps with mnestra ("Mnestra
|
|
52
|
-
-- repo …
|
|
55
|
+
-- repo … legacy single-word project name") and gets
|
|
56
|
+
-- claimed by bucket 1.
|
|
53
57
|
-- dor (tightened): 3 rows after tightening from 6 — the original
|
|
54
58
|
-- `%dor%` ILIKE pattern caught false positives like
|
|
55
59
|
-- "dormant", "vendored". Final 3 rows are all true
|
|
@@ -143,7 +147,7 @@ BEGIN
|
|
|
143
147
|
END $$;
|
|
144
148
|
|
|
145
149
|
-- ============================================================
|
|
146
|
-
-- BUCKET 4 — PVB (case-insensitive
|
|
150
|
+
-- BUCKET 4 — PVB (case-insensitive content markers — see code below)
|
|
147
151
|
-- ============================================================
|
|
148
152
|
DO $$
|
|
149
153
|
DECLARE
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
-- by Joshua; case-sensitive word-boundary token
|
|
50
50
|
-- avoids matching unrelated "[maestro]" log
|
|
51
51
|
-- prefixes)
|
|
52
|
-
-- 6. pvb — PVB,
|
|
52
|
+
-- 6. pvb — PVB, "pet vet bid"
|
|
53
53
|
-- 7. claimguard — claimguard, gorgias-ticket-monitor,
|
|
54
54
|
-- "gorgias ticket monitor"
|
|
55
55
|
-- 8. dor — \mDOR\M, /DOR/, ~/Documents/DOR, dor.config,
|
|
@@ -234,7 +234,7 @@ BEGIN
|
|
|
234
234
|
END $$;
|
|
235
235
|
|
|
236
236
|
-- ============================================================
|
|
237
|
-
-- BUCKET 6 — pvb (case-insensitive
|
|
237
|
+
-- BUCKET 6 — pvb (case-insensitive content markers — see code below)
|
|
238
238
|
--
|
|
239
239
|
-- Same pattern as 011 bucket 4. PVB is small in the chopin-nashville bucket
|
|
240
240
|
-- (Sprint 39 dry-run found 7 rows; live apply landed 3 because bucket 1
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
-- PostgREST checks table-level privileges before evaluating RLS, so
|
|
18
18
|
-- service_role's bypassrls attribute does not help.
|
|
19
19
|
--
|
|
20
|
-
-- Reported and root-caused by Brad Heath 2026-04-28 against
|
|
21
|
-
--
|
|
22
|
-
--
|
|
20
|
+
-- Reported and root-caused by Brad Heath 2026-04-28 against his Mnestra
|
|
21
|
+
-- project; fix verified end-to-end on his install before being upstreamed
|
|
22
|
+
-- here.
|
|
23
23
|
--
|
|
24
24
|
-- This migration is idempotent and safe on greenfield projects where
|
|
25
25
|
-- the auto-grant default already fired (the GRANTs become no-ops).
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
-- their grants are now wrapped in a do$$ guard that only emits them
|
|
28
28
|
-- when `pg_cron` is enabled. The doctor's cron-related probes return
|
|
29
29
|
-- the existing `unknown` band (Sprint 51.5 T2 already established it)
|
|
30
|
-
-- when the wrappers don't exist — graceful degradation.
|
|
31
|
-
--
|
|
30
|
+
-- when the wrappers don't exist — graceful degradation. Existing
|
|
31
|
+
-- rumen-bearing installs are unaffected because both have rumen installed,
|
|
32
32
|
-- which enables pg_cron via rumen's mig 002. Closes the
|
|
33
33
|
-- mnestra-only-no-rumen fresh-install path.
|
|
34
34
|
|
|
@@ -103,7 +103,7 @@ grant execute on function mnestra_doctor_vault_secret_exists(text) to ser
|
|
|
103
103
|
--
|
|
104
104
|
-- Idempotent: do$$ runs every replay; CREATE OR REPLACE keeps the
|
|
105
105
|
-- function definitions in sync if pg_cron later gets enabled and the
|
|
106
|
-
-- migration re-runs. Existing installs
|
|
106
|
+
-- migration re-runs. Existing installs typically have
|
|
107
107
|
-- pg_cron from Rumen's install path and emit these unconditionally.
|
|
108
108
|
|
|
109
109
|
do $cron_guard$
|
package/packages/server/src/setup/mnestra-migrations/017_memory_sessions_session_metadata.sql
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
-- Sprint 51.6 T3 (TermDeck v1.0.2 hotfix wave). Brings the canonical engram
|
|
4
4
|
-- memory_sessions schema in line with the rag-system writer's column set so
|
|
5
5
|
-- TermDeck's bundled session-end hook can write a uniform shape on both
|
|
6
|
-
-- fresh-canonical installs and Joshua's daily-driver
|
|
6
|
+
-- fresh-canonical installs and Joshua's daily-driver the reference Mnestra project (where the
|
|
7
7
|
-- columns were already added by hand when rag-system bootstrap ran).
|
|
8
8
|
--
|
|
9
9
|
-- Why: until v1.0.2 the bundled hook only wrote memory_items. The actual
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
-- the schema it expects exists everywhere.
|
|
18
18
|
--
|
|
19
19
|
-- Idempotent — safe on:
|
|
20
|
-
-- 1.
|
|
20
|
+
-- 1. the reference Mnestra project (where these columns are already present from hand-applied
|
|
21
21
|
-- DDL Joshua ran when setting up rag-system; the IF NOT EXISTS guards
|
|
22
22
|
-- no-op on every column).
|
|
23
23
|
-- 2. Fresh canonical installs that ran migrations 001-016 only (the canonical
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
--
|
|
27
27
|
-- The unique constraint on session_id is wrapped in a do-block because
|
|
28
28
|
-- ADD CONSTRAINT does not support IF NOT EXISTS in PostgreSQL. Joshua's
|
|
29
|
-
--
|
|
29
|
+
-- the reference Mnestra project already has the constraint as memory_sessions_session_id_key
|
|
30
30
|
-- (auto-named by the rag-system bootstrap); this block detects that name
|
|
31
31
|
-- and skips re-adding.
|
|
32
32
|
--
|
|
33
|
-
-- session_id is added NULLABLE on canonical installs even though
|
|
33
|
+
-- session_id is added NULLABLE on canonical installs even though the reference Mnestra project's
|
|
34
34
|
-- existing constraint is NOT NULL. Adding NOT NULL via ALTER TABLE on a
|
|
35
35
|
-- table with existing rows would fail; the bundled hook always supplies
|
|
36
36
|
-- session_id at write time, so nullability is non-blocking. A future sprint
|
|
@@ -56,7 +56,7 @@ alter table public.memory_sessions
|
|
|
56
56
|
-- Unique constraint on session_id. Skip if any unique constraint on
|
|
57
57
|
-- (session_id) is already in place — covers both the canonical name
|
|
58
58
|
-- memory_sessions_session_id_key and any alternate name from a manual
|
|
59
|
-
-- ALTER TABLE Joshua may have run on
|
|
59
|
+
-- ALTER TABLE Joshua may have run on the reference Mnestra project.
|
|
60
60
|
do $$
|
|
61
61
|
declare
|
|
62
62
|
has_unique boolean;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
-- 1. Joshua's daily-driver (pre-Sprint-53; column will be added with
|
|
20
20
|
-- every existing memory_sessions row at NULL → all become candidates
|
|
21
21
|
-- on the first post-deploy tick, which is the desired bootstrap).
|
|
22
|
-
-- 2.
|
|
22
|
+
-- 2. Linux SSH installs (same shape, same null-bootstrap).
|
|
23
23
|
-- 3. Fresh canonical installs (post-mig-017 schema; column added on
|
|
24
24
|
-- first run, no rows to backfill).
|
|
25
25
|
-- 4. Re-runs (ADD COLUMN IF NOT EXISTS + CREATE INDEX IF NOT EXISTS).
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
-- Mnestra v0.4.6 — security hardening (revised from 0.4.4 / 0.4.5).
|
|
2
|
+
--
|
|
3
|
+
-- Source: external Supabase-advisor sweep by Brad Heath / Nacho Money LLC,
|
|
4
|
+
-- 2026-05-06. See docs/SECURITY-HARDENING-2026-05-06.md for the full flag
|
|
5
|
+
-- and root-cause analysis. The standing rule lives in the global Claude
|
|
6
|
+
-- Code instructions: "MANDATORY: Supabase RLS + privilege hygiene".
|
|
7
|
+
--
|
|
8
|
+
-- Two corrections folded into this revision:
|
|
9
|
+
--
|
|
10
|
+
-- A. **search_path must include `extensions`.** The 0.4.4/0.4.5 version of
|
|
11
|
+
-- this migration set search_path = public, pg_catalog on the memory_*
|
|
12
|
+
-- RPCs. Supabase >= 2024 installs pgvector in the `extensions` schema,
|
|
13
|
+
-- so the `<=>` cosine-distance operator becomes unreachable from those
|
|
14
|
+
-- RPCs after the alter — semantic recall fails with "operator does not
|
|
15
|
+
-- exist: extensions.vector <=> extensions.vector". Confirmed live
|
|
16
|
+
-- against the reference Mnestra project on 2026-05-06; fixed by
|
|
17
|
+
-- including `extensions` in search_path.
|
|
18
|
+
--
|
|
19
|
+
-- B. **Schema-generation-aware.** Some Mnestra installs are on the older
|
|
20
|
+
-- "memory_items-only" generation — they have memory_items /
|
|
21
|
+
-- memory_relationships / memory_sessions + the 6 memory_* RPCs, but
|
|
22
|
+
-- NOT the layered-memory tables (mnestra_session_memory,
|
|
23
|
+
-- mnestra_developer_memory, mnestra_project_memory, mnestra_commands)
|
|
24
|
+
-- and NOT the mnestra_doctor_* SECURITY DEFINER probes. The 0.4.4 / 0.4.5
|
|
25
|
+
-- migration body assumed the layered shape and threw "relation does
|
|
26
|
+
-- not exist" / "function does not exist" mid-migration on older
|
|
27
|
+
-- installs. Brad caught this on three of his projects (Structural,
|
|
28
|
+
-- aetheria-payroll, aetheria-phase1) and worked around with a
|
|
29
|
+
-- signature-agnostic DO-block subset.
|
|
30
|
+
--
|
|
31
|
+
-- This revision restructures every section as defensive lookups
|
|
32
|
+
-- against pg_class / pg_proc / pg_views, so each statement only fires
|
|
33
|
+
-- when its target exists. The migration runs cleanly on:
|
|
34
|
+
-- - layered-memory generation (Josh's reference project): full fix
|
|
35
|
+
-- - memory_items-only generation (Brad's three projects): function
|
|
36
|
+
-- hardening only; mnestra_*-targeting statements are skipped
|
|
37
|
+
-- - mixed generation: each statement applies to whatever exists
|
|
38
|
+
--
|
|
39
|
+
-- Closes four hole classes (where applicable to the install's schema
|
|
40
|
+
-- generation):
|
|
41
|
+
--
|
|
42
|
+
-- 1. Permissive PUBLIC INSERT RLS on mnestra_{commands,developer_memory,
|
|
43
|
+
-- project_memory,session_memory}. Created by Supabase Studio's
|
|
44
|
+
-- "Allow insert for all" default-policy template at table-creation
|
|
45
|
+
-- time. Anyone with the project's anon key could write directly to
|
|
46
|
+
-- memory tables, poisoning the corpus or session-id-squatting.
|
|
47
|
+
--
|
|
48
|
+
-- 2. PUBLIC EXECUTE on every Mnestra function. Postgres defaults
|
|
49
|
+
-- function EXECUTE to PUBLIC; the explicit `grant ... to service_role`
|
|
50
|
+
-- in earlier migrations is additive, not exclusive.
|
|
51
|
+
--
|
|
52
|
+
-- 3. Mutable search_path on memory_* and mnestra_doctor_* functions
|
|
53
|
+
-- (Supabase lint 0011).
|
|
54
|
+
--
|
|
55
|
+
-- 4. mnestra_recent_activity SECURITY DEFINER view (Supabase lint 0010)
|
|
56
|
+
-- with anon+authenticated SELECT.
|
|
57
|
+
--
|
|
58
|
+
-- Backward-compat: zero behavior change for any Mnestra installation that
|
|
59
|
+
-- follows the documented architecture (service-role writes via MCP server).
|
|
60
|
+
-- service_role keeps EXECUTE on every function and SELECT on the view.
|
|
61
|
+
--
|
|
62
|
+
-- Idempotent: every section guards on object existence and uses
|
|
63
|
+
-- IF EXISTS / signature-agnostic patterns. Re-running this migration is
|
|
64
|
+
-- safe and is in fact the recommended way to upgrade a 0.4.4/0.4.5 install
|
|
65
|
+
-- to pick up the search_path fix.
|
|
66
|
+
|
|
67
|
+
-- ====================================================================
|
|
68
|
+
-- 1. Drop permissive PUBLIC INSERT policies on mnestra_* tables, when
|
|
69
|
+
-- those tables exist on this install. Skipped silently on older
|
|
70
|
+
-- memory_items-only schema generation.
|
|
71
|
+
-- ====================================================================
|
|
72
|
+
|
|
73
|
+
do $$
|
|
74
|
+
declare
|
|
75
|
+
tbl text;
|
|
76
|
+
tables text[] := array[
|
|
77
|
+
'mnestra_commands',
|
|
78
|
+
'mnestra_developer_memory',
|
|
79
|
+
'mnestra_project_memory',
|
|
80
|
+
'mnestra_session_memory'
|
|
81
|
+
];
|
|
82
|
+
begin
|
|
83
|
+
foreach tbl in array tables loop
|
|
84
|
+
if to_regclass(format('public.%I', tbl)) is not null then
|
|
85
|
+
execute format('drop policy if exists "Allow insert for all" on public.%I', tbl);
|
|
86
|
+
end if;
|
|
87
|
+
end loop;
|
|
88
|
+
end $$;
|
|
89
|
+
|
|
90
|
+
-- ====================================================================
|
|
91
|
+
-- 2 + 3. Revoke EXECUTE from public + anon + authenticated AND pin
|
|
92
|
+
-- search_path on every Mnestra function. Signature-agnostic — iterates
|
|
93
|
+
-- pg_proc to apply to whatever functions exist on this install. Covers
|
|
94
|
+
-- memory_*, match_memories, expand_memory_neighborhood, and
|
|
95
|
+
-- mnestra_doctor_*.
|
|
96
|
+
--
|
|
97
|
+
-- search_path includes `extensions` for the pgvector operator and
|
|
98
|
+
-- pg_catalog for built-ins; doctor functions don't use vectors but the
|
|
99
|
+
-- inclusion is harmless and keeps every Mnestra function uniform.
|
|
100
|
+
-- ====================================================================
|
|
101
|
+
|
|
102
|
+
do $$
|
|
103
|
+
declare
|
|
104
|
+
fn record;
|
|
105
|
+
sig text;
|
|
106
|
+
begin
|
|
107
|
+
for fn in
|
|
108
|
+
select n.nspname,
|
|
109
|
+
p.proname,
|
|
110
|
+
pg_get_function_identity_arguments(p.oid) as ident_args
|
|
111
|
+
from pg_proc p
|
|
112
|
+
join pg_namespace n on n.oid = p.pronamespace
|
|
113
|
+
where n.nspname = 'public'
|
|
114
|
+
and p.prokind = 'f'
|
|
115
|
+
and (
|
|
116
|
+
p.proname like 'memory_%'
|
|
117
|
+
or p.proname in ('match_memories', 'expand_memory_neighborhood')
|
|
118
|
+
or p.proname like 'mnestra_doctor_%'
|
|
119
|
+
)
|
|
120
|
+
loop
|
|
121
|
+
sig := format('%I.%I(%s)', fn.nspname, fn.proname, fn.ident_args);
|
|
122
|
+
execute format('revoke execute on function %s from public, anon, authenticated', sig);
|
|
123
|
+
execute format('alter function %s set search_path = public, extensions, pg_catalog', sig);
|
|
124
|
+
-- service_role keeps EXECUTE; the revoke above only targets public/anon/authenticated.
|
|
125
|
+
end loop;
|
|
126
|
+
end $$;
|
|
127
|
+
|
|
128
|
+
-- ====================================================================
|
|
129
|
+
-- 4. Recreate mnestra_recent_activity view without SECURITY DEFINER and
|
|
130
|
+
-- restrict SELECT to service_role. Skipped silently if the view doesn't
|
|
131
|
+
-- exist or any of the three underlying tables are missing.
|
|
132
|
+
-- ====================================================================
|
|
133
|
+
|
|
134
|
+
do $$
|
|
135
|
+
begin
|
|
136
|
+
if to_regclass('public.mnestra_session_memory') is not null
|
|
137
|
+
and to_regclass('public.mnestra_project_memory') is not null
|
|
138
|
+
and to_regclass('public.mnestra_developer_memory') is not null
|
|
139
|
+
then
|
|
140
|
+
drop view if exists public.mnestra_recent_activity;
|
|
141
|
+
|
|
142
|
+
execute $view$
|
|
143
|
+
create view public.mnestra_recent_activity as
|
|
144
|
+
select 'session'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_session_memory
|
|
145
|
+
union all
|
|
146
|
+
select 'project'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_project_memory
|
|
147
|
+
union all
|
|
148
|
+
select 'developer'::text as layer, id, session_id, event_type, payload, project, developer_id, "timestamp", created_at from public.mnestra_developer_memory
|
|
149
|
+
order by 8 desc
|
|
150
|
+
limit 100
|
|
151
|
+
$view$;
|
|
152
|
+
|
|
153
|
+
revoke all on public.mnestra_recent_activity from public, anon, authenticated;
|
|
154
|
+
grant select on public.mnestra_recent_activity to service_role;
|
|
155
|
+
end if;
|
|
156
|
+
end $$;
|
|
157
|
+
|
|
158
|
+
-- ====================================================================
|
|
159
|
+
-- Post-apply verification (run separately in Studio SQL editor):
|
|
160
|
+
--
|
|
161
|
+
-- -- Should return zero rows:
|
|
162
|
+
-- with bad_policies as (
|
|
163
|
+
-- select policyname from pg_policies
|
|
164
|
+
-- where schemaname='public' and tablename like 'mnestra_%'
|
|
165
|
+
-- and ('public' = any(roles) or roles = '{}')
|
|
166
|
+
-- and (with_check='true' or qual='true')
|
|
167
|
+
-- ),
|
|
168
|
+
-- public_exec as (
|
|
169
|
+
-- select p.proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace
|
|
170
|
+
-- where n.nspname='public'
|
|
171
|
+
-- and (p.proname like 'mnestra_doctor_%' or p.proname like 'memory_%'
|
|
172
|
+
-- or p.proname in ('match_memories','expand_memory_neighborhood'))
|
|
173
|
+
-- and has_function_privilege('public', p.oid, 'EXECUTE')
|
|
174
|
+
-- ),
|
|
175
|
+
-- mutable_path as (
|
|
176
|
+
-- select p.proname from pg_proc p join pg_namespace n on n.oid=p.pronamespace
|
|
177
|
+
-- where n.nspname='public' and p.prokind='f'
|
|
178
|
+
-- and (p.proname like 'memory_%' or p.proname like 'mnestra_doctor_%')
|
|
179
|
+
-- and not exists (
|
|
180
|
+
-- select 1 from unnest(coalesce(p.proconfig,'{}'::text[])) c
|
|
181
|
+
-- where c like 'search_path=%'
|
|
182
|
+
-- )
|
|
183
|
+
-- )
|
|
184
|
+
-- select 'BAD_POLICY' as kind, policyname as detail from bad_policies
|
|
185
|
+
-- union all select 'PUBLIC_EXEC', proname from public_exec
|
|
186
|
+
-- union all select 'MUTABLE_SEARCH_PATH', proname from mutable_path;
|
|
187
|
+
--
|
|
188
|
+
-- Verified zero rows on the reference Mnestra project on 2026-05-06.
|
|
189
|
+
-- Smoke test: select count(*) from memory_hybrid_search('smoke', array_fill(0::real, ARRAY[1536])::vector, 1) → 1 row, no operator-resolution error.
|
|
190
|
+
-- ====================================================================
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
-- 020_migration_tracking.sql
|
|
2
|
+
-- Adds durable tracking of which Mnestra migrations have been applied to a project,
|
|
3
|
+
-- so upgrade paths can compute (bundled - applied) and apply only the diff.
|
|
4
|
+
-- Sprint 61 (TermDeck Convergence Keystone), Mnestra 0.4.7.
|
|
5
|
+
--
|
|
6
|
+
-- Why this exists: prior to 020, the mnestra/rumen wizards re-applied every
|
|
7
|
+
-- bundled migration on every invocation, relying on per-migration
|
|
8
|
+
-- `IF NOT EXISTS` / `CREATE OR REPLACE` idempotency to avoid duplicate work.
|
|
9
|
+
-- That works for a fresh install but doesn't tell the wizard which migrations
|
|
10
|
+
-- the live database is missing — so a user running `npm install -g @latest`
|
|
11
|
+
-- against an existing project gets the new package files without any way to
|
|
12
|
+
-- detect schema drift. Class A (schema drift on package upgrade) per
|
|
13
|
+
-- termdeck/docs/INSTALLER-PITFALLS.md.
|
|
14
|
+
--
|
|
15
|
+
-- Shape:
|
|
16
|
+
-- - `filename` text PK — the bundled migration filename, e.g.
|
|
17
|
+
-- `015_source_agent.sql`. PK because each
|
|
18
|
+
-- bundled file applies at most once.
|
|
19
|
+
-- - `applied_at` timestamptz — wall-clock time of apply. Backfilled
|
|
20
|
+
-- rows (rows seeded by the post-020 backfill
|
|
21
|
+
-- probe for migrations applied pre-020) use
|
|
22
|
+
-- epoch (1970-01-01T00:00:00Z) as a sentinel.
|
|
23
|
+
-- - `checksum` text — SHA-256 of the bundled file content at apply
|
|
24
|
+
-- time. Lets future runs detect bundle drift
|
|
25
|
+
-- without auto-overwriting the live schema.
|
|
26
|
+
-- - `schema_version` text — optional free-text marker. Backfill rows use
|
|
27
|
+
-- the literal `'backfill'` so audit queries
|
|
28
|
+
-- can distinguish them.
|
|
29
|
+
--
|
|
30
|
+
-- RLS posture: ENABLE ROW LEVEL SECURITY + REVOKE ALL FROM PUBLIC. No
|
|
31
|
+
-- policies are intentional — anon and authenticated have NO access, full
|
|
32
|
+
-- stop. service_role bypasses RLS in Postgres by default, which is the only
|
|
33
|
+
-- caller that should ever touch this table (the migration runner connects
|
|
34
|
+
-- via DATABASE_URL using service-role credentials).
|
|
35
|
+
--
|
|
36
|
+
-- Idempotent: re-applying this migration on a project that already has the
|
|
37
|
+
-- table is a no-op (CREATE TABLE IF NOT EXISTS, ALTER TABLE ... ENABLE RLS
|
|
38
|
+
-- is a no-op when already enabled, REVOKE/GRANT are idempotent).
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS public.mnestra_migrations (
|
|
41
|
+
filename text PRIMARY KEY,
|
|
42
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
checksum text NOT NULL,
|
|
44
|
+
schema_version text
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
ALTER TABLE public.mnestra_migrations ENABLE ROW LEVEL SECURITY;
|
|
48
|
+
|
|
49
|
+
-- Service-role-only. anon and authenticated have NO access (no policies = denied by RLS).
|
|
50
|
+
-- Service role bypasses RLS by default; the table is queried only by the migration runner
|
|
51
|
+
-- which uses the service-role key.
|
|
52
|
+
|
|
53
|
+
REVOKE ALL ON public.mnestra_migrations FROM PUBLIC;
|
|
54
|
+
GRANT ALL ON public.mnestra_migrations TO service_role;
|
|
55
|
+
|
|
56
|
+
COMMENT ON TABLE public.mnestra_migrations IS
|
|
57
|
+
'Tracking table for applied Mnestra migrations. service_role-only; RLS-on; no policies.';
|
|
@@ -52,40 +52,10 @@ serve(async (_req: Request) => {
|
|
|
52
52
|
|
|
53
53
|
const pool = createPoolFromUrl(url);
|
|
54
54
|
|
|
55
|
-
// Sprint 56 (T3 Cell #1 backlog catch-up) — env-var overrides for one-off
|
|
56
|
-
// historic processing. Set via `supabase secrets set`:
|
|
57
|
-
// RUMEN_LOOKBACK_HOURS_OVERRIDE=2880 (120 days; bypasses default 72h)
|
|
58
|
-
// RUMEN_MAX_SESSIONS_OVERRIDE=300 (processes whole 289-session
|
|
59
|
-
// backlog in one tick rather than
|
|
60
|
-
// 28 ticks at default 10 each)
|
|
61
|
-
// After the catch-up settles, unset both with
|
|
62
|
-
// `supabase secrets unset RUMEN_LOOKBACK_HOURS_OVERRIDE
|
|
63
|
-
// RUMEN_MAX_SESSIONS_OVERRIDE`
|
|
64
|
-
// and the function reverts to the rumen-package defaults (72h / 10 sessions).
|
|
65
|
-
// Both gates fail closed: invalid integer string → ignored, default used.
|
|
66
|
-
const lookbackOverrideRaw = Deno.env.get('RUMEN_LOOKBACK_HOURS_OVERRIDE');
|
|
67
|
-
const maxSessionsOverrideRaw = Deno.env.get('RUMEN_MAX_SESSIONS_OVERRIDE');
|
|
68
|
-
const lookbackOverride = lookbackOverrideRaw && /^\d+$/.test(lookbackOverrideRaw)
|
|
69
|
-
? parseInt(lookbackOverrideRaw, 10)
|
|
70
|
-
: undefined;
|
|
71
|
-
const maxSessionsOverride = maxSessionsOverrideRaw && /^\d+$/.test(maxSessionsOverrideRaw)
|
|
72
|
-
? parseInt(maxSessionsOverrideRaw, 10)
|
|
73
|
-
: undefined;
|
|
74
|
-
if (lookbackOverride !== undefined || maxSessionsOverride !== undefined) {
|
|
75
|
-
console.log(
|
|
76
|
-
'[rumen] override active: lookbackHours=' +
|
|
77
|
-
(lookbackOverride ?? 'default') +
|
|
78
|
-
' maxSessions=' +
|
|
79
|
-
(maxSessionsOverride ?? 'default'),
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
55
|
try {
|
|
84
56
|
console.log('[rumen] edge function tick starting');
|
|
85
57
|
const summary = await runRumenJob(pool, {
|
|
86
58
|
triggeredBy: 'schedule',
|
|
87
|
-
...(lookbackOverride !== undefined ? { lookbackHours: lookbackOverride } : {}),
|
|
88
|
-
...(maxSessionsOverride !== undefined ? { maxSessions: maxSessionsOverride } : {}),
|
|
89
59
|
});
|
|
90
60
|
console.log(
|
|
91
61
|
'[rumen] edge function tick complete job_id=' +
|