Add Quick Notes example plugin demonstrating core plugin surfaces

Mirrors the Bookmarks example (PAP-3575) with a company-scoped note
library: database namespace, scoped API routes, page/sidebar/widget/
settings UI slots. All 7 tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 15:15:28 -05:00
1 parent d981d6bd08
commit 571fbac248
10 files changed
+1103

No files matched your search

@@ -0,0 +1,17 @@
import esbuild from "esbuild";
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
const watch = process.argv.includes("--watch");
const workerCtx = await esbuild.context(presets.esbuild.worker);
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
const uiCtx = await esbuild.context(presets.esbuild.ui);
if (watch) {
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
console.log("esbuild watch mode enabled for worker, manifest, and ui");
} else {
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
}
@@ -0,0 +1,12 @@
CREATE TABLE plugin_quick_notes_b34a9f8617.notes (
id uuid PRIMARY KEY,
company_id uuid NOT NULL,
title text NOT NULL,
body text NOT NULL DEFAULT '',
tags text[] NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX notes_company_idx
ON plugin_quick_notes_b34a9f8617.notes (company_id, created_at DESC);
@@ -0,0 +1,48 @@
{
"name": "@paperclipai/plugin-quick-notes-example",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "First-party example plugin: company-scoped note library backed by a plugin database namespace.",
"scripts": {
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"dev": "node ./esbuild.config.mjs --watch",
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4179",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"example",
"notes",
"knowledge"
],
"author": "Paperclip",
"license": "MIT",
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*"
},
"devDependencies": {
"@paperclipai/shared": "workspace:*",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"esbuild": "^0.27.3",
"rollup": "^4.38.0",
"tslib": "^2.8.1",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18"
}
}
@@ -0,0 +1,90 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
export const PLUGIN_ID = "paperclipai.plugin-quick-notes-example";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.1.0",
displayName: "Quick Notes (Example)",
description:
"First-party example plugin: company-scoped note library backed by a plugin database namespace. Demonstrates scoped API routes, a database namespace, dashboard widget, and a plugin page.",
author: "Paperclip",
categories: ["workspace", "ui"],
capabilities: [
"api.routes.register",
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
"companies.read",
"ui.dashboardWidget.register",
"ui.page.register",
"ui.sidebar.register",
"instance.settings.register",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
database: {
namespaceSlug: "quick_notes",
migrationsDir: "migrations",
},
apiRoutes: [
{
routeKey: "list",
method: "GET",
path: "/notes",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" },
},
{
routeKey: "create",
method: "POST",
path: "/notes",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "body", key: "companyId" },
},
{
routeKey: "delete",
method: "DELETE",
path: "/notes/:id",
auth: "board-or-agent",
capability: "api.routes.register",
companyResolution: { from: "query", key: "companyId" },
},
],
ui: {
slots: [
{
type: "page",
id: "quick-notes-page",
displayName: "Quick Notes",
exportName: "QuickNotesPage",
routePath: "quick-notes",
},
{
type: "sidebar",
id: "quick-notes-sidebar-link",
displayName: "Quick Notes",
exportName: "QuickNotesSidebarLink",
},
{
type: "dashboardWidget",
id: "quick-notes-widget",
displayName: "Quick Notes",
exportName: "QuickNotesDashboardWidget",
},
{
type: "settingsPage",
id: "quick-notes-settings",
displayName: "Quick Notes",
exportName: "QuickNotesSettingsPage",
},
],
},
};
export default manifest;
@@ -0,0 +1,325 @@
import {
usePluginAction,
usePluginData,
type PluginPageProps,
type PluginSettingsPageProps,
type PluginSidebarProps,
type PluginWidgetProps,
} from "@paperclipai/plugin-sdk/ui";
import { useState, useMemo, type CSSProperties, type FormEvent } from "react";
interface NoteRecord {
id: string;
companyId: string;
title: string;
body: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
interface NoteListResult {
databaseNamespace: string;
notes: NoteRecord[];
}
const containerStyle: CSSProperties = {
display: "grid",
gap: 16,
font: "13px system-ui, sans-serif",
color: "#111827",
padding: 16,
maxWidth: 760,
};
const cardStyle: CSSProperties = {
border: "1px solid #e5e7eb",
borderRadius: 8,
padding: 12,
background: "#fff",
};
const inputStyle: CSSProperties = {
border: "1px solid #d1d5db",
borderRadius: 6,
padding: "6px 8px",
font: "inherit",
width: "100%",
};
const buttonStyle: CSSProperties = {
border: "1px solid #1f2937",
background: "#111827",
color: "#fff",
borderRadius: 6,
padding: "6px 10px",
font: "inherit",
cursor: "pointer",
};
const subtleButtonStyle: CSSProperties = {
border: "1px solid #d1d5db",
background: "#fff",
color: "#111827",
borderRadius: 6,
padding: "4px 8px",
font: "inherit",
cursor: "pointer",
};
const tagStyle: CSSProperties = {
display: "inline-block",
border: "1px solid #cbd5f5",
background: "#eef2ff",
color: "#3730a3",
borderRadius: 999,
padding: "1px 8px",
marginRight: 4,
fontSize: 11,
};
function parseTagInput(raw: string): string[] {
return raw
.split(/[\s,]+/)
.map((entry) => entry.trim())
.filter(Boolean);
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function NotesList({
result,
onDelete,
loading,
}: {
result: NoteListResult | null;
onDelete: (id: string) => Promise<void>;
loading: boolean;
}) {
if (loading) return <div>Loading notes…</div>;
if (!result) return null;
if (result.notes.length === 0) {
return <div style={{ color: "#6b7280" }}>No notes yet.</div>;
}
return (
<div style={{ display: "grid", gap: 10 }}>
{result.notes.map((note) => (
<div key={note.id} style={cardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<strong>{note.title}</strong>
<button
type="button"
style={subtleButtonStyle}
onClick={() => {
void onDelete(note.id);
}}
>
Delete
</button>
</div>
{note.tags.length > 0 ? (
<div style={{ marginTop: 6 }}>
{note.tags.map((tag) => (
<span key={tag} style={tagStyle}>
{tag}
</span>
))}
</div>
) : null}
{note.body ? (
<div style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>{note.body}</div>
) : null}
<div style={{ marginTop: 6, color: "#9ca3af", fontSize: 11 }}>
{formatDate(note.createdAt)}
</div>
</div>
))}
</div>
);
}
function NotesSurface({ companyId }: { companyId: string }) {
const [search, setSearch] = useState("");
const [tag, setTag] = useState("");
const [title, setTitle] = useState("");
const [tagsRaw, setTagsRaw] = useState("");
const [body, setBody] = useState("");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const listParams = useMemo(
() => ({ companyId, search: search.trim() || null, tag: tag.trim() || null }),
[companyId, search, tag],
);
const { data, loading, error, refresh } = usePluginData<NoteListResult>("list", listParams);
const create = usePluginAction("create");
const remove = usePluginAction("delete");
async function handleCreate(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setErrorMessage(null);
try {
await create({
companyId,
title,
body: body || null,
tags: parseTagInput(tagsRaw),
});
setTitle("");
setBody("");
setTagsRaw("");
refresh();
} catch (cause) {
setErrorMessage(cause instanceof Error ? cause.message : "Failed to add note");
}
}
async function handleDelete(id: string) {
setErrorMessage(null);
try {
await remove({ companyId, id });
refresh();
} catch (cause) {
setErrorMessage(cause instanceof Error ? cause.message : "Failed to delete note");
}
}
return (
<div style={containerStyle}>
<header style={{ display: "grid", gap: 4 }}>
<h1 style={{ fontSize: 20, margin: 0 }}>Quick Notes</h1>
<div style={{ color: "#6b7280" }}>
Company-scoped note library backed by a plugin database namespace.
</div>
</header>
<form onSubmit={handleCreate} style={{ ...cardStyle, display: "grid", gap: 8 }}>
<strong>Add note</strong>
<input
style={inputStyle}
required
type="text"
placeholder="Title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
<input
style={inputStyle}
placeholder="Tags (comma or space separated)"
value={tagsRaw}
onChange={(event) => setTagsRaw(event.target.value)}
/>
<textarea
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
placeholder="Body (optional)"
value={body}
onChange={(event) => setBody(event.target.value)}
/>
<div>
<button type="submit" style={buttonStyle}>
Save
</button>
</div>
{errorMessage ? <div style={{ color: "#b91c1c" }}>{errorMessage}</div> : null}
</form>
<div style={{ ...cardStyle, display: "grid", gap: 8 }}>
<strong>Search</strong>
<div style={{ display: "flex", gap: 8 }}>
<input
style={inputStyle}
placeholder="Search title or body"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<input
style={{ ...inputStyle, maxWidth: 180 }}
placeholder="Filter by tag"
value={tag}
onChange={(event) => setTag(event.target.value)}
/>
</div>
</div>
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
<NotesList result={data ?? null} onDelete={handleDelete} loading={loading} />
</div>
);
}
function MissingCompanyNotice({ surface }: { surface: string }) {
return (
<div style={containerStyle}>
<div style={cardStyle}>
<strong>{surface}</strong>
<div style={{ color: "#6b7280", marginTop: 4 }}>
Open this plugin from a company workspace — notes are scoped per company.
</div>
</div>
</div>
);
}
export function QuickNotesPage({ context }: PluginPageProps) {
if (!context.companyId) return <MissingCompanyNotice surface="Quick Notes" />;
return <NotesSurface companyId={context.companyId} />;
}
export function QuickNotesSettingsPage({ context }: PluginSettingsPageProps) {
const companyId = context.companyId ?? "";
const { data, loading, error } = usePluginData<NoteListResult>(
"list",
companyId ? { companyId, limit: 1 } : undefined,
);
if (!companyId) return <MissingCompanyNotice surface="Quick Notes settings" />;
return (
<div style={containerStyle}>
<h1 style={{ fontSize: 18, margin: 0 }}>Quick Notes settings</h1>
<div style={cardStyle}>
<div style={{ display: "grid", gap: 4 }}>
<div>
<strong>Database namespace:</strong>{" "}
<code>{loading ? "…" : (data?.databaseNamespace ?? "not configured")}</code>
</div>
{error ? <div style={{ color: "#b91c1c" }}>{error.message}</div> : null}
</div>
</div>
</div>
);
}
export function QuickNotesDashboardWidget({ context }: PluginWidgetProps) {
const companyId = context.companyId;
const { data, loading, error } = usePluginData<NoteListResult>(
"list",
companyId ? { companyId, limit: 5 } : undefined,
);
if (!companyId) return null;
if (loading) return <div style={{ font: "12px system-ui, sans-serif" }}>Loading notes…</div>;
if (error) return <div style={{ color: "#b91c1c" }}>Notes error: {error.message}</div>;
const notes = data?.notes ?? [];
return (
<div style={{ display: "grid", gap: 6, font: "13px system-ui, sans-serif" }}>
<strong>Recent notes</strong>
{notes.length === 0 ? (
<div style={{ color: "#6b7280" }}>No notes yet.</div>
) : (
<ul style={{ margin: 0, paddingLeft: 18 }}>
{notes.map((note) => (
<li key={note.id}>{note.title}</li>
))}
</ul>
)}
</div>
);
}
export function QuickNotesSidebarLink(_props: PluginSidebarProps) {
return <span>Quick Notes</span>;
}
@@ -0,0 +1,284 @@
import { randomUUID } from "node:crypto";
import {
definePlugin,
runWorker,
type PluginApiRequestInput,
type PluginApiResponse,
type PluginContext,
} from "@paperclipai/plugin-sdk";
export interface NoteRecord {
id: string;
companyId: string;
title: string;
body: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
export interface NoteInput {
title: string;
body?: string | null;
tags?: string[] | null;
}
export interface NoteListResult {
databaseNamespace: string;
notes: NoteRecord[];
}
const MAX_TAGS = 16;
const MAX_TAG_LENGTH = 32;
const MAX_TITLE_LENGTH = 200;
const MAX_BODY_LENGTH = 10_000;
const MAX_NOTES_PER_LIST = 200;
function tableName(namespace: string): string {
return `${namespace}.notes`;
}
function asNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function normalizeTitle(value: unknown): string {
const raw = asNonEmptyString(value);
if (!raw) throw new Error("`title` is required");
return raw.length > MAX_TITLE_LENGTH ? raw.slice(0, MAX_TITLE_LENGTH) : raw;
}
function normalizeBody(value: unknown): string {
if (value == null) return "";
if (typeof value !== "string") throw new Error("`body` must be a string when provided");
if (value.length > MAX_BODY_LENGTH) {
throw new Error(`\`body\` must be ${MAX_BODY_LENGTH} characters or fewer`);
}
return value;
}
function normalizeTags(value: unknown): string[] {
if (value == null) return [];
if (!Array.isArray(value)) throw new Error("`tags` must be an array of strings when provided");
const seen = new Set<string>();
const tags: string[] = [];
for (const entry of value) {
if (typeof entry !== "string") throw new Error("`tags` must contain only strings");
const cleaned = entry.trim().toLowerCase();
if (!cleaned) continue;
if (cleaned.length > MAX_TAG_LENGTH) {
throw new Error(`tag "${entry}" exceeds ${MAX_TAG_LENGTH} characters`);
}
if (!/^[a-z0-9][a-z0-9_-]*$/.test(cleaned)) {
throw new Error(
`tag "${entry}" must use lowercase letters, digits, hyphens, or underscores`,
);
}
if (seen.has(cleaned)) continue;
if (tags.length >= MAX_TAGS) throw new Error(`notes accept at most ${MAX_TAGS} tags`);
seen.add(cleaned);
tags.push(cleaned);
}
return tags;
}
function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
interface NoteRow {
id: string;
company_id: string;
title: string;
body: string;
tags: string[];
created_at: string | Date;
updated_at: string | Date;
}
function rowToNote(row: NoteRow): NoteRecord {
return {
id: row.id,
companyId: row.company_id,
title: row.title,
body: row.body,
tags: Array.isArray(row.tags) ? [...row.tags] : [],
createdAt: typeof row.created_at === "string" ? row.created_at : row.created_at.toISOString(),
updatedAt: typeof row.updated_at === "string" ? row.updated_at : row.updated_at.toISOString(),
};
}
async function listNotes(
ctx: PluginContext,
companyId: string,
search: string | null,
tag: string | null,
limit: number,
): Promise<NoteRecord[]> {
const params: unknown[] = [companyId];
let where = "company_id = $1";
if (search) {
params.push(`%${escapeLikePattern(search.toLowerCase())}%`);
const idx = params.length;
where +=
` AND (lower(title) LIKE $${idx} ESCAPE '\\'` +
` OR lower(body) LIKE $${idx} ESCAPE '\\')`;
}
if (tag) {
params.push(tag);
where += ` AND $${params.length} = ANY(tags)`;
}
params.push(Math.min(Math.max(limit, 1), MAX_NOTES_PER_LIST));
const rows = await ctx.db.query<NoteRow>(
`SELECT id, company_id, title, body, tags, created_at, updated_at
FROM ${tableName(ctx.db.namespace)}
WHERE ${where}
ORDER BY created_at DESC
LIMIT $${params.length}`,
params,
);
return rows.map(rowToNote);
}
export async function createNote(
ctx: PluginContext,
companyId: string,
input: NoteInput,
): Promise<NoteRecord> {
const title = normalizeTitle(input.title);
const body = normalizeBody(input.body);
const tags = normalizeTags(input.tags);
const id = randomUUID();
const now = new Date().toISOString();
await ctx.db.execute(
`INSERT INTO ${tableName(ctx.db.namespace)}
(id, company_id, title, body, tags, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)`,
[id, companyId, title, body, tags, now],
);
return { id, companyId, title, body, tags, createdAt: now, updatedAt: now };
}
export async function deleteNote(
ctx: PluginContext,
companyId: string,
id: string,
): Promise<{ deleted: boolean; id: string }> {
const result = await ctx.db.execute(
`DELETE FROM ${tableName(ctx.db.namespace)} WHERE company_id = $1 AND id = $2`,
[companyId, id],
);
return { deleted: result.rowCount > 0, id };
}
let listHandler:
| ((
companyId: string,
search: string | null,
tag: string | null,
limit: number,
) => Promise<NoteListResult>)
| null = null;
let createHandler: ((companyId: string, input: NoteInput) => Promise<NoteRecord>) | null = null;
let deleteHandler:
| ((companyId: string, id: string) => Promise<{ deleted: boolean; id: string }>)
| null = null;
const plugin = definePlugin({
async setup(ctx) {
listHandler = (companyId, search, tag, limit) =>
listNotes(ctx, companyId, search, tag, limit).then((notes) => ({
databaseNamespace: ctx.db.namespace,
notes,
}));
createHandler = (companyId, input) => createNote(ctx, companyId, input);
deleteHandler = (companyId, id) => deleteNote(ctx, companyId, id);
ctx.data.register("list", async (params) => {
const companyId = asNonEmptyString(params.companyId);
if (!companyId) throw new Error("companyId is required");
const search = asNonEmptyString(params.search)?.toLowerCase() ?? null;
const tag = asNonEmptyString(params.tag)?.toLowerCase() ?? null;
const limit = typeof params.limit === "number" ? params.limit : 50;
if (!listHandler) throw new Error("Quick Notes plugin not ready");
return listHandler(companyId, search, tag, limit);
});
ctx.actions.register("create", async (params) => {
const companyId = asNonEmptyString(params.companyId);
if (!companyId) throw new Error("companyId is required");
if (!createHandler) throw new Error("Quick Notes plugin not ready");
return createHandler(companyId, {
title: typeof params.title === "string" ? params.title : "",
body: typeof params.body === "string" ? params.body : null,
tags: Array.isArray(params.tags) ? (params.tags as unknown[] as string[]) : null,
});
});
ctx.actions.register("delete", async (params) => {
const companyId = asNonEmptyString(params.companyId);
const id = asNonEmptyString(params.id);
if (!companyId || !id) throw new Error("companyId and id are required");
if (!deleteHandler) throw new Error("Quick Notes plugin not ready");
return deleteHandler(companyId, id);
});
},
async onApiRequest(input: PluginApiRequestInput): Promise<PluginApiResponse> {
switch (input.routeKey) {
case "list": {
if (!listHandler) return { status: 503, body: { error: "Quick Notes plugin not ready" } };
const search = asNonEmptyString(input.query.search)?.toLowerCase() ?? null;
const tag = asNonEmptyString(input.query.tag)?.toLowerCase() ?? null;
const limitRaw = asNonEmptyString(input.query.limit);
const parsedLimit = limitRaw ? Number.parseInt(limitRaw, 10) : 50;
const limit = Number.isFinite(parsedLimit) ? parsedLimit : 50;
return { body: await listHandler(input.companyId, search, tag, limit) };
}
case "create": {
if (!createHandler) return { status: 503, body: { error: "Quick Notes plugin not ready" } };
const body = (input.body ?? {}) as Record<string, unknown>;
try {
const note = await createHandler(input.companyId, {
title: typeof body.title === "string" ? body.title : "",
body: typeof body.body === "string" ? body.body : null,
tags: Array.isArray(body.tags) ? (body.tags as unknown[] as string[]) : null,
});
return { status: 201, body: note };
} catch (error) {
return {
status: 400,
body: { error: error instanceof Error ? error.message : "Invalid note" },
};
}
}
case "delete": {
if (!deleteHandler) return { status: 503, body: { error: "Quick Notes plugin not ready" } };
const id = input.params.id;
if (!id) return { status: 400, body: { error: "id is required" } };
const companyId = asNonEmptyString(input.query.companyId) ?? input.companyId;
const result = await deleteHandler(companyId, id);
return { status: result.deleted ? 200 : 404, body: result };
}
default:
return { status: 404, body: { error: `Unknown quick-notes route: ${input.routeKey}` } };
}
},
async onHealth() {
return {
status: "ok",
message: "Quick Notes plugin worker is running",
details: {
surfaces: ["scoped-api-route", "database-namespace", "page", "dashboard-widget"],
},
};
},
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,290 @@
import { describe, expect, it } from "vitest";
import { pluginManifestV1Schema } from "@paperclipai/shared";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest, { PLUGIN_ID } from "../src/manifest.js";
import plugin, {
type NoteListResult,
type NoteRecord,
createNote,
deleteNote,
} from "../src/worker.js";
interface NoteRow {
id: string;
company_id: string;
title: string;
body: string;
tags: string[];
created_at: string;
updated_at: string;
}
function makeInMemoryNotesDb() {
const rows: NoteRow[] = [];
return {
rows,
namespace: "plugin_quick_notes_test",
async query<T>(sql: string, params?: unknown[]): Promise<T[]> {
if (!sql.includes("FROM plugin_quick_notes_test.notes")) return [];
const companyId = params?.[0] as string;
let filtered = rows.filter((row) => row.company_id === companyId);
if (sql.includes("LIKE $2")) {
const like = (params?.[1] as string) ?? "";
const inner = like.startsWith("%") && like.endsWith("%") ? like.slice(1, -1) : like;
const needle = inner.replace(/\\(.)/g, "$1").toLowerCase();
filtered = filtered.filter(
(row) =>
row.title.toLowerCase().includes(needle) || row.body.toLowerCase().includes(needle),
);
}
const tagParamIndex = sql.match(/\$(\d+) = ANY\(tags\)/)?.[1];
if (tagParamIndex) {
const tag = params?.[Number(tagParamIndex) - 1] as string;
filtered = filtered.filter((row) => row.tags.includes(tag));
}
filtered = [...filtered].sort((a, b) => b.created_at.localeCompare(a.created_at));
const limitParamIndex = sql.match(/LIMIT \$(\d+)/)?.[1];
if (limitParamIndex) {
const limit = params?.[Number(limitParamIndex) - 1] as number;
filtered = filtered.slice(0, limit);
}
return filtered as unknown as T[];
},
async execute(sql: string, params?: unknown[]): Promise<{ rowCount: number }> {
if (sql.startsWith("INSERT INTO plugin_quick_notes_test.notes")) {
const [id, companyId, title, body, tags, createdAt] = params as [
string,
string,
string,
string,
string[],
string,
];
rows.push({
id,
company_id: companyId,
title,
body,
tags: [...tags],
created_at: createdAt,
updated_at: createdAt,
});
return { rowCount: 1 };
}
if (sql.startsWith("DELETE FROM plugin_quick_notes_test.notes")) {
const [companyId, id] = params as [string, string];
const before = rows.length;
for (let i = rows.length - 1; i >= 0; i -= 1) {
if (rows[i]!.company_id === companyId && rows[i]!.id === id) rows.splice(i, 1);
}
return { rowCount: before - rows.length };
}
return { rowCount: 0 };
},
};
}
function makeHarness() {
const harness = createTestHarness({ manifest });
const db = makeInMemoryNotesDb();
harness.ctx.db = db;
return { harness, db };
}
describe("plugin-quick-notes-example manifest", () => {
it("declares the expected core surface capabilities", () => {
const parsed = pluginManifestV1Schema.parse(manifest);
expect(parsed.id).toBe(PLUGIN_ID);
expect(parsed.capabilities).toEqual(
expect.arrayContaining([
"api.routes.register",
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
"ui.page.register",
"ui.dashboardWidget.register",
]),
);
expect(parsed.database).toMatchObject({
namespaceSlug: "quick_notes",
migrationsDir: "migrations",
});
expect(parsed.apiRoutes).toEqual([
expect.objectContaining({ routeKey: "list", method: "GET" }),
expect.objectContaining({ routeKey: "create", method: "POST" }),
expect.objectContaining({ routeKey: "delete", method: "DELETE" }),
]);
expect(parsed.ui?.slots ?? []).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "quick-notes-page", type: "page" }),
expect.objectContaining({ id: "quick-notes-widget", type: "dashboardWidget" }),
]),
);
});
});
describe("plugin-quick-notes-example worker", () => {
it("creates, lists, and deletes notes via registered handlers", async () => {
const companyId = "22222222-2222-2222-2222-222222222222";
const { harness } = makeHarness();
await plugin.definition.setup(harness.ctx);
const created = await harness.performAction<NoteRecord>("create", {
companyId,
title: "Hello World",
body: "This is my first note.",
tags: ["Docs", "Demo", "demo"],
});
expect(created.companyId).toBe(companyId);
expect(created.title).toBe("Hello World");
expect(created.tags).toEqual(["docs", "demo"]);
const second = await harness.performAction<NoteRecord>("create", {
companyId,
title: "Second Note",
tags: ["demo"],
});
const allListed = await harness.getData<NoteListResult>("list", { companyId });
expect(allListed.databaseNamespace).toBe("plugin_quick_notes_test");
expect(allListed.notes.map((n) => n.title).sort()).toEqual(["Hello World", "Second Note"]);
const filtered = await harness.getData<NoteListResult>("list", {
companyId,
tag: "docs",
});
expect(filtered.notes.map((n) => n.title)).toEqual(["Hello World"]);
const searched = await harness.getData<NoteListResult>("list", {
companyId,
search: "second",
});
expect(searched.notes.map((n) => n.title)).toEqual(["Second Note"]);
const deletion = await harness.performAction<{ deleted: boolean; id: string }>("delete", {
companyId,
id: second.id,
});
expect(deletion).toEqual({ deleted: true, id: second.id });
const after = await harness.getData<NoteListResult>("list", { companyId });
expect(after.notes.map((n) => n.title)).toEqual(["Hello World"]);
});
it("caps tag count exactly at MAX_TAGS", async () => {
const companyId = "88888888-8888-8888-8888-888888888888";
const { harness, db } = makeHarness();
await plugin.definition.setup(harness.ctx);
const sixteenTags = Array.from({ length: 16 }, (_, i) => `t${i}`);
const created = await harness.performAction<NoteRecord>("create", {
companyId,
title: "Tagged note",
tags: sixteenTags,
});
expect(created.tags).toHaveLength(16);
await expect(
harness.performAction("create", {
companyId,
title: "Too many tags",
tags: [...sixteenTags, "extra"],
}),
).rejects.toThrow(/at most 16 tags/);
expect(db.rows).toHaveLength(1);
});
it("requires a title", async () => {
const { harness } = makeHarness();
await plugin.definition.setup(harness.ctx);
await expect(
harness.performAction("create", {
companyId: "33333333-3333-3333-3333-333333333333",
title: "",
}),
).rejects.toThrow(/title.*required/);
});
it("validates malformed tags before touching the database", async () => {
const { harness, db } = makeHarness();
await plugin.definition.setup(harness.ctx);
await expect(
harness.performAction("create", {
companyId: "55555555-5555-5555-5555-555555555555",
title: "Bad tags note",
tags: ["good", "Bad Tag!!!"],
}),
).rejects.toThrow(/lowercase letters, digits, hyphens, or underscores/);
expect(db.rows).toHaveLength(0);
});
it("dispatches the scoped API routes through the same handlers", async () => {
const companyId = "66666666-6666-6666-6666-666666666666";
const { harness } = makeHarness();
await plugin.definition.setup(harness.ctx);
const created = await plugin.definition.onApiRequest?.({
routeKey: "create",
method: "POST",
path: "/notes",
params: {},
query: {},
body: { companyId, title: "API Note", body: "Created via API" },
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
companyId,
headers: {},
});
expect(created).toMatchObject({ status: 201 });
expect(created?.body).toMatchObject({ title: "API Note" });
const noteId = (created?.body as NoteRecord).id;
const listed = await plugin.definition.onApiRequest?.({
routeKey: "list",
method: "GET",
path: "/notes",
params: {},
query: { search: "API" },
body: null,
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
companyId,
headers: {},
});
expect(listed?.body).toMatchObject({
notes: [expect.objectContaining({ title: "API Note" })],
});
const removed = await plugin.definition.onApiRequest?.({
routeKey: "delete",
method: "DELETE",
path: `/notes/${noteId}`,
params: { id: noteId },
query: { companyId },
body: null,
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
companyId,
headers: {},
});
expect(removed).toMatchObject({ status: 200, body: { deleted: true } });
});
it("returns 404 when deleting a non-existent note", async () => {
const companyId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const { harness } = makeHarness();
await plugin.definition.setup(harness.ctx);
const result = await plugin.definition.onApiRequest?.({
routeKey: "delete",
method: "DELETE",
path: "/notes/00000000-0000-0000-0000-000000000000",
params: { id: "00000000-0000-0000-0000-000000000000" },
query: { companyId },
body: null,
actor: { actorType: "user", actorId: "board", userId: "board", agentId: null, runId: null },
companyId,
headers: {},
});
expect(result?.status).toBe(404);
});
});
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "."
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
+2
View File
@@ -12,6 +12,8 @@ packages:
# Same treatment for the bookmarks example: published as a bundled plugin,
# not a workspace importer, so its pnpm-lock.yaml entries are managed by CI.
- "!packages/plugins/examples/plugin-bookmarks-example"
# Same treatment for the quick-notes example.
- "!packages/plugins/examples/plugin-quick-notes-example"
- server
- ui
- cli