@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
|
@@ -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 {
|
|
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
|
-
//
|
|
343
|
-
//
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
| {
|
|
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
|
|
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();
|