Merge: snippets feature end-to-end (Agent B)
This commit is contained in:
@@ -114,15 +114,22 @@ export const snippets = pgTable(
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// NULL when scope = 'user' (global to the user). Mirrors `memories`.
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
scope: memoryScope("scope").notNull().default("user"),
|
||||
name: varchar("name", { length: 200 }).notNull(),
|
||||
body: text("body").notNull(),
|
||||
description: text("description"),
|
||||
tags: textArray("tags").notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueUserName: uniqueIndex("snippets_user_name_uq").on(t.userId, t.name),
|
||||
userIdx: index("snippets_user_idx").on(t.userId),
|
||||
projectIdx: index("snippets_project_idx").on(t.projectId),
|
||||
// Partial unique indexes (one per scope, live rows only) are declared
|
||||
// in the SQL migration since drizzle-kit doesn't model partial indexes.
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,9 +8,19 @@ import {
|
||||
MemoryUpdateInput,
|
||||
MemoryWriteInput,
|
||||
ProjectIdentifyInput,
|
||||
SnippetPutInput,
|
||||
SnippetGetInput,
|
||||
SnippetListInput,
|
||||
SnippetDeleteInput,
|
||||
} from "@shared-memory/schemas";
|
||||
import { embedText } from "@/lib/embedder";
|
||||
import { searchMemories } from "@/lib/memories";
|
||||
import {
|
||||
getSnippet,
|
||||
putSnippet,
|
||||
listSnippets,
|
||||
softDeleteSnippet,
|
||||
} from "@/lib/snippets";
|
||||
import type { UserContext } from "./context";
|
||||
|
||||
/**
|
||||
@@ -498,6 +508,236 @@ const memorySearch: ToolDef = {
|
||||
},
|
||||
};
|
||||
|
||||
// ---------- snippet tools ----------
|
||||
|
||||
const snippetPut: ToolDef = {
|
||||
name: "snippet.put",
|
||||
description:
|
||||
"Save or update a named reusable artifact — a template, format, or checklist the user wants applied consistently. Call this when the user says 'remember this as my X template', 'save this format as Y', or 'use this checklist whenever I do Z'. Different from memory.write (which is for facts you'll later search): snippets are fetched by EXACT name, not searched, so the name is the contract — pick something stable and predictable (e.g. 'pr-description-format', 'commit-msg-rules', 'code-review-checklist'). Use scope='user' (default) for personal templates that apply everywhere; scope='project' for repo-specific variants (requires `project`, same key you used for project.identify). Re-calling with the same name+scope replaces the body in place — there is no separate update tool. Tags help browsing in the Web UI; they do NOT enable search.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description:
|
||||
"Stable identifier for this snippet (1–200 chars; alphanumerics + ._-/). Used as the lookup key — pick something you'll remember.",
|
||||
},
|
||||
body: {
|
||||
type: "string",
|
||||
description: "The full template / format / checklist body (1–64,000 chars).",
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
description: "Optional short note on when to use this snippet.",
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["project", "user"],
|
||||
description:
|
||||
"'user' (default) = applies everywhere. 'project' = tied to one repo and requires `project`.",
|
||||
},
|
||||
project: {
|
||||
type: "string",
|
||||
description: "Project key (required when scope='project').",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional tags for grouping in the Web UI.",
|
||||
},
|
||||
},
|
||||
required: ["name", "body"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = SnippetPutInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
if (parsed.data.scope === "project") {
|
||||
const exists = await resolveProjectId(ctx, parsed.data.project!);
|
||||
if (!exists) {
|
||||
return err(
|
||||
`unknown project '${parsed.data.project}'; call project.identify first`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const { snippet, inserted } = await putSnippet(ctx.userId, {
|
||||
name: parsed.data.name,
|
||||
body: parsed.data.body,
|
||||
description: parsed.data.description,
|
||||
tags: parsed.data.tags,
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
});
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: inserted ? "snippet.put" : "snippet.update",
|
||||
entityType: "snippet",
|
||||
entityId: snippet.id,
|
||||
payload: {
|
||||
name: snippet.name,
|
||||
scope: snippet.scope,
|
||||
projectKey: snippet.projectKey,
|
||||
tags: snippet.tags,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(
|
||||
{
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
scope: snippet.scope,
|
||||
project: snippet.projectKey,
|
||||
inserted,
|
||||
},
|
||||
`${inserted ? "wrote" : "updated"} snippet '${snippet.name}' (${snippet.scope})`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const snippetGet: ToolDef = {
|
||||
name: "snippet.get",
|
||||
description:
|
||||
"Fetch a snippet by its EXACT name. Call this when the user references something by a stable label — 'use my pr-description-format', 'apply the commit-msg-rules', 'follow the code-review-checklist'. Different from memory.search/memory.get: snippets are addressed by name, not UUID, and there is no fuzzy matching — the name must match exactly. If you provide `project` alone (no `scope`), the server prefers the project-scope variant for that repo and falls back to the user-scope default. Pass scope='user' to force the global version even when a project variant exists.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Exact snippet name." },
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["project", "user"],
|
||||
description:
|
||||
"Force a specific scope. Omit to prefer the project variant (if `project` is given), else user.",
|
||||
},
|
||||
project: {
|
||||
type: "string",
|
||||
description:
|
||||
"Project key. Required for scope='project'; optional otherwise (enables project-preferred lookup).",
|
||||
},
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = SnippetGetInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const snippet = await getSnippet(ctx.userId, {
|
||||
name: parsed.data.name,
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
});
|
||||
|
||||
if (!snippet) return err(`snippet '${parsed.data.name}' not found`);
|
||||
|
||||
return ok(
|
||||
{
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
body: snippet.body,
|
||||
description: snippet.description,
|
||||
scope: snippet.scope,
|
||||
project: snippet.projectKey,
|
||||
tags: snippet.tags,
|
||||
createdAt: snippet.createdAt,
|
||||
updatedAt: snippet.updatedAt,
|
||||
},
|
||||
`snippet '${snippet.name}' (${snippet.scope})`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const snippetList: ToolDef = {
|
||||
name: "snippet.list",
|
||||
description:
|
||||
"Browse this user's snippets — useful at session start to see what templates are available before deciding whether to call snippet.get. Unlike memory.list, snippets are sorted by recency of update (they're meant to evolve over time). Filter by scope, project, or tags. Use this when you suspect a relevant template exists but you don't know the exact name; if you DO know the name, call snippet.get directly.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project: {
|
||||
type: "string",
|
||||
description: "Filter by project key (returns only project-scope snippets for that project).",
|
||||
},
|
||||
scope: { type: "string", enum: ["project", "user"], description: "Filter by scope." },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Require all of these tags.",
|
||||
},
|
||||
limit: { type: "integer", minimum: 1, maximum: 200, default: 50 },
|
||||
},
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = SnippetListInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const rows = await listSnippets(ctx.userId, {
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
tags: parsed.data.tags,
|
||||
limit: parsed.data.limit,
|
||||
});
|
||||
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
scope: r.scope,
|
||||
project: r.projectKey,
|
||||
tags: r.tags,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
|
||||
return ok({ items }, `${items.length} snippet(s)`);
|
||||
},
|
||||
};
|
||||
|
||||
const snippetDelete: ToolDef = {
|
||||
name: "snippet.delete",
|
||||
description:
|
||||
"Soft-delete a snippet by name when it becomes stale or wrong — e.g., the user revamps a template and the old version shouldn't be reachable anymore. ALWAYS prefer snippet.put with the same name (which replaces in place) over delete-then-put when you're just refining the body. Only delete when the snippet genuinely shouldn't exist. Provide `scope` (and `project` for project-scope) to disambiguate when the same name exists in multiple scopes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Exact snippet name." },
|
||||
scope: { type: "string", enum: ["project", "user"] },
|
||||
project: { type: "string", description: "Project key (required for scope='project')." },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = SnippetDeleteInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const deleted = await softDeleteSnippet(ctx.userId, {
|
||||
name: parsed.data.name,
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
});
|
||||
if (!deleted) return err(`snippet '${parsed.data.name}' not found`);
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: "snippet.delete",
|
||||
entityType: "snippet",
|
||||
entityId: deleted.id,
|
||||
payload: {
|
||||
name: parsed.data.name,
|
||||
scope: deleted.scope,
|
||||
projectKey: deleted.projectKey,
|
||||
},
|
||||
});
|
||||
|
||||
return ok(
|
||||
{ id: deleted.id, name: parsed.data.name, deleted: true },
|
||||
`deleted snippet '${parsed.data.name}' (${deleted.scope})`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const tools: ToolDef[] = [
|
||||
projectIdentify,
|
||||
memoryWrite,
|
||||
@@ -506,6 +746,10 @@ export const tools: ToolDef[] = [
|
||||
memoryGet,
|
||||
memorySearch,
|
||||
memoryDelete,
|
||||
snippetPut,
|
||||
snippetGet,
|
||||
snippetList,
|
||||
snippetDelete,
|
||||
];
|
||||
|
||||
export const toolMap: Record<string, ToolDef> = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { auditLog } from "@/lib/db/schema";
|
||||
import {
|
||||
SnippetPutInput,
|
||||
SnippetDeleteInput,
|
||||
} from "@shared-memory/schemas";
|
||||
import { putSnippet, softDeleteSnippet } from "@/lib/snippets";
|
||||
|
||||
/**
|
||||
* Server Actions for snippet CRUD from the Web UI. Mirrors the MCP
|
||||
* tools but writes through the same DB helpers, so the two paths are
|
||||
* indistinguishable on the storage layer.
|
||||
*
|
||||
* `actor` is "web" in audit_log so we can tell the two paths apart later.
|
||||
*/
|
||||
|
||||
async function requireUserId(): Promise<string> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("not authenticated");
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
function parseTags(raw: FormDataEntryValue | null): string[] {
|
||||
if (typeof raw !== "string") return [];
|
||||
return raw
|
||||
.split(/[,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
function targetUrl(scope: "project" | "user", name: string, projectKey: string | null): string {
|
||||
const params = new URLSearchParams({ scope });
|
||||
if (scope === "project" && projectKey) params.set("project", projectKey);
|
||||
return `/snippets/${encodeURIComponent(name)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function createSnippetAction(formData: FormData) {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const scope = (formData.get("scope") as "project" | "user") || "user";
|
||||
const projectRaw = (formData.get("project") as string | null)?.trim();
|
||||
const payload = {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
body: String(formData.get("body") ?? ""),
|
||||
description: ((formData.get("description") as string | null) ?? "").trim() || undefined,
|
||||
tags: parseTags(formData.get("tags")),
|
||||
scope,
|
||||
project: scope === "project" ? projectRaw || undefined : undefined,
|
||||
};
|
||||
|
||||
const parsed = SnippetPutInput.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||
}
|
||||
|
||||
const { snippet, inserted } = await putSnippet(userId, {
|
||||
name: parsed.data.name,
|
||||
body: parsed.data.body,
|
||||
description: parsed.data.description,
|
||||
tags: parsed.data.tags,
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
});
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId,
|
||||
actor: "web",
|
||||
action: inserted ? "snippet.put" : "snippet.update",
|
||||
entityType: "snippet",
|
||||
entityId: snippet.id,
|
||||
payload: {
|
||||
name: snippet.name,
|
||||
scope: snippet.scope,
|
||||
projectKey: snippet.projectKey,
|
||||
tags: snippet.tags,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/snippets");
|
||||
redirect(targetUrl(snippet.scope, snippet.name, snippet.projectKey));
|
||||
}
|
||||
|
||||
export async function updateSnippetAction(formData: FormData) {
|
||||
const userId = await requireUserId();
|
||||
|
||||
// Edits keep the row's identity (scope + name + project unchanged) —
|
||||
// body/description/tags are what changes. Treat as a put on the same key.
|
||||
const scope = (formData.get("scope") as "project" | "user") || "user";
|
||||
const projectRaw = (formData.get("project") as string | null)?.trim();
|
||||
const payload = {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
body: String(formData.get("body") ?? ""),
|
||||
description: ((formData.get("description") as string | null) ?? "").trim() || undefined,
|
||||
tags: parseTags(formData.get("tags")),
|
||||
scope,
|
||||
project: scope === "project" ? projectRaw || undefined : undefined,
|
||||
};
|
||||
|
||||
const parsed = SnippetPutInput.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||
}
|
||||
|
||||
const { snippet } = await putSnippet(userId, {
|
||||
name: parsed.data.name,
|
||||
body: parsed.data.body,
|
||||
description: parsed.data.description,
|
||||
tags: parsed.data.tags,
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
});
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId,
|
||||
actor: "web",
|
||||
action: "snippet.update",
|
||||
entityType: "snippet",
|
||||
entityId: snippet.id,
|
||||
payload: {
|
||||
name: snippet.name,
|
||||
scope: snippet.scope,
|
||||
projectKey: snippet.projectKey,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/snippets");
|
||||
revalidatePath(`/snippets/${encodeURIComponent(snippet.name)}`);
|
||||
redirect(targetUrl(snippet.scope, snippet.name, snippet.projectKey));
|
||||
}
|
||||
|
||||
export async function deleteSnippetAction(formData: FormData) {
|
||||
const userId = await requireUserId();
|
||||
|
||||
const scope = formData.get("scope") as "project" | "user" | null;
|
||||
const projectRaw = (formData.get("project") as string | null)?.trim();
|
||||
const payload = {
|
||||
name: String(formData.get("name") ?? "").trim(),
|
||||
scope: scope ?? undefined,
|
||||
project: scope === "project" ? projectRaw || undefined : undefined,
|
||||
};
|
||||
|
||||
const parsed = SnippetDeleteInput.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
throw new Error(parsed.error.issues.map((i) => i.message).join("; "));
|
||||
}
|
||||
|
||||
const deleted = await softDeleteSnippet(userId, {
|
||||
name: parsed.data.name,
|
||||
scope: parsed.data.scope,
|
||||
projectKey: parsed.data.project,
|
||||
});
|
||||
if (!deleted) throw new Error("not found");
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId,
|
||||
actor: "web",
|
||||
action: "snippet.delete",
|
||||
entityType: "snippet",
|
||||
entityId: deleted.id,
|
||||
payload: {
|
||||
name: parsed.data.name,
|
||||
scope: deleted.scope,
|
||||
projectKey: deleted.projectKey,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/snippets");
|
||||
redirect("/snippets");
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { snippets, projects } from "@/lib/db/schema";
|
||||
import type { Snippet } from "@/lib/db/schema";
|
||||
|
||||
/**
|
||||
* Snippet data layer. Shared by the MCP tool handlers and the Web UI
|
||||
* Server Actions so both paths hit the same uniqueness / scope rules.
|
||||
*
|
||||
* Snippets are looked up by EXACT name — there is no search. Names are
|
||||
* unique within a scope:
|
||||
* - user-scope: unique per (user_id)
|
||||
* - project-scope: unique per (user_id, project_id)
|
||||
*
|
||||
* The same name CAN exist in both a user-scope row and one or more
|
||||
* project-scope rows for that user; callers disambiguate by passing
|
||||
* `scope` (+ `project` when project-scoped). When `scope` is omitted on
|
||||
* a get/delete, we prefer the project match (if `project` was supplied)
|
||||
* else fall back to the user-scope row.
|
||||
*/
|
||||
|
||||
export interface ResolvedScope {
|
||||
scope: "project" | "user";
|
||||
projectId: string | null;
|
||||
}
|
||||
|
||||
async function resolveProjectId(userId: string, key: string): Promise<string | null> {
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.userId, userId), eq(projects.key, key)))
|
||||
.limit(1);
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
async function upsertProject(userId: string, key: string): Promise<string> {
|
||||
const existing = await resolveProjectId(userId, key);
|
||||
if (existing) return existing;
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({ userId, key })
|
||||
.returning({ id: projects.id });
|
||||
return row[0]!.id;
|
||||
}
|
||||
|
||||
export interface SnippetWithProjectKey extends Snippet {
|
||||
projectKey: string | null;
|
||||
}
|
||||
|
||||
async function findSnippet(
|
||||
userId: string,
|
||||
name: string,
|
||||
scope: "project" | "user",
|
||||
projectId: string | null,
|
||||
): Promise<SnippetWithProjectKey | null> {
|
||||
const where = [
|
||||
eq(snippets.userId, userId),
|
||||
eq(snippets.name, name),
|
||||
eq(snippets.scope, scope),
|
||||
isNull(snippets.deletedAt),
|
||||
];
|
||||
if (scope === "project") {
|
||||
if (!projectId) return null;
|
||||
where.push(eq(snippets.projectId, projectId));
|
||||
} else {
|
||||
where.push(isNull(snippets.projectId));
|
||||
}
|
||||
const rows = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
projectId: snippets.projectId,
|
||||
scope: snippets.scope,
|
||||
name: snippets.name,
|
||||
body: snippets.body,
|
||||
description: snippets.description,
|
||||
tags: snippets.tags,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
deletedAt: snippets.deletedAt,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(snippets)
|
||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||
.where(and(...where))
|
||||
.limit(1);
|
||||
return (rows[0] as SnippetWithProjectKey | undefined) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a single snippet by name. If `scope` is omitted, prefers a
|
||||
* project match (when `projectKey` is provided) and falls back to the
|
||||
* user-scope row. Returns null when nothing matches.
|
||||
*/
|
||||
export async function getSnippet(
|
||||
userId: string,
|
||||
args: {
|
||||
name: string;
|
||||
scope?: "project" | "user";
|
||||
projectKey?: string;
|
||||
},
|
||||
): Promise<SnippetWithProjectKey | null> {
|
||||
const { name, scope, projectKey } = args;
|
||||
|
||||
if (scope === "project") {
|
||||
if (!projectKey) return null;
|
||||
const pid = await resolveProjectId(userId, projectKey);
|
||||
if (!pid) return null;
|
||||
return findSnippet(userId, name, "project", pid);
|
||||
}
|
||||
|
||||
if (scope === "user") {
|
||||
return findSnippet(userId, name, "user", null);
|
||||
}
|
||||
|
||||
// Scope unspecified: try project first if a key was given, then user.
|
||||
if (projectKey) {
|
||||
const pid = await resolveProjectId(userId, projectKey);
|
||||
if (pid) {
|
||||
const projectHit = await findSnippet(userId, name, "project", pid);
|
||||
if (projectHit) return projectHit;
|
||||
}
|
||||
}
|
||||
return findSnippet(userId, name, "user", null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a snippet keyed by (user, scope, project, name). If the row
|
||||
* already exists (live, matching scope), it's replaced in place
|
||||
* preserving its id. Returns the resulting row plus an `inserted` flag.
|
||||
*/
|
||||
export async function putSnippet(
|
||||
userId: string,
|
||||
args: {
|
||||
name: string;
|
||||
body: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
scope: "project" | "user";
|
||||
projectKey?: string;
|
||||
},
|
||||
): Promise<{ snippet: SnippetWithProjectKey; inserted: boolean }> {
|
||||
const { name, body, description, tags, scope, projectKey } = args;
|
||||
|
||||
let projectId: string | null = null;
|
||||
if (scope === "project") {
|
||||
if (!projectKey) throw new Error("scope=project requires projectKey");
|
||||
projectId = await upsertProject(userId, projectKey);
|
||||
}
|
||||
|
||||
const existing = await findSnippet(userId, name, scope, projectId);
|
||||
if (existing) {
|
||||
const updateValues: Record<string, unknown> = {
|
||||
body,
|
||||
tags: tags ?? existing.tags,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
if (description !== undefined) updateValues.description = description;
|
||||
await db.update(snippets).set(updateValues).where(eq(snippets.id, existing.id));
|
||||
const refreshed = await findSnippet(userId, name, scope, projectId);
|
||||
return { snippet: refreshed!, inserted: false };
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(snippets)
|
||||
.values({
|
||||
userId,
|
||||
projectId,
|
||||
scope,
|
||||
name,
|
||||
body,
|
||||
description: description ?? null,
|
||||
tags: tags ?? [],
|
||||
})
|
||||
.returning({ id: snippets.id });
|
||||
|
||||
const row = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
projectId: snippets.projectId,
|
||||
scope: snippets.scope,
|
||||
name: snippets.name,
|
||||
body: snippets.body,
|
||||
description: snippets.description,
|
||||
tags: snippets.tags,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
deletedAt: snippets.deletedAt,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(snippets)
|
||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||
.where(eq(snippets.id, inserted[0]!.id))
|
||||
.limit(1);
|
||||
|
||||
return { snippet: row[0]! as SnippetWithProjectKey, inserted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* List live snippets for this user, newest first. Filters mirror
|
||||
* memory.list. No pagination cursor yet — snippets are expected to be
|
||||
* relatively low-volume; we cap at the requested limit.
|
||||
*/
|
||||
export async function listSnippets(
|
||||
userId: string,
|
||||
args: {
|
||||
scope?: "project" | "user";
|
||||
projectKey?: string;
|
||||
tags?: string[];
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<SnippetWithProjectKey[]> {
|
||||
const { scope, projectKey, tags, limit = 50 } = args;
|
||||
const where = [eq(snippets.userId, userId), isNull(snippets.deletedAt)];
|
||||
|
||||
if (scope) where.push(eq(snippets.scope, scope));
|
||||
|
||||
if (projectKey) {
|
||||
const pid = await resolveProjectId(userId, projectKey);
|
||||
if (!pid) return [];
|
||||
where.push(eq(snippets.projectId, pid));
|
||||
}
|
||||
|
||||
if (tags && tags.length > 0) {
|
||||
where.push(sql`${snippets.tags} @> ${tags}::text[]`);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
projectId: snippets.projectId,
|
||||
scope: snippets.scope,
|
||||
name: snippets.name,
|
||||
body: snippets.body,
|
||||
description: snippets.description,
|
||||
tags: snippets.tags,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
deletedAt: snippets.deletedAt,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(snippets)
|
||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||
.where(and(...where))
|
||||
.orderBy(desc(snippets.updatedAt))
|
||||
.limit(limit);
|
||||
|
||||
return rows as SnippetWithProjectKey[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a snippet. Returns the deleted row's id, or null if
|
||||
* nothing matched (already deleted or never existed).
|
||||
*
|
||||
* If `scope` is omitted and `projectKey` is provided, deletes the
|
||||
* project-scope row (if found) — falls back to user-scope otherwise.
|
||||
*/
|
||||
export async function softDeleteSnippet(
|
||||
userId: string,
|
||||
args: {
|
||||
name: string;
|
||||
scope?: "project" | "user";
|
||||
projectKey?: string;
|
||||
},
|
||||
): Promise<{ id: string; scope: "project" | "user"; projectKey: string | null } | null> {
|
||||
const target = await getSnippet(userId, args);
|
||||
if (!target) return null;
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(snippets.id, target.id));
|
||||
|
||||
return {
|
||||
id: target.id,
|
||||
scope: target.scope,
|
||||
projectKey: target.projectKey,
|
||||
};
|
||||
}
|
||||
|
||||
// Helpers re-exported so callers that need the project-id resolution
|
||||
// don't have to duplicate the lookup logic.
|
||||
export { resolveProjectId, upsertProject };
|
||||
Reference in New Issue
Block a user