@cerefox/memory 1.18.0 → 1.19.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.
- package/AGENT_GUIDE.md +8 -3
- package/AGENT_QUICK_REFERENCE.md +3 -3
- package/dist/bin/cerefox.js +847 -659
- package/dist/frontend/assets/index-01k5C3sG.css +1 -0
- package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
- package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
- package/dist/frontend/index.html +2 -2
- package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
- package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
- package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
- package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
- package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
- package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
- package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
- package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
- package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
- package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
- package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
- package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
- package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
- package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
- package/dist/server-assets/db/rpcs.sql +252 -53
- package/dist/server-assets/db/schema.sql +41 -4
- package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
- package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
- package/docs/api/openapi.json +67 -1
- package/docs/guides/cli.md +3 -1
- package/docs/guides/configuration.md +1 -1
- package/docs/guides/connect-agents.md +18 -7
- package/docs/guides/ops-scripts.md +1 -1
- package/docs/guides/setup-local.md +30 -6
- package/docs/guides/upgrading.md +14 -0
- package/package.json +6 -6
- package/dist/frontend/assets/index-B0H_N4if.css +0 -1
- package/dist/frontend/assets/index-CxMT7hjH.js +0 -121
- package/dist/frontend/assets/index-CxMT7hjH.js.map +0 -1
|
@@ -81,6 +81,11 @@ DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID,
|
|
|
81
81
|
DROP FUNCTION IF EXISTS cerefox_hybrid_search(TEXT, VECTOR(768), INT, FLOAT, BOOLEAN, UUID, FLOAT, JSONB, FLOAT);
|
|
82
82
|
DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID, FLOAT, INT, INT, JSONB, FLOAT);
|
|
83
83
|
|
|
84
|
+
-- #333 (0.21.0): metadata_revision added to the return types of the
|
|
85
|
+
-- document-shaped reads, next to content_hash. Drop the current signatures.
|
|
86
|
+
DROP FUNCTION IF EXISTS cerefox_search_docs(TEXT, VECTOR(768), INT, FLOAT, UUID, FLOAT, INT, INT, JSONB, FLOAT, TEXT);
|
|
87
|
+
DROP FUNCTION IF EXISTS cerefox_metadata_search(JSONB, UUID, TIMESTAMPTZ, TIMESTAMPTZ, INT, BOOLEAN, INT);
|
|
88
|
+
|
|
84
89
|
-- ── Shared return type note ────────────────────────────────────────────────────
|
|
85
90
|
-- All chunk-level search RPCs return the same shape for consistency:
|
|
86
91
|
-- chunk_id, document_id, chunk_index, title, content, heading_path,
|
|
@@ -788,7 +793,10 @@ RETURNS TABLE (
|
|
|
788
793
|
content_hash TEXT,
|
|
789
794
|
-- 28I: TRUE when this is a below-confidence fallback response (nothing
|
|
790
795
|
-- cleared the hybrid pass-filter). See cerefox_hybrid_search.
|
|
791
|
-
below_confidence BOOLEAN
|
|
796
|
+
below_confidence BOOLEAN,
|
|
797
|
+
-- #333: the metadata's concurrency token, to pass back as
|
|
798
|
+
-- expected_metadata_revision on a metadata write.
|
|
799
|
+
metadata_revision INT
|
|
792
800
|
)
|
|
793
801
|
LANGUAGE sql
|
|
794
802
|
SECURITY DEFINER
|
|
@@ -825,7 +833,8 @@ AS $$
|
|
|
825
833
|
cr.version_count,
|
|
826
834
|
cr.below_confidence,
|
|
827
835
|
d.updated_at AS doc_updated_at,
|
|
828
|
-
d.content_hash
|
|
836
|
+
d.content_hash,
|
|
837
|
+
d.metadata_revision
|
|
829
838
|
FROM chunk_results cr
|
|
830
839
|
JOIN cerefox_documents d ON d.id = cr.document_id
|
|
831
840
|
ORDER BY cr.document_id, cr.score DESC
|
|
@@ -910,7 +919,8 @@ AS $$
|
|
|
910
919
|
td.version_count,
|
|
911
920
|
ac.is_partial,
|
|
912
921
|
td.content_hash,
|
|
913
|
-
td.below_confidence
|
|
922
|
+
td.below_confidence,
|
|
923
|
+
td.metadata_revision
|
|
914
924
|
FROM top_docs td
|
|
915
925
|
JOIN doc_sizes ds ON ds.document_id = td.document_id
|
|
916
926
|
JOIN all_content ac ON ac.document_id = td.document_id
|
|
@@ -1069,7 +1079,9 @@ RETURNS TABLE (
|
|
|
1069
1079
|
-- Current content_hash of the document — the optimistic-concurrency token
|
|
1070
1080
|
-- to pass back as expected_content_hash on update (iter-32). Note: always
|
|
1071
1081
|
-- the CURRENT hash, even when an archived version is being retrieved.
|
|
1072
|
-
content_hash TEXT
|
|
1082
|
+
content_hash TEXT,
|
|
1083
|
+
-- #333: the metadata's concurrency token (always the current one).
|
|
1084
|
+
metadata_revision INT
|
|
1073
1085
|
)
|
|
1074
1086
|
LANGUAGE sql
|
|
1075
1087
|
SECURITY DEFINER
|
|
@@ -1091,7 +1103,8 @@ AS $$
|
|
|
1091
1103
|
COUNT(*)::INT AS chunk_count,
|
|
1092
1104
|
SUM(c.char_count)::INT AS total_chars,
|
|
1093
1105
|
d.created_at,
|
|
1094
|
-
d.content_hash
|
|
1106
|
+
d.content_hash,
|
|
1107
|
+
d.metadata_revision
|
|
1095
1108
|
FROM cerefox_documents d
|
|
1096
1109
|
JOIN cerefox_chunks c ON c.document_id = d.id
|
|
1097
1110
|
WHERE d.id = p_document_id
|
|
@@ -1099,7 +1112,8 @@ AS $$
|
|
|
1099
1112
|
(p_version_id IS NULL AND c.version_id IS NULL) OR
|
|
1100
1113
|
(p_version_id IS NOT NULL AND c.version_id = p_version_id)
|
|
1101
1114
|
)
|
|
1102
|
-
GROUP BY d.id, d.title, d.source, d.metadata, d.created_at, d.content_hash
|
|
1115
|
+
GROUP BY d.id, d.title, d.source, d.metadata, d.created_at, d.content_hash,
|
|
1116
|
+
d.metadata_revision;
|
|
1103
1117
|
$$;
|
|
1104
1118
|
|
|
1105
1119
|
-- ── cerefox_list_document_versions ────────────────────────────────────────────
|
|
@@ -1512,8 +1526,11 @@ $$;
|
|
|
1512
1526
|
-- p_source_label, which records how THIS write was
|
|
1513
1527
|
-- triggered and is stored on the version row.
|
|
1514
1528
|
-- p_metadata : JSONB metadata. NULL = "not provided" → create uses '{}',
|
|
1515
|
-
-- update keeps the existing metadata (v0.11.1).
|
|
1516
|
-
--
|
|
1529
|
+
-- update keeps the existing metadata (v0.11.1). On update
|
|
1530
|
+
-- it MERGES (JSON null removes a key) unless
|
|
1531
|
+
-- p_replace_metadata (#333, 0.21.0); '{}' is a no-op merge.
|
|
1532
|
+
-- p_expected_metadata_revision, p_replace_metadata : the metadata token and
|
|
1533
|
+
-- the replace switch (#333); see cerefox_check_metadata_revision.
|
|
1517
1534
|
-- p_review_status : ACCEPTED AND IGNORED since 0.16.0 (#241). The status is
|
|
1518
1535
|
-- decided here from p_author_type alone (agent →
|
|
1519
1536
|
-- pending_review, user → approved), so every transport
|
|
@@ -1539,8 +1556,67 @@ $$;
|
|
|
1539
1556
|
-- flows where an external source of truth makes conflicts
|
|
1540
1557
|
-- meaningless). Recorded in the audit description when used.
|
|
1541
1558
|
--
|
|
1542
|
-
-- Returns: document_id, chunk_count, total_chars, operation ('create' or
|
|
1543
|
-
-- version_id (UUID of snapshot, null on create)
|
|
1559
|
+
-- Returns: document_id, chunk_count, total_chars, operation ('create' or
|
|
1560
|
+
-- 'update-content'), version_id (UUID of snapshot, null on create).
|
|
1561
|
+
-- A create whose content_hash matches a LIVE document raises
|
|
1562
|
+
-- CEREFOX_DUPLICATE_CONTENT (23505) naming that document; nothing is
|
|
1563
|
+
-- written (0.20.0 #332; raised rather than returned since 0.21.1)
|
|
1564
|
+
|
|
1565
|
+
-- ── Metadata helpers (#333, 0.21.0) ──────────────────────────────────────────
|
|
1566
|
+
-- One copy of the merge/replace rule and of the token rule, shared by
|
|
1567
|
+
-- cerefox_set_document_metadata and cerefox_ingest_document.
|
|
1568
|
+
--
|
|
1569
|
+
-- cerefox_metadata_apply: RFC 7386-style. Merge = stored || patch; replace =
|
|
1570
|
+
-- the patch alone. Either way, keys whose value is JSON null are REMOVED, never
|
|
1571
|
+
-- stored (Cerefox metadata values are strings by convention, so a null is
|
|
1572
|
+
-- never a legitimate value).
|
|
1573
|
+
CREATE OR REPLACE FUNCTION cerefox_metadata_apply(
|
|
1574
|
+
p_stored JSONB,
|
|
1575
|
+
p_patch JSONB,
|
|
1576
|
+
p_replace BOOLEAN
|
|
1577
|
+
)
|
|
1578
|
+
RETURNS JSONB
|
|
1579
|
+
LANGUAGE sql
|
|
1580
|
+
IMMUTABLE
|
|
1581
|
+
SET search_path = public, pg_catalog
|
|
1582
|
+
AS $$
|
|
1583
|
+
SELECT (CASE WHEN p_replace THEN p_patch
|
|
1584
|
+
ELSE COALESCE(p_stored, '{}'::jsonb) || p_patch END)
|
|
1585
|
+
- COALESCE((SELECT array_agg(key) FROM jsonb_each(p_patch)
|
|
1586
|
+
WHERE value = 'null'::jsonb), ARRAY[]::TEXT[]);
|
|
1587
|
+
$$;
|
|
1588
|
+
|
|
1589
|
+
-- cerefox_check_metadata_revision: raise unless the write may proceed.
|
|
1590
|
+
-- expected passed and stale → CEREFOX_METADATA_CONFLICT (PT409)
|
|
1591
|
+
-- replace without token or LWW → CEREFOX_METADATA_TOKEN_REQUIRED (22023)
|
|
1592
|
+
-- PT409, never a retryable SQLSTATE: a stale token fails identically forever
|
|
1593
|
+
-- (see the CEREFOX_CONFLICT note in cerefox_ingest_document).
|
|
1594
|
+
CREATE OR REPLACE FUNCTION cerefox_check_metadata_revision(
|
|
1595
|
+
p_document_id UUID,
|
|
1596
|
+
p_current INT,
|
|
1597
|
+
p_expected INT,
|
|
1598
|
+
p_replace BOOLEAN,
|
|
1599
|
+
p_last_write_wins BOOLEAN
|
|
1600
|
+
)
|
|
1601
|
+
RETURNS VOID
|
|
1602
|
+
LANGUAGE plpgsql
|
|
1603
|
+
SET search_path = public, pg_catalog
|
|
1604
|
+
AS $$
|
|
1605
|
+
BEGIN
|
|
1606
|
+
IF p_expected IS NOT NULL AND p_expected <> p_current THEN
|
|
1607
|
+
RAISE EXCEPTION
|
|
1608
|
+
'CEREFOX_METADATA_CONFLICT: the metadata of document % changed since it was read (expected metadata_revision %, current %). Re-read the document, reapply your change, and retry with the new metadata_revision.',
|
|
1609
|
+
p_document_id, p_expected, p_current
|
|
1610
|
+
USING ERRCODE = 'PT409';
|
|
1611
|
+
END IF;
|
|
1612
|
+
IF p_replace AND p_expected IS NULL AND NOT COALESCE(p_last_write_wins, FALSE) THEN
|
|
1613
|
+
RAISE EXCEPTION
|
|
1614
|
+
'CEREFOX_METADATA_TOKEN_REQUIRED: replacing all metadata deletes keys, so it requires expected_metadata_revision (the metadata_revision you read) or last_write_wins=true. Current metadata_revision: %',
|
|
1615
|
+
p_current
|
|
1616
|
+
USING ERRCODE = '22023';
|
|
1617
|
+
END IF;
|
|
1618
|
+
END;
|
|
1619
|
+
$$;
|
|
1544
1620
|
|
|
1545
1621
|
DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN);
|
|
1546
1622
|
DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN);
|
|
@@ -1549,6 +1625,9 @@ DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JS
|
|
|
1549
1625
|
-- must be dropped before the 17-arg CREATE below (Postgres will not replace a
|
|
1550
1626
|
-- function whose RETURNS TABLE changed).
|
|
1551
1627
|
DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN, SMALLINT, JSONB);
|
|
1628
|
+
-- #333 (0.21.0): + p_expected_metadata_revision, p_replace_metadata; returns
|
|
1629
|
+
-- metadata_revision.
|
|
1630
|
+
DROP FUNCTION IF EXISTS cerefox_ingest_document(UUID, TEXT, TEXT, TEXT, TEXT, JSONB, TEXT, JSONB, TEXT, TEXT, TEXT, INT, BOOLEAN, TEXT, BOOLEAN, SMALLINT, JSONB, INT, BOOLEAN);
|
|
1552
1631
|
CREATE FUNCTION cerefox_ingest_document(
|
|
1553
1632
|
p_document_id UUID DEFAULT NULL,
|
|
1554
1633
|
p_title TEXT DEFAULT 'Untitled',
|
|
@@ -1560,7 +1639,9 @@ CREATE FUNCTION cerefox_ingest_document(
|
|
|
1560
1639
|
p_content_hash TEXT DEFAULT '',
|
|
1561
1640
|
-- NULL = "not provided": create uses '{}', update KEEPS existing metadata
|
|
1562
1641
|
-- (v0.11.1 fix — content updates without metadata used to wipe tags).
|
|
1563
|
-
--
|
|
1642
|
+
-- #333 (0.21.0): on update this MERGES (a JSON null removes a key), unless
|
|
1643
|
+
-- p_replace_metadata. It used to replace the whole object, checked only
|
|
1644
|
+
-- against the content token, which could wipe another writer's change.
|
|
1564
1645
|
p_metadata JSONB DEFAULT NULL,
|
|
1565
1646
|
p_review_status TEXT DEFAULT 'approved',
|
|
1566
1647
|
p_chunks JSONB DEFAULT '[]',
|
|
@@ -1588,7 +1669,15 @@ CREATE FUNCTION cerefox_ingest_document(
|
|
|
1588
1669
|
-- flattening to 'update-content'. NULL-means-today, per the #183 lesson:
|
|
1589
1670
|
-- a parameter that substitutes its own concrete default is how store policy
|
|
1590
1671
|
-- got silently overridden for a whole release.
|
|
1591
|
-
p_operations JSONB DEFAULT NULL
|
|
1672
|
+
p_operations JSONB DEFAULT NULL,
|
|
1673
|
+
-- #333: the metadata's concurrency token. Checked on update when
|
|
1674
|
+
-- p_metadata is passed: strict when given; required (or p_last_write_wins)
|
|
1675
|
+
-- when p_replace_metadata. Ignored on create.
|
|
1676
|
+
p_expected_metadata_revision INT DEFAULT NULL,
|
|
1677
|
+
-- #333: replace the whole metadata object instead of merging. For callers
|
|
1678
|
+
-- whose metadata has an external source of truth (front matter synced
|
|
1679
|
+
-- with last_write_wins); everyone else merges.
|
|
1680
|
+
p_replace_metadata BOOLEAN DEFAULT FALSE
|
|
1592
1681
|
)
|
|
1593
1682
|
RETURNS TABLE (
|
|
1594
1683
|
document_id UUID,
|
|
@@ -1603,7 +1692,9 @@ RETURNS TABLE (
|
|
|
1603
1692
|
-- iter-33: true when the new size crosses `document_size_warning_chars`
|
|
1604
1693
|
-- (dormant when that config is unset/0). A signal, never a refusal: an
|
|
1605
1694
|
-- insert-only workflow otherwise never sees a document grow.
|
|
1606
|
-
size_warning BOOLEAN
|
|
1695
|
+
size_warning BOOLEAN,
|
|
1696
|
+
-- #333: the metadata's token after this write.
|
|
1697
|
+
metadata_revision INT
|
|
1607
1698
|
)
|
|
1608
1699
|
LANGUAGE plpgsql
|
|
1609
1700
|
SECURITY DEFINER
|
|
@@ -1611,6 +1702,7 @@ SET search_path = public, pg_catalog
|
|
|
1611
1702
|
AS $$
|
|
1612
1703
|
DECLARE
|
|
1613
1704
|
v_doc_id UUID;
|
|
1705
|
+
v_duplicate_id UUID;
|
|
1614
1706
|
v_chunk_count INT;
|
|
1615
1707
|
v_total_chars INT;
|
|
1616
1708
|
v_operation TEXT;
|
|
@@ -1626,6 +1718,9 @@ DECLARE
|
|
|
1626
1718
|
v_doc_deleted TIMESTAMPTZ;
|
|
1627
1719
|
v_scannable TEXT;
|
|
1628
1720
|
v_missing TEXT[];
|
|
1721
|
+
v_meta_before JSONB;
|
|
1722
|
+
v_meta_after JSONB;
|
|
1723
|
+
v_meta_rev INT;
|
|
1629
1724
|
BEGIN
|
|
1630
1725
|
-- ── Zero-chunk guard (v0.3.1) ────────────────────────────────────────
|
|
1631
1726
|
-- Refuse to create or update a document with no chunks. Three reasons:
|
|
@@ -1736,8 +1831,9 @@ BEGIN
|
|
|
1736
1831
|
-- updaters serialize here, and the second one sees the first one's
|
|
1737
1832
|
-- hash — the race window (chunk + embed latency) is closed at the
|
|
1738
1833
|
-- only place all transports share (iter-32).
|
|
1739
|
-
SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at
|
|
1740
|
-
|
|
1834
|
+
SELECT COALESCE(d.total_chars, 0), d.content_hash, d.deleted_at,
|
|
1835
|
+
d.metadata, d.metadata_revision
|
|
1836
|
+
INTO v_old_chars, v_current_hash, v_doc_deleted, v_meta_before, v_meta_rev
|
|
1741
1837
|
FROM cerefox_documents d WHERE d.id = v_doc_id
|
|
1742
1838
|
FOR UPDATE;
|
|
1743
1839
|
|
|
@@ -1811,6 +1907,21 @@ BEGIN
|
|
|
1811
1907
|
END IF;
|
|
1812
1908
|
END IF;
|
|
1813
1909
|
|
|
1910
|
+
-- ── Metadata token (#333) ─────────────────────────────────────
|
|
1911
|
+
-- Only when this write touches metadata. Checked after the content
|
|
1912
|
+
-- token, so a caller holding a stale body learns that first.
|
|
1913
|
+
IF p_metadata IS NOT NULL THEN
|
|
1914
|
+
PERFORM cerefox_check_metadata_revision(
|
|
1915
|
+
v_doc_id, v_meta_rev, p_expected_metadata_revision,
|
|
1916
|
+
p_replace_metadata, p_last_write_wins);
|
|
1917
|
+
v_meta_after := cerefox_metadata_apply(
|
|
1918
|
+
CASE WHEN jsonb_typeof(v_meta_before) = 'object'
|
|
1919
|
+
THEN v_meta_before ELSE '{}'::jsonb END,
|
|
1920
|
+
p_metadata, p_replace_metadata);
|
|
1921
|
+
ELSE
|
|
1922
|
+
v_meta_after := v_meta_before;
|
|
1923
|
+
END IF;
|
|
1924
|
+
|
|
1814
1925
|
-- Snapshot old version (archives current chunks, runs retention cleanup)
|
|
1815
1926
|
SELECT sv.version_id INTO v_version_id
|
|
1816
1927
|
FROM cerefox_snapshot_version(v_doc_id, p_source_label, p_retention_hours, p_cleanup_enabled) sv;
|
|
@@ -1824,7 +1935,7 @@ BEGIN
|
|
|
1824
1935
|
source = COALESCE(p_source, source),
|
|
1825
1936
|
source_path = COALESCE(p_source_path, source_path),
|
|
1826
1937
|
content_hash = p_content_hash,
|
|
1827
|
-
metadata =
|
|
1938
|
+
metadata = v_meta_after,
|
|
1828
1939
|
chunk_count = v_chunk_count,
|
|
1829
1940
|
total_chars = v_total_chars,
|
|
1830
1941
|
review_status = v_status,
|
|
@@ -1835,11 +1946,45 @@ BEGIN
|
|
|
1835
1946
|
-- ── CREATE PATH ──────────────────────────────────────────────
|
|
1836
1947
|
v_operation := 'create';
|
|
1837
1948
|
|
|
1949
|
+
-- Duplicate check (0.20.0, #332). Content is no longer UNIQUE across
|
|
1950
|
+
-- the store: that constraint refused updates that happened to match
|
|
1951
|
+
-- some other document, including ones in the trash. Duplicates are
|
|
1952
|
+
-- now caught only on CREATE, and only against LIVE documents. Every
|
|
1953
|
+
-- caller pre-checks before embedding (to save the spend) and reports
|
|
1954
|
+
-- a match as skipped; this is the authoritative copy, made race-free
|
|
1955
|
+
-- by the advisory lock.
|
|
1956
|
+
--
|
|
1957
|
+
-- Losing the race RAISES rather than returning the winner (0.21.1,
|
|
1958
|
+
-- #334 review). A 1.18 client never read `operation`: handed the
|
|
1959
|
+
-- winner's id, it reported a create and replaced the winner's
|
|
1960
|
+
-- projects with its own. Raising under 23505 gives such a client what
|
|
1961
|
+
-- the old constraint gave it (a failed write, nothing after it), and
|
|
1962
|
+
-- current clients turn CEREFOX_DUPLICATE_CONTENT into a skip.
|
|
1963
|
+
IF NULLIF(BTRIM(p_content_hash), '') IS NOT NULL THEN
|
|
1964
|
+
PERFORM pg_advisory_xact_lock(
|
|
1965
|
+
hashtextextended('cerefox_content_hash:' || p_content_hash, 0));
|
|
1966
|
+
|
|
1967
|
+
-- Read into a dedicated variable, never into the create's own:
|
|
1968
|
+
-- a no-row SELECT INTO sets its targets to NULL, which once wiped
|
|
1969
|
+
-- v_chunk_count before the INSERT below.
|
|
1970
|
+
SELECT d.id INTO v_duplicate_id
|
|
1971
|
+
FROM cerefox_documents d
|
|
1972
|
+
WHERE d.content_hash = p_content_hash
|
|
1973
|
+
AND d.deleted_at IS NULL
|
|
1974
|
+
ORDER BY d.created_at
|
|
1975
|
+
LIMIT 1;
|
|
1976
|
+
IF v_duplicate_id IS NOT NULL THEN
|
|
1977
|
+
RAISE EXCEPTION 'CEREFOX_DUPLICATE_CONTENT: document % already has this content; nothing was written', v_duplicate_id
|
|
1978
|
+
USING ERRCODE = '23505';
|
|
1979
|
+
END IF;
|
|
1980
|
+
END IF;
|
|
1981
|
+
|
|
1838
1982
|
INSERT INTO cerefox_documents (
|
|
1839
1983
|
title, source, source_path, content_hash, metadata,
|
|
1840
1984
|
chunk_count, total_chars, review_status
|
|
1841
1985
|
) VALUES (
|
|
1842
|
-
p_title, COALESCE(p_source, 'agent'), p_source_path, p_content_hash,
|
|
1986
|
+
p_title, COALESCE(p_source, 'agent'), p_source_path, p_content_hash,
|
|
1987
|
+
cerefox_metadata_apply('{}'::jsonb, COALESCE(p_metadata, '{}'::jsonb), TRUE),
|
|
1843
1988
|
v_chunk_count, v_total_chars, v_status
|
|
1844
1989
|
)
|
|
1845
1990
|
RETURNING id INTO v_doc_id;
|
|
@@ -1921,6 +2066,24 @@ BEGIN
|
|
|
1921
2066
|
);
|
|
1922
2067
|
END IF;
|
|
1923
2068
|
|
|
2069
|
+
-- ── Metadata history (#333) ──────────────────────────────────────
|
|
2070
|
+
-- A content update that changed metadata also records it as its own
|
|
2071
|
+
-- 'update-metadata' entry, with the before/after values, so the metadata
|
|
2072
|
+
-- history is complete whichever path wrote it.
|
|
2073
|
+
IF v_operation = 'update-content' AND v_meta_after IS DISTINCT FROM v_meta_before THEN
|
|
2074
|
+
PERFORM cerefox_create_audit_entry(
|
|
2075
|
+
p_document_id := v_doc_id,
|
|
2076
|
+
p_version_id := v_version_id,
|
|
2077
|
+
p_operation := 'update-metadata',
|
|
2078
|
+
p_author := p_author,
|
|
2079
|
+
p_author_type := p_author_type,
|
|
2080
|
+
p_description := format('Metadata %s on "%s" (with a content update)',
|
|
2081
|
+
CASE WHEN p_replace_metadata THEN 'replaced' ELSE 'merged' END, p_title),
|
|
2082
|
+
p_metadata_before := v_meta_before,
|
|
2083
|
+
p_metadata_after := v_meta_after
|
|
2084
|
+
);
|
|
2085
|
+
END IF;
|
|
2086
|
+
|
|
1924
2087
|
-- ── Size signal (iter-33) ────────────────────────────────────────
|
|
1925
2088
|
-- Dormant unless an operator sets a threshold. Never blocks the write: a
|
|
1926
2089
|
-- size policy is not a correctness rule, and an agent that only ever
|
|
@@ -1931,7 +2094,9 @@ BEGIN
|
|
|
1931
2094
|
END IF;
|
|
1932
2095
|
|
|
1933
2096
|
RETURN QUERY SELECT v_doc_id, v_chunk_count, v_total_chars, v_operation,
|
|
1934
|
-
v_version_id, p_content_hash, v_size_warning
|
|
2097
|
+
v_version_id, p_content_hash, v_size_warning,
|
|
2098
|
+
(SELECT d.metadata_revision FROM cerefox_documents d
|
|
2099
|
+
WHERE d.id = v_doc_id);
|
|
1935
2100
|
END;
|
|
1936
2101
|
$$;
|
|
1937
2102
|
|
|
@@ -1975,6 +2140,8 @@ $$;
|
|
|
1975
2140
|
-- Returns the created entry's id and created_at.
|
|
1976
2141
|
|
|
1977
2142
|
DROP FUNCTION IF EXISTS cerefox_create_audit_entry(UUID, UUID, TEXT, TEXT, TEXT, INT, INT, TEXT);
|
|
2143
|
+
-- #333 (0.21.0): + metadata before/after, for 'update-metadata' entries.
|
|
2144
|
+
DROP FUNCTION IF EXISTS cerefox_create_audit_entry(UUID, UUID, TEXT, TEXT, TEXT, INT, INT, TEXT, JSONB, JSONB);
|
|
1978
2145
|
CREATE FUNCTION cerefox_create_audit_entry(
|
|
1979
2146
|
p_document_id UUID DEFAULT NULL,
|
|
1980
2147
|
p_version_id UUID DEFAULT NULL,
|
|
@@ -1983,7 +2150,9 @@ CREATE FUNCTION cerefox_create_audit_entry(
|
|
|
1983
2150
|
p_author_type TEXT DEFAULT 'user',
|
|
1984
2151
|
p_size_before INT DEFAULT NULL,
|
|
1985
2152
|
p_size_after INT DEFAULT NULL,
|
|
1986
|
-
p_description TEXT DEFAULT ''
|
|
2153
|
+
p_description TEXT DEFAULT '',
|
|
2154
|
+
p_metadata_before JSONB DEFAULT NULL,
|
|
2155
|
+
p_metadata_after JSONB DEFAULT NULL
|
|
1987
2156
|
)
|
|
1988
2157
|
RETURNS TABLE (
|
|
1989
2158
|
audit_id UUID,
|
|
@@ -1995,12 +2164,13 @@ SET search_path = public, pg_catalog
|
|
|
1995
2164
|
AS $$
|
|
1996
2165
|
INSERT INTO cerefox_audit_log (
|
|
1997
2166
|
document_id, version_id, operation, author, author_type,
|
|
1998
|
-
size_before, size_after, description,
|
|
2167
|
+
size_before, size_after, description, metadata_before, metadata_after,
|
|
2168
|
+
created_at
|
|
1999
2169
|
)
|
|
2000
2170
|
VALUES (
|
|
2001
2171
|
p_document_id, p_version_id, p_operation, p_author,
|
|
2002
2172
|
CASE WHEN p_author_type IN ('user', 'agent') THEN p_author_type ELSE 'user' END,
|
|
2003
|
-
p_size_before, p_size_after, p_description,
|
|
2173
|
+
p_size_before, p_size_after, p_description, p_metadata_before, p_metadata_after,
|
|
2004
2174
|
-- clock_timestamp(), not the NOW() default: NOW() is the TRANSACTION's
|
|
2005
2175
|
-- start time, so every audit entry written by one cerefox_edit batch
|
|
2006
2176
|
-- would share a timestamp and the order of operations inside the batch
|
|
@@ -2044,7 +2214,10 @@ RETURNS TABLE (
|
|
|
2044
2214
|
size_before INT,
|
|
2045
2215
|
size_after INT,
|
|
2046
2216
|
description TEXT,
|
|
2047
|
-
created_at TIMESTAMPTZ
|
|
2217
|
+
created_at TIMESTAMPTZ,
|
|
2218
|
+
-- #333 (0.21.0): set on 'update-metadata' entries, NULL otherwise.
|
|
2219
|
+
metadata_before JSONB,
|
|
2220
|
+
metadata_after JSONB
|
|
2048
2221
|
)
|
|
2049
2222
|
LANGUAGE sql
|
|
2050
2223
|
SECURITY DEFINER
|
|
@@ -2062,7 +2235,9 @@ AS $$
|
|
|
2062
2235
|
a.size_before,
|
|
2063
2236
|
a.size_after,
|
|
2064
2237
|
a.description,
|
|
2065
|
-
a.created_at
|
|
2238
|
+
a.created_at,
|
|
2239
|
+
a.metadata_before,
|
|
2240
|
+
a.metadata_after
|
|
2066
2241
|
FROM cerefox_audit_log a
|
|
2067
2242
|
LEFT JOIN cerefox_documents d ON d.id = a.document_id
|
|
2068
2243
|
WHERE (p_document_id IS NULL OR a.document_id = p_document_id)
|
|
@@ -2437,7 +2612,9 @@ RETURNS TABLE (
|
|
|
2437
2612
|
-- Optimistic-concurrency token (iter-32): pass back as
|
|
2438
2613
|
-- expected_content_hash on update.
|
|
2439
2614
|
content_hash TEXT,
|
|
2440
|
-
content TEXT
|
|
2615
|
+
content TEXT,
|
|
2616
|
+
-- #333: pass back as expected_metadata_revision on a metadata write.
|
|
2617
|
+
metadata_revision INT
|
|
2441
2618
|
)
|
|
2442
2619
|
LANGUAGE plpgsql
|
|
2443
2620
|
SECURITY DEFINER
|
|
@@ -2467,6 +2644,7 @@ BEGIN
|
|
|
2467
2644
|
(SELECT COUNT(*)::INT FROM cerefox_document_versions dv
|
|
2468
2645
|
WHERE dv.document_id = d.id) AS version_count,
|
|
2469
2646
|
d.content_hash,
|
|
2647
|
+
d.metadata_revision,
|
|
2470
2648
|
CASE WHEN p_include_content THEN
|
|
2471
2649
|
(SELECT CASE WHEN MAX(c.content_format) >= 2 THEN STRING_AGG(c.content, '' ORDER BY c.chunk_index) ELSE STRING_AGG(c.content, E'\n\n' ORDER BY c.chunk_index) END
|
|
2472
2650
|
FROM cerefox_chunks c
|
|
@@ -2507,6 +2685,7 @@ BEGIN
|
|
|
2507
2685
|
version_count := v_row.version_count;
|
|
2508
2686
|
content_hash := v_row.content_hash;
|
|
2509
2687
|
content := v_row.content;
|
|
2688
|
+
metadata_revision := v_row.metadata_revision;
|
|
2510
2689
|
RETURN NEXT;
|
|
2511
2690
|
END LOOP;
|
|
2512
2691
|
END;
|
|
@@ -3118,19 +3297,29 @@ $$;
|
|
|
3118
3297
|
--
|
|
3119
3298
|
-- Metadata-only: no re-chunk, no re-embed, no content version snapshot. The
|
|
3120
3299
|
-- audit trail records it as 'update-metadata', which the CHECK already allows.
|
|
3300
|
+
-- #333 (0.21.0): a concurrency token for metadata. `metadata_revision` goes up
|
|
3301
|
+
-- by one on every actual change (trigger). Merge writes MAY pass
|
|
3302
|
+
-- p_expected_metadata_revision (strict when passed); a replace MUST pass it, or
|
|
3303
|
+
-- p_last_write_wins, because a replace deletes keys. A merge of disjoint keys
|
|
3304
|
+
-- was already safe, and requiring a token there would force a read before
|
|
3305
|
+
-- every tag. The audit entry now records the metadata before and after.
|
|
3121
3306
|
DROP FUNCTION IF EXISTS cerefox_set_document_metadata(UUID, JSONB, BOOLEAN, TEXT, TEXT);
|
|
3307
|
+
DROP FUNCTION IF EXISTS cerefox_set_document_metadata(UUID, JSONB, BOOLEAN, TEXT, TEXT, INT, BOOLEAN);
|
|
3122
3308
|
CREATE FUNCTION cerefox_set_document_metadata(
|
|
3123
3309
|
p_document_id UUID,
|
|
3124
3310
|
p_metadata JSONB,
|
|
3125
3311
|
p_replace BOOLEAN DEFAULT FALSE,
|
|
3126
3312
|
p_author TEXT DEFAULT 'unknown',
|
|
3127
|
-
p_author_type TEXT DEFAULT 'user'
|
|
3313
|
+
p_author_type TEXT DEFAULT 'user',
|
|
3314
|
+
p_expected_metadata_revision INT DEFAULT NULL,
|
|
3315
|
+
p_last_write_wins BOOLEAN DEFAULT FALSE
|
|
3128
3316
|
)
|
|
3129
3317
|
RETURNS TABLE (
|
|
3130
3318
|
document_id UUID,
|
|
3131
3319
|
metadata JSONB,
|
|
3132
3320
|
keys_set INT,
|
|
3133
|
-
keys_removed INT
|
|
3321
|
+
keys_removed INT,
|
|
3322
|
+
metadata_revision INT
|
|
3134
3323
|
)
|
|
3135
3324
|
LANGUAGE plpgsql
|
|
3136
3325
|
SECURITY DEFINER
|
|
@@ -3138,11 +3327,12 @@ SET search_path = public, pg_catalog
|
|
|
3138
3327
|
AS $$
|
|
3139
3328
|
DECLARE
|
|
3140
3329
|
v_before JSONB;
|
|
3330
|
+
v_stored JSONB;
|
|
3141
3331
|
v_after JSONB;
|
|
3142
|
-
v_null_keys TEXT[];
|
|
3143
3332
|
v_title TEXT;
|
|
3144
3333
|
v_removed INT;
|
|
3145
3334
|
v_set INT;
|
|
3335
|
+
v_revision INT;
|
|
3146
3336
|
BEGIN
|
|
3147
3337
|
IF p_metadata IS NULL OR jsonb_typeof(p_metadata) <> 'object' THEN
|
|
3148
3338
|
RAISE EXCEPTION 'metadata must be a JSON object'
|
|
@@ -3150,8 +3340,8 @@ BEGIN
|
|
|
3150
3340
|
END IF;
|
|
3151
3341
|
|
|
3152
3342
|
-- Lock the row so a concurrent metadata write cannot interleave between
|
|
3153
|
-
-- the read and the merge.
|
|
3154
|
-
SELECT d.metadata, d.title INTO
|
|
3343
|
+
-- the read, the token check and the merge.
|
|
3344
|
+
SELECT d.metadata, d.title, d.metadata_revision INTO v_stored, v_title, v_revision
|
|
3155
3345
|
FROM cerefox_documents d
|
|
3156
3346
|
WHERE d.id = p_document_id AND d.deleted_at IS NULL
|
|
3157
3347
|
FOR UPDATE;
|
|
@@ -3161,17 +3351,21 @@ BEGIN
|
|
|
3161
3351
|
USING ERRCODE = 'P0002';
|
|
3162
3352
|
END IF;
|
|
3163
3353
|
|
|
3354
|
+
PERFORM cerefox_check_metadata_revision(
|
|
3355
|
+
p_document_id, v_revision, p_expected_metadata_revision,
|
|
3356
|
+
p_replace, p_last_write_wins);
|
|
3357
|
+
|
|
3164
3358
|
-- #212: a legacy row can hold NON-OBJECT metadata (the ingest RPC did not
|
|
3165
3359
|
-- validate its input until 0.12.2). Merging onto it with || would produce
|
|
3166
3360
|
-- an ARRAY — Postgres treats both sides as arrays — burying the stored
|
|
3167
3361
|
-- value one level deeper and leaving the row still corrupt. Only replace
|
|
3168
3362
|
-- actually repairs such a row, so refuse the merge and say so. jsonb
|
|
3169
3363
|
-- 'null' is treated like SQL NULL (empty), matching the CLI.
|
|
3170
|
-
IF NOT p_replace AND
|
|
3171
|
-
AND jsonb_typeof(
|
|
3364
|
+
IF NOT p_replace AND v_stored IS NOT NULL
|
|
3365
|
+
AND jsonb_typeof(v_stored) NOT IN ('object', 'null') THEN
|
|
3172
3366
|
RAISE EXCEPTION
|
|
3173
3367
|
'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.',
|
|
3174
|
-
p_document_id, jsonb_typeof(
|
|
3368
|
+
p_document_id, jsonb_typeof(v_stored)
|
|
3175
3369
|
USING ERRCODE = '22023';
|
|
3176
3370
|
END IF;
|
|
3177
3371
|
|
|
@@ -3181,35 +3375,27 @@ BEGIN
|
|
|
3181
3375
|
-- errors, which would roll back the repair itself (round-5 review,
|
|
3182
3376
|
-- verified live).
|
|
3183
3377
|
v_before := CASE
|
|
3184
|
-
WHEN
|
|
3185
|
-
ELSE
|
|
3378
|
+
WHEN v_stored IS NULL OR jsonb_typeof(v_stored) <> 'object' THEN '{}'::jsonb
|
|
3379
|
+
ELSE v_stored
|
|
3186
3380
|
END;
|
|
3187
3381
|
|
|
3188
|
-
|
|
3189
|
-
SELECT COALESCE(array_agg(key), ARRAY[]::TEXT[]) INTO v_null_keys
|
|
3190
|
-
FROM jsonb_each(p_metadata)
|
|
3191
|
-
WHERE value = 'null'::jsonb;
|
|
3382
|
+
v_after := cerefox_metadata_apply(v_before, p_metadata, p_replace);
|
|
3192
3383
|
|
|
3193
|
-
|
|
3194
|
-
v_after := p_metadata - v_null_keys;
|
|
3195
|
-
ELSE
|
|
3196
|
-
v_after := (COALESCE(v_before, '{}'::jsonb) || p_metadata) - v_null_keys;
|
|
3197
|
-
END IF;
|
|
3198
|
-
|
|
3199
|
-
UPDATE cerefox_documents
|
|
3384
|
+
UPDATE cerefox_documents d
|
|
3200
3385
|
SET metadata = v_after, updated_at = NOW()
|
|
3201
|
-
WHERE id = p_document_id
|
|
3386
|
+
WHERE d.id = p_document_id
|
|
3387
|
+
RETURNING d.metadata_revision INTO v_revision;
|
|
3202
3388
|
|
|
3203
3389
|
-- Report what actually changed, not what was asked for: a caller that sets
|
|
3204
3390
|
-- a key to the value it already had should see 0, so "nothing happened" is
|
|
3205
3391
|
-- distinguishable from "it worked".
|
|
3206
3392
|
SELECT count(*)::INT INTO v_removed
|
|
3207
|
-
FROM jsonb_object_keys(
|
|
3393
|
+
FROM jsonb_object_keys(v_before) k
|
|
3208
3394
|
WHERE NOT v_after ? k;
|
|
3209
3395
|
|
|
3210
3396
|
SELECT count(*)::INT INTO v_set
|
|
3211
3397
|
FROM jsonb_each(v_after) e
|
|
3212
|
-
WHERE
|
|
3398
|
+
WHERE v_before -> e.key IS DISTINCT FROM e.value;
|
|
3213
3399
|
|
|
3214
3400
|
PERFORM cerefox_create_audit_entry(
|
|
3215
3401
|
p_document_id := p_document_id,
|
|
@@ -3217,13 +3403,18 @@ BEGIN
|
|
|
3217
3403
|
p_author := p_author,
|
|
3218
3404
|
p_author_type := p_author_type,
|
|
3219
3405
|
p_description := format(
|
|
3220
|
-
'Metadata %s on "%s": %s key(s) set, %s removed',
|
|
3406
|
+
'Metadata %s on "%s": %s key(s) set, %s removed%s',
|
|
3221
3407
|
CASE WHEN p_replace THEN 'replaced' ELSE 'merged' END,
|
|
3222
|
-
v_title, v_set, v_removed
|
|
3223
|
-
|
|
3408
|
+
v_title, v_set, v_removed,
|
|
3409
|
+
CASE WHEN p_replace AND p_last_write_wins
|
|
3410
|
+
AND p_expected_metadata_revision IS NULL
|
|
3411
|
+
THEN ' [last-write-wins]' ELSE '' END
|
|
3412
|
+
),
|
|
3413
|
+
p_metadata_before := v_stored,
|
|
3414
|
+
p_metadata_after := v_after
|
|
3224
3415
|
);
|
|
3225
3416
|
|
|
3226
|
-
RETURN QUERY SELECT p_document_id, v_after, v_set, v_removed;
|
|
3417
|
+
RETURN QUERY SELECT p_document_id, v_after, v_set, v_removed, v_revision;
|
|
3227
3418
|
END;
|
|
3228
3419
|
$$;
|
|
3229
3420
|
|
|
@@ -3236,6 +3427,14 @@ SET search_path = public, pg_catalog
|
|
|
3236
3427
|
AS $$
|
|
3237
3428
|
-- Keep in lockstep with the `@version:` marker in schema.sql (cut_release.ts
|
|
3238
3429
|
-- enforces it). Bump whenever schema.sql OR rpcs.sql changes.
|
|
3430
|
+
-- 0.21.1: a create that loses the duplicate race raises
|
|
3431
|
+
-- CEREFOX_DUPLICATE_CONTENT (23505) instead of returning 'skipped'.
|
|
3432
|
+
-- 0.21.0: migration 0036 adds metadata_revision (+ trigger) and audit
|
|
3433
|
+
-- metadata_before/after; the metadata RPC and ingest RPC check the token,
|
|
3434
|
+
-- content updates merge metadata instead of replacing it (#333).
|
|
3435
|
+
-- 0.20.0: migration 0035 drops the store-wide UNIQUE on content_hash; the
|
|
3436
|
+
-- duplicate check moves into cerefox_ingest_document's create path, live
|
|
3437
|
+
-- documents only, returning operation 'skipped' (#332).
|
|
3239
3438
|
-- 0.19.0: migration 0034 drops the unused HNSW vector indexes (search has
|
|
3240
3439
|
-- scanned exactly since 0.18.1). No RPC behavior change.
|
|
3241
3440
|
-- 0.18.2: min_term_coverage built-in default 0.66 (two of three terms).
|
|
@@ -3290,7 +3489,7 @@ AS $$
|
|
|
3290
3489
|
-- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
|
|
3291
3490
|
-- the partial-edit surface, and both migrations (0019, 0020) are in the
|
|
3292
3491
|
-- sequence, so a store deploying this gets everything from both lines.
|
|
3293
|
-
SELECT '0.
|
|
3492
|
+
SELECT '0.21.1'::TEXT;
|
|
3294
3493
|
$$;
|
|
3295
3494
|
|
|
3296
3495
|
-- ── cerefox_find_dead_links ──────────────────────────────────────────────────
|