@cerefox/memory 1.17.4 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/AGENT_GUIDE.md +8 -3
  2. package/AGENT_QUICK_REFERENCE.md +3 -3
  3. package/README.md +1 -1
  4. package/dist/bin/cerefox.js +1542 -1123
  5. package/dist/frontend/assets/index-01k5C3sG.css +1 -0
  6. package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
  7. package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
  8. package/dist/frontend/index.html +2 -2
  9. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  10. package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
  11. package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
  12. package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
  13. package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
  14. package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
  15. package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
  16. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
  17. package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
  18. package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
  19. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
  20. package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
  21. package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
  22. package/dist/server-assets/db/migrations/0012_content_format.sql +1 -1
  23. package/dist/server-assets/db/migrations/0014_document_relations.sql +1 -1
  24. package/dist/server-assets/db/migrations/0020_ingest_preserves_source.sql +1 -1
  25. package/dist/server-assets/db/migrations/0022_rls_on_document_relations.sql +1 -1
  26. package/dist/server-assets/db/migrations/0032_derive_grants.sql +1 -1
  27. package/dist/server-assets/db/migrations/0034_drop_vector_indexes.sql +22 -0
  28. package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
  29. package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
  30. package/dist/server-assets/db/rpcs.sql +260 -58
  31. package/dist/server-assets/db/schema.sql +49 -18
  32. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
  33. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
  34. package/docs/api/openapi.json +77 -3
  35. package/docs/guides/access-paths.md +7 -3
  36. package/docs/guides/api.md +1 -1
  37. package/docs/guides/cli.md +5 -3
  38. package/docs/guides/configuration.md +23 -2
  39. package/docs/guides/connect-agents.md +60 -25
  40. package/docs/guides/ops-scripts.md +1 -1
  41. package/docs/guides/quickstart.md +7 -3
  42. package/docs/guides/setup-local.md +30 -6
  43. package/docs/guides/setup-supabase.md +5 -0
  44. package/docs/guides/upgrading.md +16 -1
  45. package/package.json +7 -7
  46. package/dist/frontend/assets/index-B0H_N4if.css +0 -1
  47. package/dist/frontend/assets/index-D1kdLLr8.js +0 -121
  48. package/dist/frontend/assets/index-D1kdLLr8.js.map +0 -1
@@ -27,7 +27,14 @@ import {
27
27
  } from "./_chunker.ts";
28
28
  import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
29
29
  import { ensureDocumentInProject, setDocumentProjectsByName } from "./_projects.ts";
30
- import { extractConflictHashes, logUsage } from "./_utils.ts";
30
+ import {
31
+ duplicateContentDocumentId,
32
+ extractConflictHashes,
33
+ legacyContentUniqueError,
34
+ logUsage,
35
+ metadataTokenError,
36
+ metadataTokenParams,
37
+ } from "./_utils.ts";
31
38
  import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
32
39
  import { AUTHOR_PARAM_WRITE, DEFAULT_IDENTITY, callerIdentity } from "./identity.ts";
33
40
 
@@ -49,6 +56,8 @@ function conflictError(documentId: string, expectedHash: string, currentHash: st
49
56
 
50
57
  /** Map RPC-side CEREFOX_CONFLICT / CEREFOX_TOKEN_REQUIRED errors to agent-first text. */
51
58
  function mapIngestRpcError(message: string, documentId: string): Error {
59
+ const metaErr = metadataTokenError(message);
60
+ if (metaErr) return metaErr;
52
61
  if (message.includes("CEREFOX_CONFLICT")) {
53
62
  const { expected, current } = extractConflictHashes(message);
54
63
  return conflictError(documentId, expected, current);
@@ -82,7 +91,7 @@ function mapIngestRpcError(message: string, documentId: string): Error {
82
91
  `regardless of concurrent changes, pass last_write_wins=true.`,
83
92
  );
84
93
  }
85
- return new Error(`Ingest RPC failed: ${message}`);
94
+ return legacyContentUniqueError(message) ?? new Error(`Ingest RPC failed: ${message}`);
86
95
  }
87
96
 
88
97
  async function handler(
@@ -118,6 +127,14 @@ async function handler(
118
127
  const author_type = "agent"; // MCP path is always agent
119
128
  const expected_content_hash = (args.expected_content_hash as string | undefined)?.trim() || null;
120
129
  const last_write_wins = (args.last_write_wins as boolean | undefined) ?? false;
130
+ const expectedMetaRaw = args.expected_metadata_revision;
131
+ if (
132
+ expectedMetaRaw !== undefined && expectedMetaRaw !== null &&
133
+ !(typeof expectedMetaRaw === "number" && Number.isInteger(expectedMetaRaw))
134
+ ) {
135
+ throw new McpInvalidParams("expected_metadata_revision must be an integer (the metadata_revision you read)");
136
+ }
137
+ const expected_metadata_revision = (expectedMetaRaw as number | undefined) ?? null;
121
138
 
122
139
  if (!title || !content?.trim()) {
123
140
  throw new McpInvalidParams("title and content are required");
@@ -214,6 +231,7 @@ async function handler(
214
231
  p_expected_content_hash: expected_content_hash,
215
232
  p_last_write_wins: last_write_wins,
216
233
  p_content_format: CONTENT_FORMAT_BLIND_STITCH,
234
+ ...metadataTokenParams(expected_metadata_revision, false),
217
235
  });
218
236
 
219
237
  if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
@@ -315,6 +333,7 @@ async function handler(
315
333
  p_expected_content_hash: expected_content_hash,
316
334
  p_last_write_wins: last_write_wins,
317
335
  p_content_format: CONTENT_FORMAT_BLIND_STITCH,
336
+ ...metadataTokenParams(expected_metadata_revision, false),
318
337
  });
319
338
 
320
339
  if (ingestErr) throw mapIngestRpcError(ingestErr.message, existingDoc.id);
@@ -339,26 +358,19 @@ async function handler(
339
358
  }
340
359
 
341
360
  // ── Hash deduplication (create path) ─────────────────────────────────────
342
- // content_hash is unique STORE-WIDE, trashed documents included, so this
343
- // check deliberately sees them: an insert would collide either way.
361
+ // Creates only, against LIVE documents (#332): content is not unique across
362
+ // the store, and a trashed document no longer blocks a new one. This saves
363
+ // the embedding spend; the RPC repeats the check authoritatively.
344
364
  const { data: hashMatch } = await supabase
345
365
  .from("cerefox_documents")
346
- .select("id, title, deleted_at")
366
+ .select("id, title")
347
367
  .eq("content_hash", contentHash)
368
+ .is("deleted_at", null)
369
+ .order("created_at", { ascending: true })
348
370
  .limit(1);
349
371
 
350
372
  if (hashMatch?.length) {
351
- if (hashMatch[0].deleted_at) {
352
- // "Already up-to-date" while search shows nothing is the classic
353
- // re-upload-after-delete confusion. Say where the content actually is.
354
- return (
355
- `This exact content is already in the TRASH as "${hashMatch[0].title}" ` +
356
- `(soft-deleted, id: ${hashMatch[0].id}). Restore it with ` +
357
- `cerefox_restore_document instead of re-ingesting — or have your user ` +
358
- `purge it from the web UI Trash to start fresh.`
359
- );
360
- }
361
- return `Document already up-to-date: "${hashMatch[0].title}" (id: ${hashMatch[0].id}). Content hash unchanged (${contentHash}) — pass it as expected_content_hash to edit it.`;
373
+ return alreadyExistsMessage(hashMatch[0].title, hashMatch[0].id, contentHash);
362
374
  }
363
375
 
364
376
  const chunks = chunkMarkdown(content);
@@ -391,7 +403,20 @@ async function handler(
391
403
  p_content_format: CONTENT_FORMAT_BLIND_STITCH,
392
404
  });
393
405
 
406
+ // The RPC's duplicate check caught an identical create that landed after
407
+ // the pre-check above. Nothing was written.
408
+ const duplicateId = duplicateContentDocumentId(ingestErr?.message ?? "");
409
+ if (duplicateId) {
410
+ const { data: existing } = await supabase
411
+ .from("cerefox_documents")
412
+ .select("title")
413
+ .eq("id", duplicateId)
414
+ .maybeSingle();
415
+ return alreadyExistsMessage(existing?.title ?? title, duplicateId, contentHash);
416
+ }
394
417
  if (ingestErr || !ingestResult?.length) {
418
+ const legacy = legacyContentUniqueError(ingestErr?.message ?? "");
419
+ if (legacy) throw legacy;
395
420
  throw new Error(`Ingest RPC failed: ${ingestErr?.message ?? "no data returned"}`);
396
421
  }
397
422
 
@@ -424,6 +449,10 @@ async function handler(
424
449
  );
425
450
  }
426
451
 
452
+ function alreadyExistsMessage(title: string, id: string, contentHash: string): string {
453
+ return `Document already up-to-date: "${title}" (id: ${id}). Content hash unchanged (${contentHash}) — pass it as expected_content_hash to edit it.`;
454
+ }
455
+
427
456
  export const ingestTool: ToolDefinition = {
428
457
  name: "cerefox_ingest",
429
458
  description:
@@ -477,7 +506,16 @@ export const ingestTool: ToolDefinition = {
477
506
  description:
478
507
  "Explicitly skip the concurrency check and overwrite regardless of concurrent changes (default: false). Use ONLY when an external source of truth makes conflicts meaningless (e.g. re-syncing from files). Recorded in the audit log.",
479
508
  },
480
- metadata: { type: "object", description: "Arbitrary JSON metadata (optional)" },
509
+ metadata: {
510
+ type: "object",
511
+ description:
512
+ "JSON metadata (optional). On an UPDATE it is MERGED into the existing metadata: keys you pass are set, a null value removes that key, keys you omit are kept. To change only metadata, use cerefox_set_document_metadata instead.",
513
+ },
514
+ expected_metadata_revision: {
515
+ type: "integer",
516
+ description:
517
+ "Optional, with metadata on an update: the metadata_revision you read (cerefox_get_document / cerefox_metadata_search). The write fails if the metadata changed since.",
518
+ },
481
519
  author: AUTHOR_PARAM_WRITE,
482
520
  },
483
521
  },
@@ -89,6 +89,7 @@ async function handler(
89
89
  project_names: string[];
90
90
  version_count: number;
91
91
  content_hash: string | null;
92
+ metadata_revision?: number | null;
92
93
  content: string | null;
93
94
  }>;
94
95
 
@@ -203,7 +204,8 @@ async function handler(
203
204
  const meta = Object.entries(row.doc_metadata ?? {})
204
205
  .map(([k, v]) => `${k}=${v}`)
205
206
  .join(", ");
206
- const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
207
+ const rev = row.metadata_revision != null ? ` · metadata_revision: ${row.metadata_revision}` : "";
208
+ const hash = row.content_hash ? `\nhash: ${row.content_hash}${rev}` : "";
207
209
  const header =
208
210
  `## ${row.title} [id: ${row.document_id}]\n` +
209
211
  `${meta}${projects} | ${row.total_chars} chars | ${showReview ? `${row.review_status} | ` : ""}updated ${row.updated_at?.slice(0, 10) ?? "?"}${hash}`;
@@ -36,7 +36,12 @@ import {
36
36
  sha256hex,
37
37
  } from "./_chunker.ts";
38
38
  import { activeEmbedderName, embedBatch, resolveEmbedderKind } from "../embeddings/index.ts";
39
- import { extractConflictHashes, isMissingFunctionError, logUsage } from "./_utils.ts";
39
+ import {
40
+ extractConflictHashes,
41
+ isMissingFunctionError,
42
+ legacyContentUniqueError,
43
+ logUsage,
44
+ } from "./_utils.ts";
40
45
  import { McpInvalidParams, type MCPSupabaseClient, type ToolContext, type ToolDefinition } from "./types.ts";
41
46
  import { AUTHOR_PARAM_WRITE, DEFAULT_IDENTITY, callerIdentity } from "./identity.ts";
42
47
 
@@ -329,18 +334,6 @@ async function applyAndWrite(
329
334
  if (message.includes("CEREFOX_CONFLICT")) {
330
335
  throw conflictError(documentId, expectedHash, extractConflictHashes(message).current);
331
336
  }
332
- if (message.includes("cerefox_documents_hash_unique")) {
333
- // content_hash is UNIQUE store-wide, so an edit whose result matches
334
- // ANOTHER document collides. Raw, this reads as a database fault; it is
335
- // actually a meaningful statement about the store.
336
- throw new Error(
337
- `This edit would make "${doc.title}" (id: ${documentId}) byte-identical to another ` +
338
- `document in the store, and content must be unique. No write was performed. ` +
339
- `Usually this means the two documents have converged and one should be removed or ` +
340
- `merged, or that this edit was already applied to the other one — ` +
341
- `cerefox_search for the resulting content to find it.`,
342
- );
343
- }
344
337
  if (message.includes("CEREFOX_UNRESOLVED_LINKS")) {
345
338
  // Only ids INTRODUCED by this write reject (the RPC tolerates dead
346
339
  // links the document already carried), so the diagnosis is specific.
@@ -365,6 +358,8 @@ async function applyAndWrite(
365
358
  `Run \`cerefox server deploy\`, then retry. (${message})`,
366
359
  );
367
360
  }
361
+ const legacy = legacyContentUniqueError(message);
362
+ if (legacy) throw legacy;
368
363
  throw new Error(`Edit failed: ${message}`);
369
364
  }
370
365
 
@@ -43,6 +43,7 @@ interface SearchRow {
43
43
  chunk_count?: number;
44
44
  total_chars?: number;
45
45
  content_hash?: string;
46
+ metadata_revision?: number;
46
47
  below_confidence?: boolean;
47
48
  }
48
49
 
@@ -104,6 +105,14 @@ function shortLabel(row: SearchRow): string {
104
105
  return `${leaf ? `${doc} › ${leaf}` : doc}${chunk}${id}`;
105
106
  }
106
107
 
108
+ /** The concurrency tokens: content_hash for cerefox_ingest updates (iter-32),
109
+ * metadata_revision for metadata writes (#333). */
110
+ function hashLine(row: SearchRow): string {
111
+ if (!row.content_hash) return "";
112
+ const rev = row.metadata_revision != null ? ` · metadata_revision: ${row.metadata_revision}` : "";
113
+ return `\nhash: ${row.content_hash}${rev}`;
114
+ }
115
+
107
116
  /** One rendered result: heading, score, then the row's text. */
108
117
  function renderRow(row: SearchRow): string {
109
118
  const raw = row.best_score ?? row.score;
@@ -112,7 +121,7 @@ function renderRow(row: SearchRow): string {
112
121
  ? ` -- partial (${row.chunk_count} of ${(row.total_chars ?? 0).toLocaleString()} chars)`
113
122
  : "";
114
123
  // content_hash = the concurrency token for cerefox_ingest updates (iter-32).
115
- const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
124
+ const hash = hashLine(row);
116
125
  // Whichever column this mode returns (#259): `cerefox_search_docs` gives
117
126
  // `full_content`, the chunk RPCs give `content`, and reading only the first
118
127
  // rendered every hybrid/fts result as a title with an empty body.
@@ -124,7 +133,7 @@ function headerLine(row: SearchRow): string {
124
133
  const raw = row.best_score ?? row.score;
125
134
  const score = raw != null ? ` (score: ${raw.toFixed(3)})` : "";
126
135
  const size = row.total_chars != null ? ` -- ${row.total_chars.toLocaleString()} chars` : "";
127
- const hash = row.content_hash ? `\nhash: ${row.content_hash}` : "";
136
+ const hash = hashLine(row);
128
137
  return `## ${rowHeading(row)}${score}${size}${hash}`;
129
138
  }
130
139
 
@@ -19,7 +19,7 @@
19
19
 
20
20
  import type { MCPSupabaseClient } from "./types.ts";
21
21
 
22
- import { logUsage } from "./_utils.ts";
22
+ import { logUsage, metadataTokenError, metadataTokenParams } from "./_utils.ts";
23
23
  import { McpInvalidParams, type ToolContext, type ToolDefinition } from "./types.ts";
24
24
  import { AUTHOR_PARAM_WRITE, DEFAULT_IDENTITY, callerIdentity } from "./identity.ts";
25
25
 
@@ -31,6 +31,14 @@ async function handler(
31
31
  const document_id = args.document_id as string | undefined;
32
32
  const metadata = args.metadata as Record<string, unknown> | undefined;
33
33
  const replace = (args.replace as boolean | undefined) ?? false;
34
+ const expectedRevision = args.expected_metadata_revision;
35
+ if (
36
+ expectedRevision !== undefined && expectedRevision !== null &&
37
+ !(typeof expectedRevision === "number" && Number.isInteger(expectedRevision))
38
+ ) {
39
+ throw new McpInvalidParams("expected_metadata_revision must be an integer (the metadata_revision you read)");
40
+ }
41
+ const lastWriteWins = (args.last_write_wins as boolean | undefined) ?? false;
34
42
 
35
43
  if (!document_id) throw new McpInvalidParams("document_id is required");
36
44
  if (metadata === undefined || metadata === null) {
@@ -60,12 +68,19 @@ async function handler(
60
68
  p_replace: replace,
61
69
  p_author: author ?? "unknown",
62
70
  p_author_type: authorType,
71
+ ...metadataTokenParams(expectedRevision as number | null | undefined, lastWriteWins),
63
72
  });
64
73
 
65
- if (error) throw new Error(`RPC error: ${error.message}`);
74
+ if (error) throw metadataTokenError(error.message) ?? new Error(`RPC error: ${error.message}`);
66
75
 
67
76
  const row = data?.[0] as
68
- | { document_id?: string; metadata?: Record<string, unknown>; keys_set?: number; keys_removed?: number }
77
+ | {
78
+ document_id?: string;
79
+ metadata?: Record<string, unknown>;
80
+ keys_set?: number;
81
+ keys_removed?: number;
82
+ metadata_revision?: number;
83
+ }
69
84
  | undefined;
70
85
  if (!row) throw new Error("cerefox_set_document_metadata returned no data");
71
86
 
@@ -89,6 +104,7 @@ async function handler(
89
104
  return (
90
105
  `Metadata ${replace ? "replaced" : "merged"} on ${document_id}. ${summary}\n` +
91
106
  `Now: ${JSON.stringify(row.metadata ?? {})}\n` +
107
+ (row.metadata_revision != null ? `metadata_revision: ${row.metadata_revision}\n` : "") +
92
108
  `Content untouched — no new version, no re-embedding.`
93
109
  );
94
110
  }
@@ -96,7 +112,7 @@ async function handler(
96
112
  export const setDocumentMetadataTool: ToolDefinition = {
97
113
  name: "cerefox_set_document_metadata",
98
114
  description:
99
- "Change a document's metadata WITHOUT resending its content. MERGES by default: the keys you pass are set, every other key is left alone — so you do not need to read the document first, and you cannot accidentally drop tags another agent set. To REMOVE a key, pass it with a null value ({\"stale_key\": null}). Pass replace: true to set the metadata to exactly the object given, discarding everything else (rare; the same destructive contract as cerefox_set_document_projects). Content, chunks and embeddings are untouched and no new version is created. Use this instead of cerefox_ingest whenever only the tags are changing.",
115
+ "Change a document's metadata WITHOUT resending its content. MERGES by default: the keys you pass are set, every other key is left alone — so you do not need to read the document first, and you cannot accidentally drop tags another agent set. To REMOVE a key, pass it with a null value ({\"stale_key\": null}). Pass replace: true to set the metadata to exactly the object given, discarding everything else (rare; it requires expected_metadata_revision or last_write_wins). To make a merge fail if anyone changed the metadata since you read it, pass expected_metadata_revision. Content, chunks and embeddings are untouched and no new version is created. Use this instead of cerefox_ingest whenever only the tags are changing.",
100
116
  annotations: {
101
117
  title: "Set document metadata",
102
118
  readOnlyHint: false,
@@ -118,7 +134,17 @@ export const setDocumentMetadataTool: ToolDefinition = {
118
134
  replace: {
119
135
  type: "boolean",
120
136
  description:
121
- "Set the metadata to EXACTLY this object, discarding any key not listed. Defaults to false (merge). Use only when you mean to reset a document's tags wholesale.",
137
+ "Set the metadata to EXACTLY this object, discarding any key not listed. Defaults to false (merge). Use only when you mean to reset a document's tags wholesale. Requires expected_metadata_revision (or last_write_wins).",
138
+ },
139
+ expected_metadata_revision: {
140
+ type: "integer",
141
+ description:
142
+ "The metadata_revision you read (from cerefox_get_document or cerefox_metadata_search). Optional on a merge: when given, the write fails if the metadata changed since. Required with replace: true.",
143
+ },
144
+ last_write_wins: {
145
+ type: "boolean",
146
+ description:
147
+ "Replace without a metadata_revision, regardless of concurrent changes (default false). Recorded in the audit log. Use only when an external source of truth makes conflicts meaningless.",
122
148
  },
123
149
  author: AUTHOR_PARAM_WRITE,
124
150
  },
@@ -2,7 +2,7 @@
2
2
  --
3
3
  -- Records how each chunk's content reconstructs into full document text:
4
4
  -- 1 = legacy — chunk contents were trimmed sections; reconstruction re-joins
5
- -- them with E'\n\n' (the pre-28D behaviour). All existing chunks.
5
+ -- them with E'\n\n' (the pre-28D behavior). All existing chunks.
6
6
  -- 2 = blind-stitch — chunk contents are an exact, gapless partition of the
7
7
  -- document; reconstruction is a plain concatenation (no separator
8
8
  -- synthesized on read). Written by the exact-partition chunker.
@@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS cerefox_document_relations (
12
12
  source_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
13
13
  target_id UUID NOT NULL REFERENCES cerefox_documents(id) ON DELETE CASCADE,
14
14
  -- Free-text by design: agents define new types without a migration. The
15
- -- type dictionary (in the RPCs) gives known types behaviour; unknown types
15
+ -- type dictionary (in the RPCs) gives known types behavior; unknown types
16
16
  -- are stored and returned, just without special handling.
17
17
  rel_type TEXT NOT NULL,
18
18
  metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
@@ -55,5 +55,5 @@ BEGIN
55
55
  'Migration 0020: cerefox_ingest_document now preserves a document''s '
56
56
  'source when p_source is omitted. Before this, any content update '
57
57
  'without an explicit source silently rewrote provenance to ''agent'', '
58
- 'and migrate-format relabelled every document it converted (#191).';
58
+ 'and migrate-format relabeled every document it converted (#191).';
59
59
  END $$;
@@ -26,7 +26,7 @@
26
26
 
27
27
  ALTER TABLE cerefox_document_relations ENABLE ROW LEVEL SECURITY;
28
28
 
29
- -- Defence in depth: revoke the legacy blanket grants so the table is denied by
29
+ -- Defense in depth: revoke the legacy blanket grants so the table is denied by
30
30
  -- privilege as well as by RLS. Harmless where the grants were never made.
31
31
  REVOKE ALL ON TABLE cerefox_document_relations FROM anon;
32
32
 
@@ -32,7 +32,7 @@ BEGIN
32
32
 
33
33
  GRANT USAGE ON SCHEMA public TO service_role;
34
34
 
35
- -- Derived from the catalogue, not from a list someone has to remember.
35
+ -- Derived from the catalog, not from a list someone has to remember.
36
36
  FOR t IN
37
37
  SELECT c.relname
38
38
  FROM pg_class c
@@ -0,0 +1,22 @@
1
+ -- 0034_drop_vector_indexes.sql — drop the unused HNSW vector indexes (schema
2
+ -- 0.19.0, release 1.18.0).
3
+ --
4
+ -- Since 0.18.1 the hybrid search RPCs rank vector candidates by an exact scan
5
+ -- (`ORDER BY vec_score DESC`, see cerefox_hybrid_search in rpcs.sql), because
6
+ -- the HNSW index answered the bare-distance ordering with at most
7
+ -- `hnsw.ef_search` (40) rows, and on a store with heavy version churn those
8
+ -- were mostly not the nearest ones. The two indexes have served no query since,
9
+ -- while every chunk write still paid to maintain them and their quality kept
10
+ -- degrading.
11
+ --
12
+ -- The exact scan is fast at knowledge-base scale: measured on a 1-CPU / 1 GB
13
+ -- Postgres with 768-dim vectors, top-500 candidates took 2 ms at 2k chunks,
14
+ -- 56 ms at 50k and 181 ms at 100k. A default-configured HNSW index was slower
15
+ -- than that from 50k chunks and recalled 27% of the true top-250 at 100k.
16
+ -- `cerefox doctor` reports the current chunk count and warns past 100k; a
17
+ -- tuned index path for stores that large is tracked separately.
18
+ --
19
+ -- Idempotent: safe to re-run.
20
+
21
+ DROP INDEX IF EXISTS idx_cerefox_chunks_emb_primary;
22
+ DROP INDEX IF EXISTS idx_cerefox_chunks_emb_upgrade;
@@ -0,0 +1,21 @@
1
+ -- 0035_drop_content_hash_unique.sql — content no longer has to be unique
2
+ -- across the store (schema 0.20.0, #332).
3
+ --
4
+ -- `cerefox_documents_hash_unique` made every document's body unique store-wide,
5
+ -- trashed documents included. It was meant to stop the same file being
6
+ -- ingested twice, but as an unconditional constraint it also refused
7
+ -- legitimate writes: an update whose new body matched any OTHER document (two
8
+ -- unrelated documents converging on a short or templated body, a revision
9
+ -- back to an earlier state), and any write matching a document in the trash,
10
+ -- which the caller cannot see.
11
+ --
12
+ -- Duplicate detection now happens only where it is useful, when CREATING a
13
+ -- document, and only against live documents: `cerefox_ingest_document`
14
+ -- returns the existing document with operation 'skipped' instead of inserting
15
+ -- a copy. Updates are never refused for matching another document.
16
+ --
17
+ -- The plain index idx_cerefox_docs_hash keeps hash lookups fast.
18
+ --
19
+ -- Idempotent: safe to re-run.
20
+
21
+ ALTER TABLE cerefox_documents DROP CONSTRAINT IF EXISTS cerefox_documents_hash_unique;
@@ -0,0 +1,43 @@
1
+ -- 0036_metadata_revision.sql — a concurrency token and a history for metadata
2
+ -- (schema 0.21.0, #333).
3
+ --
4
+ -- Optimistic concurrency covered the body only: content_hash does not move on
5
+ -- a metadata change, and no metadata write checked a token, so two writers
6
+ -- setting the same key, a `replace`, or a content update carrying metadata
7
+ -- could silently overwrite another writer's change. Metadata also had no
8
+ -- history: content versions never captured it and audit entries recorded only
9
+ -- key counts.
10
+ --
11
+ -- 1. cerefox_documents.metadata_revision: the metadata's own token, bumped by a
12
+ -- trigger whenever metadata actually changes (every writer is covered).
13
+ -- 2. cerefox_audit_log.metadata_before / metadata_after: written on
14
+ -- 'update-metadata' entries, so any change can be reconstructed.
15
+ --
16
+ -- The RPCs that check the token and write the history ship in rpcs.sql.
17
+ -- Idempotent: safe to re-run.
18
+
19
+ ALTER TABLE cerefox_documents
20
+ ADD COLUMN IF NOT EXISTS metadata_revision INT NOT NULL DEFAULT 1;
21
+
22
+ ALTER TABLE cerefox_audit_log
23
+ ADD COLUMN IF NOT EXISTS metadata_before JSONB,
24
+ ADD COLUMN IF NOT EXISTS metadata_after JSONB;
25
+
26
+ CREATE OR REPLACE FUNCTION cerefox_bump_metadata_revision()
27
+ RETURNS TRIGGER
28
+ LANGUAGE plpgsql
29
+ SET search_path = public, pg_catalog
30
+ AS $$
31
+ BEGIN
32
+ IF NEW.metadata IS DISTINCT FROM OLD.metadata THEN
33
+ NEW.metadata_revision := OLD.metadata_revision + 1;
34
+ ELSE
35
+ NEW.metadata_revision := OLD.metadata_revision;
36
+ END IF;
37
+ RETURN NEW;
38
+ END;
39
+ $$;
40
+
41
+ CREATE OR REPLACE TRIGGER trig_cerefox_documents_metadata_revision
42
+ BEFORE UPDATE ON cerefox_documents
43
+ FOR EACH ROW EXECUTE FUNCTION cerefox_bump_metadata_revision();