From fbe68fa6935520c02f35c06fea5f2286e4840bd9 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 00:18:21 +0100 Subject: [PATCH] feat(db): schema for ResourceRevision, ResourceProposal, Skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../migration.sql | 157 ++++++++++++ src/db/prisma/schema.prisma | 230 ++++++++++++++---- src/db/tests/helpers.ts | 6 + src/db/tests/resource-proposal-schema.test.ts | 147 +++++++++++ src/db/tests/resource-revision-schema.test.ts | 119 +++++++++ src/db/tests/skill-schema.test.ts | 192 +++++++++++++++ 6 files changed, 810 insertions(+), 41 deletions(-) create mode 100644 src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql create mode 100644 src/db/tests/resource-proposal-schema.test.ts create mode 100644 src/db/tests/resource-revision-schema.test.ts create mode 100644 src/db/tests/skill-schema.test.ts diff --git a/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql b/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql new file mode 100644 index 0000000..93b2c05 --- /dev/null +++ b/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql @@ -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; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4289ae0..3e19b63 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -300,10 +300,12 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] prompts Prompt[] promptRequests PromptRequest[] + proposals ResourceProposal[] + skills Skill[] mcpTokens McpToken[] agents Agent[] @@ -386,18 +388,27 @@ enum InstanceStatus { // ── Prompts (approved content resources) ── model Prompt { - id String @id @default(cuid()) - name String - content String @db.Text - projectId String? - agentId String? - priority Int @default(5) - summary String? @db.Text - chapters Json? - linkTarget String? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + content String @db.Text + projectId String? + agentId String? + priority Int @default(5) + 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 project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) @@ -409,6 +420,56 @@ model Prompt { @@index([agentId]) } +// ── Skills (Claude Code skill bundles, synced to ~/.claude/skills//) ── +// +// Skills are the on-disk counterpart to Prompts. mcpd is the source of truth; +// mcpctl skills sync materialises them onto disk under +// ~/.claude/skills//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 { @@ -499,13 +645,15 @@ model Agent { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) threads ChatThread[] prompts Prompt[] - personalities Personality[] @relation("AgentPersonalities") - defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) + proposals ResourceProposal[] + skills Skill[] + personalities Personality[] @relation("AgentPersonalities") + defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) @@index([name]) @@index([llmId]) @@ -619,54 +767,54 @@ model ChatMessage { // SSE channel — that's how queued tasks survive worker offline windows. enum InferenceTaskStatus { - pending // in queue, no worker has it yet (or claim was reverted) - claimed // a worker has it (SSE frame sent), no chunks back yet - running // worker started streaming chunks back (streaming tasks only) - completed // worker POSTed the final result - error // permanent failure (auth, bad request, queue timeout) - cancelled // caller said never mind via DELETE + pending // in queue, no worker has it yet (or claim was reverted) + claimed // a worker has it (SSE frame sent), no chunks back yet + running // worker started streaming chunks back (streaming tasks only) + completed // worker POSTed the final result + error // permanent failure (auth, bad request, queue timeout) + cancelled // caller said never mind via DELETE } model InferenceTask { - id String @id @default(cuid()) - status InferenceTaskStatus @default(pending) + id String @id @default(cuid()) + status InferenceTaskStatus @default(pending) // Routing — pool key drives worker matching at claim time. Stored at // enqueue time so a later rename of Llm.poolName doesn't reroute // already-queued work. - poolName String - llmName String // pinned target Llm name (for audit + agent backref) - model String - tier String? + poolName String + llmName String // pinned target Llm name (for audit + agent backref) + model String + tier String? // Worker tracking. NULL while pending; set on claim; cleared on // unbindSession-driven revert (worker disconnect mid-task). - claimedBy String? + claimedBy String? // Body + result. Both are Json so streaming chunks can be reconstructed // (see TaskService.complete) and async pollers get a structured payload. // requestBody is required (the OpenAI chat-completion request body the // worker should run); responseBody is null until status=completed. - requestBody Json - responseBody Json? - errorMessage String? + requestBody Json + responseBody Json? + errorMessage String? /** * Whether the original request asked for streaming. Drives the chunk-vs- * final-body protocol on the result POST and tells async API callers * whether `/stream` will yield chunks or just a single completion event. */ - streaming Boolean @default(false) + streaming Boolean @default(false) // Timestamps for observability + GC: // pending → claimed: claimedAt set // claimed → running: streamStartedAt set (first chunk received) // running/claimed → completed/error/cancelled: completedAt set - createdAt DateTime @default(now()) - claimedAt DateTime? - streamStartedAt DateTime? - completedAt DateTime? + createdAt DateTime @default(now()) + claimedAt DateTime? + streamStartedAt DateTime? + completedAt DateTime? // Caller tracking — RBAC + observability. ownerId references User.id; // agentId is set when the task came in via /agents//chat (null // for direct /llms//infer or async POST /inference-tasks calls // that don't pin an agent). - ownerId String - agentId String? + ownerId String + agentId String? @@index([status, poolName]) @@index([claimedBy]) diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 7083b75..8926ba1 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -36,6 +36,12 @@ export async function clearAllTables(client: PrismaClient): Promise { // 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(); diff --git a/src/db/tests/resource-proposal-schema.test.ts b/src/db/tests/resource-proposal-schema.test.ts new file mode 100644 index 0000000..96ad372 --- /dev/null +++ b/src/db/tests/resource-proposal-schema.test.ts @@ -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(); + }); +}); diff --git a/src/db/tests/resource-revision-schema.test.ts b/src/db/tests/resource-revision-schema.test.ts new file mode 100644 index 0000000..98e2e30 --- /dev/null +++ b/src/db/tests/resource-revision-schema.test.ts @@ -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); + }); +}); diff --git a/src/db/tests/skill-schema.test.ts b/src/db/tests/skill-schema.test.ts new file mode 100644 index 0000000..86d741e --- /dev/null +++ b/src/db/tests/skill-schema.test.ts @@ -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()); + }); +});