Files
mcpctl/src/db/tests/resource-revision-schema.test.ts

120 lines
3.8 KiB
TypeScript
Raw Normal View History

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>
2026-05-07 00:18:21 +01:00
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);
});
});