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:
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
147
src/db/tests/resource-proposal-schema.test.ts
Normal file
147
src/db/tests/resource-proposal-schema.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
119
src/db/tests/resource-revision-schema.test.ts
Normal file
119
src/db/tests/resource-revision-schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
192
src/db/tests/skill-schema.test.ts
Normal file
192
src/db/tests/skill-schema.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user