Merge: snippets feature end-to-end (Agent B)
This commit is contained in:
@@ -18,6 +18,7 @@ export function Nav({ user }: { user: Session["user"] }) {
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1 ml-2">
|
||||
<NavLink href="/memories">Memories</NavLink>
|
||||
<NavLink href="/snippets">Snippets</NavLink>
|
||||
<NavLink href="/projects">Projects</NavLink>
|
||||
<NavLink href="/settings">Settings</NavLink>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { snippets, projects } from "@/lib/db/schema";
|
||||
import { updateSnippetAction, deleteSnippetAction } from "@/lib/snippet-actions";
|
||||
import { getSnippet, type SnippetWithProjectKey } from "@/lib/snippets";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody, CardHeader } from "@/app/_components/ui/card";
|
||||
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface SiblingHit {
|
||||
scope: "project" | "user";
|
||||
projectKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a snippet name exists in more than one scope (e.g. a user-scope
|
||||
* default plus one or more project-scope variants), we need to either
|
||||
* disambiguate by query string or, if no hint is given, show a picker.
|
||||
*/
|
||||
async function findAllMatches(userId: string, name: string): Promise<SiblingHit[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
scope: snippets.scope,
|
||||
projectKey: projects.key,
|
||||
})
|
||||
.from(snippets)
|
||||
.leftJoin(projects, eq(snippets.projectId, projects.id))
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.name, name), isNull(snippets.deletedAt)));
|
||||
return rows as SiblingHit[];
|
||||
}
|
||||
|
||||
export default async function SnippetDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ name: string }>;
|
||||
searchParams: Promise<{ scope?: string; project?: string; edit?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const { name: rawName } = await params;
|
||||
const name = decodeURIComponent(rawName);
|
||||
const sp = await searchParams;
|
||||
const scope: "project" | "user" | undefined =
|
||||
sp.scope === "user" || sp.scope === "project" ? sp.scope : undefined;
|
||||
const project = sp.project?.trim() || undefined;
|
||||
const isEditing = sp.edit === "1";
|
||||
|
||||
const siblings = await findAllMatches(userId, name);
|
||||
if (siblings.length === 0) notFound();
|
||||
|
||||
// If multiple matches and the user hasn't disambiguated, show a picker.
|
||||
if (!scope && siblings.length > 1) {
|
||||
return (
|
||||
<Container className="pt-6 max-w-3xl">
|
||||
<PageHeader
|
||||
title={name}
|
||||
description={`This name exists in ${siblings.length} scopes — pick one to view.`}
|
||||
actions={
|
||||
<Link href="/snippets" className="no-underline">
|
||||
<Button type="button" variant="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
{siblings.map((s, i) => {
|
||||
const params = new URLSearchParams({ scope: s.scope });
|
||||
if (s.scope === "project" && s.projectKey) {
|
||||
params.set("project", s.projectKey);
|
||||
}
|
||||
return (
|
||||
<Link
|
||||
key={`${s.scope}-${s.projectKey ?? ""}`}
|
||||
href={`/snippets/${encodeURIComponent(name)}?${params.toString()}`}
|
||||
className={`block px-4 py-3 hover:bg-surface-2 no-underline ${i > 0 ? "border-t border-border" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge tone={s.scope === "user" ? "accent" : "neutral"}>{s.scope}</Badge>
|
||||
{s.projectKey ? (
|
||||
<span className="font-mono text-sm text-fg">{s.projectKey}</span>
|
||||
) : (
|
||||
<span className="text-sm text-fg-muted">applies everywhere</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const snippet: SnippetWithProjectKey | null = await getSnippet(userId, {
|
||||
name,
|
||||
scope,
|
||||
projectKey: project,
|
||||
});
|
||||
|
||||
if (!snippet) notFound();
|
||||
|
||||
return (
|
||||
<Container className="pt-6 max-w-3xl">
|
||||
<PageHeader
|
||||
title={isEditing ? `Edit ${snippet.name}` : snippet.name}
|
||||
description={
|
||||
<span className="font-mono text-xs text-fg-subtle">
|
||||
{snippet.scope}
|
||||
{snippet.projectKey ? ` · ${snippet.projectKey}` : ""}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Link href="/snippets" className="no-underline">
|
||||
<Button type="button" variant="secondary">
|
||||
Back
|
||||
</Button>
|
||||
</Link>
|
||||
{!isEditing ? (
|
||||
<Link
|
||||
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
|
||||
scope: snippet.scope,
|
||||
...(snippet.scope === "project" && snippet.projectKey
|
||||
? { project: snippet.projectKey }
|
||||
: {}),
|
||||
edit: "1",
|
||||
}).toString()}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button>Edit</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="flex items-center gap-2 text-xs text-fg-muted flex-wrap">
|
||||
<Badge tone={snippet.scope === "user" ? "accent" : "neutral"}>{snippet.scope}</Badge>
|
||||
{snippet.projectKey ? <span className="font-mono">{snippet.projectKey}</span> : null}
|
||||
<span>· Created {new Date(snippet.createdAt).toLocaleString()}</span>
|
||||
{snippet.updatedAt.getTime() !== snippet.createdAt.getTime() ? (
|
||||
<span>· Updated {new Date(snippet.updatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
|
||||
{isEditing ? (
|
||||
<CardBody>
|
||||
<form action={updateSnippetAction} className="space-y-4">
|
||||
<input type="hidden" name="name" value={snippet.name} />
|
||||
<input type="hidden" name="scope" value={snippet.scope} />
|
||||
{snippet.scope === "project" && snippet.projectKey ? (
|
||||
<input type="hidden" name="project" value={snippet.projectKey} />
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" hint="Optional">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
defaultValue={snippet.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="body">Body</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
name="body"
|
||||
required
|
||||
rows={16}
|
||||
defaultValue={snippet.body}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags" hint="comma- or space-separated">
|
||||
Tags
|
||||
</Label>
|
||||
<Input
|
||||
id="tags"
|
||||
name="tags"
|
||||
defaultValue={snippet.tags.join(", ")}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Link
|
||||
href={`/snippets/${encodeURIComponent(snippet.name)}?${new URLSearchParams({
|
||||
scope: snippet.scope,
|
||||
...(snippet.scope === "project" && snippet.projectKey
|
||||
? { project: snippet.projectKey }
|
||||
: {}),
|
||||
}).toString()}`}
|
||||
className="no-underline"
|
||||
>
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Save changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
) : (
|
||||
<CardBody>
|
||||
{snippet.description ? (
|
||||
<p className="text-sm text-fg-muted mb-3">{snippet.description}</p>
|
||||
) : null}
|
||||
<pre className="whitespace-pre-wrap break-words bg-transparent border-0 p-0 text-sm text-fg leading-relaxed font-mono">
|
||||
{snippet.body}
|
||||
</pre>
|
||||
{snippet.tags.length ? (
|
||||
<div className="flex gap-1 flex-wrap mt-4">
|
||||
{snippet.tags.map((t) => (
|
||||
<Badge key={t}>{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!isEditing ? (
|
||||
<form action={deleteSnippetAction} className="flex justify-end">
|
||||
<input type="hidden" name="name" value={snippet.name} />
|
||||
<input type="hidden" name="scope" value={snippet.scope} />
|
||||
{snippet.scope === "project" && snippet.projectKey ? (
|
||||
<input type="hidden" name="project" value={snippet.projectKey} />
|
||||
) : null}
|
||||
<Button type="submit" variant="danger" size="sm">
|
||||
Delete snippet
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import Link from "next/link";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { projects } from "@/lib/db/schema";
|
||||
import { createSnippetAction } from "@/lib/snippet-actions";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody } from "@/app/_components/ui/card";
|
||||
import { Input, Textarea, Label } from "@/app/_components/ui/input";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewSnippetPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ scope?: string; project?: string; name?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const params = await searchParams;
|
||||
const initialScope = params.scope === "project" ? "project" : "user";
|
||||
const initialProject = params.project ?? "";
|
||||
const initialName = params.name ?? "";
|
||||
|
||||
const projectList = await db
|
||||
.select({ key: projects.key, displayName: projects.displayName })
|
||||
.from(projects)
|
||||
.where(eq(projects.userId, userId))
|
||||
.orderBy(desc(projects.updatedAt))
|
||||
.limit(50);
|
||||
|
||||
return (
|
||||
<Container className="pt-6 max-w-2xl">
|
||||
<PageHeader
|
||||
title="New snippet"
|
||||
description="Name it something stable — that name is the lookup key from now on."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<CardBody>
|
||||
<form action={createSnippetAction} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" hint="alphanumerics + ._-/">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
defaultValue={initialName}
|
||||
placeholder="pr-description-format"
|
||||
className="mt-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="scope">Scope</Label>
|
||||
<select
|
||||
id="scope"
|
||||
name="scope"
|
||||
defaultValue={initialScope}
|
||||
className="mt-1 h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm w-full"
|
||||
>
|
||||
<option value="user">User — applies everywhere (default)</option>
|
||||
<option value="project">Project — tied to a specific repo</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="project" hint="Required for project scope">
|
||||
Project key
|
||||
</Label>
|
||||
<Input
|
||||
id="project"
|
||||
name="project"
|
||||
defaultValue={initialProject}
|
||||
placeholder="repo name, slug, or any stable string"
|
||||
list="project-list"
|
||||
className="mt-1"
|
||||
/>
|
||||
{projectList.length > 0 ? (
|
||||
<datalist id="project-list">
|
||||
{projectList.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.displayName ?? p.key}
|
||||
</option>
|
||||
))}
|
||||
</datalist>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" hint="Optional">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
placeholder="When should this template be used?"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="body">Body</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
name="body"
|
||||
required
|
||||
rows={14}
|
||||
placeholder="The full template, format, or checklist…"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags" hint="comma- or space-separated">
|
||||
Tags
|
||||
</Label>
|
||||
<Input id="tags" name="tags" placeholder="format, review, …" className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Link href="/snippets" className="no-underline">
|
||||
<Button type="button" variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</Link>
|
||||
<Button type="submit">Save snippet</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { listSnippets } from "@/lib/snippets";
|
||||
import { Container, PageHeader } from "@/app/_components/ui/container";
|
||||
import { Card, CardBody } from "@/app/_components/ui/card";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Input } from "@/app/_components/ui/input";
|
||||
import { EmptyState } from "@/app/_components/ui/empty-state";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type Scope = "project" | "user";
|
||||
|
||||
function detailHref(name: string, scope: Scope, projectKey: string | null): string {
|
||||
const params = new URLSearchParams({ scope });
|
||||
if (scope === "project" && projectKey) params.set("project", projectKey);
|
||||
return `/snippets/${encodeURIComponent(name)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export default async function SnippetsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ scope?: string; project?: string; tag?: string }>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const userId = session!.user.id;
|
||||
const params = await searchParams;
|
||||
const scope: Scope | undefined =
|
||||
params.scope === "user" || params.scope === "project" ? params.scope : undefined;
|
||||
const project = params.project?.trim() || undefined;
|
||||
const tag = params.tag?.trim() || undefined;
|
||||
|
||||
const rows = await listSnippets(userId, {
|
||||
scope,
|
||||
projectKey: project,
|
||||
tags: tag ? [tag] : undefined,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
return (
|
||||
<Container className="pt-6">
|
||||
<PageHeader
|
||||
title="Snippets"
|
||||
description={
|
||||
rows.length === 0
|
||||
? "Named, reusable templates. Fetched by exact name, never searched."
|
||||
: `${rows.length} snippet${rows.length === 1 ? "" : "s"}, most recently updated first.`
|
||||
}
|
||||
actions={
|
||||
<Link href="/snippets/new" className="no-underline">
|
||||
<Button>New snippet</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<form method="GET" action="/snippets" className="mb-6 flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
name="scope"
|
||||
value={scope}
|
||||
options={["", "project", "user"]}
|
||||
placeholder="Any scope"
|
||||
/>
|
||||
<Input
|
||||
name="project"
|
||||
placeholder="Project key…"
|
||||
defaultValue={project ?? ""}
|
||||
className="w-44"
|
||||
/>
|
||||
<Input name="tag" placeholder="Tag…" defaultValue={tag ?? ""} className="w-32" />
|
||||
<Button type="submit" variant="secondary">
|
||||
Apply
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No snippets yet"
|
||||
description="Create a snippet to save a template, format, or checklist you want to reuse. Snippets are fetched by exact name — pick something stable like 'pr-description-format' or 'commit-msg-rules'."
|
||||
action={
|
||||
<Link href="/snippets/new" className="no-underline">
|
||||
<Button>Create the first one</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rows.map((s) => (
|
||||
<li key={s.id}>
|
||||
<Link
|
||||
href={detailHref(s.name, s.scope, s.projectKey)}
|
||||
className="block no-underline"
|
||||
>
|
||||
<Card className="hover:border-border-strong transition-colors">
|
||||
<CardBody className="space-y-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-sm text-fg">{s.name}</span>
|
||||
<Badge tone={s.scope === "user" ? "accent" : "neutral"}>
|
||||
{s.scope}
|
||||
</Badge>
|
||||
{s.projectKey ? (
|
||||
<span className="font-mono text-xs text-fg-subtle">
|
||||
· {s.projectKey}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="ml-auto text-xs text-fg-subtle">
|
||||
updated {new Date(s.updatedAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
{s.description ? (
|
||||
<p className="text-sm text-fg-muted line-clamp-2">{s.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-fg-subtle line-clamp-2 font-mono">{s.body}</p>
|
||||
)}
|
||||
{s.tags.length ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{s.tags.map((t) => (
|
||||
<Badge key={t}>{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
placeholder,
|
||||
}: {
|
||||
name: string;
|
||||
value: string | undefined;
|
||||
options: string[];
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
name={name}
|
||||
defaultValue={value ?? ""}
|
||||
className="h-9 px-2 rounded-md bg-surface-1 border border-border text-fg text-sm"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o === "" ? placeholder : o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Snippets gain scope/project mirroring memories.
|
||||
--
|
||||
-- Phase 1 created `snippets` as a flat per-user table. To make snippets
|
||||
-- behave like memories (user-scope = global, project-scope = tied to a
|
||||
-- repo) we add the same three columns: scope, project_id, deleted_at.
|
||||
--
|
||||
-- Uniqueness of `name` is enforced WITHIN a scope:
|
||||
-- - within (user_id) for user-scope rows
|
||||
-- - within (user_id, project_id) for project-scope rows
|
||||
-- Soft-deleted rows are excluded from uniqueness so a name can be reused
|
||||
-- after deletion.
|
||||
|
||||
ALTER TABLE "snippets"
|
||||
ADD COLUMN "scope" memory_scope NOT NULL DEFAULT 'user',
|
||||
ADD COLUMN "project_id" uuid REFERENCES "projects"("id") ON DELETE SET NULL,
|
||||
ADD COLUMN "deleted_at" timestamptz;
|
||||
|
||||
-- Scope/project_id consistency mirrors memories_scope_project_chk.
|
||||
ALTER TABLE "snippets"
|
||||
ADD CONSTRAINT "snippets_scope_project_chk"
|
||||
CHECK (
|
||||
(scope = 'project' AND project_id IS NOT NULL)
|
||||
OR (scope = 'user' AND project_id IS NULL)
|
||||
);
|
||||
|
||||
-- Drop the old global per-user uniqueness; replace with two partial
|
||||
-- unique indexes scoped to live (non-deleted) rows.
|
||||
DROP INDEX IF EXISTS "snippets_user_name_uq";
|
||||
|
||||
CREATE UNIQUE INDEX "snippets_user_name_user_scope_uq"
|
||||
ON "snippets" ("user_id", "name")
|
||||
WHERE scope = 'user' AND deleted_at IS NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "snippets_user_project_name_uq"
|
||||
ON "snippets" ("user_id", "project_id", "name")
|
||||
WHERE scope = 'project' AND deleted_at IS NULL;
|
||||
|
||||
CREATE INDEX "snippets_user_idx" ON "snippets" ("user_id");
|
||||
CREATE INDEX "snippets_project_idx" ON "snippets" ("project_id");
|
||||
@@ -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 };
|
||||
@@ -81,3 +81,75 @@ export const ProjectIdentifyInput = z.object({
|
||||
display_name: z.string().min(1).max(200).optional(),
|
||||
});
|
||||
export type ProjectIdentifyInput = z.infer<typeof ProjectIdentifyInput>;
|
||||
|
||||
// =============================================================================
|
||||
// Snippets
|
||||
//
|
||||
// Snippets are named, exactly-reproducible artifacts (templates, formats,
|
||||
// checklists). Unlike memories, they're fetched by EXACT name — never
|
||||
// searched. They mirror the memory scope/project model so the same key
|
||||
// can have a global default plus per-repo variants.
|
||||
// =============================================================================
|
||||
|
||||
export const SnippetName = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9._\-/]+$/,
|
||||
"snippet name may only contain alphanumerics, ._-/",
|
||||
);
|
||||
export type SnippetName = z.infer<typeof SnippetName>;
|
||||
|
||||
export const SnippetBody = z.string().min(1).max(64_000);
|
||||
export const SnippetDescription = z.string().max(2_000);
|
||||
|
||||
// Shared scope/project consistency: project-scope requires `project`,
|
||||
// user-scope forbids it. Matches the DB CHECK constraint and the same
|
||||
// refinement used implicitly for memories at the handler level.
|
||||
const scopeProjectRefinement = {
|
||||
check: (v: { scope?: "project" | "user"; project?: string }) => {
|
||||
if (v.scope === "project") return Boolean(v.project);
|
||||
if (v.scope === "user") return v.project === undefined;
|
||||
return true;
|
||||
},
|
||||
message: "scope='project' requires `project`; scope='user' forbids `project`",
|
||||
};
|
||||
|
||||
export const SnippetPutInput = z
|
||||
.object({
|
||||
name: SnippetName,
|
||||
body: SnippetBody,
|
||||
description: SnippetDescription.optional(),
|
||||
tags: Tags.optional(),
|
||||
scope: MemoryScope.default("user"),
|
||||
project: ProjectKey.optional(),
|
||||
})
|
||||
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
|
||||
export type SnippetPutInput = z.infer<typeof SnippetPutInput>;
|
||||
|
||||
export const SnippetGetInput = z
|
||||
.object({
|
||||
name: SnippetName,
|
||||
scope: MemoryScope.optional(),
|
||||
project: ProjectKey.optional(),
|
||||
})
|
||||
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
|
||||
export type SnippetGetInput = z.infer<typeof SnippetGetInput>;
|
||||
|
||||
export const SnippetListInput = z.object({
|
||||
project: ProjectKey.optional(),
|
||||
scope: MemoryScope.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
limit: z.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
export type SnippetListInput = z.infer<typeof SnippetListInput>;
|
||||
|
||||
export const SnippetDeleteInput = z
|
||||
.object({
|
||||
name: SnippetName,
|
||||
scope: MemoryScope.optional(),
|
||||
project: ProjectKey.optional(),
|
||||
})
|
||||
.refine(scopeProjectRefinement.check, { message: scopeProjectRefinement.message });
|
||||
export type SnippetDeleteInput = z.infer<typeof SnippetDeleteInput>;
|
||||
|
||||
Reference in New Issue
Block a user