feat: add memory.patch, trim memory.get, unify the memory write path

memory.get no longer returns the embedding and content_tsv
----------------------------------------------------------
It used a bare select() and returned the raw DB row, while memory.list
and memory.search already projected an explicit 9-field shape. On a
~13k-char memory those two internal columns were 55% of the response
and pushed it past the MCP tool-output cap, so large memories could not
be fetched inline at all. memory.get now returns the same 9 fields as
its siblings; user_id is still selected for the authorization check and
stripped before responding.

memory.patch
------------
memory.update only accepts full replacement, so adding one line to a
large document meant resending the whole document — expensive enough
that edits were being skipped rather than risk silently truncating
shared team documents.

memory.patch replaces one exact occurrence of old_string. An absent or
ambiguous match is an error, never a silent no-op and never an
arbitrary pick; that refusal is what makes the operation safe to hand
to an agent. The semantics live in lib/memory-patch.ts as a pure
function, free of DB and auth, so both surfaces share them.

Shared mutation layer
---------------------
The MCP tools and the Web UI Server Actions each reimplemented
authorize -> mutate -> re-embed -> CAS -> audit, and had drifted. Both
now route through lib/memory-mutations.ts.

BEHAVIOUR CHANGE: memory.delete over MCP skipped the project ACL
whenever the caller authored the row, so a memory written while a share
was rw stayed deletable by its author after an owner downgraded that
share to ro. memory.update and the whole Web UI always checked.
Authoring a row now grants no standing write privilege on any path.

The one deliberate difference between the surfaces is injected as a
ProjectResolver: MCP refuses an unknown project key so an agent cannot
spawn near-miss projects off a typo, while the Web UI creates one
because a person typing a name into a form means to.

Tests and lint
--------------
Adds vitest. The integration tests run against a real Postgres rather
than a mocked DB. The embedder sidecar is the only stub and it is
deterministic per-text, so re-embedding is verified by asserting the
stored vector actually changed rather than that a mock was called. One
test pins that content_tsv is a generated column and therefore cannot
rot after a patch — only the embedding needs an explicit recompute.

pnpm lint previously dropped into an interactive `next lint` setup
prompt and exited 1; ESLint had never been configured here. Replaced
with the ESLint CLI and a flat config bridging eslint-config-next
through FlatCompat. Clean at --max-warnings=0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 14:58:11 -07:00
co-authored by Claude Opus 5
parent d9306884d3
commit c3bbea5134
20 changed files with 2435 additions and 527 deletions
+357
View File
@@ -0,0 +1,357 @@
import { and, eq, isNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { memories, projects, auditLog } from "@/lib/db/schema";
import { embedText } from "@/lib/embedder";
import { applyPatch } from "@/lib/memory-patch";
import { CONCURRENT_EDIT_ERROR, canWriteProject } from "@/lib/access";
import type {
MemoryDeleteInput,
MemoryPatchInput,
MemoryUpdateInput,
MemoryWriteInput,
} from "@shared-memory/schemas";
/**
* The single write path for memories.
*
* Both surfaces — the MCP tools and the Web UI Server Actions — used to
* reimplement authorize → mutate → re-embed → CAS → audit independently.
* They drifted: `memory.delete` over MCP skipped the project ACL whenever
* the caller happened to author the row, which `memory.update` and the
* whole Web UI did not. Consolidating here is what keeps those rules in
* one place, so a change to the sharing model can't be half-applied.
*
* Callers keep their own presentation concerns: MCP maps Outcome to a
* ToolResult, the Web UI throws and then revalidates/redirects.
*/
export interface Actor {
userId: string;
/** Group names, for project-share authorization. */
groups: string[];
/** Recorded as audit_log.actor so the two surfaces stay distinguishable. */
via: "web" | "mcp";
}
export type Outcome<T> = { ok: true; value: T } | { ok: false; error: string };
const fail = (error: string): Outcome<never> => ({ ok: false, error });
const succeed = <T>(value: T): Outcome<T> => ({ ok: true, value });
/**
* Resolves a project key to an id for a write. Injected because this is
* the one place the two surfaces genuinely, deliberately differ: MCP
* refuses unknown projects (the caller is expected to run project.identify
* first), while the Web UI creates one owned by the user. Everything else
* about a write is identical.
*/
export type ProjectResolver = (key: string) => Promise<Outcome<string>>;
interface WriteTarget {
scope: "project" | "user";
projectId: string | null;
userId: string;
}
/**
* The authorization rule for every mutating operation:
* - user-scope → only the owner may write (anything else reads as 404)
* - project-scope → owner of the project, or a group with `rw`
*
* Authoring a row grants nothing on its own. A memory you wrote while a
* share was `rw` becomes read-only to you when an owner downgrades that
* share to `ro` — the project ACL is the authority, not the byline.
*/
async function authorizeWrite(actor: Actor, row: WriteTarget): Promise<Outcome<null>> {
if (row.scope === "user") {
return row.userId === actor.userId ? succeed(null) : fail("not found");
}
if (row.projectId) {
const allowed = await canWriteProject(actor.userId, actor.groups, row.projectId);
if (!allowed) return fail("no write access to this project");
}
return succeed(null);
}
export async function createMemory(
actor: Actor,
input: MemoryWriteInput,
resolveProject: ProjectResolver,
): Promise<Outcome<{ id: string; createdAt: Date }>> {
let projectId: string | null = null;
const projectKey = input.scope === "project" ? input.project : undefined;
if (input.scope === "project") {
if (!projectKey) return fail("scope=project requires `project`");
const resolved = await resolveProject(projectKey);
if (!resolved.ok) return resolved;
projectId = resolved.value;
const allowed = await canWriteProject(actor.userId, actor.groups, projectId);
if (!allowed) return fail(`no write access to project '${projectKey}'`);
}
// Embed inline so the new memory is searchable immediately. Slower
// writes (~50150 ms) are an acceptable price for that guarantee.
const embedding = await embedText(input.content);
const inserted = await db
.insert(memories)
.values({
userId: actor.userId,
projectId,
scope: input.scope,
content: input.content,
tags: input.tags ?? [],
embedding,
lastEditedBy: actor.userId,
})
.returning({ id: memories.id, createdAt: memories.createdAt });
const row = inserted[0]!;
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.write",
entityType: "memory",
entityId: row.id,
payload: {
scope: input.scope,
projectKey: projectKey ?? null,
tags: input.tags ?? [],
},
});
return succeed(row);
}
export interface MutatedMemory {
id: string;
updatedAt: Date;
version: number;
}
export async function updateMemory(
actor: Actor,
input: MemoryUpdateInput,
resolveProject: ProjectResolver,
): Promise<Outcome<MutatedMemory>> {
const existingRows = await db
.select({
id: memories.id,
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
projectKey: projects.key,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.leftJoin(projects, eq(memories.projectId, projects.id))
.where(and(eq(memories.id, input.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) return fail("not found");
const authorized = await authorizeWrite(actor, existing);
if (!authorized.ok) return authorized;
const update: Record<string, unknown> = {
updatedAt: new Date(),
lastEditedBy: actor.userId,
version: existing.version + 1,
};
if (input.tags !== undefined) update.tags = input.tags;
if (input.content !== undefined && input.content !== existing.content) {
update.content = input.content;
update.embedding = await embedText(input.content);
}
let scopeChanged = false;
let projectChanged = false;
let newProjectKey: string | null = existing.projectKey ?? null;
if (input.scope !== undefined) {
if (input.scope === "user") {
if (existing.scope !== "user") {
update.scope = "user";
scopeChanged = true;
}
if (existing.projectId !== null) {
update.projectId = null;
projectChanged = true;
newProjectKey = null;
}
} else {
// scope === 'project' — the schema refine guarantees `project` is set.
// Moving INTO a project requires write access there.
const projectKey = input.project!;
const resolved = await resolveProject(projectKey);
if (!resolved.ok) return resolved;
const targetId = resolved.value;
const allowed = await canWriteProject(actor.userId, actor.groups, targetId);
if (!allowed) return fail(`no write access to project '${projectKey}'`);
if (existing.scope !== "project") {
update.scope = "project";
scopeChanged = true;
}
if (existing.projectId !== targetId) {
update.projectId = targetId;
projectChanged = true;
newProjectKey = projectKey;
}
}
}
const updated = await casUpdate(input.id, update, input.version ?? existing.version);
if (!updated) return fail(CONCURRENT_EDIT_ERROR);
const auditFields = Object.keys(update).filter(
(k) => k !== "updatedAt" && k !== "version" && k !== "lastEditedBy",
);
const auditPayload: Record<string, unknown> = { fields: auditFields };
if (scopeChanged || projectChanged) {
auditPayload.scope = { from: existing.scope, to: update.scope ?? existing.scope };
auditPayload.projectKey = { from: existing.projectKey ?? null, to: newProjectKey };
}
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.update",
entityType: "memory",
entityId: updated.id,
payload: auditPayload,
});
return succeed(updated);
}
export async function patchMemory(
actor: Actor,
input: MemoryPatchInput,
): Promise<Outcome<MutatedMemory & { contentLength: number; delta: number }>> {
const existingRows = await db
.select({
content: memories.content,
scope: memories.scope,
projectId: memories.projectId,
version: memories.version,
userId: memories.userId,
})
.from(memories)
.where(and(eq(memories.id, input.id), isNull(memories.deletedAt)))
.limit(1);
const existing = existingRows[0];
if (!existing) return fail("not found");
const authorized = await authorizeWrite(actor, existing);
if (!authorized.ok) return authorized;
const patch = applyPatch(existing.content, input.old_string, input.new_string);
if (!patch.ok) return fail(patch.error);
const updated = await casUpdate(
input.id,
{
content: patch.content,
embedding: await embedText(patch.content),
updatedAt: new Date(),
lastEditedBy: actor.userId,
version: existing.version + 1,
},
input.version ?? existing.version,
);
if (!updated) return fail(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.patch",
entityType: "memory",
entityId: updated.id,
payload: {
fields: ["content"],
patch: {
offset: existing.content.indexOf(input.old_string),
removed: input.old_string.length,
added: input.new_string.length,
},
},
});
return succeed({
...updated,
contentLength: patch.content.length,
delta: patch.content.length - existing.content.length,
});
}
export async function softDeleteMemory(
actor: Actor,
input: MemoryDeleteInput,
): Promise<Outcome<{ id: string }>> {
const rows = await db
.select({
id: memories.id,
userId: memories.userId,
projectId: memories.projectId,
scope: memories.scope,
version: memories.version,
})
.from(memories)
.where(and(eq(memories.id, input.id), isNull(memories.deletedAt)))
.limit(1);
const existing = rows[0];
if (!existing) return fail("not found");
const authorized = await authorizeWrite(actor, existing);
if (!authorized.ok) return authorized;
const updated = await db
.update(memories)
.set({ deletedAt: new Date(), lastEditedBy: actor.userId })
.where(
and(
eq(memories.id, input.id),
eq(memories.version, input.version ?? existing.version),
isNull(memories.deletedAt),
),
)
.returning({ id: memories.id });
if (!updated[0]) return fail(CONCURRENT_EDIT_ERROR);
await db.insert(auditLog).values({
userId: actor.userId,
actor: actor.via,
action: "memory.delete",
entityType: "memory",
entityId: updated[0].id,
});
return succeed(updated[0]);
}
/**
* Compare-and-set on `version`. A zero-row result means a peer edited the
* row between our read and this write. Callers that omit an explicit
* version pass the one they just read, which still closes the read-
* modify-write window inside a single handler.
*/
async function casUpdate(
id: string,
update: Record<string, unknown>,
expectedVersion: number,
): Promise<MutatedMemory | null> {
const rows = await db
.update(memories)
.set(update)
.where(and(eq(memories.id, id), eq(memories.version, expectedVersion)))
.returning({
id: memories.id,
updatedAt: memories.updatedAt,
version: memories.version,
});
return rows[0] ?? null;
}