@cerefox/memory 1.18.0 → 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 (37) hide show
  1. package/AGENT_GUIDE.md +8 -3
  2. package/AGENT_QUICK_REFERENCE.md +3 -3
  3. package/dist/bin/cerefox.js +846 -657
  4. package/dist/frontend/assets/index-01k5C3sG.css +1 -0
  5. package/dist/frontend/assets/index-HNs8rgY1.js +121 -0
  6. package/dist/frontend/assets/index-HNs8rgY1.js.map +1 -0
  7. package/dist/frontend/index.html +2 -2
  8. package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
  9. package/dist/server-assets/_shared/embeddings/onnx-embedder.ts +25 -9
  10. package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +24 -2
  11. package/dist/server-assets/_shared/mcp-tools/_utils.ts +92 -0
  12. package/dist/server-assets/_shared/mcp-tools/audit-log.ts +6 -2
  13. package/dist/server-assets/_shared/mcp-tools/audit-ops.ts +27 -0
  14. package/dist/server-assets/_shared/mcp-tools/get-document.ts +11 -1
  15. package/dist/server-assets/_shared/mcp-tools/get-help-content.ts +3 -3
  16. package/dist/server-assets/_shared/mcp-tools/ingest.ts +55 -17
  17. package/dist/server-assets/_shared/mcp-tools/metadata-search.ts +3 -1
  18. package/dist/server-assets/_shared/mcp-tools/partial-edits.ts +8 -13
  19. package/dist/server-assets/_shared/mcp-tools/search.ts +11 -2
  20. package/dist/server-assets/_shared/mcp-tools/set-document-metadata.ts +31 -5
  21. package/dist/server-assets/db/migrations/0035_drop_content_hash_unique.sql +21 -0
  22. package/dist/server-assets/db/migrations/0036_metadata_revision.sql +43 -0
  23. package/dist/server-assets/db/rpcs.sql +252 -53
  24. package/dist/server-assets/db/schema.sql +41 -4
  25. package/dist/server-assets/supabase/functions/cerefox-get-document/index.ts +8 -1
  26. package/dist/server-assets/supabase/functions/cerefox-ingest/index.ts +42 -6
  27. package/docs/api/openapi.json +61 -1
  28. package/docs/guides/cli.md +3 -1
  29. package/docs/guides/configuration.md +1 -1
  30. package/docs/guides/connect-agents.md +18 -7
  31. package/docs/guides/ops-scripts.md +1 -1
  32. package/docs/guides/setup-local.md +30 -6
  33. package/docs/guides/upgrading.md +14 -0
  34. package/package.json +6 -6
  35. package/dist/frontend/assets/index-B0H_N4if.css +0 -1
  36. package/dist/frontend/assets/index-CxMT7hjH.js +0 -121
  37. package/dist/frontend/assets/index-CxMT7hjH.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
  },
@@ -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();