@cerefox/memory 1.7.0 → 1.8.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.
@@ -18,7 +18,7 @@
18
18
  * doesn't touch `supabase/functions/` leaves it alone).
19
19
  */
20
20
 
21
- export const EF_VERSION = "1.7.0";
21
+ export const EF_VERSION = "1.8.0";
22
22
 
23
23
  /**
24
24
  * The Cerefox RELEASE version — what `cerefox --version` reports and what npm
@@ -36,7 +36,7 @@ export const EF_VERSION = "1.7.0";
36
36
  * is imported by the Deno Edge Functions, which cannot reach into the npm
37
37
  * package.
38
38
  */
39
- export const CEREFOX_VERSION = "1.7.0";
39
+ export const CEREFOX_VERSION = "1.8.0";
40
40
 
41
41
  /**
42
42
  * The most recent version whose EF-side SOURCE actually changed (#127).
@@ -0,0 +1,16 @@
1
+ -- 0025_drop_orphaned_overloads.sql — remove pre-author-era 1-arg overloads.
2
+ --
3
+ -- cerefox_purge_document(UUID) and cerefox_restore_document(UUID) survived
4
+ -- every CREATE OR REPLACE since their signatures grew (OR REPLACE only
5
+ -- replaces the SAME signature), leaving long-lived databases with BOTH
6
+ -- overloads. A named 1-arg call is then ambiguous — PostgREST PGRST203
7
+ -- ("could not choose the best candidate") — which is how the first
8
+ -- acceptance run against a long-lived database failed to purge its
9
+ -- fixtures (v1.7.0).
10
+ -- Fresh databases never had the old signatures and are unaffected.
11
+ --
12
+ -- Schema version 0.12.0 → 0.12.1. The DROPs also run from rpcs.sql on every
13
+ -- deploy; this migration makes the version advance signal the redeploy.
14
+
15
+ DROP FUNCTION IF EXISTS cerefox_purge_document(UUID);
16
+ DROP FUNCTION IF EXISTS cerefox_restore_document(UUID);
@@ -0,0 +1,46 @@
1
+ -- 0026_metadata_guard_and_dead_links.sql — #212 metadata type guards +
2
+ -- #214 phase-2 dead-link sweep.
3
+ --
4
+ -- RPC changes ship via rpcs.sql on this deploy: cerefox_ingest_document
5
+ -- rejects non-object p_metadata (the MCP layer always did; now every write
6
+ -- path agrees), cerefox_set_document_metadata refuses to MERGE onto a
7
+ -- non-object stored value (|| would produce an array; only replace=true
8
+ -- repairs), and two read-only RPCs arrive: cerefox_find_dead_links (whole-KB
9
+ -- [Text](uuid) sweep) and cerefox_metadata_health (rows with non-object
10
+ -- metadata, surfaced by doctor).
11
+ --
12
+ -- Schema version 0.12.1 → 0.12.2. This migration also REPORTS (not repairs)
13
+ -- any rows already in the non-object state, so the operator sees them at
14
+ -- upgrade time; repair is `cerefox document set-metadata <id> --replace`.
15
+
16
+ -- Table-level backstop (round-5 review): closes every current and future
17
+ -- direct writer at once, not only the RPC/CLI/web paths patched in code.
18
+ -- NOT VALID: legacy non-object rows survive (reported below) until repaired
19
+ -- with `document set-metadata --replace`, which the constraint then checks.
20
+ DO $$
21
+ BEGIN
22
+ IF NOT EXISTS (
23
+ SELECT 1 FROM pg_constraint
24
+ WHERE conname = 'cerefox_documents_metadata_object'
25
+ ) THEN
26
+ ALTER TABLE cerefox_documents
27
+ ADD CONSTRAINT cerefox_documents_metadata_object
28
+ CHECK (jsonb_typeof(metadata) = 'object') NOT VALID;
29
+ END IF;
30
+ END $$;
31
+
32
+ DO $$
33
+ DECLARE
34
+ v_count INT;
35
+ BEGIN
36
+ SELECT count(*) INTO v_count
37
+ FROM cerefox_documents
38
+ WHERE metadata IS NOT NULL AND jsonb_typeof(metadata) <> 'object';
39
+ IF v_count > 0 THEN
40
+ RAISE NOTICE
41
+ 'Migration 0026: % document(s) hold NON-OBJECT metadata (legacy #212 state). List them with cerefox doctor (or SELECT * FROM cerefox_metadata_health()); repair each with cerefox document set-metadata <id> --replace --json ''<object>''.',
42
+ v_count;
43
+ ELSE
44
+ RAISE NOTICE 'Migration 0026: metadata guards + dead-link sweep arrive with rpcs.sql. No non-object metadata rows found. Schema 0.12.2.';
45
+ END IF;
46
+ END $$;
@@ -0,0 +1,172 @@
1
+ -- 0027_drop_archived_search_artifacts.sql — archived chunks carry no search
2
+ -- artifacts (#216). THE CANONICAL RATIONALE LIVES HERE; code comments
3
+ -- reference it.
4
+ --
5
+ -- Search is current-chunks-only by design (every search index is partial on
6
+ -- version_id IS NULL), version reconstruction and diffs read `content`, and
7
+ -- restoring an old version is deliberately manual re-ingest (which
8
+ -- re-embeds). Embeddings and fts on archived chunks were therefore never
9
+ -- readable by anything — pure storage cost, measured at ~30-45% of the chunk
10
+ -- relation on long-lived stores — and after a reindex they are stale for the
11
+ -- current embedder besides. There is no config and no maintenance command:
12
+ -- with nothing able to read the artifacts, this is an invariant, not a
13
+ -- policy. The archived content — the actual safety copy — is untouched.
14
+ --
15
+ -- Four parts:
16
+ -- 1. embedding_primary becomes nullable, WITH a replacement guard: a CHECK
17
+ -- that a CURRENT chunk always carries an embedding. Without it, a short
18
+ -- embedding-API response could insert a silently search-invisible chunk
19
+ -- where the old NOT NULL failed loudly (review round 6).
20
+ -- 2. The NEW cerefox_snapshot_version ships INSIDE this migration (repo
21
+ -- precedent: 0005-0008, 0011, 0025 carry function bodies). This closes
22
+ -- two windows where the OLD snapshot could keep archiving WITH
23
+ -- artifacts after 0027 was stamped: `bun scripts/db_migrate.ts` (which
24
+ -- never refreshes rpcs.sql), and a `server deploy` that failed between
25
+ -- the migration step and the RPC refresh.
26
+ -- 3. Back-fill: strip artifacts from existing archived rows, reporting
27
+ -- rows and bytes. embedder_upgrade is nulled with its vector;
28
+ -- embedder_primary is deliberately kept (NOT NULL, harmless provenance).
29
+ -- 4. Space note: Postgres frees the bytes for REUSE via autovacuum rather
30
+ -- than shrinking files immediately — growth stops even if the reported
31
+ -- database size does not drop the same day.
32
+ --
33
+ -- Schema version 0.12.2 → 0.13.0.
34
+
35
+ ALTER TABLE cerefox_chunks ALTER COLUMN embedding_primary DROP NOT NULL;
36
+
37
+ DO $$
38
+ BEGIN
39
+ IF NOT EXISTS (
40
+ SELECT 1 FROM pg_constraint WHERE conname = 'cerefox_chunks_current_has_embedding'
41
+ ) THEN
42
+ ALTER TABLE cerefox_chunks
43
+ ADD CONSTRAINT cerefox_chunks_current_has_embedding
44
+ CHECK (version_id IS NOT NULL OR embedding_primary IS NOT NULL);
45
+ END IF;
46
+ END $$;
47
+
48
+ -- The new snapshot (identical to the rpcs.sql copy this release ships):
49
+
50
+ DROP FUNCTION IF EXISTS cerefox_snapshot_version(UUID, TEXT, INT);
51
+ DROP FUNCTION IF EXISTS cerefox_snapshot_version(UUID, TEXT, INT, BOOLEAN);
52
+ CREATE FUNCTION cerefox_snapshot_version(
53
+ p_document_id UUID,
54
+ p_source TEXT DEFAULT 'manual',
55
+ -- NULL (the new default) means "use the store's policy from
56
+ -- cerefox_config". Passing a value still overrides, for deliberate one-off
57
+ -- admin operations — but callers no longer supply one by accident.
58
+ p_retention_hours INT DEFAULT NULL,
59
+ p_cleanup_enabled BOOLEAN DEFAULT NULL
60
+ )
61
+ RETURNS TABLE (
62
+ version_id UUID,
63
+ version_number INT,
64
+ chunk_count INT,
65
+ total_chars INT
66
+ )
67
+ LANGUAGE plpgsql
68
+ SECURITY DEFINER
69
+ SET search_path = public, pg_catalog
70
+ AS $$
71
+ DECLARE
72
+ v_version_id UUID;
73
+ v_version_number INT;
74
+ v_chunk_count INT;
75
+ v_total_chars INT;
76
+ -- Resolve the retention policy from the STORE, not the caller.
77
+ --
78
+ -- These used to arrive as parameters filled from each client's own env, so
79
+ -- the surviving version history depended on which client wrote last: an
80
+ -- agent running defaults would prune versions that an operator had
81
+ -- configured to keep. Retention describes the data, so it belongs to the
82
+ -- data. Same COALESCE(param, config, default) shape the retrieval tunables
83
+ -- already use.
84
+ v_retention INT := COALESCE(p_retention_hours,
85
+ cerefox_config_int('version_retention_hours', 120));
86
+ v_cleanup BOOLEAN := COALESCE(p_cleanup_enabled,
87
+ cerefox_config_bool('version_cleanup_enabled', TRUE));
88
+ BEGIN
89
+ -- Count current chunks to record in the version metadata
90
+ SELECT COUNT(*), COALESCE(SUM(char_count), 0)
91
+ INTO v_chunk_count, v_total_chars
92
+ FROM cerefox_chunks c
93
+ WHERE c.document_id = p_document_id
94
+ AND c.version_id IS NULL;
95
+
96
+ -- Compute the next version number (sequential per document)
97
+ SELECT COALESCE(MAX(dv.version_number), 0) + 1
98
+ INTO v_version_number
99
+ FROM cerefox_document_versions dv
100
+ WHERE dv.document_id = p_document_id;
101
+
102
+ -- Create the version row
103
+ INSERT INTO cerefox_document_versions (
104
+ document_id, version_number, source, chunk_count, total_chars
105
+ ) VALUES (
106
+ p_document_id, v_version_number, p_source, v_chunk_count, v_total_chars
107
+ )
108
+ RETURNING id INTO v_version_id;
109
+
110
+ -- Archive all current chunks by pointing them at the new version, and
111
+ -- NULL their search artifacts in the same write (0.13.0, #216 — full
112
+ -- rationale in migration 0027). The content — the actual safety copy —
113
+ -- is untouched. embedder_upgrade is nulled with its vector;
114
+ -- embedder_primary is deliberately KEPT (it is NOT NULL, and the label
115
+ -- is harmless provenance for a vector that no longer exists — nothing
116
+ -- reads embedder columns without a version_id IS NULL filter).
117
+ UPDATE cerefox_chunks c
118
+ SET version_id = v_version_id,
119
+ embedding_primary = NULL,
120
+ embedding_upgrade = NULL,
121
+ embedder_upgrade = NULL,
122
+ fts = NULL
123
+ WHERE c.document_id = p_document_id
124
+ AND c.version_id IS NULL;
125
+
126
+ -- Lazy retention: delete versions outside the retention window,
127
+ -- but always keep the most recently created version (the one we just made).
128
+ -- Skip archived versions (archived=true) -- they are protected from cleanup.
129
+ -- Skip cleanup entirely if p_cleanup_enabled is false (immutable mode).
130
+ IF v_cleanup THEN
131
+ DELETE FROM cerefox_document_versions dv
132
+ WHERE dv.document_id = p_document_id
133
+ AND dv.archived IS NOT TRUE
134
+ AND dv.created_at < NOW() - (v_retention || ' hours')::INTERVAL
135
+ AND dv.id != (
136
+ SELECT id FROM cerefox_document_versions
137
+ WHERE document_id = p_document_id
138
+ ORDER BY created_at DESC
139
+ LIMIT 1
140
+ );
141
+ END IF;
142
+
143
+ RETURN QUERY SELECT v_version_id, v_version_number, v_chunk_count, v_total_chars;
144
+ END;
145
+ $$;
146
+
147
+ DO $$
148
+ DECLARE
149
+ v_rows INT;
150
+ v_bytes BIGINT;
151
+ BEGIN
152
+ SELECT count(*),
153
+ COALESCE(SUM(COALESCE(pg_column_size(embedding_primary), 0))
154
+ + SUM(COALESCE(pg_column_size(embedding_upgrade), 0))
155
+ + SUM(COALESCE(pg_column_size(fts), 0)), 0)
156
+ INTO v_rows, v_bytes
157
+ FROM cerefox_chunks
158
+ WHERE version_id IS NOT NULL
159
+ AND (embedding_primary IS NOT NULL OR embedding_upgrade IS NOT NULL OR fts IS NOT NULL);
160
+
161
+ UPDATE cerefox_chunks
162
+ SET embedding_primary = NULL,
163
+ embedding_upgrade = NULL,
164
+ embedder_upgrade = NULL,
165
+ fts = NULL
166
+ WHERE version_id IS NOT NULL
167
+ AND (embedding_primary IS NOT NULL OR embedding_upgrade IS NOT NULL OR fts IS NOT NULL);
168
+
169
+ RAISE NOTICE
170
+ 'Migration 0027: stripped search artifacts from % archived chunk row(s), freeing ~% for reuse. Archived content is untouched; current chunks keep their embeddings.',
171
+ v_rows, pg_size_pretty(v_bytes);
172
+ END $$;
@@ -1009,9 +1009,19 @@ BEGIN
1009
1009
  )
1010
1010
  RETURNING id INTO v_version_id;
1011
1011
 
1012
- -- Archive all current chunks by pointing them at the new version
1012
+ -- Archive all current chunks by pointing them at the new version, and
1013
+ -- NULL their search artifacts in the same write (0.13.0, #216 — full
1014
+ -- rationale in migration 0027). The content — the actual safety copy —
1015
+ -- is untouched. embedder_upgrade is nulled with its vector;
1016
+ -- embedder_primary is deliberately KEPT (it is NOT NULL, and the label
1017
+ -- is harmless provenance for a vector that no longer exists — nothing
1018
+ -- reads embedder columns without a version_id IS NULL filter).
1013
1019
  UPDATE cerefox_chunks c
1014
- SET version_id = v_version_id
1020
+ SET version_id = v_version_id,
1021
+ embedding_primary = NULL,
1022
+ embedding_upgrade = NULL,
1023
+ embedder_upgrade = NULL,
1024
+ fts = NULL
1015
1025
  WHERE c.document_id = p_document_id
1016
1026
  AND c.version_id IS NULL;
1017
1027
 
@@ -1247,6 +1257,9 @@ $$;
1247
1257
 
1248
1258
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT, TEXT);
1249
1259
  DROP FUNCTION IF EXISTS cerefox_restore_document(UUID, TEXT, TEXT);
1260
+ -- 0.12.1: the pre-author 1-arg overload survived every CREATE OR REPLACE
1261
+ -- since the signature grew (same orphan class as purge; found live on a long-lived database).
1262
+ DROP FUNCTION IF EXISTS cerefox_restore_document(UUID);
1250
1263
  CREATE FUNCTION cerefox_restore_document(
1251
1264
  p_document_id UUID,
1252
1265
  p_author TEXT DEFAULT 'unknown',
@@ -1309,7 +1322,14 @@ $$;
1309
1322
  -- ── cerefox_purge_document ───────────────────────────────────────────────────
1310
1323
  -- Permanently deletes a soft-deleted document (CASCADE). Only works on
1311
1324
  -- documents that are already soft-deleted (deleted_at IS NOT NULL).
1325
+ --
1326
+ -- 0.12.1: drop the pre-author 1-arg overload. CREATE OR REPLACE never removed
1327
+ -- it when the signature grew, so long-lived databases carried BOTH — and a
1328
+ -- 1-arg named call was ambiguous there (PGRST203), which is how the first
1329
+ -- prod acceptance run failed to purge its fixtures. Same cleanup for
1330
+ -- cerefox_restore_document below.
1312
1331
 
1332
+ DROP FUNCTION IF EXISTS cerefox_purge_document(UUID);
1313
1333
  CREATE OR REPLACE FUNCTION cerefox_purge_document(
1314
1334
  p_document_id UUID,
1315
1335
  p_author TEXT DEFAULT 'unknown',
@@ -1347,6 +1367,43 @@ END;
1347
1367
  $$;
1348
1368
 
1349
1369
 
1370
+ -- ── cerefox_extract_doc_link_ids ─────────────────────────────────────────────
1371
+ -- The ONE implementation of [Text](uuid) link scanning (#214), shared by the
1372
+ -- write-time guard in cerefox_ingest_document and the cerefox_find_dead_links
1373
+ -- sweep — "same scanning rules" is enforced by this being the only copy.
1374
+ --
1375
+ -- Fences are LINE-ANCHORED and handled by SPLITTING, not by a paired-fence
1376
+ -- regex: Postgres AREs give the whole RE the greediness of their first
1377
+ -- quantified atom, which silently overrode a later .*? and made a closed
1378
+ -- fence strip everything to end-of-string (round-5 review, verified live) —
1379
+ -- blinding the scan to every link after any code block. Odd-numbered split
1380
+ -- segments are outside fences; an unterminated fence leaves its tail inside
1381
+ -- an even segment, dropped (under-validate, never false-reject). Inline code
1382
+ -- spans are stripped after (their regex has no greediness hazard).
1383
+
1384
+ CREATE OR REPLACE FUNCTION cerefox_extract_doc_link_ids(p_content TEXT)
1385
+ RETURNS TABLE (link_id TEXT)
1386
+ LANGUAGE sql
1387
+ IMMUTABLE
1388
+ SET search_path = public, pg_catalog
1389
+ AS $$
1390
+ WITH segments AS (
1391
+ SELECT seg, row_number() OVER () AS rn
1392
+ FROM regexp_split_to_table(COALESCE(p_content, ''), '^[ \t]*```.*$', 'n') AS seg
1393
+ ),
1394
+ outside AS (
1395
+ SELECT string_agg(regexp_replace(seg, '`[^`]*`', ' ', 'g'), ' ') AS s
1396
+ FROM segments
1397
+ WHERE rn % 2 = 1
1398
+ )
1399
+ SELECT lower(m[1])
1400
+ FROM outside,
1401
+ LATERAL regexp_matches(
1402
+ outside.s,
1403
+ '\]\(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)',
1404
+ 'g') AS m;
1405
+ $$;
1406
+
1350
1407
  -- ── cerefox_ingest_document ──────────────────────────────────────────────────
1351
1408
  -- Single RPC for ingesting a document (create or update). Handles:
1352
1409
  -- - Create: insert document row, insert chunks, set review_status, create audit entry
@@ -1494,6 +1551,19 @@ BEGIN
1494
1551
  USING ERRCODE = '22023'; -- invalid_parameter_value
1495
1552
  END IF;
1496
1553
 
1554
+ -- ── Metadata type guard (#212, 0.12.2) ───────────────────────────────
1555
+ -- metadata is jsonb and accepts ANY JSON value, but every reader assumes
1556
+ -- an object — and `document edit`'s JS spread DECOMPOSED a stored string
1557
+ -- into per-character keys (13 real documents were in the vulnerable
1558
+ -- state). The MCP layer always validated this; the RPC now does too, so
1559
+ -- every write path agrees (CLI, scripts, direct PostgREST included).
1560
+ IF p_metadata IS NOT NULL AND jsonb_typeof(p_metadata) <> 'object' THEN
1561
+ RAISE EXCEPTION
1562
+ 'cerefox_ingest_document: metadata must be a JSON object, got %. Wrap scalar values in a key ({"value": ...}).',
1563
+ jsonb_typeof(p_metadata)
1564
+ USING ERRCODE = '22023'; -- invalid_parameter_value
1565
+ END IF;
1566
+
1497
1567
  -- ── Link integrity (#214, 0.12.0) ────────────────────────────────────
1498
1568
  -- Validate [Text](uuid) document links against the store: agents mangle
1499
1569
  -- long random ids when regenerating text, and a mangled id silently
@@ -1506,23 +1576,14 @@ BEGIN
1506
1576
  -- See docs/specs/link-integrity-design.md.
1507
1577
  SELECT string_agg(c->>'content', E'\n') INTO v_scannable
1508
1578
  FROM jsonb_array_elements(p_chunks) c;
1509
- -- Fences are LINE-ANCHORED, matching markdown semantics: only ``` at a
1510
- -- line start opens/closes a block, so a stray backtick run mid-prose
1511
- -- cannot mis-pair the fences and un-escape a later real code block. An
1512
- -- unterminated fence strips to end-of-content (under-validates, never
1513
- -- false-rejects). Inline code is stripped after, so fence markers are
1514
- -- intact when pairing runs.
1515
- v_scannable := regexp_replace(
1516
- COALESCE(v_scannable, ''),
1517
- E'(^|\\n)[ \\t]*```.*?(\\n[ \\t]*```[^\\n]*|$)', ' ', 'g');
1518
- v_scannable := regexp_replace(v_scannable, '`[^`]*`', ' ', 'g');
1519
-
1520
- SELECT array_agg(DISTINCT m[1]) INTO v_missing
1521
- FROM regexp_matches(
1522
- v_scannable,
1523
- '\]\(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\)',
1524
- 'g') m
1525
- WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents d WHERE d.id = m[1]::uuid);
1579
+
1580
+ -- Scanning delegated to cerefox_extract_doc_link_ids the one copy of
1581
+ -- the fence/inline-code/uuid rules, shared with the dead-link sweep
1582
+ -- (0.12.2; the previous inline regex was defeated by ARE whole-RE
1583
+ -- greediness see the helper's header).
1584
+ SELECT array_agg(DISTINCT l.link_id) INTO v_missing
1585
+ FROM cerefox_extract_doc_link_ids(v_scannable) l
1586
+ WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents d WHERE d.id = l.link_id::uuid);
1526
1587
 
1527
1588
  -- On UPDATE, tolerate dead links the document ALREADY carries: a target
1528
1589
  -- purged after linking must not make the document unwritable — sync
@@ -1778,9 +1839,9 @@ $$;
1778
1839
  -- Reads chunk title and content directly from the DB -- caller only needs to
1779
1840
  -- supply the new document title.
1780
1841
  --
1781
- -- Only affects current chunks (version_id IS NULL). Archived chunks retain their
1782
- -- original tsvectors (they are excluded from all search indexes and require
1783
- -- re-ingestion to restore anyway).
1842
+ -- Only affects current chunks (version_id IS NULL). Archived chunks carry NO
1843
+ -- tsvector at all since 0.13.0 (#216) fts is nulled at archive time, and a
1844
+ -- restore is a re-ingest that recomputes everything.
1784
1845
 
1785
1846
  DROP FUNCTION IF EXISTS cerefox_update_chunk_fts(UUID, TEXT);
1786
1847
  CREATE FUNCTION cerefox_update_chunk_fts(
@@ -2662,6 +2723,30 @@ BEGIN
2662
2723
  USING ERRCODE = 'P0002';
2663
2724
  END IF;
2664
2725
 
2726
+ -- #212: a legacy row can hold NON-OBJECT metadata (the ingest RPC did not
2727
+ -- validate its input until 0.12.2). Merging onto it with || would produce
2728
+ -- an ARRAY — Postgres treats both sides as arrays — burying the stored
2729
+ -- value one level deeper and leaving the row still corrupt. Only replace
2730
+ -- actually repairs such a row, so refuse the merge and say so. jsonb
2731
+ -- 'null' is treated like SQL NULL (empty), matching the CLI.
2732
+ IF NOT p_replace AND v_before IS NOT NULL
2733
+ AND jsonb_typeof(v_before) NOT IN ('object', 'null') THEN
2734
+ RAISE EXCEPTION
2735
+ 'CEREFOX_BAD_METADATA: stored metadata on document % is not an object (%). A merge cannot repair it — retry with replace (CLI: --replace; MCP: replace: true), passing the full intended object.',
2736
+ p_document_id, jsonb_typeof(v_before)
2737
+ USING ERRCODE = '22023';
2738
+ END IF;
2739
+
2740
+ -- Normalized BEFORE value for merge and reporting: everything below must
2741
+ -- work when the stored value is a scalar/array/'null' and p_replace is
2742
+ -- true — that is the REPAIR path, and jsonb_object_keys on a scalar
2743
+ -- errors, which would roll back the repair itself (round-5 review,
2744
+ -- verified live).
2745
+ v_before := CASE
2746
+ WHEN v_before IS NULL OR jsonb_typeof(v_before) <> 'object' THEN '{}'::jsonb
2747
+ ELSE v_before
2748
+ END;
2749
+
2665
2750
  -- Keys explicitly set to null are removals, never stored values.
2666
2751
  SELECT COALESCE(array_agg(key), ARRAY[]::TEXT[]) INTO v_null_keys
2667
2752
  FROM jsonb_each(p_metadata)
@@ -2713,6 +2798,15 @@ SET search_path = public, pg_catalog
2713
2798
  AS $$
2714
2799
  -- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
2715
2800
  -- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
2801
+ -- 0.13.0 (#216): archived chunks carry no search artifacts —
2802
+ -- cerefox_snapshot_version nulls embedding_primary/embedding_upgrade/fts
2803
+ -- at archive time; embedding_primary becomes nullable; migration 0027
2804
+ -- strips existing archived rows.
2805
+ -- 0.12.2 (#212, #214): metadata must be a JSON object (ingest input guard
2806
+ -- + set_document_metadata stored-state merge guard); cerefox_find_dead_links
2807
+ -- (link-integrity phase-2 sweep); cerefox_metadata_health (doctor check).
2808
+ -- 0.12.1: drop orphaned 1-arg overloads of purge/restore (pre-author era;
2809
+ -- CREATE OR REPLACE never removed them; ambiguous PGRST203 on 1-arg calls).
2716
2810
  -- 0.12.0 (#208, #210): cerefox_delete_document — CAS
2717
2811
  -- (p_expected_content_hash), p_reason in audit description, JSONB return,
2718
2812
  -- idempotent re-delete; cerefox_restore_document — same rework (JSONB,
@@ -2726,7 +2820,75 @@ AS $$
2726
2820
  -- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
2727
2821
  -- the partial-edit surface, and both migrations (0019, 0020) are in the
2728
2822
  -- sequence, so a store deploying this gets everything from both lines.
2729
- SELECT '0.12.0'::TEXT;
2823
+ SELECT '0.13.0'::TEXT;
2824
+ $$;
2825
+
2826
+ -- ── cerefox_find_dead_links ──────────────────────────────────────────────────
2827
+ -- Phase 2 of link integrity (#214): a read-only whole-KB sweep for
2828
+ -- [Text](uuid) links whose target document no longer EXISTS (purged after
2829
+ -- linking). Complements the write-time guard, which validates only
2830
+ -- newly-introduced links on updates. Same scanning rules as the guard:
2831
+ -- line-anchored fences and inline code spans are stripped (examples are not
2832
+ -- links); a trashed target still exists and is NOT a dead link.
2833
+ -- Full chunk scan — run on demand (CLI `cerefox document dead-links`), not on
2834
+ -- every doctor.
2835
+
2836
+ CREATE OR REPLACE FUNCTION cerefox_find_dead_links()
2837
+ RETURNS TABLE (
2838
+ document_id UUID,
2839
+ document_title TEXT,
2840
+ dead_link_id UUID,
2841
+ occurrences BIGINT
2842
+ )
2843
+ LANGUAGE sql
2844
+ STABLE
2845
+ SECURITY DEFINER
2846
+ SET search_path = public, pg_catalog
2847
+ AS $$
2848
+ -- Scanning delegated to cerefox_extract_doc_link_ids — the one copy of
2849
+ -- the fence/inline-code/uuid rules, shared with the write-time guard.
2850
+ -- Deliberate scope: trashed LINKER documents are excluded (they are
2851
+ -- inert until restored; restoring one re-enters it into the next sweep).
2852
+ WITH doc_content AS (
2853
+ SELECT d.id, d.title, string_agg(c.content, E'\n' ORDER BY c.chunk_index) AS content
2854
+ FROM cerefox_documents d
2855
+ JOIN cerefox_chunks c ON c.document_id = d.id AND c.version_id IS NULL
2856
+ WHERE d.deleted_at IS NULL
2857
+ GROUP BY d.id, d.title
2858
+ ),
2859
+ links AS (
2860
+ SELECT dc.id, dc.title, l.link_id AS target
2861
+ FROM doc_content dc,
2862
+ LATERAL cerefox_extract_doc_link_ids(dc.content) l
2863
+ )
2864
+ SELECT l.id, l.title, l.target::uuid, count(*)
2865
+ FROM links l
2866
+ WHERE NOT EXISTS (SELECT 1 FROM cerefox_documents t WHERE t.id = l.target::uuid)
2867
+ GROUP BY l.id, l.title, l.target
2868
+ ORDER BY l.title, l.target;
2869
+ $$;
2870
+
2871
+ -- ── cerefox_metadata_health ──────────────────────────────────────────────────
2872
+ -- #212: rows whose stored metadata is not a JSON object — the state the
2873
+ -- 0.12.2 write guards now prevent, but which legacy rows may still be in.
2874
+ -- Cheap (documents table only); surfaced by `cerefox doctor`. Repair:
2875
+ -- `cerefox document set-metadata <id> --replace --json '<object>'`.
2876
+
2877
+ CREATE OR REPLACE FUNCTION cerefox_metadata_health()
2878
+ RETURNS TABLE (
2879
+ document_id UUID,
2880
+ document_title TEXT,
2881
+ metadata_type TEXT
2882
+ )
2883
+ LANGUAGE sql
2884
+ STABLE
2885
+ SECURITY DEFINER
2886
+ SET search_path = public, pg_catalog
2887
+ AS $$
2888
+ SELECT d.id, d.title, jsonb_typeof(d.metadata)
2889
+ FROM cerefox_documents d
2890
+ WHERE d.metadata IS NOT NULL AND jsonb_typeof(d.metadata) <> 'object'
2891
+ ORDER BY d.title;
2730
2892
  $$;
2731
2893
 
2732
2894
  -- ── cerefox_content_format_stats ─────────────────────────────────────────────
@@ -5,7 +5,7 @@
5
5
  -- Requires extensions: vector (pgvector), uuid-ossp
6
6
  -- These are enabled at the top of db_deploy.py before this file is applied.
7
7
  --
8
- -- @version: 0.12.0
8
+ -- @version: 0.13.0
9
9
  -- The `@version` marker above is read by the schema-version-mismatch banner
10
10
  -- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
11
11
  -- changes in a way that requires `cerefox server deploy` to be re-run —
@@ -42,7 +42,15 @@ CREATE TABLE IF NOT EXISTS cerefox_documents (
42
42
  source_path TEXT,
43
43
  -- SHA-256 of raw markdown content; used for deduplication
44
44
  content_hash TEXT NOT NULL,
45
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
45
+ -- #212 (0.12.2): must be a JSON OBJECT — jsonb accepts any JSON value,
46
+ -- but every reader assumes an object, and non-object values were silently
47
+ -- destroyed by read-modify-write paths. The table-level CHECK closes all
48
+ -- current AND future direct writers at once; existing databases get it as
49
+ -- NOT VALID via migration 0026 (legacy rows survive until repaired with
50
+ -- `document set-metadata --replace`, which the constraint then validates).
51
+ metadata JSONB NOT NULL DEFAULT '{}'::jsonb
52
+ CONSTRAINT cerefox_documents_metadata_object
53
+ CHECK (jsonb_typeof(metadata) = 'object'),
46
54
  chunk_count INT NOT NULL DEFAULT 0,
47
55
  total_chars INT NOT NULL DEFAULT 0,
48
56
  -- review_status: human governance flag. 'approved' = validated by human,
@@ -204,8 +212,16 @@ CREATE TABLE IF NOT EXISTS cerefox_chunks (
204
212
  -- reconstructs with its OWN format. See docs/guides/content-format.md.
205
213
  content_format SMALLINT NOT NULL DEFAULT 1,
206
214
 
207
- -- Primary embedding: always computed, cloud API (default: OpenAI text-embedding-3-small)
208
- embedding_primary VECTOR(768) NOT NULL,
215
+ -- Primary embedding: always computed on write, cloud API (default: OpenAI
216
+ -- text-embedding-3-small). NULL only on ARCHIVED rows (0.13.0, #216 —
217
+ -- rationale in migration 0027): archiving nulls the search artifacts.
218
+ -- The CHECK below preserves the pre-0.13.0 guarantee that a CURRENT
219
+ -- chunk always has an embedding — without it, a short embedding-API
220
+ -- response could insert a silently search-invisible chunk where the old
221
+ -- NOT NULL failed loudly.
222
+ embedding_primary VECTOR(768)
223
+ CONSTRAINT cerefox_chunks_current_has_embedding
224
+ CHECK (version_id IS NOT NULL OR embedding_primary IS NOT NULL),
209
225
  -- Upgrade embedding: optional, alternative model (Fireworks, Vertex, etc.)
210
226
  embedding_upgrade VECTOR(768),
211
227
 
@@ -226,6 +226,12 @@ cerefox document list [OPTIONS]
226
226
 
227
227
  **Purpose**: update a document's title and/or metadata in place, without re-ingesting content.
228
228
 
229
+ > **v1.7.1 (#212)**: a title-only edit no longer writes `metadata` at all, and
230
+ > the command refuses to patch a document whose stored metadata is not a JSON
231
+ > object (a legacy state older writes could create) instead of destroying it —
232
+ > repair such a row with `cerefox document set-metadata <id> --replace --json
233
+ > '<object>'`. `cerefox doctor` lists any rows in that state.
234
+
229
235
  **Synopsis**:
230
236
  ```
231
237
  cerefox document edit [OPTIONS] DOCUMENT_ID
@@ -341,6 +347,20 @@ cerefox document set-projects <doc-id> --clear
341
347
 
342
348
  ---
343
349
 
350
+ ### `cerefox document dead-links`
351
+
352
+ **Purpose**: whole-KB sweep for `[Text](uuid)` links whose target document no longer exists (phase 2 of link integrity, #214; v1.7.1, needs schema 0.12.2).
353
+
354
+ **Synopsis**: `cerefox document dead-links [--json]`
355
+
356
+ The write-time guard (v1.7.0) validates only links a write *introduces* — deliberately, so a target purged after linking cannot make its linkers unwritable. This command finds those legacy dead links on demand. A trashed target still exists and is **not** reported; trashed **linker** documents are also excluded (inert until restored — a restore re-enters them into the next sweep). Full chunk scan server-side (one RPC call); run on demand, not part of `doctor`.
357
+
358
+ **Fix each hit** by editing the linking document: correct the id, remove the link, or backtick it as an example. Full linking overview: [`linking.md`](linking.md).
359
+
360
+ **MCP equivalent**: none (maintenance verb).
361
+
362
+ ---
363
+
344
364
  ### `cerefox document restore`
345
365
 
346
366
  **Purpose**: restore a soft-deleted (trashed) document back to active.
@@ -806,6 +826,7 @@ surface).
806
826
  | `document get` | `cerefox_get_document` | ✅ |
807
827
  | `document list` | `cerefox_metadata_search` (scope by `project_name` / metadata / time) | ✅ as of this change. Unscoped whole-KB listing has no MCP path by design (scope it) |
808
828
  | `document edit` (title / metadata in place) | — | 🔒 intentional: a human/web-parity convenience. Agents update title+metadata deterministically via `cerefox_ingest` (with `document_id`); a metadata-only edit isn't a needed agent primitive |
829
+ | `document dead-links` | — | 🔒 intentional: a maintenance sweep for the operator (v1.7.1, #214 phase 2); agents encounter dead links through the write-time guard instead |
809
830
  | `document delete` (soft-delete) | `cerefox_delete_document` | ✅ v1.7.0 (#208). MCP requires the caller's read-hash; the CLI confirms interactively instead |
810
831
  | `document restore` | `cerefox_restore_document` | ✅ v1.7.0 (#210). Permanent purge remains web-UI-only |
811
832
  | `document version list` | `cerefox_list_versions` | ✅ |
@@ -150,6 +150,15 @@ knowing about:
150
150
  you're coming from a pre-installer 0.1.x clone, see the "old pre-installer
151
151
  clone" note above: install the package and run `cerefox init`.
152
152
 
153
+ ## Notable: v1.8.0 storage reclaim (migration 0027)
154
+
155
+ Upgrading to v1.8.0 strips never-read search artifacts from archived version
156
+ chunks (rationale: #216 / the migration's own header) and the migration
157
+ prints what it freed. Postgres releases the bytes for **reuse** via
158
+ autovacuum rather than shrinking files immediately, so expect growth to stop
159
+ rather than the reported database size to drop the same day. Archived
160
+ version *content* is untouched.
161
+
153
162
  ## After upgrading: AI agents
154
163
 
155
164
  New tools and updated tool signatures are picked up by MCP clients in **new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerefox/memory",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Cerefox — user-owned shared memory for AI agents. CLI + stdio MCP server + web UI + ingestion for a knowledge base on your own Supabase project (or fully self-hosted with Cerefox Local).",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://github.com/fstamatelopoulos/cerefox",
@@ -65,7 +65,7 @@
65
65
  "typescript": "^6.0.3"
66
66
  },
67
67
  "scripts": {
68
- "build": "bun build src/bin/cerefox.ts --outdir dist/bin --target node --format esm --external @huggingface/transformers --external onnxruntime-node",
68
+ "build": "bun run bundle-server-assets && bun build src/bin/cerefox.ts --outdir dist/bin --target node --format esm --external @huggingface/transformers --external onnxruntime-node",
69
69
  "clean": "rm -rf dist docs AGENT_GUIDE.md AGENT_QUICK_REFERENCE.md",
70
70
  "bundle-docs": "bun run ../../scripts/bundle_package_docs.ts",
71
71
  "bundle-server-assets": "bun run ../../scripts/bundle_server_assets.ts",