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

@@ -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());
});
});