@cerefox/memory 1.9.2 → 1.10.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.
- package/dist/bin/cerefox.js +259 -84
- package/dist/frontend/assets/index-i67RH1VY.js +121 -0
- package/dist/frontend/assets/index-i67RH1VY.js.map +1 -0
- package/dist/frontend/index.html +1 -1
- package/dist/server-assets/_shared/ef-meta/index.ts +3 -3
- package/dist/server-assets/_shared/mcp-tools/_document-meta.ts +377 -0
- package/dist/server-assets/_shared/mcp-tools/_projects.ts +13 -32
- package/dist/server-assets/_shared/mcp-tools/_utils.ts +9 -2
- package/dist/server-assets/_shared/mcp-tools/types.ts +6 -2
- package/dist/server-assets/db/migrations/0030_rename_document_rpc.sql +80 -0
- package/dist/server-assets/db/rpcs.sql +63 -1
- package/dist/server-assets/db/schema.sql +1 -1
- package/package.json +1 -1
- package/dist/frontend/assets/index-DWT7wZMR.js +0 -121
- package/dist/frontend/assets/index-DWT7wZMR.js.map +0 -1
package/dist/frontend/index.html
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&display=swap"
|
|
16
16
|
/>
|
|
17
17
|
<title>Cerefox</title>
|
|
18
|
-
<script type="module" crossorigin src="/app/assets/index-
|
|
18
|
+
<script type="module" crossorigin src="/app/assets/index-i67RH1VY.js"></script>
|
|
19
19
|
<link rel="stylesheet" crossorigin href="/app/assets/index-Dm_zCch4.css">
|
|
20
20
|
</head>
|
|
21
21
|
<body>
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* doesn't touch `supabase/functions/` leaves it alone).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
export const EF_VERSION = "1.
|
|
21
|
+
export const EF_VERSION = "1.10.0";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* The Cerefox RELEASE version — what `cerefox --version` reports and what npm
|
|
@@ -36,7 +36,7 @@ export const EF_VERSION = "1.9.2";
|
|
|
36
36
|
* is imported by the Deno Edge Functions, which cannot reach into the npm
|
|
37
37
|
* package.
|
|
38
38
|
*/
|
|
39
|
-
export const CEREFOX_VERSION = "1.
|
|
39
|
+
export const CEREFOX_VERSION = "1.10.0";
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
42
|
* The most recent version whose EF-side SOURCE actually changed (#127).
|
|
@@ -46,7 +46,7 @@ export const CEREFOX_VERSION = "1.9.2";
|
|
|
46
46
|
* `cut_release.ts` ONLY when EF source changed since the last tag; doctor
|
|
47
47
|
* uses it to stay silent on label-only drift.
|
|
48
48
|
*/
|
|
49
|
-
export const EF_LAST_CHANGED = "1.
|
|
49
|
+
export const EF_LAST_CHANGED = "1.10.0";
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* The 8 peer EFs the cerefox-mcp aggregator probes (excludes cerefox-mcp
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document meta-facet cores + orchestrator (iteration 39, v1.10.0).
|
|
3
|
+
*
|
|
4
|
+
* The web document-save was the last multi-facet write not built on shared
|
|
5
|
+
* cores: it raw-updated title (silently skipping the FTS refresh that title
|
|
6
|
+
* boosting requires), raw-replaced metadata (bypassing the #212 merge
|
|
7
|
+
* guards), replaced memberships with its own helper (no audit, no usage
|
|
8
|
+
* log), and recorded one entry describing the REQUEST shape rather than
|
|
9
|
+
* what changed.
|
|
10
|
+
*
|
|
11
|
+
* These cores are the single implementation for each facet; the orchestrator
|
|
12
|
+
* sequences them for surfaces (web today) that edit several facets in one
|
|
13
|
+
* user action. Deliberately NO combined audit entry: per-facet entries are
|
|
14
|
+
* the house pattern (iteration 33 — the trail distinguishes what happened),
|
|
15
|
+
* and each core emits the same description regardless of interface. Every
|
|
16
|
+
* facet diffs against the STORED value first — a facet the request carried
|
|
17
|
+
* but did not change is skipped entirely, so the trail never records
|
|
18
|
+
* non-events.
|
|
19
|
+
*
|
|
20
|
+
* Errors are TYPED (review round 1): callers map FacetNotFoundError → 404 /
|
|
21
|
+
* notFound and FacetValidationError → 400 / userError without string-matching
|
|
22
|
+
* prose that another module owns.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { AccessPath, MCPSupabaseClient } from "./types.ts";
|
|
26
|
+
|
|
27
|
+
import { logUsage, storeWriteRemediation } from "./_utils.ts";
|
|
28
|
+
|
|
29
|
+
export interface FacetActor {
|
|
30
|
+
author: string;
|
|
31
|
+
authorType: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The document (or a referenced project) does not exist or is in the trash. */
|
|
35
|
+
export class FacetNotFoundError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "FacetNotFoundError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The request is well-formed but fails a semantic check the caller can fix. */
|
|
43
|
+
export class FacetValidationError extends Error {
|
|
44
|
+
constructor(message: string) {
|
|
45
|
+
super(message);
|
|
46
|
+
this.name = "FacetValidationError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Key-order-independent JSONB-style equality for metadata objects. */
|
|
51
|
+
export function stableStringify(value: unknown): string {
|
|
52
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
53
|
+
if (value !== null && typeof value === "object") {
|
|
54
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
55
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`).join(",")}}`;
|
|
56
|
+
}
|
|
57
|
+
return JSON.stringify(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Replace-mode normalization mirroring the RPC: a null value means
|
|
61
|
+
* "remove this key", so null-valued keys vanish before comparison —
|
|
62
|
+
* otherwise a request that NORMALIZES to the stored value would fire the
|
|
63
|
+
* RPC and record a non-change (review round 1). */
|
|
64
|
+
export function normalizeMetadata(value: Record<string, unknown>): Record<string, unknown> {
|
|
65
|
+
const out: Record<string, unknown> = {};
|
|
66
|
+
for (const [k, v] of Object.entries(value)) {
|
|
67
|
+
if (v !== null) out[k] = v;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Rename a document — a thin wrapper over `cerefox_rename_document`
|
|
74
|
+
* (0.15.0), which commits the row update, the chunk-FTS refresh (title
|
|
75
|
+
* boosting), and the audit entry in ONE transaction. The client-side
|
|
76
|
+
* sequencing this replaces could commit the rename and then fail the
|
|
77
|
+
* refresh, leaving the document ranking under its old title with no retry
|
|
78
|
+
* path. Unchanged title → RPC-side no-op, no entry.
|
|
79
|
+
*/
|
|
80
|
+
export async function changeDocumentTitle(
|
|
81
|
+
supabase: MCPSupabaseClient,
|
|
82
|
+
documentId: string,
|
|
83
|
+
newTitle: string,
|
|
84
|
+
who: FacetActor,
|
|
85
|
+
): Promise<{ changed: boolean; title: string }> {
|
|
86
|
+
const trimmed = (newTitle ?? "").trim();
|
|
87
|
+
if (!trimmed) throw new FacetValidationError("Title cannot be empty.");
|
|
88
|
+
|
|
89
|
+
const { data, error } = await supabase.rpc("cerefox_rename_document", {
|
|
90
|
+
p_document_id: documentId,
|
|
91
|
+
p_new_title: trimmed,
|
|
92
|
+
p_author: who.author,
|
|
93
|
+
p_author_type: who.authorType,
|
|
94
|
+
});
|
|
95
|
+
if (error) {
|
|
96
|
+
const msg = error.message ?? String(error);
|
|
97
|
+
if (/not found/i.test(msg)) throw new FacetNotFoundError(msg);
|
|
98
|
+
if (/cannot be empty/i.test(msg)) throw new FacetValidationError(msg);
|
|
99
|
+
// 0.15.0 grew the server surface; against a 0.14.x server the RPC is
|
|
100
|
+
// absent — say "redeploy", with the shared remediation prose.
|
|
101
|
+
const remediation = storeWriteRemediation(msg, "cerefox_rename_document", "0.15.0");
|
|
102
|
+
if (remediation) throw new Error(`Title update failed: ${remediation}`);
|
|
103
|
+
throw new Error(`Title update failed: ${msg}`);
|
|
104
|
+
}
|
|
105
|
+
const row = (data as Array<{ renamed: boolean; new_title: string }> | null)?.[0];
|
|
106
|
+
return { changed: row?.renamed ?? false, title: row?.new_title ?? trimmed };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The shared replace + audit + usage-log tail used by BOTH membership twins
|
|
111
|
+
* (ids here, names in `replaceDocumentProjects`) — review round 1 caught the
|
|
112
|
+
* two carrying drifting copies. Unified semantics: an unchanged set is a
|
|
113
|
+
* complete no-op (no entry — the trail never records non-events; this also
|
|
114
|
+
* changes the previously always-logging name path).
|
|
115
|
+
*/
|
|
116
|
+
export async function applyMembershipReplace(
|
|
117
|
+
supabase: MCPSupabaseClient,
|
|
118
|
+
opts: {
|
|
119
|
+
documentId: string;
|
|
120
|
+
projectIds: string[];
|
|
121
|
+
projectNames: string[];
|
|
122
|
+
accessPath: AccessPath;
|
|
123
|
+
} & FacetActor,
|
|
124
|
+
): Promise<{ changed: boolean }> {
|
|
125
|
+
const { data: current, error: curErr } = await supabase
|
|
126
|
+
.from("cerefox_document_projects")
|
|
127
|
+
.select("project_id")
|
|
128
|
+
.eq("document_id", opts.documentId);
|
|
129
|
+
if (curErr) throw new Error(`Membership read failed: ${curErr.message}`);
|
|
130
|
+
const currentSet = new Set((current ?? []).map((r: { project_id: string }) => r.project_id));
|
|
131
|
+
if (
|
|
132
|
+
opts.projectIds.length === currentSet.size &&
|
|
133
|
+
opts.projectIds.every((id) => currentSet.has(id))
|
|
134
|
+
) {
|
|
135
|
+
return { changed: false };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { error: delErr } = await supabase
|
|
139
|
+
.from("cerefox_document_projects")
|
|
140
|
+
.delete()
|
|
141
|
+
.eq("document_id", opts.documentId);
|
|
142
|
+
if (delErr) throw new Error(`Membership replace failed: ${delErr.message}`);
|
|
143
|
+
if (opts.projectIds.length > 0) {
|
|
144
|
+
const rows = opts.projectIds.map((pid) => ({
|
|
145
|
+
document_id: opts.documentId,
|
|
146
|
+
project_id: pid,
|
|
147
|
+
}));
|
|
148
|
+
const { error: insErr } = await supabase.from("cerefox_document_projects").insert(rows);
|
|
149
|
+
if (insErr) throw new Error(`Membership replace failed: ${insErr.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { error: auditErr } = await supabase.rpc("cerefox_create_audit_entry", {
|
|
153
|
+
p_document_id: opts.documentId,
|
|
154
|
+
p_version_id: null,
|
|
155
|
+
p_operation: "update-metadata",
|
|
156
|
+
p_author: opts.author,
|
|
157
|
+
p_author_type: opts.authorType,
|
|
158
|
+
p_size_before: null,
|
|
159
|
+
p_size_after: null,
|
|
160
|
+
p_description:
|
|
161
|
+
opts.projectNames.length > 0
|
|
162
|
+
? `Set document projects to [${opts.projectNames.join(", ")}]`
|
|
163
|
+
: "Cleared all project memberships",
|
|
164
|
+
});
|
|
165
|
+
if (auditErr) console.warn("applyMembershipReplace: audit entry failed", auditErr.message);
|
|
166
|
+
|
|
167
|
+
logUsage(supabase, {
|
|
168
|
+
operation: "set-document-projects",
|
|
169
|
+
accessPath: opts.accessPath,
|
|
170
|
+
requestor: opts.author,
|
|
171
|
+
document_id: opts.documentId,
|
|
172
|
+
result_count: opts.projectIds.length,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return { changed: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Guard shared by the facet writes: the document must exist and not be in
|
|
179
|
+
* the trash (a trashed document is immutable until restored — 0.12.0). */
|
|
180
|
+
async function assertDocumentLive(
|
|
181
|
+
supabase: MCPSupabaseClient,
|
|
182
|
+
documentId: string,
|
|
183
|
+
): Promise<void> {
|
|
184
|
+
const { data, error } = await supabase
|
|
185
|
+
.from("cerefox_documents")
|
|
186
|
+
.select("id")
|
|
187
|
+
.eq("id", documentId)
|
|
188
|
+
.is("deleted_at", null)
|
|
189
|
+
.limit(1);
|
|
190
|
+
if (error) throw new Error(`Document read failed: ${error.message}`);
|
|
191
|
+
if (!data?.length) {
|
|
192
|
+
throw new FacetNotFoundError(`Document not found (or in the trash): ${documentId}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Replace a document's project memberships from project IDS (the web UI's
|
|
198
|
+
* currency; the name-based twin `replaceDocumentProjects` serves MCP/CLI and
|
|
199
|
+
* delegates to the same tail). Validates the document and every id BEFORE
|
|
200
|
+
* the destructive replace. No-op when the set is unchanged.
|
|
201
|
+
*/
|
|
202
|
+
export async function setDocumentProjectsByIds(
|
|
203
|
+
supabase: MCPSupabaseClient,
|
|
204
|
+
opts: {
|
|
205
|
+
documentId: string;
|
|
206
|
+
projectIds: string[];
|
|
207
|
+
accessPath: AccessPath;
|
|
208
|
+
} & FacetActor,
|
|
209
|
+
): Promise<{ changed: boolean; names: string[] }> {
|
|
210
|
+
const wanted = [...new Set(opts.projectIds)];
|
|
211
|
+
|
|
212
|
+
// Diff FIRST: an unchanged set is a complete no-op and needs no
|
|
213
|
+
// validation (its ids provably exist — they are current memberships).
|
|
214
|
+
const { data: current, error: curErr } = await supabase
|
|
215
|
+
.from("cerefox_document_projects")
|
|
216
|
+
.select("project_id")
|
|
217
|
+
.eq("document_id", opts.documentId);
|
|
218
|
+
if (curErr) throw new Error(`Membership read failed: ${curErr.message}`);
|
|
219
|
+
const currentSet = new Set((current ?? []).map((r: { project_id: string }) => r.project_id));
|
|
220
|
+
if (wanted.length === currentSet.size && wanted.every((id) => currentSet.has(id))) {
|
|
221
|
+
return { changed: false, names: [] };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await assertDocumentLive(supabase, opts.documentId);
|
|
225
|
+
|
|
226
|
+
let names: string[] = [];
|
|
227
|
+
if (wanted.length > 0) {
|
|
228
|
+
const { data: found, error: valErr } = await supabase
|
|
229
|
+
.from("cerefox_projects")
|
|
230
|
+
.select("id, name")
|
|
231
|
+
.in("id", wanted);
|
|
232
|
+
if (valErr) throw new Error(`Project validation failed: ${valErr.message}`);
|
|
233
|
+
const byId = new Map<string, string>(
|
|
234
|
+
(found ?? []).map((r: { id: string; name: string }) => [r.id, r.name] as [string, string]),
|
|
235
|
+
);
|
|
236
|
+
const missing = wanted.filter((id) => !byId.has(id));
|
|
237
|
+
if (missing.length > 0) {
|
|
238
|
+
throw new FacetValidationError(
|
|
239
|
+
`Unknown project id(s): ${missing.join(", ")} — memberships left unchanged.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
names = wanted.map((id) => byId.get(id)!);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const r = await applyMembershipReplace(supabase, {
|
|
246
|
+
documentId: opts.documentId,
|
|
247
|
+
projectIds: wanted,
|
|
248
|
+
projectNames: names,
|
|
249
|
+
accessPath: opts.accessPath,
|
|
250
|
+
author: opts.author,
|
|
251
|
+
authorType: opts.authorType,
|
|
252
|
+
});
|
|
253
|
+
return { changed: r.changed, names };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface FacetUpdateResult {
|
|
257
|
+
titleChanged: boolean;
|
|
258
|
+
metadataChanged: boolean;
|
|
259
|
+
projectsChanged: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Thrown when a later facet fails after earlier ones committed: carries
|
|
263
|
+
* what DID apply so the surface can report the partial state honestly
|
|
264
|
+
* (the facets are separate transactions by design — full cross-facet
|
|
265
|
+
* atomicity would need one mega-RPC for three loosely related writes). */
|
|
266
|
+
export class FacetUpdateError extends Error {
|
|
267
|
+
applied: FacetUpdateResult;
|
|
268
|
+
constructor(applied: FacetUpdateResult, cause: Error) {
|
|
269
|
+
const done = [
|
|
270
|
+
applied.metadataChanged ? "metadata" : null,
|
|
271
|
+
applied.projectsChanged ? "projects" : null,
|
|
272
|
+
applied.titleChanged ? "title" : null,
|
|
273
|
+
].filter(Boolean);
|
|
274
|
+
super(
|
|
275
|
+
done.length > 0
|
|
276
|
+
? `${cause.message} (already applied before the failure: ${done.join(", ")})`
|
|
277
|
+
: cause.message,
|
|
278
|
+
);
|
|
279
|
+
this.name = "FacetUpdateError";
|
|
280
|
+
this.applied = applied;
|
|
281
|
+
this.cause = cause;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Orchestrate a multi-facet document meta update (metadata / memberships /
|
|
287
|
+
* title) for surfaces that edit them in one user action. Sequencing wrapper
|
|
288
|
+
* ONLY: each facet applies through its single implementation and writes its
|
|
289
|
+
* own audit entry — deliberately no combined entry. Order runs from the
|
|
290
|
+
* facet most likely to be rejected (metadata: free-form user input) to the
|
|
291
|
+
* least (title: one atomic RPC), minimizing partial application; a mid-way
|
|
292
|
+
* failure raises FacetUpdateError naming what already committed.
|
|
293
|
+
*/
|
|
294
|
+
export async function updateDocumentFacets(
|
|
295
|
+
supabase: MCPSupabaseClient,
|
|
296
|
+
opts: {
|
|
297
|
+
documentId: string;
|
|
298
|
+
title?: string;
|
|
299
|
+
metadata?: Record<string, unknown>;
|
|
300
|
+
projectIds?: string[];
|
|
301
|
+
accessPath: AccessPath;
|
|
302
|
+
} & FacetActor,
|
|
303
|
+
): Promise<FacetUpdateResult> {
|
|
304
|
+
const result: FacetUpdateResult = {
|
|
305
|
+
titleChanged: false,
|
|
306
|
+
metadataChanged: false,
|
|
307
|
+
projectsChanged: false,
|
|
308
|
+
};
|
|
309
|
+
const who: FacetActor = { author: opts.author, authorType: opts.authorType };
|
|
310
|
+
|
|
311
|
+
const step = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
312
|
+
try {
|
|
313
|
+
return await fn();
|
|
314
|
+
} catch (err) {
|
|
315
|
+
throw err instanceof FacetUpdateError
|
|
316
|
+
? err
|
|
317
|
+
: new FacetUpdateError(result, err instanceof Error ? err : new Error(String(err)));
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
if (opts.metadata !== undefined) {
|
|
322
|
+
await step(async () => {
|
|
323
|
+
const { data: doc, error } = await supabase
|
|
324
|
+
.from("cerefox_documents")
|
|
325
|
+
.select("metadata")
|
|
326
|
+
.eq("id", opts.documentId)
|
|
327
|
+
.is("deleted_at", null)
|
|
328
|
+
.limit(1);
|
|
329
|
+
if (error) throw new Error(`Metadata read failed: ${error.message}`);
|
|
330
|
+
if (!doc?.length) {
|
|
331
|
+
throw new FacetNotFoundError(`Document not found (or in the trash): ${opts.documentId}`);
|
|
332
|
+
}
|
|
333
|
+
const stored = (doc[0].metadata ?? {}) as Record<string, unknown>;
|
|
334
|
+
const wanted = normalizeMetadata(opts.metadata!);
|
|
335
|
+
if (stableStringify(stored) !== stableStringify(wanted)) {
|
|
336
|
+
// Replace mode: the save surface edits the whole object (an empty
|
|
337
|
+
// object CLEARS all keys — "remove the last key" must not be a
|
|
338
|
+
// silent no-op). The RPC carries the #212 guards and writes its own
|
|
339
|
+
// per-key audit report.
|
|
340
|
+
const { error: rpcErr } = await supabase.rpc("cerefox_set_document_metadata", {
|
|
341
|
+
p_document_id: opts.documentId,
|
|
342
|
+
p_metadata: wanted,
|
|
343
|
+
p_replace: true,
|
|
344
|
+
p_author: opts.author,
|
|
345
|
+
p_author_type: opts.authorType,
|
|
346
|
+
});
|
|
347
|
+
if (rpcErr) {
|
|
348
|
+
const msg = rpcErr.message ?? String(rpcErr);
|
|
349
|
+
if (/not found/i.test(msg)) throw new FacetNotFoundError(msg);
|
|
350
|
+
throw new Error(`Metadata update failed: ${msg}`);
|
|
351
|
+
}
|
|
352
|
+
result.metadataChanged = true;
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (opts.projectIds !== undefined) {
|
|
358
|
+
await step(async () => {
|
|
359
|
+
const r = await setDocumentProjectsByIds(supabase, {
|
|
360
|
+
documentId: opts.documentId,
|
|
361
|
+
projectIds: opts.projectIds!,
|
|
362
|
+
accessPath: opts.accessPath,
|
|
363
|
+
...who,
|
|
364
|
+
});
|
|
365
|
+
result.projectsChanged = r.changed;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (opts.title !== undefined) {
|
|
370
|
+
await step(async () => {
|
|
371
|
+
const r = await changeDocumentTitle(supabase, opts.documentId, opts.title!, who);
|
|
372
|
+
result.titleChanged = r.changed;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
|
|
24
24
|
import type { AccessPath, MCPSupabaseClient } from "./types.ts";
|
|
25
25
|
|
|
26
|
-
import {
|
|
26
|
+
import { applyMembershipReplace } from "./_document-meta.ts";
|
|
27
|
+
import { storeWriteRemediation } from "./_utils.ts";
|
|
27
28
|
|
|
28
29
|
/** Who to attribute an implicit/explicit project write to in the audit log. */
|
|
29
30
|
export interface ProjectAuditContext {
|
|
@@ -208,38 +209,18 @@ export async function replaceDocumentProjects(
|
|
|
208
209
|
}
|
|
209
210
|
const projectIds = resolved.map((r) => r!.projectId);
|
|
210
211
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
await supabase.rpc("cerefox_create_audit_entry", {
|
|
221
|
-
p_document_id: documentId,
|
|
222
|
-
p_version_id: null,
|
|
223
|
-
p_operation: "update-metadata",
|
|
224
|
-
p_author: author,
|
|
225
|
-
p_author_type: authorType,
|
|
226
|
-
p_size_before: null,
|
|
227
|
-
p_size_after: null,
|
|
228
|
-
p_description:
|
|
229
|
-
cleanNames.length > 0
|
|
230
|
-
? `Set document projects to [${cleanNames.join(", ")}]`
|
|
231
|
-
: "Cleared all project memberships",
|
|
232
|
-
});
|
|
233
|
-
} catch (err) {
|
|
234
|
-
console.warn("replaceDocumentProjects: audit entry failed", err);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
logUsage(supabase, {
|
|
238
|
-
operation: "set-document-projects",
|
|
212
|
+
// Delegate the replace + audit + usage-log tail to the shared core
|
|
213
|
+
// (review round 1: the id-based twin had a drifting copy). Unified
|
|
214
|
+
// semantics: an unchanged set is a complete no-op with NO audit entry —
|
|
215
|
+
// the trail never records non-events (this changes the previously
|
|
216
|
+
// always-logging behavior of this path, deliberately).
|
|
217
|
+
await applyMembershipReplace(supabase, {
|
|
218
|
+
documentId,
|
|
219
|
+
projectIds,
|
|
220
|
+
projectNames: cleanNames,
|
|
239
221
|
accessPath,
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
result_count: projectIds.length,
|
|
222
|
+
author,
|
|
223
|
+
authorType,
|
|
243
224
|
});
|
|
244
225
|
|
|
245
226
|
return { documentTitle: doc[0].title as string, cleanNames, projectIds };
|
|
@@ -243,10 +243,17 @@ export function isDuplicateKeyError(message: string): boolean {
|
|
|
243
243
|
* deployment-state problem. Keeps the CLI and web surfaces in lockstep —
|
|
244
244
|
* round 4 found the two carrying hand-copied, already-diverging prose.
|
|
245
245
|
*/
|
|
246
|
-
export function storeWriteRemediation(
|
|
246
|
+
export function storeWriteRemediation(
|
|
247
|
+
message: string,
|
|
248
|
+
fnName: string,
|
|
249
|
+
// The schema floor the CALLING feature requires — round 2 caught the
|
|
250
|
+
// hardcoded "0.14.0" contradicting doctor on a 0.14.1 store missing a
|
|
251
|
+
// 0.15.0-only function.
|
|
252
|
+
requiredSchema = "0.14.0",
|
|
253
|
+
): string | null {
|
|
247
254
|
if (isMissingFunctionError(message, fnName)) {
|
|
248
255
|
return (
|
|
249
|
-
|
|
256
|
+
`The deployed server predates schema ${requiredSchema} — or PostgREST's schema cache ` +
|
|
250
257
|
"is stale right after a deploy. If you just deployed, retry in a few " +
|
|
251
258
|
"seconds; otherwise run `cerefox server deploy`."
|
|
252
259
|
);
|
|
@@ -52,9 +52,13 @@ export type JsonSchema = Record<string, unknown>;
|
|
|
52
52
|
* Python CLI's `access_path = "cli"`.
|
|
53
53
|
*
|
|
54
54
|
* Adding a new channel here also requires updating
|
|
55
|
-
* `cerefox_usage_log.access_path`'s domain (
|
|
55
|
+
* `cerefox_usage_log.access_path`'s documented domain (no DB CHECK exists —
|
|
56
|
+
* verified in review; the column is free text and this type is the guard).
|
|
56
57
|
*/
|
|
57
|
-
|
|
58
|
+
// The documented access_path domain (CLAUDE.md → usage tracking) — the type
|
|
59
|
+
// had lagged at the three MCP-era values while webapp/edge-function callers
|
|
60
|
+
// logged through wider-typed wrappers.
|
|
61
|
+
export type AccessPath = "remote-mcp" | "local-mcp" | "cli" | "webapp" | "edge-function";
|
|
58
62
|
|
|
59
63
|
export interface ToolContext {
|
|
60
64
|
/** OpenAI/Fireworks API key for tools that need to embed (search, ingest).
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
-- Migration 0030 — cerefox_rename_document (schema 0.15.0, iteration 39)
|
|
2
|
+
--
|
|
3
|
+
-- Title renames become atomic: row update + chunk-FTS refresh (title
|
|
4
|
+
-- boosting) + audit entry in one transaction. Replaces client-side
|
|
5
|
+
-- sequencing that could commit the rename, fail the FTS refresh, and leave
|
|
6
|
+
-- the document ranking under its old title with no retry path. The web
|
|
7
|
+
-- editor had additionally NEVER refreshed FTS on rename (since iter-24E).
|
|
8
|
+
--
|
|
9
|
+
-- Body carried verbatim from rpcs.sql (repair-path closure, as 0027-0029),
|
|
10
|
+
-- plus the standard ACL lockdown. Re-runnable.
|
|
11
|
+
|
|
12
|
+
CREATE OR REPLACE FUNCTION cerefox_rename_document(
|
|
13
|
+
p_document_id UUID,
|
|
14
|
+
p_new_title TEXT,
|
|
15
|
+
p_author TEXT DEFAULT 'unknown',
|
|
16
|
+
p_author_type TEXT DEFAULT 'user'
|
|
17
|
+
)
|
|
18
|
+
RETURNS TABLE (renamed BOOLEAN, old_title TEXT, new_title TEXT)
|
|
19
|
+
LANGUAGE plpgsql
|
|
20
|
+
SECURITY DEFINER
|
|
21
|
+
SET search_path = public, pg_catalog
|
|
22
|
+
AS $$
|
|
23
|
+
DECLARE
|
|
24
|
+
v_old TEXT;
|
|
25
|
+
v_new TEXT := BTRIM(p_new_title);
|
|
26
|
+
BEGIN
|
|
27
|
+
IF NULLIF(v_new, '') IS NULL THEN
|
|
28
|
+
RAISE EXCEPTION 'Title cannot be empty' USING ERRCODE = '22023';
|
|
29
|
+
END IF;
|
|
30
|
+
|
|
31
|
+
SELECT d.title INTO v_old
|
|
32
|
+
FROM cerefox_documents d
|
|
33
|
+
WHERE d.id = p_document_id AND d.deleted_at IS NULL
|
|
34
|
+
FOR UPDATE;
|
|
35
|
+
IF NOT FOUND THEN
|
|
36
|
+
RAISE EXCEPTION 'Document not found (or in the trash): %', p_document_id
|
|
37
|
+
USING ERRCODE = '22023';
|
|
38
|
+
END IF;
|
|
39
|
+
|
|
40
|
+
IF v_old = v_new THEN
|
|
41
|
+
RETURN QUERY SELECT FALSE, v_old, v_old;
|
|
42
|
+
RETURN;
|
|
43
|
+
END IF;
|
|
44
|
+
|
|
45
|
+
UPDATE cerefox_documents SET title = v_new, updated_at = NOW()
|
|
46
|
+
WHERE id = p_document_id;
|
|
47
|
+
|
|
48
|
+
-- Same transaction: the FTS vectors and the title row can never disagree.
|
|
49
|
+
PERFORM cerefox_update_chunk_fts(p_document_id, v_new);
|
|
50
|
+
|
|
51
|
+
PERFORM cerefox_create_audit_entry(
|
|
52
|
+
p_document_id := p_document_id,
|
|
53
|
+
p_operation := 'update-metadata',
|
|
54
|
+
p_author := p_author,
|
|
55
|
+
p_author_type := p_author_type,
|
|
56
|
+
p_description := 'Title changed: ''' || v_old || ''' → ''' || v_new || ''''
|
|
57
|
+
);
|
|
58
|
+
RETURN QUERY SELECT TRUE, v_old, v_new;
|
|
59
|
+
END;
|
|
60
|
+
$$;
|
|
61
|
+
|
|
62
|
+
DO $$
|
|
63
|
+
DECLARE
|
|
64
|
+
r TEXT;
|
|
65
|
+
BEGIN
|
|
66
|
+
REVOKE EXECUTE ON FUNCTION cerefox_rename_document(UUID, TEXT, TEXT, TEXT) FROM PUBLIC;
|
|
67
|
+
FOREACH r IN ARRAY ARRAY['anon', 'authenticated'] LOOP
|
|
68
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = r) THEN
|
|
69
|
+
EXECUTE format('REVOKE EXECUTE ON FUNCTION cerefox_rename_document(UUID, TEXT, TEXT, TEXT) FROM %I', r);
|
|
70
|
+
END IF;
|
|
71
|
+
END LOOP;
|
|
72
|
+
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
|
|
73
|
+
GRANT EXECUTE ON FUNCTION cerefox_rename_document(UUID, TEXT, TEXT, TEXT) TO service_role;
|
|
74
|
+
END IF;
|
|
75
|
+
END $$;
|
|
76
|
+
|
|
77
|
+
DO $$
|
|
78
|
+
BEGIN
|
|
79
|
+
RAISE NOTICE 'Migration 0030: title renames are atomic (row + FTS refresh + audit in one transaction).';
|
|
80
|
+
END $$;
|
|
@@ -1975,6 +1975,68 @@ AS $$
|
|
|
1975
1975
|
ORDER BY p.name;
|
|
1976
1976
|
$$;
|
|
1977
1977
|
|
|
1978
|
+
-- ── cerefox_rename_document (0.15.0, iteration 39) ──────────────────────────
|
|
1979
|
+
-- Title is a search-ranking input (title boosting bakes it into every current
|
|
1980
|
+
-- chunk's FTS vector at weight A), so a rename is a write WITH a side effect:
|
|
1981
|
+
-- business logic, which lives here per the single-implementation principle.
|
|
1982
|
+
-- The row update, the FTS refresh, and the audit entry commit or roll back
|
|
1983
|
+
-- TOGETHER — the client-side sequencing this replaces could commit the rename
|
|
1984
|
+
-- and then fail the refresh, leaving the document ranking under its old title
|
|
1985
|
+
-- with no retry path (the retry saw the new title and did nothing).
|
|
1986
|
+
-- Unchanged title → no-op (renamed=FALSE), no audit entry: the trail never
|
|
1987
|
+
-- records non-events. Semantic embeddings deliberately defer to the next
|
|
1988
|
+
-- content update / reindex (they cost API calls; FTS is free).
|
|
1989
|
+
|
|
1990
|
+
CREATE OR REPLACE FUNCTION cerefox_rename_document(
|
|
1991
|
+
p_document_id UUID,
|
|
1992
|
+
p_new_title TEXT,
|
|
1993
|
+
p_author TEXT DEFAULT 'unknown',
|
|
1994
|
+
p_author_type TEXT DEFAULT 'user'
|
|
1995
|
+
)
|
|
1996
|
+
RETURNS TABLE (renamed BOOLEAN, old_title TEXT, new_title TEXT)
|
|
1997
|
+
LANGUAGE plpgsql
|
|
1998
|
+
SECURITY DEFINER
|
|
1999
|
+
SET search_path = public, pg_catalog
|
|
2000
|
+
AS $$
|
|
2001
|
+
DECLARE
|
|
2002
|
+
v_old TEXT;
|
|
2003
|
+
v_new TEXT := BTRIM(p_new_title);
|
|
2004
|
+
BEGIN
|
|
2005
|
+
IF NULLIF(v_new, '') IS NULL THEN
|
|
2006
|
+
RAISE EXCEPTION 'Title cannot be empty' USING ERRCODE = '22023';
|
|
2007
|
+
END IF;
|
|
2008
|
+
|
|
2009
|
+
SELECT d.title INTO v_old
|
|
2010
|
+
FROM cerefox_documents d
|
|
2011
|
+
WHERE d.id = p_document_id AND d.deleted_at IS NULL
|
|
2012
|
+
FOR UPDATE;
|
|
2013
|
+
IF NOT FOUND THEN
|
|
2014
|
+
RAISE EXCEPTION 'Document not found (or in the trash): %', p_document_id
|
|
2015
|
+
USING ERRCODE = '22023';
|
|
2016
|
+
END IF;
|
|
2017
|
+
|
|
2018
|
+
IF v_old = v_new THEN
|
|
2019
|
+
RETURN QUERY SELECT FALSE, v_old, v_old;
|
|
2020
|
+
RETURN;
|
|
2021
|
+
END IF;
|
|
2022
|
+
|
|
2023
|
+
UPDATE cerefox_documents SET title = v_new, updated_at = NOW()
|
|
2024
|
+
WHERE id = p_document_id;
|
|
2025
|
+
|
|
2026
|
+
-- Same transaction: the FTS vectors and the title row can never disagree.
|
|
2027
|
+
PERFORM cerefox_update_chunk_fts(p_document_id, v_new);
|
|
2028
|
+
|
|
2029
|
+
PERFORM cerefox_create_audit_entry(
|
|
2030
|
+
p_document_id := p_document_id,
|
|
2031
|
+
p_operation := 'update-metadata',
|
|
2032
|
+
p_author := p_author,
|
|
2033
|
+
p_author_type := p_author_type,
|
|
2034
|
+
p_description := 'Title changed: ''' || v_old || ''' → ''' || v_new || ''''
|
|
2035
|
+
);
|
|
2036
|
+
RETURN QUERY SELECT TRUE, v_old, v_new;
|
|
2037
|
+
END;
|
|
2038
|
+
$$;
|
|
2039
|
+
|
|
1978
2040
|
-- ── Project write RPCs (0.14.0, #147/#219) ──────────────────────────────────
|
|
1979
2041
|
-- Project writes carried no business logic until the audit trail arrived —
|
|
1980
2042
|
-- then every caller (CLI, web, shared helpers, ingestion pipeline, the ingest
|
|
@@ -3017,7 +3079,7 @@ AS $$
|
|
|
3017
3079
|
-- 0.11.0 supersedes 0.10.6 (v1.2.1, #191): this branch carries that fix plus
|
|
3018
3080
|
-- the partial-edit surface, and both migrations (0019, 0020) are in the
|
|
3019
3081
|
-- sequence, so a store deploying this gets everything from both lines.
|
|
3020
|
-
SELECT '0.
|
|
3082
|
+
SELECT '0.15.0'::TEXT;
|
|
3021
3083
|
$$;
|
|
3022
3084
|
|
|
3023
3085
|
-- ── cerefox_find_dead_links ──────────────────────────────────────────────────
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
-- Requires extensions: vector (pgvector), uuid-ossp
|
|
6
6
|
-- These are enabled at the top of db_deploy.py before this file is applied.
|
|
7
7
|
--
|
|
8
|
-
-- @version: 0.
|
|
8
|
+
-- @version: 0.15.0
|
|
9
9
|
-- The `@version` marker above is read by the schema-version-mismatch banner
|
|
10
10
|
-- (see /api/v1/schema-version). Bump it whenever schema.sql OR rpcs.sql
|
|
11
11
|
-- changes in a way that requires `cerefox server deploy` to be re-run —
|