feat(db): schema for ResourceRevision, ResourceProposal, Skill

Phase 1 of the Skills + Revisions + Proposals work. Purely additive — no
existing rows are touched, no tables renamed, no columns dropped.

New tables:
- ResourceRevision — append-only audit + diff log keyed by
  (resourceType, resourceId). Both Prompt and Skill produce revisions on
  every change. Soft FK so revisions outlive the resources they describe.
  Indexed for history viewer (latest-first), semver lookup, and
  cross-resource sync diff via contentHash.
- ResourceProposal — generic propose/approve/reject queue. Drop-in
  replacement for the prompt-only PromptRequest. Created empty here;
  PR-2 will rename PromptRequest → _PromptRequest_legacy and backfill.
- Skill — new resource type that mirrors Prompt for everything CRUD-
  shaped. Adds `files` Json (multi-file bundles, materialised onto disk
  by `mcpctl skills sync` in PR-5) and `metadata` Json (typed app-layer
  in PR-3: hooks, mcpServers, postInstall, …).

New columns on Prompt:
- semver (semver string, default '0.1.0') — auto-bumped patch on save
  by PromptService.update once PR-2 wires it. Distinct from `version`,
  which stays as the optimistic-concurrency counter.
- currentRevisionId — soft pointer to the latest ResourceRevision row.

DB tests cover scope rules (project XOR agent XOR neither), name
uniqueness across both compound keys, cascade-on-delete, soft-FK
survival of deletion, and JSON column persistence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-05-07 00:18:21 +01:00
parent f8aa6c2f0d
commit fbe68fa693
6 changed files with 810 additions and 41 deletions

View File

@@ -0,0 +1,157 @@
-- Phase 1 of the Skills+Revisions+Proposals work. Purely additive — no
-- existing rows are touched, no tables renamed, no columns dropped. PR-2
-- will follow up with the PromptRequest → ResourceProposal cutover (rename
-- + backfill + service rewire) once the new tables have settled.
--
-- New objects:
-- 1. ResourceRevision — append-only audit + diff log keyed by
-- (resourceType, resourceId). Both Prompt and Skill produce revisions.
-- Hot reads stay on the resource row's inline content; revisions are
-- only consulted by history/diff/restore endpoints.
-- 2. ResourceProposal — generic propose/approve/reject queue, drop-in
-- replacement for the prompt-only PromptRequest. Created empty here;
-- backfill from PromptRequest happens in PR-2.
-- 3. Skill — new resource type that mirrors Prompt for everything CRUD-
-- shaped. Adds `files` Json (multi-file bundles, materialised onto
-- disk by `mcpctl skills sync`) and `metadata` Json (typed at the
-- app layer in PR-3: hooks, mcpServers, postInstall, …).
-- 4. semver + currentRevisionId columns on Prompt.
-- ── 1. ResourceRevision ──
CREATE TABLE "ResourceRevision" (
"id" TEXT NOT NULL,
-- Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a
-- third resource type later a non-migration change.
"resourceType" TEXT NOT NULL,
-- Soft FK — no relation declared. Survives resource deletion so the
-- audit trail isn't lost when a prompt or skill is removed.
"resourceId" TEXT NOT NULL,
"semver" TEXT NOT NULL DEFAULT '0.1.0',
-- sha256 of the canonicalised body — stable diff key. Two revisions
-- with the same hash are byte-identical (skills sync uses this to
-- skip work even when semver hasn't bumped).
"contentHash" TEXT NOT NULL,
-- Snapshot of the resource at this revision: { content, metadata?, ... }
"body" JSONB NOT NULL,
"authorUserId" TEXT,
"authorSessionId" TEXT,
"note" TEXT NOT NULL DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ResourceRevision_pkey" PRIMARY KEY ("id")
);
-- History viewer: latest-first within a resource.
CREATE INDEX "ResourceRevision_resourceType_resourceId_createdAt_idx"
ON "ResourceRevision"("resourceType", "resourceId", "createdAt" DESC);
-- Direct lookup by semver: GET /api/v1/revisions?resourceType=&resourceId=&semver=
CREATE INDEX "ResourceRevision_resourceType_resourceId_semver_idx"
ON "ResourceRevision"("resourceType", "resourceId", "semver");
-- Cross-resource sync diff: "do I already have a revision with this hash?"
CREATE INDEX "ResourceRevision_contentHash_idx"
ON "ResourceRevision"("contentHash");
-- Author drilldown for audit views.
CREATE INDEX "ResourceRevision_authorUserId_idx"
ON "ResourceRevision"("authorUserId");
-- ── 2. ResourceProposal ──
CREATE TABLE "ResourceProposal" (
"id" TEXT NOT NULL,
"resourceType" TEXT NOT NULL,
"name" TEXT NOT NULL,
-- Proposed body — { content, metadata? } shaped per resourceType.
"body" JSONB NOT NULL,
"projectId" TEXT,
"agentId" TEXT,
"createdBySession" TEXT,
"createdByUserId" TEXT,
-- Status lifecycle: pending → (approved|rejected). Reviewer note set
-- on either terminal state.
"status" TEXT NOT NULL DEFAULT 'pending',
"reviewerNote" TEXT NOT NULL DEFAULT '',
-- Set when status='approved': the ResourceRevision the approval created.
"approvedRevisionId" TEXT,
-- Optimistic-concurrency counter for concurrent reviewer actions.
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ResourceProposal_pkey" PRIMARY KEY ("id")
);
-- Cascade matches Prompt's behaviour: deleting a project drops its proposals.
ALTER TABLE "ResourceProposal"
ADD CONSTRAINT "ResourceProposal_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ResourceProposal"
ADD CONSTRAINT "ResourceProposal_agentId_fkey"
FOREIGN KEY ("agentId") REFERENCES "Agent"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- Mirrors Prompt's two-unique pattern: a (type, name) pair can have one
-- proposal per project XOR one per agent. NULL semantics inherit Postgres
-- defaults; the app layer reuses the `?? ''` workaround for direct
-- compound-key lookups, same as Prompt.
CREATE UNIQUE INDEX "ResourceProposal_resourceType_name_projectId_key"
ON "ResourceProposal"("resourceType", "name", "projectId");
CREATE UNIQUE INDEX "ResourceProposal_resourceType_name_agentId_key"
ON "ResourceProposal"("resourceType", "name", "agentId");
-- Reviewer queue: SELECT … WHERE resourceType=? AND status='pending'.
CREATE INDEX "ResourceProposal_resourceType_status_idx"
ON "ResourceProposal"("resourceType", "status");
CREATE INDEX "ResourceProposal_projectId_idx"
ON "ResourceProposal"("projectId");
CREATE INDEX "ResourceProposal_createdBySession_idx"
ON "ResourceProposal"("createdBySession");
-- ── 3. Skill ──
-- Mirrors Prompt for scoping (project XOR agent XOR neither = global) and
-- carries the inline SKILL.md body in `content`. Multi-file bundles live
-- in `files` (path → content map). Typed skill metadata (hooks,
-- mcpServers, postInstall, …) lives opaquely in `metadata` here and is
-- validated app-layer (PR-3).
CREATE TABLE "Skill" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"content" TEXT NOT NULL,
"files" JSONB NOT NULL DEFAULT '{}',
"metadata" JSONB NOT NULL DEFAULT '{}',
"projectId" TEXT,
"agentId" TEXT,
"priority" INTEGER NOT NULL DEFAULT 5,
"summary" TEXT,
"chapters" JSONB,
"semver" TEXT NOT NULL DEFAULT '0.1.0',
-- Soft pointer to the latest ResourceRevision row for this skill.
-- NULL before the first revision is recorded.
"currentRevisionId" TEXT,
-- Optimistic-concurrency counter. NOT semver — that's `semver` above.
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Skill_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "Skill"
ADD CONSTRAINT "Skill_projectId_fkey"
FOREIGN KEY ("projectId") REFERENCES "Project"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Skill"
ADD CONSTRAINT "Skill_agentId_fkey"
FOREIGN KEY ("agentId") REFERENCES "Agent"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
CREATE UNIQUE INDEX "Skill_name_projectId_key" ON "Skill"("name", "projectId");
CREATE UNIQUE INDEX "Skill_name_agentId_key" ON "Skill"("name", "agentId");
CREATE INDEX "Skill_projectId_idx" ON "Skill"("projectId");
CREATE INDEX "Skill_agentId_idx" ON "Skill"("agentId");
CREATE INDEX "Skill_name_idx" ON "Skill"("name");
-- ── 4. semver + currentRevisionId on Prompt ──
-- ADD COLUMN with a default is instant on Postgres ≥11 (no table rewrite).
ALTER TABLE "Prompt"
ADD COLUMN "semver" TEXT NOT NULL DEFAULT '0.1.0',
ADD COLUMN "currentRevisionId" TEXT;

View File

@@ -304,6 +304,8 @@ model Project {
servers ProjectServer[]
prompts Prompt[]
promptRequests PromptRequest[]
proposals ResourceProposal[]
skills Skill[]
mcpTokens McpToken[]
agents Agent[]
@@ -395,6 +397,15 @@ model Prompt {
summary String? @db.Text
chapters Json?
linkTarget String?
// Semantic version of the current content. Auto-bumped patch on every save
// by PromptService.update; author can pass --bump major|minor|patch or
// --semver X.Y.Z to override. NOT the same as `version` below — that one
// is the optimistic-concurrency counter and stays Int.
semver String @default("0.1.0")
// Soft pointer to the latest ResourceRevision row for this prompt. NULL
// before the first revision is recorded. Set in the same transaction as
// create/update by PromptService.
currentRevisionId String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -409,6 +420,56 @@ model Prompt {
@@index([agentId])
}
// ── Skills (Claude Code skill bundles, synced to ~/.claude/skills/<name>/) ──
//
// Skills are the on-disk counterpart to Prompts. mcpd is the source of truth;
// mcpctl skills sync materialises them onto disk under
// ~/.claude/skills/<name>/SKILL.md (+ optional aux files) where Claude Code
// reads them natively. Same scoping rules as Prompt (project XOR agent XOR
// neither = global). Multi-file bundles live in `files` (path → content).
// Typed skill metadata (hooks, mcpServers, postInstall, …) is validated in
// the app layer and stored opaquely here in `metadata`.
model Skill {
id String @id @default(cuid())
name String
description String @default("")
// Body of the SKILL.md file delivered to Claude Code.
content String @db.Text
// Auxiliary files in the skill bundle. Map of relative path → file content
// (UTF-8 text only in v1; binaries deferred). Materialised onto disk at sync
// time alongside SKILL.md.
files Json @default("{}")
// Typed-but-stored-as-Json: { hooks?, mcpServers?, postInstall?,
// preUninstall?, postInstallTimeoutSec? }. Validated at the route layer
// (see src/mcpd/src/validation/skill.schema.ts in PR-3).
metadata Json @default("{}")
projectId String?
agentId String?
priority Int @default(5)
summary String? @db.Text
chapters Json?
// Semantic version of the current content. Auto-bumped patch on every save
// by SkillService.update; author can override via --bump or --semver.
semver String @default("0.1.0")
// Soft pointer to the latest ResourceRevision row for this skill. NULL
// before the first revision is recorded.
currentRevisionId String?
// Optimistic-concurrency counter. NOT semver — that's `semver` above.
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@unique([name, agentId])
@@index([projectId])
@@index([agentId])
@@index([name])
}
// ── Prompt Requests (pending proposals from LLM sessions) ──
model PromptRequest {
@@ -428,6 +489,91 @@ model PromptRequest {
@@index([createdBySession])
}
// ── ResourceRevision (append-only audit + diff log) ──
//
// Both Prompt and Skill rows produce revisions on every change. Hot reads
// (gate plugin, mcpctl skills sync, prompt index for LLM selection) stay on
// the resource row's inline content; revisions are only consulted by
// history/diff/restore endpoints. Approving a ResourceProposal atomically
// inserts the resource + a revision in the same transaction.
//
// `resourceId` is a soft FK with NO referential constraint — revisions
// outlive the resources they describe so audit history isn't lost when
// a resource is deleted. Validated app-layer.
model ResourceRevision {
id String @id @default(cuid())
// Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a
// third resource type later a non-migration change.
resourceType String
resourceId String
semver String @default("0.1.0")
// sha256 of the canonicalised body — stable diff key. Two revisions with
// the same hash are byte-identical (skills sync uses this to skip work
// even when semver hasn't bumped).
contentHash String
// Snapshot of the resource at this revision: { content, metadata?, ... }
body Json
authorUserId String?
authorSessionId String?
note String @default("")
createdAt DateTime @default(now())
// History viewer: latest-first within a resource.
@@index([resourceType, resourceId, createdAt(sort: Desc)])
// Direct lookup by semver.
@@index([resourceType, resourceId, semver])
// Sync diff cross-resource lookup ("do I already have a revision with
// this contentHash anywhere?") — useful for detecting renames + dedup.
@@index([contentHash])
@@index([authorUserId])
}
// ── ResourceProposal (generic propose/approve/reject queue) ──
//
// A pending change to a Prompt or Skill, submitted by an LLM session via
// the propose_prompt / propose_skill MCP tools (see mcplocal gate plugin)
// or by a human via the web UI / CLI. Reviewers drain the queue via
// `mcpctl review next`. Approving creates the underlying resource (if new)
// and writes a ResourceRevision; the proposal status flips to 'approved'
// and `approvedRevisionId` points at the resulting revision row.
//
// Replaces the prompt-only PromptRequest table; the cutover happens in PR-2
// (rename + backfill + service rewire).
model ResourceProposal {
id String @id @default(cuid())
resourceType String // 'prompt' | 'skill'
name String
// Proposed body — { content, metadata? } shaped per resourceType.
body Json
projectId String?
agentId String?
createdBySession String?
createdByUserId String?
status String @default("pending") // pending | approved | rejected
reviewerNote String @default("")
// Set when status='approved': the ResourceRevision the approval created.
approvedRevisionId String?
// Optimistic-concurrency counter for concurrent reviewer actions.
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade)
// Mirrors Prompt's two-unique pattern: a (type, name) pair can have one
// proposal per project XOR one per agent. NULL semantics inherit Postgres
// defaults (NULL distinct from NULL); the app layer reuses the `?? ''`
// workaround for direct compound-key lookups, same as Prompt.
@@unique([resourceType, name, projectId])
@@unique([resourceType, name, agentId])
@@index([resourceType, status])
@@index([projectId])
@@index([createdBySession])
}
// ── Audit Events (pipeline/gate/tool trace from mcplocal) ──
model AuditEvent {
@@ -504,6 +650,8 @@ model Agent {
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
threads ChatThread[]
prompts Prompt[]
proposals ResourceProposal[]
skills Skill[]
personalities Personality[] @relation("AgentPersonalities")
defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull)

View File

@@ -36,6 +36,12 @@ export async function clearAllTables(client: PrismaClient): Promise<void> {
// Break Agent.defaultPersonalityId before personalities can be removed.
await client.agent.updateMany({ data: { defaultPersonalityId: null } });
await client.personality.deleteMany();
// Skills + Proposals cascade from Project/Agent, but globals (NULL FK)
// need explicit cleanup so they don't leak between tests.
await client.skill.deleteMany();
await client.resourceProposal.deleteMany();
// Revisions have no FK (soft FK); always orphans without explicit clear.
await client.resourceRevision.deleteMany();
await client.agent.deleteMany();
await client.llm.deleteMany();
await client.mcpInstance.deleteMany();

View File

@@ -0,0 +1,147 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
describe('ResourceProposal schema', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
async function createUser() {
return prisma.user.create({
data: {
email: `test-${Date.now()}-${Math.random()}@example.com`,
name: 'Test',
passwordHash: '!locked',
role: 'USER',
},
});
}
async function createProject(name = `project-${Date.now()}-${Math.random()}`) {
const user = await createUser();
return prisma.project.create({ data: { name, ownerId: user.id } });
}
it('creates a pending prompt proposal with defaults', async () => {
const project = await createProject();
const proposal = await prisma.resourceProposal.create({
data: {
resourceType: 'prompt',
name: 'my-proposal',
body: { content: 'hello', priority: 5 },
projectId: project.id,
},
});
expect(proposal.id).toBeDefined();
expect(proposal.status).toBe('pending');
expect(proposal.reviewerNote).toBe('');
expect(proposal.approvedRevisionId).toBeNull();
expect(proposal.version).toBe(1);
expect(proposal.body).toEqual({ content: 'hello', priority: 5 });
});
it('creates a pending skill proposal', async () => {
const project = await createProject();
const proposal = await prisma.resourceProposal.create({
data: {
resourceType: 'skill',
name: 'my-skill-proposal',
body: { content: 'SKILL.md body', metadata: { postInstall: 'hooks/x.sh' } },
projectId: project.id,
},
});
expect(proposal.resourceType).toBe('skill');
});
it('enforces unique (resourceType, name, projectId)', async () => {
const project = await createProject();
await prisma.resourceProposal.create({
data: {
resourceType: 'prompt',
name: 'dup',
body: { content: 'a' },
projectId: project.id,
},
});
await expect(
prisma.resourceProposal.create({
data: {
resourceType: 'prompt',
name: 'dup',
body: { content: 'b' },
projectId: project.id,
},
}),
).rejects.toThrow();
});
it('allows same name across different resource types in same project', async () => {
const project = await createProject();
await prisma.resourceProposal.create({
data: {
resourceType: 'prompt',
name: 'shared',
body: { content: 'a' },
projectId: project.id,
},
});
const second = await prisma.resourceProposal.create({
data: {
resourceType: 'skill',
name: 'shared',
body: { content: 'b' },
projectId: project.id,
},
});
expect(second.id).toBeDefined();
});
it('allows status lifecycle: pending → approved', async () => {
const project = await createProject();
const proposal = await prisma.resourceProposal.create({
data: {
resourceType: 'prompt',
name: 'flow',
body: { content: 'hi' },
projectId: project.id,
},
});
const approved = await prisma.resourceProposal.update({
where: { id: proposal.id },
data: {
status: 'approved',
reviewerNote: 'looks good',
approvedRevisionId: 'rev-fake-123',
},
});
expect(approved.status).toBe('approved');
expect(approved.reviewerNote).toBe('looks good');
expect(approved.approvedRevisionId).toBe('rev-fake-123');
});
it('cascades on project delete', async () => {
const project = await createProject();
const proposal = await prisma.resourceProposal.create({
data: {
resourceType: 'prompt',
name: 'will-cascade',
body: { content: 'hi' },
projectId: project.id,
},
});
await prisma.project.delete({ where: { id: project.id } });
const found = await prisma.resourceProposal.findUnique({ where: { id: proposal.id } });
expect(found).toBeNull();
});
});

View File

@@ -0,0 +1,119 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
describe('ResourceRevision schema', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
it('creates a revision with required fields and defaults', async () => {
const rev = await prisma.resourceRevision.create({
data: {
resourceType: 'prompt',
resourceId: 'fake-prompt-id',
contentHash: 'sha256:abc',
body: { content: 'hello' },
},
});
expect(rev.id).toBeDefined();
expect(rev.semver).toBe('0.1.0');
expect(rev.note).toBe('');
expect(rev.authorUserId).toBeNull();
expect(rev.authorSessionId).toBeNull();
expect(rev.body).toEqual({ content: 'hello' });
expect(rev.createdAt).toBeInstanceOf(Date);
});
it('survives resource deletion (soft FK)', async () => {
// No actual prompt exists with this id — the soft FK design lets
// revisions outlive their resources.
const rev = await prisma.resourceRevision.create({
data: {
resourceType: 'skill',
resourceId: 'never-existed',
contentHash: 'sha256:def',
body: { content: 'ghost' },
},
});
expect(rev.resourceId).toBe('never-existed');
});
it('orders revisions latest-first within a resource', async () => {
const resourceId = 'r1';
const a = await prisma.resourceRevision.create({
data: {
resourceType: 'prompt', resourceId,
semver: '0.1.0', contentHash: 'h1', body: {},
},
});
await new Promise((r) => setTimeout(r, 10));
const b = await prisma.resourceRevision.create({
data: {
resourceType: 'prompt', resourceId,
semver: '0.1.1', contentHash: 'h2', body: {},
},
});
await new Promise((r) => setTimeout(r, 10));
const c = await prisma.resourceRevision.create({
data: {
resourceType: 'prompt', resourceId,
semver: '0.2.0', contentHash: 'h3', body: {},
},
});
const rows = await prisma.resourceRevision.findMany({
where: { resourceType: 'prompt', resourceId },
orderBy: { createdAt: 'desc' },
});
expect(rows.map((r) => r.id)).toEqual([c.id, b.id, a.id]);
});
it('allows multiple revisions with the same contentHash (rollback)', async () => {
const resourceId = 'r2';
await prisma.resourceRevision.create({
data: {
resourceType: 'prompt', resourceId,
semver: '0.1.0', contentHash: 'identical', body: {},
},
});
const second = await prisma.resourceRevision.create({
data: {
resourceType: 'prompt', resourceId,
semver: '0.2.0', contentHash: 'identical', body: {},
},
});
expect(second.id).toBeDefined();
const rows = await prisma.resourceRevision.findMany({
where: { contentHash: 'identical' },
});
expect(rows.length).toBe(2);
});
it('discriminates between prompt and skill revisions', async () => {
await prisma.resourceRevision.create({
data: { resourceType: 'prompt', resourceId: 'x', contentHash: 'a', body: {} },
});
await prisma.resourceRevision.create({
data: { resourceType: 'skill', resourceId: 'x', contentHash: 'a', body: {} },
});
const prompts = await prisma.resourceRevision.findMany({
where: { resourceType: 'prompt', resourceId: 'x' },
});
const skills = await prisma.resourceRevision.findMany({
where: { resourceType: 'skill', resourceId: 'x' },
});
expect(prompts.length).toBe(1);
expect(skills.length).toBe(1);
});
});

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
describe('Skill schema', () => {
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
async function createUser() {
return prisma.user.create({
data: {
email: `test-${Date.now()}-${Math.random()}@example.com`,
name: 'Test',
passwordHash: '!locked',
role: 'USER',
},
});
}
async function createProject(name = `project-${Date.now()}-${Math.random()}`) {
const user = await createUser();
return prisma.project.create({ data: { name, ownerId: user.id } });
}
async function createAgent(name = `agent-${Date.now()}-${Math.random()}`) {
const user = await createUser();
const llm = await prisma.llm.create({
data: { name: `llm-${Date.now()}-${Math.random()}`, type: 'openai', model: 'test' },
});
return prisma.agent.create({
data: { name, llmId: llm.id, ownerId: user.id },
});
}
it('creates a global skill (both FKs null) with defaults', async () => {
const skill = await prisma.skill.create({
data: { name: 'global-test', content: 'hello' },
});
expect(skill.id).toBeDefined();
expect(skill.projectId).toBeNull();
expect(skill.agentId).toBeNull();
expect(skill.priority).toBe(5);
expect(skill.semver).toBe('0.1.0');
expect(skill.version).toBe(1);
expect(skill.currentRevisionId).toBeNull();
expect(skill.files).toEqual({});
expect(skill.metadata).toEqual({});
expect(skill.description).toBe('');
expect(skill.summary).toBeNull();
expect(skill.chapters).toBeNull();
});
it('creates a project-scoped skill', async () => {
const project = await createProject();
const skill = await prisma.skill.create({
data: { name: 'proj-test', content: 'hi', projectId: project.id },
});
expect(skill.projectId).toBe(project.id);
expect(skill.agentId).toBeNull();
});
it('creates an agent-scoped skill', async () => {
const agent = await createAgent();
const skill = await prisma.skill.create({
data: { name: 'agent-test', content: 'hi', agentId: agent.id },
});
expect(skill.agentId).toBe(agent.id);
expect(skill.projectId).toBeNull();
});
it('persists files and metadata as JSON', async () => {
const skill = await prisma.skill.create({
data: {
name: 'with-files',
content: 'hi',
files: { 'scripts/setup.sh': '#!/bin/sh\necho hi' },
metadata: {
hooks: { PreToolUse: [{ command: 'echo' }] },
postInstall: 'scripts/setup.sh',
},
},
});
expect(skill.files).toEqual({ 'scripts/setup.sh': '#!/bin/sh\necho hi' });
expect(skill.metadata).toEqual({
hooks: { PreToolUse: [{ command: 'echo' }] },
postInstall: 'scripts/setup.sh',
});
});
it('enforces unique (name, projectId)', async () => {
const project = await createProject();
await prisma.skill.create({
data: { name: 'dup', content: 'a', projectId: project.id },
});
await expect(
prisma.skill.create({
data: { name: 'dup', content: 'b', projectId: project.id },
}),
).rejects.toThrow();
});
it('enforces unique (name, agentId)', async () => {
const agent = await createAgent();
await prisma.skill.create({
data: { name: 'dup', content: 'a', agentId: agent.id },
});
await expect(
prisma.skill.create({
data: { name: 'dup', content: 'b', agentId: agent.id },
}),
).rejects.toThrow();
});
it('allows same name across different projects', async () => {
const p1 = await createProject(`p1-${Date.now()}`);
const p2 = await createProject(`p2-${Date.now()}`);
await prisma.skill.create({
data: { name: 'shared', content: 'a', projectId: p1.id },
});
const second = await prisma.skill.create({
data: { name: 'shared', content: 'b', projectId: p2.id },
});
expect(second.id).toBeDefined();
});
it('allows same name across project + agent (different scopes)', async () => {
const project = await createProject();
const agent = await createAgent();
await prisma.skill.create({
data: { name: 'overlap', content: 'a', projectId: project.id },
});
const second = await prisma.skill.create({
data: { name: 'overlap', content: 'b', agentId: agent.id },
});
expect(second.id).toBeDefined();
});
it('cascades on project delete', async () => {
const project = await createProject();
const skill = await prisma.skill.create({
data: { name: 'cascade-me', content: 'hi', projectId: project.id },
});
await prisma.project.delete({ where: { id: project.id } });
const found = await prisma.skill.findUnique({ where: { id: skill.id } });
expect(found).toBeNull();
});
it('cascades on agent delete', async () => {
const agent = await createAgent();
const skill = await prisma.skill.create({
data: { name: 'cascade-agent', content: 'hi', agentId: agent.id },
});
await prisma.agent.delete({ where: { id: agent.id } });
const found = await prisma.skill.findUnique({ where: { id: skill.id } });
expect(found).toBeNull();
});
it('preserves global skills when projects are deleted', async () => {
const project = await createProject();
const skill = await prisma.skill.create({
data: { name: 'global-survives', content: 'hi' },
});
await prisma.project.delete({ where: { id: project.id } });
const found = await prisma.skill.findUnique({ where: { id: skill.id } });
expect(found).not.toBeNull();
expect(found?.id).toBe(skill.id);
});
it('updates updatedAt on change', async () => {
const skill = await prisma.skill.create({
data: { name: 'mut', content: 'a' },
});
const original = skill.updatedAt;
await new Promise((r) => setTimeout(r, 50));
const updated = await prisma.skill.update({
where: { id: skill.id },
data: { content: 'b' },
});
expect(updated.updatedAt.getTime()).toBeGreaterThan(original.getTime());
});
});