Files
shared-memory/apps/web/lib/memory-mutations.ts
shadowdaoandClaude Opus 5 c3bbea5134 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>
2026-08-11 14:58:11 -07:00

358 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}