feat(s1): add app foundation
This commit is contained in:
parent
6e363ae351
commit
bba5e13a73
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
dist/
|
||||
build/
|
||||
coverage/
|
||||
.next/
|
||||
.turbo/
|
||||
.vite/
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.idea/
|
||||
*.log
|
||||
.DS_Store
|
||||
5
apps/api/.env.example
Normal file
5
apps/api/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
DATABASE_URL=postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public
|
||||
REDIS_URL=redis://localhost:6379
|
||||
QUEUE_ADAPTER=bullmq
|
||||
S1_STORAGE_ROOT=.local/storage
|
||||
HARNESS_CLI_TIMEOUT_MS=5000
|
||||
33
apps/api/package.json
Normal file
33
apps/api/package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@huijing/api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -p tsconfig.json && node -e \"await import('./dist/main.js')\"",
|
||||
"dev:smoke": "vitest run",
|
||||
"prisma": "prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huijing/harness-client": "workspace:*",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@prisma/adapter-pg": "7.8.0",
|
||||
"@prisma/client": "7.8.0",
|
||||
"bullmq": "^5.77.6",
|
||||
"ioredis": "^5.11.0",
|
||||
"pg": "^8.21.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"prisma": "^7.8.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
11
apps/api/prisma.config.ts
Normal file
11
apps/api/prisma.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations"
|
||||
},
|
||||
datasource: {
|
||||
url: process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public"
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,493 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserStatus" AS ENUM ('active', 'disabled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "UserRoleName" AS ENUM ('admin', 'operator', 'creator', 'player');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectStatus" AS ENUM ('active', 'archived');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GameVersionStatus" AS ENUM ('draft', 'candidate', 'active', 'archived', 'failed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetStatus" AS ENUM ('uploaded', 'deleted');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JobStatus" AS ENUM ('queued', 'running', 'succeeded', 'pending_retry', 'failed', 'canceled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "JobTargetType" AS ENUM ('project', 'version');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MainCreationAgentSessionStatus" AS ENUM ('routing_internal_tasks', 'completed', 'failed', 'canceled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AgentTaskType" AS ENUM ('requirement_clarifier', 'game_design_draft_generator');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AgentTaskStatus" AS ENUM ('queued', 'running', 'succeeded', 'failed', 'canceled', 'timed_out');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReviewRecordStatus" AS ENUM ('pending_review', 'approved', 'rejected', 'canceled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ReviewDecision" AS ENUM ('approved', 'rejected');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"status" "UserStatus" NOT NULL DEFAULT 'active',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserRole" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"role" "UserRoleName" NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "UserRole_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnonymousIdentity" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"deviceKey" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnonymousIdentity_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GameProject" (
|
||||
"id" TEXT NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "ProjectStatus" NOT NULL DEFAULT 'active',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GameProject_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "GameVersion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"versionNumber" INTEGER NOT NULL,
|
||||
"status" "GameVersionStatus" NOT NULL,
|
||||
"configJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "GameVersion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Asset" (
|
||||
"id" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"storageKey" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"byteSize" INTEGER NOT NULL,
|
||||
"sha256" TEXT NOT NULL,
|
||||
"status" "AssetStatus" NOT NULL DEFAULT 'uploaded',
|
||||
"metadataJson" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Asset_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Job" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"status" "JobStatus" NOT NULL DEFAULT 'queued',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"maxAttempts" INTEGER NOT NULL DEFAULT 3,
|
||||
"timeoutAt" TIMESTAMP(3),
|
||||
"nextRetryAt" TIMESTAMP(3),
|
||||
"errorCode" TEXT,
|
||||
"targetType" "JobTargetType" NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"targetScopeKey" TEXT NOT NULL,
|
||||
"gameProjectId" TEXT,
|
||||
"gameVersionId" TEXT,
|
||||
"payloadJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Job_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"targetId" TEXT NOT NULL,
|
||||
"eventJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MainCreationAgentSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"creatorId" TEXT NOT NULL,
|
||||
"projectId" TEXT NOT NULL,
|
||||
"versionId" TEXT NOT NULL,
|
||||
"status" "MainCreationAgentSessionStatus" NOT NULL,
|
||||
"contextSummary" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MainCreationAgentSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AgentTask" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"taskType" "AgentTaskType" NOT NULL,
|
||||
"subagentId" TEXT NOT NULL,
|
||||
"inputRef" TEXT NOT NULL,
|
||||
"outputRef" TEXT,
|
||||
"status" "AgentTaskStatus" NOT NULL,
|
||||
"timeoutAt" TIMESTAMP(3) NOT NULL,
|
||||
"errorCode" TEXT,
|
||||
"auditLogId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AgentTask_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ReviewRecord" (
|
||||
"id" TEXT NOT NULL,
|
||||
"gameVersionId" TEXT NOT NULL,
|
||||
"status" "ReviewRecordStatus" NOT NULL,
|
||||
"decision" "ReviewDecision",
|
||||
"reasonCode" TEXT,
|
||||
"decidedById" TEXT,
|
||||
"decidedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ReviewRecord_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "LifecycleEvent" (
|
||||
"eventId" TEXT NOT NULL,
|
||||
"gameVersionId" TEXT,
|
||||
"event" TEXT NOT NULL,
|
||||
"from" TEXT NOT NULL,
|
||||
"to" TEXT NOT NULL,
|
||||
"actorJson" JSONB NOT NULL,
|
||||
"requiredRole" TEXT NOT NULL,
|
||||
"requiredRecordRefsJson" JSONB NOT NULL,
|
||||
"auditEvent" TEXT NOT NULL,
|
||||
"reasonCode" TEXT NOT NULL,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"approval" TEXT NOT NULL,
|
||||
"requiredRecordsJson" JSONB NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "LifecycleEvent_pkey" PRIMARY KEY ("eventId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserRole_role_idx" ON "UserRole"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserRole_userId_role_key" ON "UserRole"("userId", "role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnonymousIdentity_userId_idx" ON "AnonymousIdentity"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "AnonymousIdentity_deviceKey_key" ON "AnonymousIdentity"("deviceKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GameProject_slug_key" ON "GameProject"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameProject_ownerId_idx" ON "GameProject"("ownerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "GameVersion_projectId_status_idx" ON "GameVersion"("projectId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GameVersion_projectId_versionNumber_key" ON "GameVersion"("projectId", "versionNumber");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "GameVersion_id_projectId_key" ON "GameVersion"("id", "projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Asset_projectId_kind_idx" ON "Asset"("projectId", "kind");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Asset_projectId_storageKey_key" ON "Asset"("projectId", "storageKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Job_projectId_status_idx" ON "Job"("projectId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Job_actorId_status_idx" ON "Job"("actorId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Job_gameProjectId_idx" ON "Job"("gameProjectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Job_gameVersionId_idx" ON "Job"("gameVersionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Job_actorId_projectId_type_targetScopeKey_idempotencyKey_key" ON "Job"("actorId", "projectId", "type", "targetScopeKey", "idempotencyKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_actorId_createdAt_idx" ON "AuditLog"("actorId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_targetType_targetId_idx" ON "AuditLog"("targetType", "targetId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MainCreationAgentSession_creatorId_idx" ON "MainCreationAgentSession"("creatorId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MainCreationAgentSession_projectId_idx" ON "MainCreationAgentSession"("projectId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MainCreationAgentSession_versionId_idx" ON "MainCreationAgentSession"("versionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentTask_sessionId_idx" ON "AgentTask"("sessionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AgentTask_status_idx" ON "AgentTask"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReviewRecord_gameVersionId_status_idx" ON "ReviewRecord"("gameVersionId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ReviewRecord_decidedById_idx" ON "ReviewRecord"("decidedById");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LifecycleEvent_gameVersionId_idx" ON "LifecycleEvent"("gameVersionId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "LifecycleEvent_event_occurredAt_idx" ON "LifecycleEvent"("event", "occurredAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "UserRole" ADD CONSTRAINT "UserRole_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AnonymousIdentity" ADD CONSTRAINT "AnonymousIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GameProject" ADD CONSTRAINT "GameProject_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "GameVersion" ADD CONSTRAINT "GameVersion_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Asset" ADD CONSTRAINT "Asset_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_gameProjectId_fkey" FOREIGN KEY ("gameProjectId") REFERENCES "GameProject"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_gameVersionId_projectId_fkey" FOREIGN KEY ("gameVersionId", "projectId") REFERENCES "GameVersion"("id", "projectId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "GameProject"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_versionId_fkey" FOREIGN KEY ("versionId") REFERENCES "GameVersion"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AgentTask" ADD CONSTRAINT "AgentTask_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "MainCreationAgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_gameVersionId_fkey" FOREIGN KEY ("gameVersionId") REFERENCES "GameVersion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_decidedById_fkey" FOREIGN KEY ("decidedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "LifecycleEvent" ADD CONSTRAINT "LifecycleEvent_gameVersionId_fkey" FOREIGN KEY ("gameVersionId") REFERENCES "GameVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Task 4 DB invariants: 用命名 CHECK 锁住 Job 的具体目标、幂等作用域和重试计数。
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_target_xor_check" CHECK (
|
||||
(
|
||||
"targetType" = 'project'
|
||||
AND "gameProjectId" IS NOT NULL
|
||||
AND "gameVersionId" IS NULL
|
||||
)
|
||||
OR (
|
||||
"targetType" = 'version'
|
||||
AND "gameVersionId" IS NOT NULL
|
||||
AND "gameProjectId" IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_target_id_matches_check" CHECK (
|
||||
(
|
||||
"targetType" = 'project'
|
||||
AND "targetId" = "gameProjectId"
|
||||
)
|
||||
OR (
|
||||
"targetType" = 'version'
|
||||
AND "targetId" = "gameVersionId"
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_project_scope_matches_check" CHECK (
|
||||
"targetType" <> 'project'
|
||||
OR "projectId" = "gameProjectId"
|
||||
);
|
||||
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_target_scope_key_check" CHECK (
|
||||
(
|
||||
"targetType" = 'project'
|
||||
AND "targetScopeKey" = ('project:' || "gameProjectId")
|
||||
)
|
||||
OR (
|
||||
"targetType" = 'version'
|
||||
AND "targetScopeKey" = ('version:' || "gameVersionId")
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_non_empty_idempotency_scope_check" CHECK (
|
||||
length(btrim("idempotencyKey")) > 0
|
||||
AND length(btrim("targetScopeKey")) > 0
|
||||
);
|
||||
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_retry_counts_check" CHECK (
|
||||
"attempts" >= 0
|
||||
AND "maxAttempts" > 0
|
||||
AND "attempts" <= "maxAttempts"
|
||||
);
|
||||
|
||||
-- AgentTask 只能使用 S0 已确认的 taskType/subagentId 组合。
|
||||
ALTER TABLE "AgentTask" ADD CONSTRAINT "AgentTask_task_subagent_allowlist_check" CHECK (
|
||||
(
|
||||
"taskType" = 'requirement_clarifier'
|
||||
AND "subagentId" = 'subagent-requirement-clarifier-001'
|
||||
)
|
||||
OR (
|
||||
"taskType" = 'game_design_draft_generator'
|
||||
AND "subagentId" = 'subagent-game-draft-001'
|
||||
)
|
||||
);
|
||||
|
||||
-- ReviewRecord 的人工决策事实必须带 reasonCode,并且 status 与 decision 保持一致。
|
||||
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_decision_reason_check" CHECK (
|
||||
"decision" IS NULL
|
||||
OR (
|
||||
"reasonCode" IS NOT NULL
|
||||
AND "status"::text = "decision"::text
|
||||
)
|
||||
);
|
||||
|
||||
-- 状态边界由后续 state-transition service 在事务内 SET LOCAL;Task 4 只安装 DB guard。
|
||||
CREATE OR REPLACE FUNCTION app_require_state_transition_guard()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF current_setting('app.state_transition_guard', true) IS DISTINCT FROM 'on' THEN
|
||||
RAISE EXCEPTION 'state transition guard is required' USING ERRCODE = '42501';
|
||||
END IF;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "GameVersion_a_guard_status_insert"
|
||||
BEFORE INSERT ON "GameVersion"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
CREATE TRIGGER "GameVersion_a_guard_status_update"
|
||||
BEFORE UPDATE OF "status" ON "GameVersion"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."status" IS DISTINCT FROM NEW."status")
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
CREATE TRIGGER "ReviewRecord_a_guard_status_insert"
|
||||
BEFORE INSERT ON "ReviewRecord"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
CREATE TRIGGER "ReviewRecord_a_guard_status_update"
|
||||
BEFORE UPDATE OF "status" ON "ReviewRecord"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."status" IS DISTINCT FROM NEW."status")
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
CREATE TRIGGER "LifecycleEvent_a_guard_insert"
|
||||
BEFORE INSERT ON "LifecycleEvent"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
CREATE TRIGGER "LifecycleEvent_a_guard_update"
|
||||
BEFORE UPDATE ON "LifecycleEvent"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
CREATE TRIGGER "LifecycleEvent_a_guard_delete"
|
||||
BEFORE DELETE ON "LifecycleEvent"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_require_state_transition_guard();
|
||||
|
||||
-- AuditLog 是 append-only 事实表,任何 UPDATE/DELETE 都在 DB 层拒绝。
|
||||
CREATE OR REPLACE FUNCTION app_reject_audit_log_mutation()
|
||||
RETURNS trigger AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'AuditLog is append-only' USING ERRCODE = '42501';
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "AuditLog_reject_update"
|
||||
BEFORE UPDATE ON "AuditLog"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_reject_audit_log_mutation();
|
||||
|
||||
CREATE TRIGGER "AuditLog_reject_delete"
|
||||
BEFORE DELETE ON "AuditLog"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION app_reject_audit_log_mutation();
|
||||
@ -0,0 +1,27 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "MainCreationAgentSession" DROP CONSTRAINT "MainCreationAgentSession_versionId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MainCreationAgentSession" ADD CONSTRAINT "MainCreationAgentSession_versionId_projectId_fkey" FOREIGN KEY ("versionId", "projectId") REFERENCES "GameVersion"("id", "projectId") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- ReviewRecord 审核事实必须完整:pending/canceled 不能带决策字段,approved/rejected 必须带完整可审计证据。
|
||||
ALTER TABLE "ReviewRecord" DROP CONSTRAINT "ReviewRecord_decision_reason_check";
|
||||
|
||||
ALTER TABLE "ReviewRecord" ADD CONSTRAINT "ReviewRecord_decision_reason_check" CHECK (
|
||||
(
|
||||
"status" IN ('pending_review', 'canceled')
|
||||
AND "decision" IS NULL
|
||||
AND "reasonCode" IS NULL
|
||||
AND "decidedById" IS NULL
|
||||
AND "decidedAt" IS NULL
|
||||
)
|
||||
OR (
|
||||
"status" IN ('approved', 'rejected')
|
||||
AND "decision" IS NOT NULL
|
||||
AND "decision"::text = "status"::text
|
||||
AND "reasonCode" IS NOT NULL
|
||||
AND length(btrim("reasonCode")) > 0
|
||||
AND "decidedById" IS NOT NULL
|
||||
AND "decidedAt" IS NOT NULL
|
||||
)
|
||||
);
|
||||
@ -0,0 +1,28 @@
|
||||
-- Task 7 Job 执行边界:持久化 lease 和乐观版本号,避免 worker 用内存状态伪装并发语义。
|
||||
ALTER TABLE "Job" ADD COLUMN "leaseToken" TEXT;
|
||||
ALTER TABLE "Job" ADD COLUMN "leasedBy" TEXT;
|
||||
ALTER TABLE "Job" ADD COLUMN "leaseExpiresAt" TIMESTAMP(3);
|
||||
ALTER TABLE "Job" ADD COLUMN "lockVersion" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX "Job_status_nextRetryAt_createdAt_idx" ON "Job"("status", "nextRetryAt", "createdAt");
|
||||
CREATE INDEX "Job_leaseToken_idx" ON "Job"("leaseToken");
|
||||
|
||||
-- running 必须带完整 lease;非 running 状态不得保留可完成的 lease,避免 stale worker 误提交。
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_lease_consistency_check" CHECK (
|
||||
(
|
||||
"status" = 'running'
|
||||
AND "leaseToken" IS NOT NULL
|
||||
AND length(btrim("leaseToken")) > 0
|
||||
AND "leasedBy" IS NOT NULL
|
||||
AND length(btrim("leasedBy")) > 0
|
||||
AND "leaseExpiresAt" IS NOT NULL
|
||||
)
|
||||
OR (
|
||||
"status" <> 'running'
|
||||
AND "leaseToken" IS NULL
|
||||
AND "leasedBy" IS NULL
|
||||
AND "leaseExpiresAt" IS NULL
|
||||
)
|
||||
);
|
||||
|
||||
ALTER TABLE "Job" ADD CONSTRAINT "Job_lock_version_check" CHECK ("lockVersion" >= 0);
|
||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
306
apps/api/prisma/schema.prisma
Normal file
306
apps/api/prisma/schema.prisma
Normal file
@ -0,0 +1,306 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum UserStatus {
|
||||
active
|
||||
disabled
|
||||
}
|
||||
|
||||
enum UserRoleName {
|
||||
admin
|
||||
operator
|
||||
creator
|
||||
player
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
active
|
||||
archived
|
||||
}
|
||||
|
||||
enum GameVersionStatus {
|
||||
draft
|
||||
candidate
|
||||
active
|
||||
archived
|
||||
failed
|
||||
}
|
||||
|
||||
enum AssetStatus {
|
||||
uploaded
|
||||
deleted
|
||||
}
|
||||
|
||||
enum JobStatus {
|
||||
queued
|
||||
running
|
||||
succeeded
|
||||
pending_retry
|
||||
failed
|
||||
canceled
|
||||
}
|
||||
|
||||
enum JobTargetType {
|
||||
project
|
||||
version
|
||||
}
|
||||
|
||||
enum MainCreationAgentSessionStatus {
|
||||
routing_internal_tasks
|
||||
completed
|
||||
failed
|
||||
canceled
|
||||
}
|
||||
|
||||
enum AgentTaskType {
|
||||
requirement_clarifier
|
||||
game_design_draft_generator
|
||||
}
|
||||
|
||||
enum AgentTaskStatus {
|
||||
queued
|
||||
running
|
||||
succeeded
|
||||
failed
|
||||
canceled
|
||||
timed_out
|
||||
}
|
||||
|
||||
enum ReviewRecordStatus {
|
||||
pending_review
|
||||
approved
|
||||
rejected
|
||||
canceled
|
||||
}
|
||||
|
||||
enum ReviewDecision {
|
||||
approved
|
||||
rejected
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
email String? @unique
|
||||
displayName String
|
||||
status UserStatus @default(active)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
roles UserRole[]
|
||||
anonymousIdentities AnonymousIdentity[]
|
||||
gameProjects GameProject[]
|
||||
auditLogs AuditLog[] @relation("AuditActor")
|
||||
jobs Job[] @relation("JobActor")
|
||||
mainCreationAgentSessions MainCreationAgentSession[]
|
||||
reviewDecisions ReviewRecord[] @relation("ReviewDecider")
|
||||
}
|
||||
|
||||
model UserRole {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
role UserRoleName
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, role])
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
model AnonymousIdentity {
|
||||
id String @id
|
||||
userId String
|
||||
deviceKey String
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([deviceKey])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model GameProject {
|
||||
id String @id
|
||||
ownerId String
|
||||
slug String @unique
|
||||
title String
|
||||
status ProjectStatus @default(active)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
|
||||
versions GameVersion[]
|
||||
assets Asset[]
|
||||
jobs Job[] @relation("JobProjectScope")
|
||||
projectTargetJobs Job[] @relation("JobGameProjectTarget")
|
||||
mainCreationAgentSessionScopes MainCreationAgentSession[] @relation("MainCreationAgentSessionProjectScope")
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model GameVersion {
|
||||
id String @id
|
||||
projectId String
|
||||
versionNumber Int
|
||||
status GameVersionStatus
|
||||
configJson Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
project GameProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
versionTargetJobs Job[] @relation("JobGameVersionTarget")
|
||||
mainCreationAgentSessionScopes MainCreationAgentSession[] @relation("MainCreationAgentSessionVersionScope")
|
||||
reviewRecords ReviewRecord[]
|
||||
lifecycleEvents LifecycleEvent[]
|
||||
|
||||
@@unique([projectId, versionNumber])
|
||||
@@unique([id, projectId])
|
||||
@@index([projectId, status])
|
||||
}
|
||||
|
||||
model Asset {
|
||||
id String @id
|
||||
projectId String
|
||||
kind String
|
||||
storageKey String
|
||||
mimeType String
|
||||
byteSize Int
|
||||
sha256 String
|
||||
status AssetStatus @default(uploaded)
|
||||
metadataJson Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
project GameProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([projectId, storageKey])
|
||||
@@index([projectId, kind])
|
||||
}
|
||||
|
||||
model Job {
|
||||
id String @id
|
||||
actorId String
|
||||
projectId String
|
||||
type String
|
||||
idempotencyKey String
|
||||
status JobStatus @default(queued)
|
||||
attempts Int @default(0)
|
||||
maxAttempts Int @default(3)
|
||||
timeoutAt DateTime?
|
||||
nextRetryAt DateTime?
|
||||
errorCode String?
|
||||
leaseToken String?
|
||||
leasedBy String?
|
||||
leaseExpiresAt DateTime?
|
||||
lockVersion Int @default(0)
|
||||
targetType JobTargetType
|
||||
targetId String
|
||||
targetScopeKey String
|
||||
gameProjectId String?
|
||||
gameVersionId String?
|
||||
payloadJson Json
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
actor User @relation("JobActor", fields: [actorId], references: [id], onDelete: Restrict)
|
||||
project GameProject @relation("JobProjectScope", fields: [projectId], references: [id], onDelete: Cascade)
|
||||
gameProject GameProject? @relation("JobGameProjectTarget", fields: [gameProjectId], references: [id], onDelete: Restrict)
|
||||
gameVersion GameVersion? @relation("JobGameVersionTarget", fields: [gameVersionId, projectId], references: [id, projectId], onDelete: Restrict)
|
||||
|
||||
@@unique([actorId, projectId, type, targetScopeKey, idempotencyKey])
|
||||
@@index([projectId, status])
|
||||
@@index([actorId, status])
|
||||
@@index([status, nextRetryAt, createdAt])
|
||||
@@index([leaseToken])
|
||||
@@index([gameProjectId])
|
||||
@@index([gameVersionId])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id
|
||||
actorId String
|
||||
action String
|
||||
targetType String
|
||||
targetId String
|
||||
eventJson Json
|
||||
createdAt DateTime @default(now())
|
||||
actor User @relation("AuditActor", fields: [actorId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([actorId, createdAt])
|
||||
@@index([targetType, targetId])
|
||||
}
|
||||
|
||||
model MainCreationAgentSession {
|
||||
id String @id
|
||||
creatorId String
|
||||
projectId String
|
||||
versionId String
|
||||
status MainCreationAgentSessionStatus
|
||||
contextSummary String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
creator User @relation(fields: [creatorId], references: [id], onDelete: Restrict)
|
||||
project GameProject @relation("MainCreationAgentSessionProjectScope", fields: [projectId], references: [id], onDelete: Cascade)
|
||||
version GameVersion @relation("MainCreationAgentSessionVersionScope", fields: [versionId, projectId], references: [id, projectId], onDelete: Restrict)
|
||||
tasks AgentTask[]
|
||||
|
||||
@@index([creatorId])
|
||||
@@index([projectId])
|
||||
@@index([versionId])
|
||||
}
|
||||
|
||||
model AgentTask {
|
||||
id String @id
|
||||
sessionId String
|
||||
taskType AgentTaskType
|
||||
subagentId String
|
||||
inputRef String
|
||||
outputRef String?
|
||||
status AgentTaskStatus
|
||||
timeoutAt DateTime
|
||||
errorCode String?
|
||||
auditLogId String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
session MainCreationAgentSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([sessionId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model ReviewRecord {
|
||||
id String @id
|
||||
gameVersionId String
|
||||
status ReviewRecordStatus
|
||||
decision ReviewDecision?
|
||||
reasonCode String?
|
||||
decidedById String?
|
||||
decidedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
gameVersion GameVersion @relation(fields: [gameVersionId], references: [id], onDelete: Cascade)
|
||||
decidedBy User? @relation("ReviewDecider", fields: [decidedById], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([gameVersionId, status])
|
||||
@@index([decidedById])
|
||||
}
|
||||
|
||||
model LifecycleEvent {
|
||||
eventId String @id
|
||||
gameVersionId String?
|
||||
event String
|
||||
from String
|
||||
to String
|
||||
actorJson Json
|
||||
requiredRole String
|
||||
requiredRecordRefsJson Json
|
||||
auditEvent String
|
||||
reasonCode String
|
||||
occurredAt DateTime
|
||||
approval String
|
||||
requiredRecordsJson Json
|
||||
createdAt DateTime @default(now())
|
||||
gameVersion GameVersion? @relation(fields: [gameVersionId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([gameVersionId])
|
||||
@@index([event, occurredAt])
|
||||
}
|
||||
13
apps/api/src/app.module.ts
Normal file
13
apps/api/src/app.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
import { AuditModule } from "./modules/audit/index.js";
|
||||
import { AssetsModule } from "./modules/assets/index.js";
|
||||
import { AuthModule } from "./modules/auth/index.js";
|
||||
import { JobsApiModule } from "./modules/jobs/index.js";
|
||||
import { ProjectsModule } from "./modules/projects/index.js";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ProjectsModule, AssetsModule, JobsApiModule, AuditModule],
|
||||
controllers: [HealthController]
|
||||
})
|
||||
export class AppModule {}
|
||||
79
apps/api/src/generated/prisma/browser.ts
Normal file
79
apps/api/src/generated/prisma/browser.ts
Normal file
@ -0,0 +1,79 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma-related types and utilities in a browser.
|
||||
* Use it to get access to models, enums, and input types.
|
||||
*
|
||||
* This file does not contain a `PrismaClient` class, nor several other helpers that are intended as server-side only.
|
||||
* See `client.ts` for the standard, server-side entry point.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as Prisma from './internal/prismaNamespaceBrowser.js'
|
||||
export { Prisma }
|
||||
export * as $Enums from './enums.js'
|
||||
export * from './enums.js';
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model UserRole
|
||||
*
|
||||
*/
|
||||
export type UserRole = Prisma.UserRoleModel
|
||||
/**
|
||||
* Model AnonymousIdentity
|
||||
*
|
||||
*/
|
||||
export type AnonymousIdentity = Prisma.AnonymousIdentityModel
|
||||
/**
|
||||
* Model GameProject
|
||||
*
|
||||
*/
|
||||
export type GameProject = Prisma.GameProjectModel
|
||||
/**
|
||||
* Model GameVersion
|
||||
*
|
||||
*/
|
||||
export type GameVersion = Prisma.GameVersionModel
|
||||
/**
|
||||
* Model Asset
|
||||
*
|
||||
*/
|
||||
export type Asset = Prisma.AssetModel
|
||||
/**
|
||||
* Model Job
|
||||
*
|
||||
*/
|
||||
export type Job = Prisma.JobModel
|
||||
/**
|
||||
* Model AuditLog
|
||||
*
|
||||
*/
|
||||
export type AuditLog = Prisma.AuditLogModel
|
||||
/**
|
||||
* Model MainCreationAgentSession
|
||||
*
|
||||
*/
|
||||
export type MainCreationAgentSession = Prisma.MainCreationAgentSessionModel
|
||||
/**
|
||||
* Model AgentTask
|
||||
*
|
||||
*/
|
||||
export type AgentTask = Prisma.AgentTaskModel
|
||||
/**
|
||||
* Model ReviewRecord
|
||||
*
|
||||
*/
|
||||
export type ReviewRecord = Prisma.ReviewRecordModel
|
||||
/**
|
||||
* Model LifecycleEvent
|
||||
*
|
||||
*/
|
||||
export type LifecycleEvent = Prisma.LifecycleEventModel
|
||||
103
apps/api/src/generated/prisma/client.ts
Normal file
103
apps/api/src/generated/prisma/client.ts
Normal file
@ -0,0 +1,103 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file should be your main import to use Prisma. Through it you get access to all the models, enums, and input types.
|
||||
* If you're looking for something you can import in the client-side of your application, please refer to the `browser.ts` file instead.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import * as process from 'node:process'
|
||||
import * as path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
globalThis['__dirname'] = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.js"
|
||||
import * as $Class from "./internal/class.js"
|
||||
import * as Prisma from "./internal/prismaNamespace.js"
|
||||
|
||||
export * as $Enums from './enums.js'
|
||||
export * from "./enums.js"
|
||||
/**
|
||||
* ## Prisma Client
|
||||
*
|
||||
* Type-safe database client for TypeScript
|
||||
* @example
|
||||
* ```
|
||||
* const prisma = new PrismaClient({
|
||||
* adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||
* })
|
||||
* // Fetch zero or more Users
|
||||
* const users = await prisma.user.findMany()
|
||||
* ```
|
||||
*
|
||||
* Read more in our [docs](https://pris.ly/d/client).
|
||||
*/
|
||||
export const PrismaClient = $Class.getPrismaClientClass()
|
||||
export type PrismaClient<LogOpts extends Prisma.LogLevel = never, OmitOpts extends Prisma.PrismaClientOptions["omit"] = Prisma.PrismaClientOptions["omit"], ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = $Class.PrismaClient<LogOpts, OmitOpts, ExtArgs>
|
||||
export { Prisma }
|
||||
|
||||
/**
|
||||
* Model User
|
||||
*
|
||||
*/
|
||||
export type User = Prisma.UserModel
|
||||
/**
|
||||
* Model UserRole
|
||||
*
|
||||
*/
|
||||
export type UserRole = Prisma.UserRoleModel
|
||||
/**
|
||||
* Model AnonymousIdentity
|
||||
*
|
||||
*/
|
||||
export type AnonymousIdentity = Prisma.AnonymousIdentityModel
|
||||
/**
|
||||
* Model GameProject
|
||||
*
|
||||
*/
|
||||
export type GameProject = Prisma.GameProjectModel
|
||||
/**
|
||||
* Model GameVersion
|
||||
*
|
||||
*/
|
||||
export type GameVersion = Prisma.GameVersionModel
|
||||
/**
|
||||
* Model Asset
|
||||
*
|
||||
*/
|
||||
export type Asset = Prisma.AssetModel
|
||||
/**
|
||||
* Model Job
|
||||
*
|
||||
*/
|
||||
export type Job = Prisma.JobModel
|
||||
/**
|
||||
* Model AuditLog
|
||||
*
|
||||
*/
|
||||
export type AuditLog = Prisma.AuditLogModel
|
||||
/**
|
||||
* Model MainCreationAgentSession
|
||||
*
|
||||
*/
|
||||
export type MainCreationAgentSession = Prisma.MainCreationAgentSessionModel
|
||||
/**
|
||||
* Model AgentTask
|
||||
*
|
||||
*/
|
||||
export type AgentTask = Prisma.AgentTaskModel
|
||||
/**
|
||||
* Model ReviewRecord
|
||||
*
|
||||
*/
|
||||
export type ReviewRecord = Prisma.ReviewRecordModel
|
||||
/**
|
||||
* Model LifecycleEvent
|
||||
*
|
||||
*/
|
||||
export type LifecycleEvent = Prisma.LifecycleEventModel
|
||||
884
apps/api/src/generated/prisma/commonInputTypes.ts
Normal file
884
apps/api/src/generated/prisma/commonInputTypes.ts
Normal file
@ -0,0 +1,884 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports various common sort, input & filter types that are not directly linked to a particular model.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
import type * as runtime from "@prisma/client/runtime/client"
|
||||
import * as $Enums from "./enums.js"
|
||||
import type * as Prisma from "./internal/prismaNamespace.js"
|
||||
|
||||
|
||||
export type StringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type StringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type EnumUserStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserStatusFilter<$PrismaModel> | $Enums.UserStatus
|
||||
}
|
||||
|
||||
export type DateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type SortOrderInput = {
|
||||
sort: Prisma.SortOrder
|
||||
nulls?: Prisma.NullsOrder
|
||||
}
|
||||
|
||||
export type StringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
mode?: Prisma.QueryMode
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumUserStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserStatusWithAggregatesFilter<$PrismaModel> | $Enums.UserStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumUserRoleNameFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel> | $Enums.UserRoleName
|
||||
}
|
||||
|
||||
export type EnumUserRoleNameWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserRoleNameWithAggregatesFilter<$PrismaModel> | $Enums.UserRoleName
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumProjectStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel> | $Enums.ProjectStatus
|
||||
}
|
||||
|
||||
export type EnumProjectStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumProjectStatusWithAggregatesFilter<$PrismaModel> | $Enums.ProjectStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type IntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type EnumGameVersionStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel> | $Enums.GameVersionStatus
|
||||
}
|
||||
|
||||
export type JsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type IntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumGameVersionStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumGameVersionStatusWithAggregatesFilter<$PrismaModel> | $Enums.GameVersionStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumAssetStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel> | $Enums.AssetStatus
|
||||
}
|
||||
|
||||
export type JsonNullableFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonNullableFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonNullableFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type EnumAssetStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAssetStatusWithAggregatesFilter<$PrismaModel> | $Enums.AssetStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumJobStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobStatusFilter<$PrismaModel> | $Enums.JobStatus
|
||||
}
|
||||
|
||||
export type DateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type EnumJobTargetTypeFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel> | $Enums.JobTargetType
|
||||
}
|
||||
|
||||
export type EnumJobStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.JobStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type DateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumJobTargetTypeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobTargetTypeWithAggregatesFilter<$PrismaModel> | $Enums.JobTargetType
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumMainCreationAgentSessionStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
|
||||
}
|
||||
|
||||
export type EnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumAgentTaskTypeFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel> | $Enums.AgentTaskType
|
||||
}
|
||||
|
||||
export type EnumAgentTaskStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel> | $Enums.AgentTaskStatus
|
||||
}
|
||||
|
||||
export type EnumAgentTaskTypeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskTypeWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskType
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumAgentTaskStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskStatusWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumReviewRecordStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel> | $Enums.ReviewRecordStatus
|
||||
}
|
||||
|
||||
export type EnumReviewDecisionNullableFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel> | $Enums.ReviewDecision | null
|
||||
}
|
||||
|
||||
export type EnumReviewRecordStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumReviewRecordStatusWithAggregatesFilter<$PrismaModel> | $Enums.ReviewRecordStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type EnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel> | $Enums.ReviewDecision | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedStringFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringFilter<$PrismaModel> | string
|
||||
}
|
||||
|
||||
export type NestedStringNullableFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableFilter<$PrismaModel> | string | null
|
||||
}
|
||||
|
||||
export type NestedEnumUserStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserStatusFilter<$PrismaModel> | $Enums.UserStatus
|
||||
}
|
||||
|
||||
export type NestedDateTimeFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeFilter<$PrismaModel> | Date | string
|
||||
}
|
||||
|
||||
export type NestedStringWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringWithAggregatesFilter<$PrismaModel> | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel> | null
|
||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
notIn?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel> | null
|
||||
lt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
lte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gt?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
gte?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
startsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
endsWith?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedStringNullableWithAggregatesFilter<$PrismaModel> | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedIntNullableFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel> | null
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel> | null
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntNullableFilter<$PrismaModel> | number | null
|
||||
}
|
||||
|
||||
export type NestedEnumUserStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserStatus | Prisma.EnumUserStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserStatus[] | Prisma.ListEnumUserStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserStatusWithAggregatesFilter<$PrismaModel> | $Enums.UserStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumUserStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel>
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeWithAggregatesFilter<$PrismaModel> | Date | string
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumUserRoleNameFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel> | $Enums.UserRoleName
|
||||
}
|
||||
|
||||
export type NestedEnumUserRoleNameWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.UserRoleName | Prisma.EnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.UserRoleName[] | Prisma.ListEnumUserRoleNameFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumUserRoleNameWithAggregatesFilter<$PrismaModel> | $Enums.UserRoleName
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumUserRoleNameFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumProjectStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel> | $Enums.ProjectStatus
|
||||
}
|
||||
|
||||
export type NestedEnumProjectStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ProjectStatus | Prisma.EnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ProjectStatus[] | Prisma.ListEnumProjectStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumProjectStatusWithAggregatesFilter<$PrismaModel> | $Enums.ProjectStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumProjectStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumGameVersionStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel> | $Enums.GameVersionStatus
|
||||
}
|
||||
|
||||
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.IntFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedIntWithAggregatesFilter<$PrismaModel> | number
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
|
||||
_sum?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedFloatFilter<$PrismaModel = never> = {
|
||||
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
|
||||
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
|
||||
}
|
||||
|
||||
export type NestedEnumGameVersionStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.GameVersionStatus | Prisma.EnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.GameVersionStatus[] | Prisma.ListEnumGameVersionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumGameVersionStatusWithAggregatesFilter<$PrismaModel> | $Enums.GameVersionStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumGameVersionStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedJsonFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedEnumAssetStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel> | $Enums.AssetStatus
|
||||
}
|
||||
|
||||
export type NestedEnumAssetStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AssetStatus | Prisma.EnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AssetStatus[] | Prisma.ListEnumAssetStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAssetStatusWithAggregatesFilter<$PrismaModel> | $Enums.AssetStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumAssetStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedJsonNullableFilter<$PrismaModel = never> =
|
||||
| Prisma.PatchUndefined<
|
||||
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
|
||||
Required<NestedJsonNullableFilterBase<$PrismaModel>>
|
||||
>
|
||||
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
|
||||
|
||||
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
|
||||
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
path?: string[]
|
||||
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
|
||||
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
|
||||
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
|
||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
||||
}
|
||||
|
||||
export type NestedEnumJobStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobStatusFilter<$PrismaModel> | $Enums.JobStatus
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableFilter<$PrismaModel> | Date | string | null
|
||||
}
|
||||
|
||||
export type NestedEnumJobTargetTypeFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel> | $Enums.JobTargetType
|
||||
}
|
||||
|
||||
export type NestedEnumJobStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobStatus | Prisma.EnumJobStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobStatus[] | Prisma.ListEnumJobStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobStatusWithAggregatesFilter<$PrismaModel> | $Enums.JobStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumJobStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedDateTimeNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel> | null
|
||||
in?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
notIn?: Date[] | string[] | Prisma.ListDateTimeFieldRefInput<$PrismaModel> | null
|
||||
lt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
lte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gt?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
gte?: Date | string | Prisma.DateTimeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedDateTimeNullableWithAggregatesFilter<$PrismaModel> | Date | string | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedDateTimeNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumJobTargetTypeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.JobTargetType | Prisma.EnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.JobTargetType[] | Prisma.ListEnumJobTargetTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumJobTargetTypeWithAggregatesFilter<$PrismaModel> | $Enums.JobTargetType
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumJobTargetTypeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
|
||||
}
|
||||
|
||||
export type NestedEnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.MainCreationAgentSessionStatus | Prisma.EnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.MainCreationAgentSessionStatus[] | Prisma.ListEnumMainCreationAgentSessionStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumMainCreationAgentSessionStatusWithAggregatesFilter<$PrismaModel> | $Enums.MainCreationAgentSessionStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumMainCreationAgentSessionStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumAgentTaskTypeFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel> | $Enums.AgentTaskType
|
||||
}
|
||||
|
||||
export type NestedEnumAgentTaskStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel> | $Enums.AgentTaskStatus
|
||||
}
|
||||
|
||||
export type NestedEnumAgentTaskTypeWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskType | Prisma.EnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskType[] | Prisma.ListEnumAgentTaskTypeFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskTypeWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskType
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumAgentTaskTypeFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumAgentTaskStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.AgentTaskStatus | Prisma.EnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.AgentTaskStatus[] | Prisma.ListEnumAgentTaskStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumAgentTaskStatusWithAggregatesFilter<$PrismaModel> | $Enums.AgentTaskStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumAgentTaskStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumReviewRecordStatusFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel> | $Enums.ReviewRecordStatus
|
||||
}
|
||||
|
||||
export type NestedEnumReviewDecisionNullableFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel> | $Enums.ReviewDecision | null
|
||||
}
|
||||
|
||||
export type NestedEnumReviewRecordStatusWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewRecordStatus | Prisma.EnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
in?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
notIn?: $Enums.ReviewRecordStatus[] | Prisma.ListEnumReviewRecordStatusFieldRefInput<$PrismaModel>
|
||||
not?: Prisma.NestedEnumReviewRecordStatusWithAggregatesFilter<$PrismaModel> | $Enums.ReviewRecordStatus
|
||||
_count?: Prisma.NestedIntFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumReviewRecordStatusFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
export type NestedEnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||
equals?: $Enums.ReviewDecision | Prisma.EnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
in?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
notIn?: $Enums.ReviewDecision[] | Prisma.ListEnumReviewDecisionFieldRefInput<$PrismaModel> | null
|
||||
not?: Prisma.NestedEnumReviewDecisionNullableWithAggregatesFilter<$PrismaModel> | $Enums.ReviewDecision | null
|
||||
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||
_min?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
|
||||
_max?: Prisma.NestedEnumReviewDecisionNullableFilter<$PrismaModel>
|
||||
}
|
||||
|
||||
|
||||
122
apps/api/src/generated/prisma/enums.ts
Normal file
122
apps/api/src/generated/prisma/enums.ts
Normal file
@ -0,0 +1,122 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This file exports all enum related types from the schema.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
|
||||
export const UserStatus = {
|
||||
active: 'active',
|
||||
disabled: 'disabled'
|
||||
} as const
|
||||
|
||||
export type UserStatus = (typeof UserStatus)[keyof typeof UserStatus]
|
||||
|
||||
|
||||
export const UserRoleName = {
|
||||
admin: 'admin',
|
||||
operator: 'operator',
|
||||
creator: 'creator',
|
||||
player: 'player'
|
||||
} as const
|
||||
|
||||
export type UserRoleName = (typeof UserRoleName)[keyof typeof UserRoleName]
|
||||
|
||||
|
||||
export const ProjectStatus = {
|
||||
active: 'active',
|
||||
archived: 'archived'
|
||||
} as const
|
||||
|
||||
export type ProjectStatus = (typeof ProjectStatus)[keyof typeof ProjectStatus]
|
||||
|
||||
|
||||
export const GameVersionStatus = {
|
||||
draft: 'draft',
|
||||
candidate: 'candidate',
|
||||
active: 'active',
|
||||
archived: 'archived',
|
||||
failed: 'failed'
|
||||
} as const
|
||||
|
||||
export type GameVersionStatus = (typeof GameVersionStatus)[keyof typeof GameVersionStatus]
|
||||
|
||||
|
||||
export const AssetStatus = {
|
||||
uploaded: 'uploaded',
|
||||
deleted: 'deleted'
|
||||
} as const
|
||||
|
||||
export type AssetStatus = (typeof AssetStatus)[keyof typeof AssetStatus]
|
||||
|
||||
|
||||
export const JobStatus = {
|
||||
queued: 'queued',
|
||||
running: 'running',
|
||||
succeeded: 'succeeded',
|
||||
pending_retry: 'pending_retry',
|
||||
failed: 'failed',
|
||||
canceled: 'canceled'
|
||||
} as const
|
||||
|
||||
export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus]
|
||||
|
||||
|
||||
export const JobTargetType = {
|
||||
project: 'project',
|
||||
version: 'version'
|
||||
} as const
|
||||
|
||||
export type JobTargetType = (typeof JobTargetType)[keyof typeof JobTargetType]
|
||||
|
||||
|
||||
export const MainCreationAgentSessionStatus = {
|
||||
routing_internal_tasks: 'routing_internal_tasks',
|
||||
completed: 'completed',
|
||||
failed: 'failed',
|
||||
canceled: 'canceled'
|
||||
} as const
|
||||
|
||||
export type MainCreationAgentSessionStatus = (typeof MainCreationAgentSessionStatus)[keyof typeof MainCreationAgentSessionStatus]
|
||||
|
||||
|
||||
export const AgentTaskType = {
|
||||
requirement_clarifier: 'requirement_clarifier',
|
||||
game_design_draft_generator: 'game_design_draft_generator'
|
||||
} as const
|
||||
|
||||
export type AgentTaskType = (typeof AgentTaskType)[keyof typeof AgentTaskType]
|
||||
|
||||
|
||||
export const AgentTaskStatus = {
|
||||
queued: 'queued',
|
||||
running: 'running',
|
||||
succeeded: 'succeeded',
|
||||
failed: 'failed',
|
||||
canceled: 'canceled',
|
||||
timed_out: 'timed_out'
|
||||
} as const
|
||||
|
||||
export type AgentTaskStatus = (typeof AgentTaskStatus)[keyof typeof AgentTaskStatus]
|
||||
|
||||
|
||||
export const ReviewRecordStatus = {
|
||||
pending_review: 'pending_review',
|
||||
approved: 'approved',
|
||||
rejected: 'rejected',
|
||||
canceled: 'canceled'
|
||||
} as const
|
||||
|
||||
export type ReviewRecordStatus = (typeof ReviewRecordStatus)[keyof typeof ReviewRecordStatus]
|
||||
|
||||
|
||||
export const ReviewDecision = {
|
||||
approved: 'approved',
|
||||
rejected: 'rejected'
|
||||
} as const
|
||||
|
||||
export type ReviewDecision = (typeof ReviewDecision)[keyof typeof ReviewDecision]
|
||||
314
apps/api/src/generated/prisma/internal/class.ts
Normal file
314
apps/api/src/generated/prisma/internal/class.ts
Normal file
File diff suppressed because one or more lines are too long
2004
apps/api/src/generated/prisma/internal/prismaNamespace.ts
Normal file
2004
apps/api/src/generated/prisma/internal/prismaNamespace.ts
Normal file
File diff suppressed because it is too large
Load Diff
314
apps/api/src/generated/prisma/internal/prismaNamespaceBrowser.ts
Normal file
314
apps/api/src/generated/prisma/internal/prismaNamespaceBrowser.ts
Normal file
@ -0,0 +1,314 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* WARNING: This is an internal file that is subject to change!
|
||||
*
|
||||
* 🛑 Under no circumstances should you import this file directly! 🛑
|
||||
*
|
||||
* All exports from this file are wrapped under a `Prisma` namespace object in the browser.ts file.
|
||||
* While this enables partial backward compatibility, it is not part of the stable public API.
|
||||
*
|
||||
* If you are looking for your Models, Enums, and Input Types, please import them from the respective
|
||||
* model files in the `model` directory!
|
||||
*/
|
||||
|
||||
import * as runtime from "@prisma/client/runtime/index-browser"
|
||||
|
||||
export type * from '../models.js'
|
||||
export type * from './prismaNamespace.js'
|
||||
|
||||
export const Decimal = runtime.Decimal
|
||||
|
||||
|
||||
export const NullTypes = {
|
||||
DbNull: runtime.NullTypes.DbNull as (new (secret: never) => typeof runtime.DbNull),
|
||||
JsonNull: runtime.NullTypes.JsonNull as (new (secret: never) => typeof runtime.JsonNull),
|
||||
AnyNull: runtime.NullTypes.AnyNull as (new (secret: never) => typeof runtime.AnyNull),
|
||||
}
|
||||
/**
|
||||
* Helper for filtering JSON entries that have `null` on the database (empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const DbNull = runtime.DbNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that have JSON `null` values (not empty on the db)
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const JsonNull = runtime.JsonNull
|
||||
|
||||
/**
|
||||
* Helper for filtering JSON entries that are `Prisma.DbNull` or `Prisma.JsonNull`
|
||||
*
|
||||
* @see https://www.prisma.io/docs/concepts/components/prisma-client/working-with-fields/working-with-json-fields#filtering-on-a-json-field
|
||||
*/
|
||||
export const AnyNull = runtime.AnyNull
|
||||
|
||||
|
||||
export const ModelName = {
|
||||
User: 'User',
|
||||
UserRole: 'UserRole',
|
||||
AnonymousIdentity: 'AnonymousIdentity',
|
||||
GameProject: 'GameProject',
|
||||
GameVersion: 'GameVersion',
|
||||
Asset: 'Asset',
|
||||
Job: 'Job',
|
||||
AuditLog: 'AuditLog',
|
||||
MainCreationAgentSession: 'MainCreationAgentSession',
|
||||
AgentTask: 'AgentTask',
|
||||
ReviewRecord: 'ReviewRecord',
|
||||
LifecycleEvent: 'LifecycleEvent'
|
||||
} as const
|
||||
|
||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||
|
||||
/*
|
||||
* Enums
|
||||
*/
|
||||
|
||||
export const TransactionIsolationLevel = runtime.makeStrictEnum({
|
||||
ReadUncommitted: 'ReadUncommitted',
|
||||
ReadCommitted: 'ReadCommitted',
|
||||
RepeatableRead: 'RepeatableRead',
|
||||
Serializable: 'Serializable'
|
||||
} as const)
|
||||
|
||||
export type TransactionIsolationLevel = (typeof TransactionIsolationLevel)[keyof typeof TransactionIsolationLevel]
|
||||
|
||||
|
||||
export const UserScalarFieldEnum = {
|
||||
id: 'id',
|
||||
email: 'email',
|
||||
displayName: 'displayName',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof UserScalarFieldEnum]
|
||||
|
||||
|
||||
export const UserRoleScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
role: 'role',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type UserRoleScalarFieldEnum = (typeof UserRoleScalarFieldEnum)[keyof typeof UserRoleScalarFieldEnum]
|
||||
|
||||
|
||||
export const AnonymousIdentityScalarFieldEnum = {
|
||||
id: 'id',
|
||||
userId: 'userId',
|
||||
deviceKey: 'deviceKey',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type AnonymousIdentityScalarFieldEnum = (typeof AnonymousIdentityScalarFieldEnum)[keyof typeof AnonymousIdentityScalarFieldEnum]
|
||||
|
||||
|
||||
export const GameProjectScalarFieldEnum = {
|
||||
id: 'id',
|
||||
ownerId: 'ownerId',
|
||||
slug: 'slug',
|
||||
title: 'title',
|
||||
status: 'status',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type GameProjectScalarFieldEnum = (typeof GameProjectScalarFieldEnum)[keyof typeof GameProjectScalarFieldEnum]
|
||||
|
||||
|
||||
export const GameVersionScalarFieldEnum = {
|
||||
id: 'id',
|
||||
projectId: 'projectId',
|
||||
versionNumber: 'versionNumber',
|
||||
status: 'status',
|
||||
configJson: 'configJson',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type GameVersionScalarFieldEnum = (typeof GameVersionScalarFieldEnum)[keyof typeof GameVersionScalarFieldEnum]
|
||||
|
||||
|
||||
export const AssetScalarFieldEnum = {
|
||||
id: 'id',
|
||||
projectId: 'projectId',
|
||||
kind: 'kind',
|
||||
storageKey: 'storageKey',
|
||||
mimeType: 'mimeType',
|
||||
byteSize: 'byteSize',
|
||||
sha256: 'sha256',
|
||||
status: 'status',
|
||||
metadataJson: 'metadataJson',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type AssetScalarFieldEnum = (typeof AssetScalarFieldEnum)[keyof typeof AssetScalarFieldEnum]
|
||||
|
||||
|
||||
export const JobScalarFieldEnum = {
|
||||
id: 'id',
|
||||
actorId: 'actorId',
|
||||
projectId: 'projectId',
|
||||
type: 'type',
|
||||
idempotencyKey: 'idempotencyKey',
|
||||
status: 'status',
|
||||
attempts: 'attempts',
|
||||
maxAttempts: 'maxAttempts',
|
||||
timeoutAt: 'timeoutAt',
|
||||
nextRetryAt: 'nextRetryAt',
|
||||
errorCode: 'errorCode',
|
||||
leaseToken: 'leaseToken',
|
||||
leasedBy: 'leasedBy',
|
||||
leaseExpiresAt: 'leaseExpiresAt',
|
||||
lockVersion: 'lockVersion',
|
||||
targetType: 'targetType',
|
||||
targetId: 'targetId',
|
||||
targetScopeKey: 'targetScopeKey',
|
||||
gameProjectId: 'gameProjectId',
|
||||
gameVersionId: 'gameVersionId',
|
||||
payloadJson: 'payloadJson',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type JobScalarFieldEnum = (typeof JobScalarFieldEnum)[keyof typeof JobScalarFieldEnum]
|
||||
|
||||
|
||||
export const AuditLogScalarFieldEnum = {
|
||||
id: 'id',
|
||||
actorId: 'actorId',
|
||||
action: 'action',
|
||||
targetType: 'targetType',
|
||||
targetId: 'targetId',
|
||||
eventJson: 'eventJson',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
|
||||
|
||||
|
||||
export const MainCreationAgentSessionScalarFieldEnum = {
|
||||
id: 'id',
|
||||
creatorId: 'creatorId',
|
||||
projectId: 'projectId',
|
||||
versionId: 'versionId',
|
||||
status: 'status',
|
||||
contextSummary: 'contextSummary',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type MainCreationAgentSessionScalarFieldEnum = (typeof MainCreationAgentSessionScalarFieldEnum)[keyof typeof MainCreationAgentSessionScalarFieldEnum]
|
||||
|
||||
|
||||
export const AgentTaskScalarFieldEnum = {
|
||||
id: 'id',
|
||||
sessionId: 'sessionId',
|
||||
taskType: 'taskType',
|
||||
subagentId: 'subagentId',
|
||||
inputRef: 'inputRef',
|
||||
outputRef: 'outputRef',
|
||||
status: 'status',
|
||||
timeoutAt: 'timeoutAt',
|
||||
errorCode: 'errorCode',
|
||||
auditLogId: 'auditLogId',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type AgentTaskScalarFieldEnum = (typeof AgentTaskScalarFieldEnum)[keyof typeof AgentTaskScalarFieldEnum]
|
||||
|
||||
|
||||
export const ReviewRecordScalarFieldEnum = {
|
||||
id: 'id',
|
||||
gameVersionId: 'gameVersionId',
|
||||
status: 'status',
|
||||
decision: 'decision',
|
||||
reasonCode: 'reasonCode',
|
||||
decidedById: 'decidedById',
|
||||
decidedAt: 'decidedAt',
|
||||
createdAt: 'createdAt',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
export type ReviewRecordScalarFieldEnum = (typeof ReviewRecordScalarFieldEnum)[keyof typeof ReviewRecordScalarFieldEnum]
|
||||
|
||||
|
||||
export const LifecycleEventScalarFieldEnum = {
|
||||
eventId: 'eventId',
|
||||
gameVersionId: 'gameVersionId',
|
||||
event: 'event',
|
||||
from: 'from',
|
||||
to: 'to',
|
||||
actorJson: 'actorJson',
|
||||
requiredRole: 'requiredRole',
|
||||
requiredRecordRefsJson: 'requiredRecordRefsJson',
|
||||
auditEvent: 'auditEvent',
|
||||
reasonCode: 'reasonCode',
|
||||
occurredAt: 'occurredAt',
|
||||
approval: 'approval',
|
||||
requiredRecordsJson: 'requiredRecordsJson',
|
||||
createdAt: 'createdAt'
|
||||
} as const
|
||||
|
||||
export type LifecycleEventScalarFieldEnum = (typeof LifecycleEventScalarFieldEnum)[keyof typeof LifecycleEventScalarFieldEnum]
|
||||
|
||||
|
||||
export const SortOrder = {
|
||||
asc: 'asc',
|
||||
desc: 'desc'
|
||||
} as const
|
||||
|
||||
export type SortOrder = (typeof SortOrder)[keyof typeof SortOrder]
|
||||
|
||||
|
||||
export const JsonNullValueInput = {
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
|
||||
|
||||
|
||||
export const NullableJsonNullValueInput = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull
|
||||
} as const
|
||||
|
||||
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
|
||||
|
||||
|
||||
export const QueryMode = {
|
||||
default: 'default',
|
||||
insensitive: 'insensitive'
|
||||
} as const
|
||||
|
||||
export type QueryMode = (typeof QueryMode)[keyof typeof QueryMode]
|
||||
|
||||
|
||||
export const NullsOrder = {
|
||||
first: 'first',
|
||||
last: 'last'
|
||||
} as const
|
||||
|
||||
export type NullsOrder = (typeof NullsOrder)[keyof typeof NullsOrder]
|
||||
|
||||
|
||||
export const JsonNullValueFilter = {
|
||||
DbNull: DbNull,
|
||||
JsonNull: JsonNull,
|
||||
AnyNull: AnyNull
|
||||
} as const
|
||||
|
||||
export type JsonNullValueFilter = (typeof JsonNullValueFilter)[keyof typeof JsonNullValueFilter]
|
||||
|
||||
23
apps/api/src/generated/prisma/models.ts
Normal file
23
apps/api/src/generated/prisma/models.ts
Normal file
@ -0,0 +1,23 @@
|
||||
|
||||
/* !!! This is code generated by Prisma. Do not edit directly. !!! */
|
||||
/* eslint-disable */
|
||||
// biome-ignore-all lint: generated file
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* This is a barrel export file for all models and their related types.
|
||||
*
|
||||
* 🟢 You can import this file directly.
|
||||
*/
|
||||
export type * from './models/User.js'
|
||||
export type * from './models/UserRole.js'
|
||||
export type * from './models/AnonymousIdentity.js'
|
||||
export type * from './models/GameProject.js'
|
||||
export type * from './models/GameVersion.js'
|
||||
export type * from './models/Asset.js'
|
||||
export type * from './models/Job.js'
|
||||
export type * from './models/AuditLog.js'
|
||||
export type * from './models/MainCreationAgentSession.js'
|
||||
export type * from './models/AgentTask.js'
|
||||
export type * from './models/ReviewRecord.js'
|
||||
export type * from './models/LifecycleEvent.js'
|
||||
export type * from './commonInputTypes.js'
|
||||
1598
apps/api/src/generated/prisma/models/AgentTask.ts
Normal file
1598
apps/api/src/generated/prisma/models/AgentTask.ts
Normal file
File diff suppressed because it is too large
Load Diff
1310
apps/api/src/generated/prisma/models/AnonymousIdentity.ts
Normal file
1310
apps/api/src/generated/prisma/models/AnonymousIdentity.ts
Normal file
File diff suppressed because it is too large
Load Diff
1603
apps/api/src/generated/prisma/models/Asset.ts
Normal file
1603
apps/api/src/generated/prisma/models/Asset.ts
Normal file
File diff suppressed because it is too large
Load Diff
1409
apps/api/src/generated/prisma/models/AuditLog.ts
Normal file
1409
apps/api/src/generated/prisma/models/AuditLog.ts
Normal file
File diff suppressed because it is too large
Load Diff
2123
apps/api/src/generated/prisma/models/GameProject.ts
Normal file
2123
apps/api/src/generated/prisma/models/GameProject.ts
Normal file
File diff suppressed because it is too large
Load Diff
2033
apps/api/src/generated/prisma/models/GameVersion.ts
Normal file
2033
apps/api/src/generated/prisma/models/GameVersion.ts
Normal file
File diff suppressed because it is too large
Load Diff
2764
apps/api/src/generated/prisma/models/Job.ts
Normal file
2764
apps/api/src/generated/prisma/models/Job.ts
Normal file
File diff suppressed because it is too large
Load Diff
1661
apps/api/src/generated/prisma/models/LifecycleEvent.ts
Normal file
1661
apps/api/src/generated/prisma/models/LifecycleEvent.ts
Normal file
File diff suppressed because it is too large
Load Diff
1883
apps/api/src/generated/prisma/models/MainCreationAgentSession.ts
Normal file
1883
apps/api/src/generated/prisma/models/MainCreationAgentSession.ts
Normal file
File diff suppressed because it is too large
Load Diff
1656
apps/api/src/generated/prisma/models/ReviewRecord.ts
Normal file
1656
apps/api/src/generated/prisma/models/ReviewRecord.ts
Normal file
File diff suppressed because it is too large
Load Diff
2210
apps/api/src/generated/prisma/models/User.ts
Normal file
2210
apps/api/src/generated/prisma/models/User.ts
Normal file
File diff suppressed because it is too large
Load Diff
1320
apps/api/src/generated/prisma/models/UserRole.ts
Normal file
1320
apps/api/src/generated/prisma/models/UserRole.ts
Normal file
File diff suppressed because it is too large
Load Diff
19
apps/api/src/health.controller.spec.ts
Normal file
19
apps/api/src/health.controller.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AppModule } from "./app.module.js";
|
||||
import { HealthController } from "./health.controller.js";
|
||||
|
||||
describe("HealthController", () => {
|
||||
it("is wired by AppModule and returns the API health payload", async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
}).compile();
|
||||
|
||||
const controller = moduleRef.get(HealthController);
|
||||
|
||||
expect(controller.check()).toEqual({
|
||||
service: "@huijing/api",
|
||||
status: "ok"
|
||||
});
|
||||
});
|
||||
});
|
||||
17
apps/api/src/health.controller.ts
Normal file
17
apps/api/src/health.controller.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
export interface HealthResponse {
|
||||
readonly service: "@huijing/api";
|
||||
readonly status: "ok";
|
||||
}
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check(): HealthResponse {
|
||||
return {
|
||||
service: "@huijing/api",
|
||||
status: "ok"
|
||||
};
|
||||
}
|
||||
}
|
||||
17
apps/api/src/main.ts
Normal file
17
apps/api/src/main.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { AppModule } from "./app.module.js";
|
||||
|
||||
export async function bootstrap(port = 3001): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(port);
|
||||
}
|
||||
|
||||
const currentModulePath = fileURLToPath(import.meta.url);
|
||||
const executedModulePath = process.argv[1] ? resolve(process.argv[1]) : undefined;
|
||||
|
||||
if (executedModulePath === currentModulePath) {
|
||||
void bootstrap();
|
||||
}
|
||||
247
apps/api/src/modules/assets/assets.api.spec.ts
Normal file
247
apps/api/src/modules/assets/assets.api.spec.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import { type INestApplication } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppModule } from "../../app.module.js";
|
||||
import { PrismaClient } from "../../generated/prisma/client.js";
|
||||
import { StorageBoundaryError } from "../storage/index.js";
|
||||
import { S1_STORAGE_ADAPTER, type StorageObjectPlanner } from "./index.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task8-assets-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type TestApp = {
|
||||
readonly app: INestApplication;
|
||||
readonly baseUrl: string;
|
||||
};
|
||||
|
||||
async function createTestApp(storageRoot: string, storagePlanner?: StorageObjectPlanner): Promise<TestApp> {
|
||||
process.env.S1_STORAGE_ROOT = storageRoot;
|
||||
const builder = Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
});
|
||||
if (storagePlanner) builder.overrideProvider(S1_STORAGE_ADAPTER).useValue(storagePlanner);
|
||||
const moduleRef = await builder.compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const server = await app.listen(0);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
|
||||
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
async function requestJson(testApp: TestApp, requestPath: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${testApp.baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function login(testApp: TestApp, email: string): Promise<string> {
|
||||
const response = await requestJson(testApp, "/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
expect(response.response.status).toBe(201);
|
||||
return (response.body as { token: string }).token;
|
||||
}
|
||||
|
||||
async function seedDb(): Promise<{ readonly ownedProjectId: string; readonly foreignProjectId: string }> {
|
||||
await prisma.user.upsert({
|
||||
where: { id: "seed-creator" },
|
||||
update: {},
|
||||
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
|
||||
});
|
||||
await prisma.user.upsert({
|
||||
where: { id: "seed-creator-other" },
|
||||
update: {},
|
||||
create: { id: "seed-creator-other", email: "creator-other@example.test", displayName: "Seed Other Creator" }
|
||||
});
|
||||
const ownedProjectId = `${runId}-owned-project`;
|
||||
const foreignProjectId = `${runId}-foreign-project`;
|
||||
await prisma.gameProject.create({
|
||||
data: { id: ownedProjectId, ownerId: "seed-creator", slug: `${runId}-owned`, title: "owned" }
|
||||
});
|
||||
await prisma.gameProject.create({
|
||||
data: { id: foreignProjectId, ownerId: "seed-creator-other", slug: `${runId}-foreign`, title: "foreign" }
|
||||
});
|
||||
return { ownedProjectId, foreignProjectId };
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
}
|
||||
|
||||
describe("Asset HTTP APIs", () => {
|
||||
let testApp: TestApp | undefined;
|
||||
let storageRoot: string | undefined;
|
||||
let previousStorageRoot: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
previousStorageRoot = process.env.S1_STORAGE_ROOT;
|
||||
storageRoot = await mkdtemp(path.join(os.tmpdir(), "huijing-task8-assets-"));
|
||||
testApp = await createTestApp(storageRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testApp?.app.close();
|
||||
testApp = undefined;
|
||||
await cleanup();
|
||||
if (storageRoot) await rm(storageRoot, { recursive: true, force: true });
|
||||
storageRoot = undefined;
|
||||
if (previousStorageRoot === undefined) {
|
||||
delete process.env.S1_STORAGE_ROOT;
|
||||
} else {
|
||||
process.env.S1_STORAGE_ROOT = previousStorageRoot;
|
||||
}
|
||||
previousStorageRoot = undefined;
|
||||
});
|
||||
|
||||
it("POST /assets/presign returns a local mock upload target for the owning creator and writes audit", async () => {
|
||||
const { ownedProjectId } = await seedDb();
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const presign = await requestJson(testApp!, "/assets/presign", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
projectId: ownedProjectId,
|
||||
kind: "cover",
|
||||
objectName: "cover.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 128,
|
||||
sha256: "a".repeat(64)
|
||||
})
|
||||
});
|
||||
|
||||
expect(presign.response.status).toBe(201);
|
||||
expect(presign.body).toMatchObject({
|
||||
projectId: ownedProjectId,
|
||||
storageKey: `owners/seed-creator/projects/${ownedProjectId}/cover.png`,
|
||||
uploadUrl: `local://s1-storage/owners/seed-creator/projects/${ownedProjectId}/cover.png`,
|
||||
method: "PUT"
|
||||
});
|
||||
await expect(prisma.auditLog.findFirst({ where: { targetId: ownedProjectId, action: "asset.presign.created" } })).resolves.toMatchObject({
|
||||
actorId: "seed-creator",
|
||||
targetType: "GameProject"
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /assets/presign rejects foreign project access before any audit mutation", async () => {
|
||||
const { foreignProjectId } = await seedDb();
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const denied = await requestJson(testApp!, "/assets/presign", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
projectId: foreignProjectId,
|
||||
kind: "cover",
|
||||
objectName: "cover.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 128,
|
||||
sha256: "a".repeat(64)
|
||||
})
|
||||
});
|
||||
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toEqual({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
await expect(prisma.auditLog.count({ where: { targetId: foreignProjectId } })).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("POST /assets/presign delegates key construction to the storage boundary planner", async () => {
|
||||
const { ownedProjectId } = await seedDb();
|
||||
const plannedInputs: unknown[] = [];
|
||||
await testApp?.app.close();
|
||||
testApp = await createTestApp(storageRoot!, {
|
||||
planObject(input) {
|
||||
plannedInputs.push(input);
|
||||
return {
|
||||
storageKey: "owners/from-storage-boundary/projects/fake/planned.png",
|
||||
byteSize: input.byteSize,
|
||||
sha256: input.expectedSha256.toLowerCase(),
|
||||
mimeType: input.mimeType
|
||||
};
|
||||
}
|
||||
});
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const presign = await requestJson(testApp!, "/assets/presign", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
projectId: ownedProjectId,
|
||||
kind: "cover",
|
||||
objectName: "api-layer-must-not-parse%2Fthis.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 128,
|
||||
sha256: "b".repeat(64)
|
||||
})
|
||||
});
|
||||
|
||||
expect(presign.response.status).toBe(201);
|
||||
expect(presign.body).toMatchObject({
|
||||
storageKey: "owners/from-storage-boundary/projects/fake/planned.png",
|
||||
uploadUrl: "local://s1-storage/owners/from-storage-boundary/projects/fake/planned.png"
|
||||
});
|
||||
expect(plannedInputs).toEqual([
|
||||
expect.objectContaining({
|
||||
ownerId: "seed-creator",
|
||||
projectId: ownedProjectId,
|
||||
objectName: "api-layer-must-not-parse%2Fthis.png"
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
it("POST /assets/presign maps storage boundary errors to structured API errors without audit mutation", async () => {
|
||||
const { ownedProjectId } = await seedDb();
|
||||
await testApp?.app.close();
|
||||
testApp = await createTestApp(storageRoot!, {
|
||||
planObject() {
|
||||
throw new StorageBoundaryError("MIME_NOT_ALLOWED");
|
||||
}
|
||||
});
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
const beforeAuditCount = await prisma.auditLog.count({ where: { targetId: ownedProjectId, action: "asset.presign.created" } });
|
||||
|
||||
const denied = await requestJson(testApp!, "/assets/presign", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({
|
||||
projectId: ownedProjectId,
|
||||
kind: "cover",
|
||||
objectName: "cover.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: 128,
|
||||
sha256: "c".repeat(64)
|
||||
})
|
||||
});
|
||||
|
||||
expect(denied.response.status).toBe(400);
|
||||
expect(denied.body).toEqual({
|
||||
code: "MIME_NOT_ALLOWED",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
await expect(prisma.auditLog.count({ where: { targetId: ownedProjectId, action: "asset.presign.created" } })).resolves.toBe(
|
||||
beforeAuditCount
|
||||
);
|
||||
});
|
||||
});
|
||||
167
apps/api/src/modules/assets/index.ts
Normal file
167
apps/api/src/modules/assets/index.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards
|
||||
} from "@nestjs/common";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
|
||||
import { AuditService } from "../audit/index.js";
|
||||
import { creatorOwnsResource } from "../rbac/index.js";
|
||||
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
|
||||
import { LocalStorageAdapter, StorageBoundaryError, type PlanObjectInput, type PlanObjectResult } from "../storage/index.js";
|
||||
|
||||
type RequestWithActor = {
|
||||
readonly actor: AuthActor;
|
||||
};
|
||||
|
||||
type PresignBody = {
|
||||
readonly projectId?: unknown;
|
||||
readonly kind?: unknown;
|
||||
readonly objectName?: unknown;
|
||||
readonly mimeType?: unknown;
|
||||
readonly byteSize?: unknown;
|
||||
readonly sha256?: unknown;
|
||||
};
|
||||
|
||||
type LocalUploadTarget = {
|
||||
readonly projectId: string;
|
||||
readonly kind: string;
|
||||
readonly storageKey: string;
|
||||
readonly uploadUrl: string;
|
||||
readonly method: "PUT";
|
||||
readonly headers: {
|
||||
readonly "content-type": string;
|
||||
readonly "x-sha256": string;
|
||||
};
|
||||
readonly maxBytes: number;
|
||||
};
|
||||
|
||||
export interface StorageObjectPlanner {
|
||||
planObject(input: PlanObjectInput): PlanObjectResult;
|
||||
}
|
||||
|
||||
export const S1_STORAGE_ADAPTER = Symbol("S1_STORAGE_ADAPTER");
|
||||
|
||||
const maxUploadBytes = 10 * 1024 * 1024;
|
||||
const allowedMimeTypes = ["image/png", "image/jpeg", "image/webp", "text/plain", "application/json"] as const;
|
||||
|
||||
function requireNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new BadRequestException(apiError("INVALID_REQUEST", `${field} must be a non-empty string`, { field }));
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requireInteger(value: unknown, field: string): number {
|
||||
if (typeof value !== "number" || !Number.isInteger(value)) {
|
||||
throw new BadRequestException(apiError("INVALID_REQUEST", `${field} must be an integer`, { field }));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AssetsApiService {
|
||||
constructor(
|
||||
private readonly db: S1PrismaClient,
|
||||
@Inject(S1_STORAGE_ADAPTER) private readonly storage: StorageObjectPlanner
|
||||
) {}
|
||||
|
||||
async presign(actor: AuthActor, body: PresignBody): Promise<LocalUploadTarget> {
|
||||
const projectId = requireNonEmptyString(body.projectId, "projectId");
|
||||
const kind = requireNonEmptyString(body.kind, "kind");
|
||||
const objectName = requireNonEmptyString(body.objectName, "objectName");
|
||||
const mimeType = requireNonEmptyString(body.mimeType, "mimeType");
|
||||
const byteSize = requireInteger(body.byteSize, "byteSize");
|
||||
const sha256 = requireNonEmptyString(body.sha256, "sha256");
|
||||
|
||||
const project = await this.db.gameProject.findUnique({ where: { id: projectId } });
|
||||
if (!project) throw new NotFoundException(apiError("NOT_FOUND", "Project not found", { projectId }));
|
||||
if (!creatorOwnsResource(actor, project)) {
|
||||
// 外部项目在任何 presign/audit 写入前拒绝,避免为越权尝试产生可误用的 upload target。
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Actor cannot presign assets for this project", { projectId }));
|
||||
}
|
||||
|
||||
let planned: PlanObjectResult;
|
||||
try {
|
||||
planned = this.storage.planObject({
|
||||
ownerId: actor.id,
|
||||
projectId,
|
||||
objectName,
|
||||
mimeType,
|
||||
byteSize,
|
||||
expectedSha256: sha256
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StorageBoundaryError) {
|
||||
throw new BadRequestException(apiError(error.code, error.message, { storageBoundary: true }));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
await new AuditService({ db: this.db }).append({
|
||||
id: randomUUID(),
|
||||
actorId: actor.id,
|
||||
action: "asset.presign.created",
|
||||
targetType: "GameProject",
|
||||
targetId: projectId,
|
||||
eventJson: {
|
||||
kind,
|
||||
storageKey: planned.storageKey,
|
||||
mimeType: planned.mimeType,
|
||||
byteSize: planned.byteSize,
|
||||
sha256: planned.sha256
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
projectId,
|
||||
kind,
|
||||
storageKey: planned.storageKey,
|
||||
uploadUrl: `local://s1-storage/${planned.storageKey}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": planned.mimeType,
|
||||
"x-sha256": planned.sha256
|
||||
},
|
||||
maxBytes: maxUploadBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseGuards(AuthGuard)
|
||||
export class AssetsController {
|
||||
constructor(private readonly service: AssetsApiService) {}
|
||||
|
||||
@Post("assets/presign")
|
||||
presign(@Req() request: RequestWithActor, @Body() body: PresignBody): Promise<LocalUploadTarget> {
|
||||
return this.service.presign(request.actor, body);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [S1ApiRuntimeModule, AuthModule],
|
||||
controllers: [AssetsController],
|
||||
providers: [
|
||||
AssetsApiService,
|
||||
{
|
||||
provide: S1_STORAGE_ADAPTER,
|
||||
useFactory: () =>
|
||||
new LocalStorageAdapter({
|
||||
root: process.env.S1_STORAGE_ROOT ?? ".s1-storage",
|
||||
allowedMimeTypes,
|
||||
maxBytes: maxUploadBytes
|
||||
})
|
||||
}
|
||||
],
|
||||
exports: [S1_STORAGE_ADAPTER]
|
||||
})
|
||||
export class AssetsModule {}
|
||||
134
apps/api/src/modules/audit/audit.api.spec.ts
Normal file
134
apps/api/src/modules/audit/audit.api.spec.ts
Normal file
@ -0,0 +1,134 @@
|
||||
import { type INestApplication } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppModule } from "../../app.module.js";
|
||||
import { PrismaClient } from "../../generated/prisma/client.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task8-audit-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type TestApp = {
|
||||
readonly app: INestApplication;
|
||||
readonly baseUrl: string;
|
||||
};
|
||||
|
||||
async function createTestApp(): Promise<TestApp> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const server = await app.listen(0);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
|
||||
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
async function requestJson(testApp: TestApp, requestPath: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${testApp.baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function login(testApp: TestApp, email: string): Promise<string> {
|
||||
const response = await requestJson(testApp, "/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
expect(response.response.status).toBe(201);
|
||||
return (response.body as { token: string }).token;
|
||||
}
|
||||
|
||||
async function ensureSeedUsers(): Promise<void> {
|
||||
await prisma.user.upsert({
|
||||
where: { id: "seed-creator" },
|
||||
update: {},
|
||||
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
|
||||
});
|
||||
await prisma.user.upsert({
|
||||
where: { id: "seed-operator" },
|
||||
update: {},
|
||||
create: { id: "seed-operator", email: "operator@example.test", displayName: "Seed Operator" }
|
||||
});
|
||||
await prisma.user.upsert({
|
||||
where: { id: "seed-admin" },
|
||||
update: {},
|
||||
create: { id: "seed-admin", email: "admin@example.test", displayName: "Seed Admin" }
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
}
|
||||
|
||||
describe("Audit HTTP APIs", () => {
|
||||
let testApp: TestApp | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
await ensureSeedUsers();
|
||||
testApp = await createTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testApp?.app.close();
|
||||
testApp = undefined;
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("GET /audit-logs requires operator/admin and returns S1 foundation audit facts", async () => {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
id: `${runId}-audit`,
|
||||
actorId: "seed-creator",
|
||||
action: "project.created",
|
||||
targetType: "GameProject",
|
||||
targetId: `${runId}-project`,
|
||||
eventJson: { source: "task8-test" }
|
||||
}
|
||||
});
|
||||
const creatorToken = await login(testApp!, "creator@example.test");
|
||||
const operatorToken = await login(testApp!, "operator@example.test");
|
||||
const adminToken = await login(testApp!, "admin@example.test");
|
||||
|
||||
const denied = await requestJson(testApp!, "/audit-logs", {
|
||||
headers: { authorization: `Bearer ${creatorToken}` }
|
||||
});
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toEqual({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
|
||||
const operatorRead = await requestJson(testApp!, "/audit-logs", {
|
||||
headers: { authorization: `Bearer ${operatorToken}` }
|
||||
});
|
||||
expect(operatorRead.response.status).toBe(200);
|
||||
expect(operatorRead.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: `${runId}-audit`, action: "project.created", targetType: "GameProject" })
|
||||
])
|
||||
);
|
||||
|
||||
const adminRead = await requestJson(testApp!, "/audit-logs", {
|
||||
headers: { authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
expect(adminRead.response.status).toBe(200);
|
||||
expect(adminRead.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: `${runId}-audit`, action: "project.created", targetId: `${runId}-project` })
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
189
apps/api/src/modules/audit/audit.spec.ts
Normal file
189
apps/api/src/modules/audit/audit.spec.ts
Normal file
@ -0,0 +1,189 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "../../generated/prisma/client.js";
|
||||
import {
|
||||
AuditMutationRejectedError,
|
||||
AuditService,
|
||||
findAuditBoundaryViolations,
|
||||
scanAuditBoundary
|
||||
} from "./index.js";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task7-audit-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const rollbackToken = Symbol("audit rollback");
|
||||
let savepointSequence = 0;
|
||||
|
||||
type DbClient = Prisma.TransactionClient;
|
||||
|
||||
async function withRollback(callback: (tx: DbClient) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await callback(tx);
|
||||
throw rollbackToken;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== rollbackToken) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectPrismaRejectedInSavepoint(
|
||||
db: DbClient,
|
||||
action: () => Promise<unknown>,
|
||||
pattern: RegExp
|
||||
): Promise<void> {
|
||||
const savepointName = `task7_audit_sp_${++savepointSequence}`;
|
||||
await db.$executeRawUnsafe(`SAVEPOINT ${savepointName}`);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
await db.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
await db.$executeRawUnsafe(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
|
||||
if (caught === undefined) throw new Error(`Expected Prisma action to reject with ${pattern}`);
|
||||
expect(() => {
|
||||
throw caught;
|
||||
}).toThrow(pattern);
|
||||
}
|
||||
|
||||
async function seedActor(db: DbClient, suffix: string) {
|
||||
return db.user.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-actor`,
|
||||
email: `${runId}-${suffix}@example.test`,
|
||||
displayName: `${suffix} actor`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("AuditService", () => {
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("appends audit entries and exposes no update/delete path", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const actor = await seedActor(tx, "append");
|
||||
const service = new AuditService({ db: tx });
|
||||
|
||||
const audit = await service.append({
|
||||
id: `${runId}-append-audit`,
|
||||
actorId: actor.id,
|
||||
action: "job.cancel.blocked",
|
||||
targetType: "Job",
|
||||
targetId: `${runId}-job`,
|
||||
eventJson: { reasonCode: "NOT_QUEUED" }
|
||||
});
|
||||
|
||||
expect(audit.action).toBe("job.cancel.blocked");
|
||||
expect("update" in service).toBe(false);
|
||||
expect("delete" in service).toBe(false);
|
||||
await expect(service.rejectMutation("update")).rejects.toMatchObject<AuditMutationRejectedError>({
|
||||
code: "AUDIT_APPEND_ONLY"
|
||||
});
|
||||
await expect(service.rejectMutation("delete")).rejects.toMatchObject<AuditMutationRejectedError>({
|
||||
code: "AUDIT_APPEND_ONLY"
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("DB trigger rejects direct Prisma auditLog.update/delete", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const actor = await seedActor(tx, "trigger");
|
||||
const service = new AuditService({ db: tx });
|
||||
const audit = await service.append({
|
||||
id: `${runId}-trigger-audit`,
|
||||
actorId: actor.id,
|
||||
action: "audit.trigger",
|
||||
targetType: "AuditLog",
|
||||
targetId: "self",
|
||||
eventJson: {}
|
||||
});
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() => tx.auditLog.update({ where: { id: audit.id }, data: { action: "changed" } }),
|
||||
/AuditLog is append-only/
|
||||
);
|
||||
await expectPrismaRejectedInSavepoint(tx, () => tx.auditLog.delete({ where: { id: audit.id } }), /AuditLog is append-only/);
|
||||
});
|
||||
});
|
||||
|
||||
it("static scan fails if auditLog.update/delete appears in S1 application code", async () => {
|
||||
expect(
|
||||
findAuditBoundaryViolations([
|
||||
{
|
||||
path: "apps/api/src/modules/projects/project.service.ts",
|
||||
content: "await db.auditLog.update({ where: { id }, data: {} });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/audit/index.ts",
|
||||
content: "await db.auditLog.create({ data });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/audit/bad.ts",
|
||||
content: "await db.auditLog.update({ where: { id }, data: {} });"
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{ path: "apps/api/src/modules/projects/project.service.ts", reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" },
|
||||
{ path: "apps/api/src/modules/audit/bad.ts", reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" }
|
||||
]);
|
||||
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "huijing-audit-scan-"));
|
||||
await writeFile(
|
||||
path.join(tempRoot, "bad.ts"),
|
||||
"async function bad(db) { await db.auditLog.delete({ where: { id: 'x' } }); }",
|
||||
"utf8"
|
||||
);
|
||||
await expect(scanAuditBoundary(tempRoot)).resolves.toEqual([
|
||||
{ path: "bad.ts", reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" }
|
||||
]);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
|
||||
await expect(scanAuditBoundary(process.cwd())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("high-risk blocked actions can write audit facts without mutating target state", async () => {
|
||||
const actor = await prisma.$transaction((tx) => seedActor(tx, "blocked"));
|
||||
await expect(
|
||||
prisma.$transaction(async (tx) => {
|
||||
const service = new AuditService({ db: prisma });
|
||||
await service.append({
|
||||
id: `${runId}-blocked-audit`,
|
||||
actorId: actor.id,
|
||||
action: "asset.upload.blocked",
|
||||
targetType: "Asset",
|
||||
targetId: `${runId}-asset`,
|
||||
eventJson: { reasonCode: "MIME_NOT_ALLOWED" }
|
||||
});
|
||||
await tx.asset.create({
|
||||
data: {
|
||||
id: `${runId}-asset`,
|
||||
projectId: "missing-project",
|
||||
kind: "image",
|
||||
storageKey: "bad",
|
||||
mimeType: "application/x-msdownload",
|
||||
byteSize: 1,
|
||||
sha256: "0".repeat(64)
|
||||
}
|
||||
});
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(prisma.auditLog.findUnique({ where: { id: `${runId}-blocked-audit` } })).resolves.toMatchObject({
|
||||
action: "asset.upload.blocked",
|
||||
targetId: `${runId}-asset`
|
||||
});
|
||||
await expect(prisma.asset.findUnique({ where: { id: `${runId}-asset` } })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
271
apps/api/src/modules/audit/index.ts
Normal file
271
apps/api/src/modules/audit/index.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Controller, ForbiddenException, Get, Injectable, Module, Req, UseGuards } from "@nestjs/common";
|
||||
import { Prisma, type AuditLog } from "../../generated/prisma/client.js";
|
||||
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
|
||||
import { canReadReviewFoundationData } from "../rbac/index.js";
|
||||
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
|
||||
|
||||
export type AppendAuditInput = {
|
||||
readonly id: string;
|
||||
readonly actorId: string;
|
||||
readonly action: string;
|
||||
readonly targetType: string;
|
||||
readonly targetId: string;
|
||||
readonly eventJson: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export type AuditServiceOptions = {
|
||||
readonly db: Pick<Prisma.TransactionClient, "auditLog">;
|
||||
};
|
||||
|
||||
export type AuditBoundaryViolation = {
|
||||
readonly path: string;
|
||||
readonly reason: "AUDIT_UPDATE_DELETE_FORBIDDEN";
|
||||
};
|
||||
|
||||
type RequestWithActor = {
|
||||
readonly actor: AuthActor;
|
||||
};
|
||||
|
||||
export type AuditLogDto = {
|
||||
readonly id: string;
|
||||
readonly actorId: string;
|
||||
readonly action: string;
|
||||
readonly targetType: string;
|
||||
readonly targetId: string;
|
||||
readonly eventJson: unknown;
|
||||
readonly createdAt: string;
|
||||
};
|
||||
|
||||
type ScanFile = {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
};
|
||||
|
||||
const generatedPrismaPrefix = "apps/api/src/generated/prisma/";
|
||||
const historicalAuditTriggerTest = "apps/api/src/prisma.schema.spec.ts";
|
||||
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next"]);
|
||||
|
||||
export class AuditMutationRejectedError extends Error {
|
||||
readonly code = "AUDIT_APPEND_ONLY";
|
||||
|
||||
constructor(action: "update" | "delete") {
|
||||
super(`AuditLog is append-only; ${action} is not allowed`);
|
||||
this.name = "AuditMutationRejectedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
private readonly db: Pick<Prisma.TransactionClient, "auditLog">;
|
||||
|
||||
constructor(options: AuditServiceOptions) {
|
||||
this.db = options.db;
|
||||
}
|
||||
|
||||
async append(input: AppendAuditInput): Promise<AuditLog> {
|
||||
return this.db.auditLog.create({
|
||||
data: {
|
||||
id: input.id,
|
||||
actorId: input.actorId,
|
||||
action: input.action,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
eventJson: input.eventJson
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async rejectMutation(action: "update" | "delete"): Promise<never> {
|
||||
// S1 service 层只暴露 append;测试用这个显式拒绝入口证明没有静默 update/delete 路径。
|
||||
throw new AuditMutationRejectedError(action);
|
||||
}
|
||||
}
|
||||
|
||||
function auditLogDto(auditLog: AuditLog): AuditLogDto {
|
||||
return {
|
||||
id: auditLog.id,
|
||||
actorId: auditLog.actorId,
|
||||
action: auditLog.action,
|
||||
targetType: auditLog.targetType,
|
||||
targetId: auditLog.targetId,
|
||||
eventJson: auditLog.eventJson,
|
||||
createdAt: auditLog.createdAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditApiService {
|
||||
constructor(private readonly db: S1PrismaClient) {}
|
||||
|
||||
async listAuditLogs(actor: AuthActor): Promise<AuditLogDto[]> {
|
||||
if (!canReadReviewFoundationData(actor, "audit-log")) {
|
||||
// S1 audit endpoint 是审核基础数据读面,不是 creator 自助查询或管理 CRUD。
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Operator or admin role is required for audit logs"));
|
||||
}
|
||||
|
||||
const auditLogs = await this.db.auditLog.findMany({
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
take: 200
|
||||
});
|
||||
return auditLogs.map(auditLogDto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseGuards(AuthGuard)
|
||||
export class AuditController {
|
||||
constructor(private readonly service: AuditApiService) {}
|
||||
|
||||
@Get("audit-logs")
|
||||
listAuditLogs(@Req() request: RequestWithActor): Promise<AuditLogDto[]> {
|
||||
return this.service.listAuditLogs(request.actor);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [S1ApiRuntimeModule, AuthModule],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditApiService],
|
||||
exports: [AuditApiService]
|
||||
})
|
||||
export class AuditModule {}
|
||||
|
||||
function isIgnoredScanPath(filePath: string): boolean {
|
||||
return (
|
||||
filePath.split("/").some((segment) => ignoredScanSegments.has(segment)) ||
|
||||
filePath.includes("/generated/prisma/") ||
|
||||
filePath.endsWith(".spec.ts")
|
||||
);
|
||||
}
|
||||
|
||||
function findMatchingParen(content: string, openParenIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openParenIndex; index < content.length; index += 1) {
|
||||
const char = content[index];
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function maskStringLiterals(content: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < content.length) {
|
||||
const quote = content[index];
|
||||
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
||||
output += quote;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += quote;
|
||||
index += 1;
|
||||
while (index < content.length) {
|
||||
const char = content[index];
|
||||
if (char === "\\") {
|
||||
output += " ";
|
||||
index += 1;
|
||||
if (index < content.length) {
|
||||
output += " ";
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
output += quote;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
output += " ";
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function isAuditTriggerAssertion(filePath: string, content: string, structure: string, mutationIndex: number): boolean {
|
||||
if (filePath !== historicalAuditTriggerTest && filePath !== "apps/api/src/modules/audit/audit.spec.ts") return false;
|
||||
|
||||
let searchCursor = mutationIndex;
|
||||
while (searchCursor >= 0) {
|
||||
const assertionIndex = structure.lastIndexOf("expectPrismaRejectedInSavepoint", searchCursor);
|
||||
if (assertionIndex === -1) return false;
|
||||
|
||||
const openParenIndex = structure.indexOf("(", assertionIndex);
|
||||
if (openParenIndex === -1 || openParenIndex > mutationIndex) {
|
||||
searchCursor = assertionIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeParenIndex = findMatchingParen(structure, openParenIndex);
|
||||
if (closeParenIndex === -1 || closeParenIndex < mutationIndex) {
|
||||
searchCursor = assertionIndex - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const assertionSource = content.slice(openParenIndex, closeParenIndex + 1);
|
||||
return /AuditLog is append-only/.test(assertionSource);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAuditMutation(filePath: string, content: string): boolean {
|
||||
const structure = maskStringLiterals(content);
|
||||
const mutationPattern = /\.auditLog\.(?:update|updateMany|updateManyAndReturn|upsert|delete|deleteMany)\s*\(/g;
|
||||
|
||||
for (const match of structure.matchAll(mutationPattern)) {
|
||||
if (typeof match.index === "number" && isAuditTriggerAssertion(filePath, content, structure, match.index)) continue;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function findAuditBoundaryViolations(files: readonly ScanFile[]): AuditBoundaryViolation[] {
|
||||
const violations: AuditBoundaryViolation[] = [];
|
||||
for (const file of files) {
|
||||
const normalizedPath = file.path.split(path.sep).join("/");
|
||||
if (isIgnoredScanPath(normalizedPath)) continue;
|
||||
if (normalizedPath.startsWith(generatedPrismaPrefix) || normalizedPath.startsWith("src/generated/prisma/")) continue;
|
||||
if (hasAuditMutation(normalizedPath, file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "AUDIT_UPDATE_DELETE_FORBIDDEN" });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function collectFiles(root: string, relativeDir = ""): Promise<ScanFile[]> {
|
||||
const absoluteDir = path.join(root, relativeDir);
|
||||
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: ScanFile[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && ignoredScanSegments.has(entry.name)) continue;
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
if (entry.isDirectory()) files.push(...(await collectFiles(root, relativePath)));
|
||||
if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name)) {
|
||||
files.push({ path: relativePath, content: await readFile(absolutePath, "utf8") });
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function scanAuditBoundary(root: string): Promise<AuditBoundaryViolation[]> {
|
||||
const files = root.endsWith("games-development-ai")
|
||||
? [
|
||||
...(await collectFiles(root, "apps/api/src/modules")),
|
||||
...(await collectFiles(root, "apps/worker/src"))
|
||||
]
|
||||
: await collectFiles(root);
|
||||
return findAuditBoundaryViolations(files);
|
||||
}
|
||||
118
apps/api/src/modules/auth/auth.spec.ts
Normal file
118
apps/api/src/modules/auth/auth.spec.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import { type INestApplication, Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AppModule } from "../../app.module.js";
|
||||
import { AuthGuard, AuthModule } from "./index.js";
|
||||
|
||||
@Controller("task6-protected")
|
||||
class Task6ProtectedController {
|
||||
@Get()
|
||||
@UseGuards(AuthGuard)
|
||||
check() {
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
type Task6TestApp = {
|
||||
readonly app: INestApplication;
|
||||
readonly baseUrl: string;
|
||||
};
|
||||
|
||||
async function createTestApp(): Promise<Task6TestApp> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, AuthModule],
|
||||
controllers: [Task6ProtectedController]
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const server = await app.listen(0);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
|
||||
|
||||
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
async function requestJson(testApp: Task6TestApp, path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${testApp.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
describe("Auth HTTP boundary", () => {
|
||||
let testApp: Task6TestApp | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
await testApp?.app.close();
|
||||
testApp = undefined;
|
||||
});
|
||||
|
||||
it("POST /auth/login returns a usable token for a seeded user and GET /me returns the actor roles", async () => {
|
||||
testApp = await createTestApp();
|
||||
|
||||
const login = await requestJson(testApp, "/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "creator@example.test" })
|
||||
});
|
||||
|
||||
expect(login.response.status).toBe(201);
|
||||
expect(login.body).toEqual({
|
||||
token: expect.any(String),
|
||||
actor: {
|
||||
id: "seed-creator",
|
||||
email: "creator@example.test",
|
||||
displayName: "Seed Creator",
|
||||
roles: ["creator"]
|
||||
}
|
||||
});
|
||||
|
||||
const token = (login.body as { token: string }).token;
|
||||
const me = await requestJson(testApp, "/me", {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
expect(me.response.status).toBe(200);
|
||||
expect(me.body).toEqual({
|
||||
id: "seed-creator",
|
||||
email: "creator@example.test",
|
||||
displayName: "Seed Creator",
|
||||
roles: ["creator"]
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /auth/logout invalidates the session token used by tests", async () => {
|
||||
testApp = await createTestApp();
|
||||
const login = await requestJson(testApp, "/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "operator@example.test" })
|
||||
});
|
||||
const token = (login.body as { token: string }).token;
|
||||
|
||||
const logout = await requestJson(testApp, "/auth/logout", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
expect(logout.response.status).toBe(201);
|
||||
expect(logout.body).toEqual({ ok: true });
|
||||
|
||||
const meAfterLogout = await requestJson(testApp, "/me", {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
expect(meAfterLogout.response.status).toBe(401);
|
||||
expect(meAfterLogout.body).toMatchObject({ code: "UNAUTHENTICATED" });
|
||||
});
|
||||
|
||||
it("rejects anonymous access to a protected test route without calling later project/version endpoints", async () => {
|
||||
testApp = await createTestApp();
|
||||
|
||||
const protectedResponse = await requestJson(testApp, "/task6-protected");
|
||||
|
||||
expect(protectedResponse.response.status).toBe(403);
|
||||
expect(protectedResponse.body).toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
181
apps/api/src/modules/auth/index.ts
Normal file
181
apps/api/src/modules/auth/index.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
Injectable,
|
||||
Module,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
type CanActivate,
|
||||
type ExecutionContext
|
||||
} from "@nestjs/common";
|
||||
import type { AuthenticatedActor } from "../rbac/index.js";
|
||||
|
||||
export type AuthActor = AuthenticatedActor & {
|
||||
readonly email: string;
|
||||
readonly displayName: string;
|
||||
};
|
||||
|
||||
export type LoginResult = {
|
||||
readonly token: string;
|
||||
readonly actor: AuthActor;
|
||||
};
|
||||
|
||||
type LoginBody = {
|
||||
readonly email?: unknown;
|
||||
readonly userId?: unknown;
|
||||
};
|
||||
|
||||
type RequestLike = {
|
||||
readonly headers?: {
|
||||
readonly authorization?: string | readonly string[];
|
||||
};
|
||||
actor?: AuthActor;
|
||||
};
|
||||
|
||||
const seededUsers: readonly AuthActor[] = [
|
||||
{
|
||||
id: "seed-admin",
|
||||
email: "admin@example.test",
|
||||
displayName: "Seed Admin",
|
||||
roles: ["admin"]
|
||||
},
|
||||
{
|
||||
id: "seed-operator",
|
||||
email: "operator@example.test",
|
||||
displayName: "Seed Operator",
|
||||
roles: ["operator"]
|
||||
},
|
||||
{
|
||||
id: "seed-creator",
|
||||
email: "creator@example.test",
|
||||
displayName: "Seed Creator",
|
||||
roles: ["creator"]
|
||||
},
|
||||
{
|
||||
id: "seed-player",
|
||||
email: "player@example.test",
|
||||
displayName: "Seed Player",
|
||||
roles: ["player"]
|
||||
}
|
||||
];
|
||||
|
||||
function apiError(code: string, message: string, details: unknown = null) {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
requestId: null,
|
||||
details
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAuthorizationHeader(header: string | readonly string[] | undefined): string | undefined {
|
||||
if (typeof header === "string") return header;
|
||||
return header?.[0];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly usersById = new Map(seededUsers.map((user) => [user.id, user]));
|
||||
private readonly usersByEmail = new Map(seededUsers.map((user) => [user.email, user]));
|
||||
private readonly sessions = new Map<string, string>();
|
||||
|
||||
login(body: LoginBody): LoginResult {
|
||||
const actor = this.findSeededUser(body);
|
||||
if (!actor) {
|
||||
throw new UnauthorizedException(apiError("AUTH_INVALID_CREDENTIALS", "Unknown local S1 test user"));
|
||||
}
|
||||
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
this.sessions.set(token, actor.id);
|
||||
return { token, actor };
|
||||
}
|
||||
|
||||
logout(authorization: string | readonly string[] | undefined): { readonly ok: true } {
|
||||
const token = this.extractBearerToken(authorization);
|
||||
if (!token || !this.sessions.delete(token)) {
|
||||
throw new UnauthorizedException(apiError("UNAUTHENTICATED", "Session token is missing or invalid"));
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
getActorFromAuthorizationHeader(authorization: string | readonly string[] | undefined): AuthActor | null {
|
||||
const token = this.extractBearerToken(authorization);
|
||||
if (!token) return null;
|
||||
|
||||
const userId = this.sessions.get(token);
|
||||
return userId ? this.usersById.get(userId) ?? null : null;
|
||||
}
|
||||
|
||||
requireActorFromAuthorizationHeader(authorization: string | readonly string[] | undefined): AuthActor {
|
||||
const actor = this.getActorFromAuthorizationHeader(authorization);
|
||||
if (!actor) {
|
||||
throw new UnauthorizedException(apiError("UNAUTHENTICATED", "Session token is missing or invalid"));
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
private findSeededUser(body: LoginBody): AuthActor | undefined {
|
||||
if (typeof body.email === "string") return this.usersByEmail.get(body.email);
|
||||
if (typeof body.userId === "string") return this.usersById.get(body.userId);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private extractBearerToken(authorization: string | readonly string[] | undefined): string | null {
|
||||
const header = normalizeAuthorizationHeader(authorization);
|
||||
if (!header) return null;
|
||||
|
||||
const [scheme, token, extra] = header.trim().split(/\s+/);
|
||||
if (scheme !== "Bearer" || !token || extra) return null;
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<RequestLike>();
|
||||
const actor = this.authService.getActorFromAuthorizationHeader(request.headers?.authorization);
|
||||
if (!actor) {
|
||||
// 受保护路由必须在服务端拒绝匿名访问;S6 之前不把 public feed 行为混入这里。
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Authenticated actor is required"));
|
||||
}
|
||||
|
||||
request.actor = actor;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post("auth/login")
|
||||
login(@Body() body: LoginBody): LoginResult {
|
||||
return this.authService.login(body);
|
||||
}
|
||||
|
||||
@Post("auth/logout")
|
||||
logout(@Headers("authorization") authorization: string | readonly string[] | undefined): { readonly ok: true } {
|
||||
return this.authService.logout(authorization);
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
me(@Headers("authorization") authorization: string | readonly string[] | undefined): AuthActor {
|
||||
return this.authService.requireActorFromAuthorizationHeader(authorization);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthGuard],
|
||||
exports: [AuthService, AuthGuard]
|
||||
})
|
||||
export class AuthModule {}
|
||||
62
apps/api/src/modules/harness-gate/harness-gate.spec.ts
Normal file
62
apps/api/src/modules/harness-gate/harness-gate.spec.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HarnessGateService, parseHarnessGateConfig } from "./index.js";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
|
||||
const fixturePath = (...segments: string[]) => path.join(repoRoot, "harness", "fixtures", ...segments);
|
||||
|
||||
async function readFixture(...segments: string[]): Promise<unknown> {
|
||||
return JSON.parse(await readFile(fixturePath(...segments), "utf8"));
|
||||
}
|
||||
|
||||
describe("HarnessGateService", () => {
|
||||
it("uses the S0 CLI client to accept a real passing contract fixture", async () => {
|
||||
const gate = new HarnessGateService();
|
||||
|
||||
await expect(
|
||||
gate.validateContract("GameIR", await readFixture("mvp", "valid", "simulation-game-ir-valid.json"))
|
||||
).resolves.toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
|
||||
it("preserves S0 reasonCode for a real failing contract fixture", async () => {
|
||||
const gate = new HarnessGateService();
|
||||
|
||||
await expect(
|
||||
gate.validateContract(
|
||||
"ValidationReport",
|
||||
await readFixture("mvp", "invalid", "game-logic-validation-report-web-only-sdk-invalid.json")
|
||||
)
|
||||
).resolves.toEqual({ ok: false, reasonCode: "WEB_ONLY_SDK_USAGE" });
|
||||
});
|
||||
|
||||
it("accepts the real S0 review_approved transition fixture", async () => {
|
||||
const gate = new HarnessGateService();
|
||||
|
||||
await expect(
|
||||
gate.validateTransition("review_approved", await readFixture("lifecycle", "publish-approved-valid.json"))
|
||||
).resolves.toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
|
||||
it("parses HARNESS_CLI_TIMEOUT_MS from the API env boundary with a 5000ms default", () => {
|
||||
expect(parseHarnessGateConfig({})).toEqual({ timeoutMs: 5_000 });
|
||||
expect(parseHarnessGateConfig({ HARNESS_CLI_TIMEOUT_MS: "17" })).toEqual({ timeoutMs: 17 });
|
||||
expect(() => parseHarnessGateConfig({ HARNESS_CLI_TIMEOUT_MS: "0" })).toThrow(/HARNESS_CLI_TIMEOUT_MS/);
|
||||
});
|
||||
|
||||
it("injects non-default env timeout into the harness client", async () => {
|
||||
const gate = new HarnessGateService(parseHarnessGateConfig({ HARNESS_CLI_TIMEOUT_MS: "1" }));
|
||||
|
||||
await expect(
|
||||
gate.validateContract("GameIR", await readFixture("mvp", "valid", "simulation-game-ir-valid.json"))
|
||||
).resolves.toEqual({ ok: false, reasonCode: "HARNESS_TIMEOUT" });
|
||||
});
|
||||
|
||||
it("delegates CLI runtime ownership to the harness-client runtime subpath", async () => {
|
||||
const source = await readFile(path.join(repoRoot, "apps/api/src/modules/harness-gate/index.ts"), "utf8");
|
||||
|
||||
expect(source).toContain('from "@huijing/harness-client/runtime"');
|
||||
expect(source).not.toMatch(/node:child_process|mkdtemp|writeFile|validate-harness\.mjs|JSON\.parse/);
|
||||
});
|
||||
});
|
||||
40
apps/api/src/modules/harness-gate/index.ts
Normal file
40
apps/api/src/modules/harness-gate/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { HarnessClient, HarnessGateResult } from "@huijing/harness-client";
|
||||
import { createHarnessClient } from "@huijing/harness-client/runtime";
|
||||
|
||||
export type { HarnessClient, HarnessGateResult };
|
||||
|
||||
export type HarnessGateConfig = {
|
||||
readonly timeoutMs: number;
|
||||
};
|
||||
|
||||
const defaultHarnessTimeoutMs = 5_000;
|
||||
|
||||
export function parseHarnessGateConfig(env: { readonly HARNESS_CLI_TIMEOUT_MS?: string } = process.env): HarnessGateConfig {
|
||||
const rawTimeout = env.HARNESS_CLI_TIMEOUT_MS;
|
||||
const timeoutMs = rawTimeout === undefined || rawTimeout.trim() === "" ? defaultHarnessTimeoutMs : Number(rawTimeout);
|
||||
|
||||
if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new Error("HARNESS_CLI_TIMEOUT_MS must be a positive integer");
|
||||
}
|
||||
|
||||
return { timeoutMs };
|
||||
}
|
||||
|
||||
export class HarnessGateService implements HarnessClient {
|
||||
private readonly client: HarnessClient;
|
||||
|
||||
constructor(clientOrConfig?: HarnessClient | HarnessGateConfig) {
|
||||
this.client =
|
||||
clientOrConfig && "validateContract" in clientOrConfig
|
||||
? clientOrConfig
|
||||
: createHarnessClient(clientOrConfig ?? parseHarnessGateConfig());
|
||||
}
|
||||
|
||||
validateContract(contract: string, payload: unknown): Promise<HarnessGateResult> {
|
||||
return this.client.validateContract(contract, payload);
|
||||
}
|
||||
|
||||
validateTransition(event: string, payload: unknown): Promise<HarnessGateResult> {
|
||||
return this.client.validateTransition(event, payload);
|
||||
}
|
||||
}
|
||||
982
apps/api/src/modules/jobs/index.ts
Normal file
982
apps/api/src/modules/jobs/index.ts
Normal file
@ -0,0 +1,982 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Inject,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Req,
|
||||
UseGuards,
|
||||
type OnModuleDestroy
|
||||
} from "@nestjs/common";
|
||||
import { Prisma, type Job } from "../../generated/prisma/client.js";
|
||||
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
|
||||
import { AuditService } from "../audit/index.js";
|
||||
import { BullMqQueueAdapter, InMemoryQueueAdapter, QueueUnavailableError } from "../queue/index.js";
|
||||
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "../projects/api-runtime.js";
|
||||
import type { QueueAdapter } from "../queue/index.js";
|
||||
|
||||
export type JobTarget =
|
||||
| {
|
||||
readonly type: "project";
|
||||
readonly id: string;
|
||||
}
|
||||
| {
|
||||
readonly type: "version";
|
||||
readonly id: string;
|
||||
};
|
||||
|
||||
export type EnqueueScopedJobInput = {
|
||||
readonly id: string;
|
||||
readonly actorId: string;
|
||||
readonly projectId: string;
|
||||
readonly type: string;
|
||||
readonly idempotencyKey: string;
|
||||
readonly target: JobTarget;
|
||||
readonly payloadJson: Prisma.InputJsonValue;
|
||||
readonly maxAttempts?: number;
|
||||
readonly timeoutMs?: number;
|
||||
readonly onJobEnqueueFailed?: (db: Prisma.TransactionClient, job: Job, error: unknown) => Promise<void>;
|
||||
};
|
||||
|
||||
export type EnqueueScopedJobResult = {
|
||||
readonly job: Job;
|
||||
readonly created: boolean;
|
||||
readonly enqueued: boolean;
|
||||
};
|
||||
|
||||
export type JobExecutionStoreOptions = {
|
||||
readonly db: Prisma.TransactionClient;
|
||||
readonly now?: () => Date;
|
||||
readonly leaseMs?: number;
|
||||
readonly retryDelayMs?: number;
|
||||
};
|
||||
|
||||
export type JobExecutionErrorCode =
|
||||
| "TARGET_NOT_FOUND"
|
||||
| "TARGET_PROJECT_MISMATCH"
|
||||
| "JOB_NOT_FOUND"
|
||||
| "STALE_JOB_LEASE"
|
||||
| "JOB_CANCEL_NOT_ALLOWED"
|
||||
| "JOB_ENQUEUE_REQUIRES_ROOT_CLIENT";
|
||||
|
||||
export type JobExecutionBoundaryViolation = {
|
||||
readonly path: string;
|
||||
readonly reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE";
|
||||
};
|
||||
|
||||
type RequestWithActor = {
|
||||
readonly actor: AuthActor;
|
||||
};
|
||||
|
||||
export type JobDto = {
|
||||
readonly id: string;
|
||||
readonly actorId: string;
|
||||
readonly projectId: string;
|
||||
readonly type: string;
|
||||
readonly status: string;
|
||||
readonly attempts: number;
|
||||
readonly maxAttempts: number;
|
||||
readonly targetType: string;
|
||||
readonly targetId: string;
|
||||
readonly payloadJson: unknown;
|
||||
readonly errorCode: string | null;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
};
|
||||
|
||||
type ScanFile = {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
};
|
||||
|
||||
const jobExecutionStorePath = "apps/api/src/modules/jobs/index.ts";
|
||||
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next", "generated"]);
|
||||
const jobRuntimeFields = [
|
||||
"status",
|
||||
"attempts",
|
||||
"nextRetryAt",
|
||||
"errorCode",
|
||||
"leaseToken",
|
||||
"leasedBy",
|
||||
"leaseExpiresAt",
|
||||
"lockVersion",
|
||||
"timeoutAt"
|
||||
];
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export const S1_QUEUE_ADAPTER = Symbol("S1_QUEUE_ADAPTER");
|
||||
export const S1_QUEUE_ADAPTER_INNER = Symbol("S1_QUEUE_ADAPTER_INNER");
|
||||
|
||||
export type QueueEnv = {
|
||||
readonly QUEUE_ADAPTER?: string;
|
||||
readonly REDIS_URL?: string;
|
||||
};
|
||||
|
||||
class UnavailableQueueAdapter implements QueueAdapter {
|
||||
constructor(private readonly reason: string) {}
|
||||
|
||||
async enqueue(): Promise<void> {
|
||||
throw new QueueUnavailableError(this.reason);
|
||||
}
|
||||
|
||||
async process(): Promise<void> {
|
||||
throw new QueueUnavailableError(this.reason);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function createQueueAdapterFromEnv(env: QueueEnv = process.env): QueueAdapter {
|
||||
const adapter = env.QUEUE_ADAPTER?.trim();
|
||||
if (!adapter) return new UnavailableQueueAdapter("QUEUE_ADAPTER is required");
|
||||
if (adapter === "memory") return new InMemoryQueueAdapter();
|
||||
if (adapter === "bullmq") {
|
||||
if (!env.REDIS_URL || env.REDIS_URL.trim() === "") {
|
||||
return new UnavailableQueueAdapter("REDIS_URL is required when QUEUE_ADAPTER=bullmq");
|
||||
}
|
||||
return new BullMqQueueAdapter({ queueName: "huijing-s1-jobs", redisUrl: env.REDIS_URL });
|
||||
}
|
||||
return new UnavailableQueueAdapter(`Unsupported QUEUE_ADAPTER: ${adapter}`);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ManagedQueueAdapter implements QueueAdapter, OnModuleDestroy {
|
||||
constructor(private readonly inner: QueueAdapter) {}
|
||||
|
||||
async enqueue(message: Parameters<QueueAdapter["enqueue"]>[0]): Promise<void> {
|
||||
return this.inner.enqueue(message);
|
||||
}
|
||||
|
||||
async process(handler: Parameters<QueueAdapter["process"]>[0]): Promise<void> {
|
||||
return this.inner.process(handler);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.inner.close();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
// Nest app.close() 必须关闭底层队列连接;BullMQ/ioredis 不能依赖进程退出回收。
|
||||
await this.close();
|
||||
}
|
||||
}
|
||||
|
||||
export class JobExecutionError extends Error {
|
||||
readonly code: JobExecutionErrorCode;
|
||||
|
||||
constructor(code: JobExecutionErrorCode, message = code) {
|
||||
super(message);
|
||||
this.name = "JobExecutionError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function targetScopeKey(target: JobTarget): string {
|
||||
return `${target.type}:${target.id}`;
|
||||
}
|
||||
|
||||
function terminalOrIdleLeaseData() {
|
||||
return {
|
||||
leaseToken: null,
|
||||
leasedBy: null,
|
||||
leaseExpiresAt: null
|
||||
};
|
||||
}
|
||||
|
||||
export class JobExecutionStore {
|
||||
private readonly db: Prisma.TransactionClient;
|
||||
private readonly now: () => Date;
|
||||
private readonly leaseMs: number;
|
||||
private readonly retryDelayMs: number;
|
||||
|
||||
constructor(options: JobExecutionStoreOptions) {
|
||||
this.db = options.db;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.leaseMs = options.leaseMs ?? 30_000;
|
||||
this.retryDelayMs = options.retryDelayMs ?? 30_000;
|
||||
}
|
||||
|
||||
async enqueueScopedJob(input: EnqueueScopedJobInput, queue: QueueAdapter): Promise<EnqueueScopedJobResult> {
|
||||
if (!this.isRootClient()) {
|
||||
throw new JobExecutionError("JOB_ENQUEUE_REQUIRES_ROOT_CLIENT");
|
||||
}
|
||||
|
||||
const existing = await this.db.job.findUnique({
|
||||
where: {
|
||||
actorId_projectId_type_targetScopeKey_idempotencyKey: {
|
||||
actorId: input.actorId,
|
||||
projectId: input.projectId,
|
||||
type: input.type,
|
||||
targetScopeKey: targetScopeKey(input.target),
|
||||
idempotencyKey: input.idempotencyKey
|
||||
}
|
||||
}
|
||||
});
|
||||
if (existing) return { job: existing, created: false, enqueued: false };
|
||||
|
||||
const { job, created } = await this.withTransaction(async (tx) => {
|
||||
await this.assertTargetMatchesProject(tx, input);
|
||||
|
||||
const inserted = await tx.job.createManyAndReturn({
|
||||
data: [
|
||||
{
|
||||
id: input.id,
|
||||
actorId: input.actorId,
|
||||
projectId: input.projectId,
|
||||
type: input.type,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
status: "queued",
|
||||
attempts: 0,
|
||||
maxAttempts: input.maxAttempts ?? 3,
|
||||
timeoutAt: input.timeoutMs ? new Date(this.now().getTime() + input.timeoutMs) : null,
|
||||
nextRetryAt: null,
|
||||
errorCode: null,
|
||||
targetType: input.target.type,
|
||||
targetId: input.target.id,
|
||||
targetScopeKey: targetScopeKey(input.target),
|
||||
gameProjectId: input.target.type === "project" ? input.target.id : null,
|
||||
gameVersionId: input.target.type === "version" ? input.target.id : null,
|
||||
payloadJson: input.payloadJson
|
||||
}
|
||||
],
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
const job =
|
||||
inserted[0] ??
|
||||
(await tx.job.findUniqueOrThrow({
|
||||
where: {
|
||||
actorId_projectId_type_targetScopeKey_idempotencyKey: {
|
||||
actorId: input.actorId,
|
||||
projectId: input.projectId,
|
||||
type: input.type,
|
||||
targetScopeKey: targetScopeKey(input.target),
|
||||
idempotencyKey: input.idempotencyKey
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// S1 generic Job enqueue 不暴露 pre-enqueue DB callback;事务只创建 Job,避免 queue 前扩展点制造半成品 target 或错误审计。
|
||||
return { job, created: inserted.length > 0 };
|
||||
});
|
||||
|
||||
if (!created) return { job, created: false, enqueued: false };
|
||||
|
||||
try {
|
||||
await queue.enqueue({ jobId: job.id, type: job.type, payload: job.payloadJson });
|
||||
} catch (error) {
|
||||
await this.markEnqueueFailed(job.id);
|
||||
try {
|
||||
// queue 失败后必须留下可排障事实;若审计自身失败,仍保留原始 QUEUE_UNAVAILABLE 作为 API 失败语义。
|
||||
await input.onJobEnqueueFailed?.(this.db, job, error);
|
||||
} catch {
|
||||
// 原始 queue failure 比审计失败更接近调用方需要处理的故障边界。
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { job, created: true, enqueued: true };
|
||||
}
|
||||
|
||||
async claimNext(workerId: string, now = this.now()): Promise<Job | null> {
|
||||
const candidate = await this.db.job.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ status: "queued" },
|
||||
{
|
||||
status: "pending_retry",
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
|
||||
});
|
||||
if (!candidate) return null;
|
||||
|
||||
const leaseToken = randomUUID();
|
||||
const claimed = await this.db.job.updateManyAndReturn({
|
||||
where: {
|
||||
id: candidate.id,
|
||||
lockVersion: candidate.lockVersion,
|
||||
OR: [
|
||||
{ status: "queued" },
|
||||
{
|
||||
status: "pending_retry",
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
data: {
|
||||
status: "running",
|
||||
leaseToken,
|
||||
leasedBy: workerId,
|
||||
leaseExpiresAt: new Date(now.getTime() + this.leaseMs),
|
||||
nextRetryAt: null,
|
||||
errorCode: null,
|
||||
lockVersion: { increment: 1 }
|
||||
}
|
||||
});
|
||||
|
||||
return claimed[0] ?? null;
|
||||
}
|
||||
|
||||
async markSucceeded(jobId: string, leaseToken: string): Promise<Job> {
|
||||
const updated = await this.db.job.updateManyAndReturn({
|
||||
where: { id: jobId, status: "running", leaseToken },
|
||||
data: {
|
||||
status: "succeeded",
|
||||
...terminalOrIdleLeaseData(),
|
||||
nextRetryAt: null,
|
||||
errorCode: null,
|
||||
lockVersion: { increment: 1 }
|
||||
}
|
||||
});
|
||||
return this.requireLeaseUpdate(updated);
|
||||
}
|
||||
|
||||
async markFailedOrRetry(jobId: string, leaseToken: string, errorCode: string): Promise<Job> {
|
||||
const job = await this.db.job.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new JobExecutionError("JOB_NOT_FOUND");
|
||||
if (job.status !== "running" || job.leaseToken !== leaseToken) throw new JobExecutionError("STALE_JOB_LEASE");
|
||||
|
||||
const attempts = job.attempts + 1;
|
||||
const willRetry = attempts < job.maxAttempts;
|
||||
const updated = await this.db.job.updateManyAndReturn({
|
||||
where: { id: jobId, status: "running", leaseToken, lockVersion: job.lockVersion },
|
||||
data: {
|
||||
status: willRetry ? "pending_retry" : "failed",
|
||||
attempts,
|
||||
errorCode,
|
||||
nextRetryAt: willRetry ? new Date(this.now().getTime() + this.retryDelayMs) : null,
|
||||
...terminalOrIdleLeaseData(),
|
||||
lockVersion: { increment: 1 }
|
||||
}
|
||||
});
|
||||
return this.requireLeaseUpdate(updated);
|
||||
}
|
||||
|
||||
async markTimedOut(jobId: string, now = this.now(), errorCode = "JOB_TIMEOUT"): Promise<Job> {
|
||||
const job = await this.db.job.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new JobExecutionError("JOB_NOT_FOUND");
|
||||
const leaseTimedOut = job.leaseExpiresAt !== null && job.leaseExpiresAt <= now;
|
||||
const jobTimedOut = job.timeoutAt !== null && job.timeoutAt <= now;
|
||||
if (job.status !== "running" || (!leaseTimedOut && !jobTimedOut)) {
|
||||
throw new JobExecutionError("STALE_JOB_LEASE");
|
||||
}
|
||||
|
||||
const attempts = job.attempts + 1;
|
||||
const willRetry = attempts < job.maxAttempts;
|
||||
const updated = await this.db.job.updateManyAndReturn({
|
||||
where: { id: jobId, status: "running", lockVersion: job.lockVersion },
|
||||
data: {
|
||||
status: willRetry ? "pending_retry" : "failed",
|
||||
attempts,
|
||||
errorCode,
|
||||
nextRetryAt: willRetry ? new Date(now.getTime() + this.retryDelayMs) : null,
|
||||
...terminalOrIdleLeaseData(),
|
||||
lockVersion: { increment: 1 }
|
||||
}
|
||||
});
|
||||
return this.requireLeaseUpdate(updated);
|
||||
}
|
||||
|
||||
async cancelQueued(jobId: string, actorId: string): Promise<Job> {
|
||||
const updated = await this.db.job.updateManyAndReturn({
|
||||
where: { id: jobId, actorId, status: "queued" },
|
||||
data: {
|
||||
status: "canceled",
|
||||
...terminalOrIdleLeaseData(),
|
||||
lockVersion: { increment: 1 }
|
||||
}
|
||||
});
|
||||
if (updated[0]) return updated[0];
|
||||
|
||||
const existing = await this.db.job.findUnique({ where: { id: jobId } });
|
||||
if (!existing) throw new JobExecutionError("JOB_NOT_FOUND");
|
||||
throw new JobExecutionError("JOB_CANCEL_NOT_ALLOWED");
|
||||
}
|
||||
|
||||
private async markEnqueueFailed(jobId: string): Promise<void> {
|
||||
await this.db.job.updateMany({
|
||||
where: { id: jobId, status: "queued" },
|
||||
data: {
|
||||
status: "pending_retry",
|
||||
errorCode: "QUEUE_ENQUEUE_FAILED",
|
||||
nextRetryAt: new Date(this.now().getTime() + this.retryDelayMs),
|
||||
lockVersion: { increment: 1 }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async assertTargetMatchesProject(tx: Prisma.TransactionClient, input: EnqueueScopedJobInput): Promise<void> {
|
||||
if (input.target.type === "project") {
|
||||
const project = await tx.gameProject.findUnique({ where: { id: input.target.id } });
|
||||
if (!project) throw new JobExecutionError("TARGET_NOT_FOUND");
|
||||
if (project.id !== input.projectId) throw new JobExecutionError("TARGET_PROJECT_MISMATCH");
|
||||
return;
|
||||
}
|
||||
|
||||
const version = await tx.gameVersion.findUnique({ where: { id: input.target.id } });
|
||||
if (!version) throw new JobExecutionError("TARGET_NOT_FOUND");
|
||||
if (version.projectId !== input.projectId) throw new JobExecutionError("TARGET_PROJECT_MISMATCH");
|
||||
}
|
||||
|
||||
private requireLeaseUpdate(updated: Job[]): Job {
|
||||
if (updated[0]) return updated[0];
|
||||
throw new JobExecutionError("STALE_JOB_LEASE");
|
||||
}
|
||||
|
||||
private async withTransaction<T>(callback: (tx: Prisma.TransactionClient) => Promise<T>): Promise<T> {
|
||||
const maybeRootClient = this.db as Prisma.TransactionClient & {
|
||||
$transaction?: <Result>(fn: (tx: Prisma.TransactionClient) => Promise<Result>) => Promise<Result>;
|
||||
};
|
||||
if (typeof maybeRootClient.$transaction === "function") return maybeRootClient.$transaction(callback);
|
||||
return callback(this.db);
|
||||
}
|
||||
|
||||
private isRootClient(): boolean {
|
||||
const candidate = this.db as { $transaction?: unknown; $connect?: unknown; $disconnect?: unknown };
|
||||
return (
|
||||
typeof candidate.$transaction === "function" &&
|
||||
typeof candidate.$connect === "function" &&
|
||||
typeof candidate.$disconnect === "function"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function jobDto(job: Job): JobDto {
|
||||
return {
|
||||
id: job.id,
|
||||
actorId: job.actorId,
|
||||
projectId: job.projectId,
|
||||
type: job.type,
|
||||
status: job.status,
|
||||
attempts: job.attempts,
|
||||
maxAttempts: job.maxAttempts,
|
||||
targetType: job.targetType,
|
||||
targetId: job.targetId,
|
||||
payloadJson: job.payloadJson,
|
||||
errorCode: job.errorCode,
|
||||
createdAt: job.createdAt.toISOString(),
|
||||
updatedAt: job.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JobApiService {
|
||||
constructor(
|
||||
private readonly db: S1PrismaClient,
|
||||
@Inject(S1_QUEUE_ADAPTER) private readonly queue: QueueAdapter
|
||||
) {}
|
||||
|
||||
async getJob(actor: AuthActor, jobId: string): Promise<JobDto> {
|
||||
const job = await this.db.job.findUnique({ where: { id: jobId } });
|
||||
if (!job) throw new NotFoundException(apiError("NOT_FOUND", "Job not found", { jobId }));
|
||||
if (job.actorId !== actor.id) {
|
||||
// Job 是 creator 发起的直接对象;S1 不给 operator/admin 暴露未列明的 job 管理读权限。
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Actor cannot access this job", { jobId }));
|
||||
}
|
||||
return jobDto(job);
|
||||
}
|
||||
|
||||
async enqueueScopedJobForActor(input: EnqueueScopedJobInput): Promise<Job> {
|
||||
const store = new JobExecutionStore({ db: this.db });
|
||||
const result = await store.enqueueScopedJob(
|
||||
{
|
||||
...input,
|
||||
onJobEnqueueFailed: async (db, job) => {
|
||||
await new AuditService({ db }).append({
|
||||
id: randomUUID(),
|
||||
actorId: input.actorId,
|
||||
action: "job.enqueue_failed",
|
||||
targetType: "Job",
|
||||
targetId: job.id,
|
||||
eventJson: {
|
||||
projectId: input.projectId,
|
||||
type: input.type,
|
||||
targetType: input.target.type,
|
||||
targetId: input.target.id,
|
||||
errorCode: "QUEUE_ENQUEUE_FAILED"
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
this.queue
|
||||
);
|
||||
|
||||
if (result.created && result.enqueued) {
|
||||
// job.enqueued 只在 queue side effect 成功后追加;审计失败会向调用方抛出,避免静默成功但缺审计。
|
||||
await new AuditService({ db: this.db }).append({
|
||||
id: randomUUID(),
|
||||
actorId: input.actorId,
|
||||
action: "job.enqueued",
|
||||
targetType: "Job",
|
||||
targetId: result.job.id,
|
||||
eventJson: {
|
||||
projectId: input.projectId,
|
||||
type: input.type,
|
||||
targetType: input.target.type,
|
||||
targetId: input.target.id,
|
||||
idempotencyKey: input.idempotencyKey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result.job;
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseGuards(AuthGuard)
|
||||
export class JobsController {
|
||||
constructor(private readonly service: JobApiService) {}
|
||||
|
||||
@Get("jobs/:jobId")
|
||||
getJob(@Req() request: RequestWithActor, @Param("jobId") jobId: string): Promise<JobDto> {
|
||||
return this.service.getJob(request.actor, jobId);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [S1ApiRuntimeModule, AuthModule],
|
||||
controllers: [JobsController],
|
||||
providers: [
|
||||
JobApiService,
|
||||
{
|
||||
provide: S1_QUEUE_ADAPTER_INNER,
|
||||
useFactory: () => createQueueAdapterFromEnv()
|
||||
},
|
||||
{
|
||||
provide: S1_QUEUE_ADAPTER,
|
||||
useFactory: (queue: QueueAdapter) => new ManagedQueueAdapter(queue),
|
||||
inject: [S1_QUEUE_ADAPTER_INNER]
|
||||
}
|
||||
],
|
||||
exports: [JobApiService, S1_QUEUE_ADAPTER, S1_QUEUE_ADAPTER_INNER]
|
||||
})
|
||||
export class JobsApiModule {}
|
||||
|
||||
function skipWhitespaceForward(content: string, index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor < content.length && /\s/.test(content.charAt(cursor))) cursor += 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function skipWhitespaceBackward(content: string, index: number): number {
|
||||
let cursor = index;
|
||||
while (cursor >= 0 && /\s/.test(content.charAt(cursor))) cursor -= 1;
|
||||
return cursor;
|
||||
}
|
||||
|
||||
function isStaticQuotedPropertyKey(content: string, literalStart: number, literalEnd: number): boolean {
|
||||
const afterLiteral = skipWhitespaceForward(content, literalEnd);
|
||||
if (content[afterLiteral] === ":") return true;
|
||||
|
||||
const afterComputedKey = skipWhitespaceForward(content, afterLiteral + 1);
|
||||
if (content[afterLiteral] !== "]" || content[afterComputedKey] !== ":") return false;
|
||||
|
||||
const beforeLiteral = skipWhitespaceBackward(content, literalStart - 1);
|
||||
return content[beforeLiteral] === "[";
|
||||
}
|
||||
|
||||
function stripStringLiteralsExceptPropertyKeys(content: string): string {
|
||||
let output = "";
|
||||
|
||||
for (let index = 0; index < content.length; index += 1) {
|
||||
const quote = content[index];
|
||||
if (quote === "/" && isRegexLiteralStart(content, index)) {
|
||||
const literalEnd = findRegexLiteralEnd(content, index);
|
||||
if (literalEnd !== -1) {
|
||||
output += "/".padEnd(literalEnd - index, " ") + "/";
|
||||
index = literalEnd;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
||||
output += quote;
|
||||
continue;
|
||||
}
|
||||
|
||||
const literalStart = index;
|
||||
let literalEnd = literalStart + 1;
|
||||
while (literalEnd < content.length) {
|
||||
const char = content[literalEnd];
|
||||
if (char === "\\") {
|
||||
literalEnd += 2;
|
||||
continue;
|
||||
}
|
||||
literalEnd += 1;
|
||||
if (char === quote) break;
|
||||
}
|
||||
|
||||
const literal = content.slice(literalStart, literalEnd);
|
||||
// 只保留对象静态 key,普通字符串值和测试 fixture 字符串仍剥离,避免边界扫描误报。
|
||||
output += isStaticQuotedPropertyKey(content, literalStart, literalEnd) ? literal : `${quote}${quote}`;
|
||||
index = literalEnd - 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function maskCommentsOutsideStringLiterals(content: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < content.length) {
|
||||
const char = content[index];
|
||||
const next = content[index + 1];
|
||||
|
||||
if (char === "/" && next === "/") {
|
||||
output += " ";
|
||||
index += 2;
|
||||
while (index < content.length && content[index] !== "\n" && content[index] !== "\r") {
|
||||
output += " ";
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && next === "*") {
|
||||
output += " ";
|
||||
index += 2;
|
||||
while (index < content.length) {
|
||||
const blockChar = content[index];
|
||||
const blockNext = content[index + 1];
|
||||
if (blockChar === "*" && blockNext === "/") {
|
||||
output += " ";
|
||||
index += 2;
|
||||
break;
|
||||
}
|
||||
output += blockChar === "\n" || blockChar === "\r" ? blockChar : " ";
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "/" && isRegexLiteralStart(content, index)) {
|
||||
const literalEnd = findRegexLiteralEnd(content, index);
|
||||
if (literalEnd !== -1) {
|
||||
output += content.slice(index, literalEnd + 1);
|
||||
index = literalEnd + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (char !== '"' && char !== "'" && char !== "`") {
|
||||
output += char;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += char;
|
||||
index += 1;
|
||||
while (index < content.length) {
|
||||
const stringChar = content[index];
|
||||
output += stringChar;
|
||||
index += 1;
|
||||
if (stringChar === "\\") {
|
||||
if (index < content.length) {
|
||||
output += content[index];
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (stringChar === char) break;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function isRegexLiteralStart(content: string, slashIndex: number): boolean {
|
||||
const cursor = skipWhitespaceBackward(content, slashIndex - 1);
|
||||
if (cursor < 0) return true;
|
||||
|
||||
const previous = content.charAt(cursor);
|
||||
if ("([{:;,=!?&|+-*%^~<>".includes(previous)) return true;
|
||||
return /\b(?:return|throw|case|delete|typeof|void|new|in|of|yield|await)\b/.test(content.slice(Math.max(0, cursor - 12), cursor + 1));
|
||||
}
|
||||
|
||||
function findRegexLiteralEnd(content: string, slashIndex: number): number {
|
||||
let inCharClass = false;
|
||||
for (let cursor = slashIndex + 1; cursor < content.length; cursor += 1) {
|
||||
const char = content[cursor];
|
||||
if (char === "\\") {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") inCharClass = true;
|
||||
if (char === "]") inCharClass = false;
|
||||
if (char === "/" && !inCharClass) {
|
||||
let end = cursor + 1;
|
||||
while (end < content.length && /[a-z]/i.test(content.charAt(end))) end += 1;
|
||||
return end - 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findMatchingParen(content: string, openParenIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openParenIndex; index < content.length; index += 1) {
|
||||
const char = content[index];
|
||||
if (char === "(") depth += 1;
|
||||
if (char === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findMatchingBrace(content: string, openBraceIndex: number): number {
|
||||
let depth = 0;
|
||||
for (let index = openBraceIndex; index < content.length; index += 1) {
|
||||
const char = content[index];
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function maskStringLiterals(content: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
|
||||
while (index < content.length) {
|
||||
const quote = content[index];
|
||||
if (quote === "/" && isRegexLiteralStart(content, index)) {
|
||||
const literalEnd = findRegexLiteralEnd(content, index);
|
||||
if (literalEnd !== -1) {
|
||||
output += "/".padEnd(literalEnd - index, " ") + "/";
|
||||
index = literalEnd + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (quote !== '"' && quote !== "'" && quote !== "`") {
|
||||
output += quote;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += quote;
|
||||
index += 1;
|
||||
while (index < content.length) {
|
||||
const char = content[index];
|
||||
if (char === "\\") {
|
||||
output += " ";
|
||||
index += 1;
|
||||
if (index < content.length) {
|
||||
output += " ";
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
output += quote;
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
output += " ";
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function findStringLiteralEnd(content: string, literalStart: number): number {
|
||||
const quote = content[literalStart];
|
||||
let cursor = literalStart + 1;
|
||||
|
||||
while (cursor < content.length) {
|
||||
const char = content[cursor];
|
||||
if (char === "\\") {
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
if (char === quote) return cursor;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function stripSqlComments(content: string): string {
|
||||
return content
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/--.*$/, ""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function hasVariableizedOrSpreadJobData(callBody: string): boolean {
|
||||
return (
|
||||
/\bdata\s*(?:[,}])/.test(callBody) ||
|
||||
/\bdata\s*:\s*[A-Za-z_$][\w$]*\b/.test(callBody) ||
|
||||
/\bdata\s*:\s*(?:\{|\[)[\s\S]*\.\.\./.test(callBody)
|
||||
);
|
||||
}
|
||||
|
||||
function hasComputedJobDataKey(callBody: string): boolean {
|
||||
const dataObjectPattern = /\bdata\s*:\s*\{/g;
|
||||
for (const match of callBody.matchAll(dataObjectPattern)) {
|
||||
const openBraceIndex = callBody.indexOf("{", match.index);
|
||||
if (openBraceIndex === -1) continue;
|
||||
const closeBraceIndex = findMatchingBrace(callBody, openBraceIndex);
|
||||
if (closeBraceIndex === -1) continue;
|
||||
const dataObjectBody = callBody.slice(openBraceIndex, closeBraceIndex + 1);
|
||||
// Job 运行态字段只能通过 JobExecutionStore 写入;computed key 无法静态证明安全,统一拦截。
|
||||
if (/\[[^\]]+\]\s*:/.test(dataObjectBody)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasForbiddenJobWriteCallBody(callBody: string): boolean {
|
||||
const runtimeFieldPattern = new RegExp(`\\b(?:${jobRuntimeFields.map(escapeRegExp).join("|")})\\b`);
|
||||
return runtimeFieldPattern.test(callBody) || hasVariableizedOrSpreadJobData(callBody) || hasComputedJobDataKey(callBody);
|
||||
}
|
||||
|
||||
function hasRawJobRuntimeFieldWrite(sqlSource: string): boolean {
|
||||
const sql = stripSqlComments(sqlSource)
|
||||
.replace(/\\(["'`])/g, "$1")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\t/g, "\t");
|
||||
const quotedJob = String.raw`["'\`]?Job["'\`]?`;
|
||||
const runtimeField = String.raw`["'\`]?(?:${jobRuntimeFields.map(escapeRegExp).join("|")})["'\`]?`;
|
||||
return (
|
||||
new RegExp(String.raw`\bUPDATE\s+${quotedJob}\s+SET\b[\s\S]*?${runtimeField}\s*=`, "i").test(sql) ||
|
||||
new RegExp(String.raw`\bINSERT\s+INTO\s+${quotedJob}\s*\([^)]*${runtimeField}[\s\S]*?\)\s*VALUES\b`, "i").test(sql)
|
||||
);
|
||||
}
|
||||
|
||||
function hasRawJobStatusWrite(content: string): boolean {
|
||||
// raw SQL 检测仍从原始 content 切片读取 SQL 字符串;structure 只负责可靠找到调用边界。
|
||||
const structure = maskStringLiterals(maskCommentsOutsideStringLiterals(content));
|
||||
const rawCallPattern = /\.\$(?:executeRawUnsafe|executeRaw|queryRawUnsafe|queryRaw)\s*(?:<[^>()`]*>)?\s*/g;
|
||||
|
||||
for (const match of structure.matchAll(rawCallPattern)) {
|
||||
const callStart = skipWhitespaceForward(structure, match.index + match[0].length);
|
||||
if (structure[callStart] === "(") {
|
||||
const closeParenIndex = findMatchingParen(structure, callStart);
|
||||
if (closeParenIndex !== -1 && hasRawJobRuntimeFieldWrite(content.slice(callStart, closeParenIndex + 1))) return true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (structure[callStart] === "`") {
|
||||
const templateEnd = findStringLiteralEnd(content, callStart);
|
||||
if (templateEnd !== -1 && hasRawJobRuntimeFieldWrite(content.slice(callStart, templateEnd + 1))) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function jobExecutionStoreClassSpans(content: string): Array<{ readonly start: number; readonly end: number }> {
|
||||
const spans: Array<{ readonly start: number; readonly end: number }> = [];
|
||||
const structure = maskStringLiterals(maskCommentsOutsideStringLiterals(content));
|
||||
const classPattern = /\b(?:export\s+)?class\s+JobExecutionStore\b/g;
|
||||
|
||||
for (const match of structure.matchAll(classPattern)) {
|
||||
if (typeof match.index !== "number") continue;
|
||||
const openBraceIndex = structure.indexOf("{", match.index + match[0].length);
|
||||
if (openBraceIndex === -1) continue;
|
||||
const closeBraceIndex = findMatchingBrace(structure, openBraceIndex);
|
||||
if (closeBraceIndex === -1) continue;
|
||||
spans.push({ start: match.index, end: closeBraceIndex + 1 });
|
||||
}
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
function maskSpans(content: string, spans: ReadonlyArray<{ readonly start: number; readonly end: number }>): string {
|
||||
if (spans.length === 0) return content;
|
||||
let output = "";
|
||||
let cursor = 0;
|
||||
|
||||
for (const span of [...spans].sort((left, right) => left.start - right.start)) {
|
||||
if (span.start < cursor) continue;
|
||||
output += content.slice(cursor, span.start);
|
||||
output += " ".repeat(span.end - span.start);
|
||||
cursor = span.end;
|
||||
}
|
||||
|
||||
return output + content.slice(cursor);
|
||||
}
|
||||
|
||||
function maskJobExecutionStoreClassBodies(content: string): string {
|
||||
// 本地静态边界只豁免 JobExecutionStore 类体;同文件其他 class 仍按外部写入处理。
|
||||
return maskSpans(content, jobExecutionStoreClassSpans(content));
|
||||
}
|
||||
|
||||
function hasJobStatusWrite(content: string): boolean {
|
||||
// 逐个 Prisma Job 写调用检查调用体,避免测试中的字符串 fixture 或后续断言文本造成跨调用误报。
|
||||
const scanContent = stripStringLiteralsExceptPropertyKeys(maskCommentsOutsideStringLiterals(content));
|
||||
const callPattern = /\.job\.(?:create|createMany|createManyAndReturn|update|updateMany|updateManyAndReturn|upsert)\s*\(/g;
|
||||
for (const match of scanContent.matchAll(callPattern)) {
|
||||
const openParenIndex = scanContent.indexOf("(", match.index);
|
||||
if (openParenIndex === -1) continue;
|
||||
const closeParenIndex = findMatchingParen(scanContent, openParenIndex);
|
||||
if (closeParenIndex === -1) continue;
|
||||
const callBody = scanContent.slice(openParenIndex, closeParenIndex + 1);
|
||||
if (hasForbiddenJobWriteCallBody(callBody)) return true;
|
||||
}
|
||||
return hasRawJobStatusWrite(content);
|
||||
}
|
||||
|
||||
function isIgnoredScanPath(filePath: string): boolean {
|
||||
return filePath.split("/").some((segment) => ignoredScanSegments.has(segment));
|
||||
}
|
||||
|
||||
export function findJobExecutionBoundaryViolations(files: readonly ScanFile[]): JobExecutionBoundaryViolation[] {
|
||||
const violations: JobExecutionBoundaryViolation[] = [];
|
||||
for (const file of files) {
|
||||
const normalizedPath = file.path.split(path.sep).join("/");
|
||||
if (isIgnoredScanPath(normalizedPath)) continue;
|
||||
const scanContent = normalizedPath === jobExecutionStorePath ? maskJobExecutionStoreClassBodies(file.content) : file.content;
|
||||
if (hasJobStatusWrite(scanContent)) {
|
||||
violations.push({ path: normalizedPath, reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE" });
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
async function collectFiles(root: string, relativeDir: string): Promise<ScanFile[]> {
|
||||
const absoluteDir = path.join(root, relativeDir);
|
||||
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: ScanFile[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && ignoredScanSegments.has(entry.name)) continue;
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
if (entry.isDirectory()) files.push(...(await collectFiles(root, relativePath)));
|
||||
if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs)$/.test(entry.name)) {
|
||||
files.push({ path: relativePath, content: await readFile(absolutePath, "utf8") });
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export async function scanJobExecutionBoundary(repoRoot: string): Promise<JobExecutionBoundaryViolation[]> {
|
||||
return findJobExecutionBoundaryViolations([
|
||||
...(await collectFiles(repoRoot, "apps/api/src")),
|
||||
...(await collectFiles(repoRoot, "apps/worker/src"))
|
||||
]);
|
||||
}
|
||||
449
apps/api/src/modules/jobs/jobs.api.spec.ts
Normal file
449
apps/api/src/modules/jobs/jobs.api.spec.ts
Normal file
@ -0,0 +1,449 @@
|
||||
import { type INestApplication } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import pg from "pg";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppModule } from "../../app.module.js";
|
||||
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
|
||||
import { BullMqQueueAdapter, InMemoryQueueAdapter, QueueUnavailableError, type QueueAdapter, type QueueMessage } from "../queue/index.js";
|
||||
import { StateTransitionService } from "../state-transition/index.js";
|
||||
import {
|
||||
JobApiService,
|
||||
JobExecutionStore,
|
||||
ManagedQueueAdapter,
|
||||
S1_QUEUE_ADAPTER,
|
||||
S1_QUEUE_ADAPTER_INNER,
|
||||
createQueueAdapterFromEnv
|
||||
} from "./index.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task8-jobs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const jobTestIsolationLockKey = 810_008;
|
||||
let lockClient: pg.Client | undefined;
|
||||
|
||||
type DbClient = Prisma.TransactionClient;
|
||||
|
||||
class RecordingQueue implements QueueAdapter {
|
||||
readonly messages: QueueMessage[] = [];
|
||||
|
||||
async enqueue(message: QueueMessage): Promise<void> {
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async process(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class FailingQueue implements QueueAdapter {
|
||||
async enqueue(): Promise<void> {
|
||||
throw new QueueUnavailableError("test queue unavailable");
|
||||
}
|
||||
|
||||
async process(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class CloseCountingQueue implements QueueAdapter {
|
||||
closeCalls = 0;
|
||||
|
||||
async enqueue(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async process(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class CompletingQueue extends RecordingQueue {
|
||||
async enqueue(message: QueueMessage): Promise<void> {
|
||||
// 测试队列模拟 worker 完成时也必须走 JobExecutionStore,避免 spec 夹具绕过 Job 状态单写入口。
|
||||
const store = new JobExecutionStore({ db: prisma });
|
||||
await expectOnlyEligibleJob(message.jobId);
|
||||
const claimed = await store.claimNext(`completing-queue-${runId}`);
|
||||
expect(claimed).toMatchObject({ id: message.jobId, leaseToken: expect.any(String) });
|
||||
await store.markSucceeded(message.jobId, claimed?.leaseToken ?? "missing");
|
||||
await super.enqueue(message);
|
||||
}
|
||||
}
|
||||
|
||||
type TestApp = {
|
||||
readonly app: INestApplication;
|
||||
readonly baseUrl: string;
|
||||
readonly jobApiService: JobApiService;
|
||||
readonly queue: RecordingQueue;
|
||||
};
|
||||
|
||||
async function createTestApp(queue: RecordingQueue = new RecordingQueue()): Promise<TestApp> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(S1_QUEUE_ADAPTER)
|
||||
.useValue(queue)
|
||||
.compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const server = await app.listen(0);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
|
||||
return {
|
||||
app,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
jobApiService: moduleRef.get(JobApiService),
|
||||
queue
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(testApp: TestApp, requestPath: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${testApp.baseUrl}${requestPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function login(testApp: TestApp, email: string): Promise<string> {
|
||||
const response = await requestJson(testApp, "/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
expect(response.response.status).toBe(201);
|
||||
return (response.body as { token: string }).token;
|
||||
}
|
||||
|
||||
async function ensureSeedUsers(db: DbClient): Promise<void> {
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-creator" },
|
||||
update: {},
|
||||
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
|
||||
});
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-creator-other" },
|
||||
update: {},
|
||||
create: { id: "seed-creator-other", email: "creator-other@example.test", displayName: "Seed Other Creator" }
|
||||
});
|
||||
}
|
||||
|
||||
async function seedProject(db: DbClient, suffix: string, ownerId: string) {
|
||||
return db.gameProject.create({
|
||||
data: { id: `${runId}-${suffix}-project`, ownerId, slug: `${runId}-${suffix}`, title: `${suffix} project` }
|
||||
});
|
||||
}
|
||||
|
||||
async function seedDraftVersion(db: DbClient, projectId: string, suffix: string) {
|
||||
const service = new StateTransitionService({
|
||||
db,
|
||||
blockedAuditDb: db,
|
||||
harnessGate: {
|
||||
validateContract: async () => ({ ok: true, reasonCode: null }),
|
||||
validateTransition: async () => ({ ok: true, reasonCode: null })
|
||||
}
|
||||
});
|
||||
return service.createDraftVersion({
|
||||
id: `${runId}-${suffix}-version`,
|
||||
projectId,
|
||||
versionNumber: 1,
|
||||
configJson: { suffix }
|
||||
});
|
||||
}
|
||||
|
||||
async function seedCompletedProjectJob(db: PrismaClient, suffix: string, actorId: string, projectId: string) {
|
||||
const store = new JobExecutionStore({ db });
|
||||
const result = await store.enqueueScopedJob(
|
||||
{
|
||||
id: `${runId}-${suffix}`,
|
||||
actorId,
|
||||
projectId,
|
||||
type: "noop",
|
||||
idempotencyKey: suffix,
|
||||
target: { type: "project", id: projectId },
|
||||
payloadJson: { ok: true }
|
||||
},
|
||||
new RecordingQueue()
|
||||
);
|
||||
await expectOnlyEligibleJob(result.job.id);
|
||||
const claimed = await store.claimNext(`completed-fixture-${suffix}`);
|
||||
expect(claimed).toMatchObject({ id: result.job.id, leaseToken: expect.any(String) });
|
||||
return store.markSucceeded(result.job.id, claimed?.leaseToken ?? "missing");
|
||||
}
|
||||
|
||||
async function expectOnlyEligibleJob(jobId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const externalJobs = await prisma.job.findMany({
|
||||
where: {
|
||||
id: { not: jobId },
|
||||
OR: [
|
||||
{ status: "queued" },
|
||||
{
|
||||
status: "pending_retry",
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: now } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
take: 5,
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
|
||||
});
|
||||
|
||||
// claimNext 是全局领取;测试只能在确认没有外部 eligible job 时模拟 worker 完成。
|
||||
expect(externalJobs, "external eligible jobs would make this API fixture mutate data outside the current runId").toEqual([]);
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
await prisma.job.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
}
|
||||
|
||||
describe("Job HTTP APIs", () => {
|
||||
let testApp: TestApp | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireJobTestIsolationLock();
|
||||
await cleanup();
|
||||
await ensureSeedUsers(prisma);
|
||||
testApp = await createTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testApp?.app.close();
|
||||
testApp = undefined;
|
||||
await cleanup();
|
||||
await releaseJobTestIsolationLock();
|
||||
});
|
||||
|
||||
it("GET /jobs/:jobId returns own job state", async () => {
|
||||
const project = await seedProject(prisma, "own-job", "seed-creator");
|
||||
const job = await seedCompletedProjectJob(prisma, "own-job", "seed-creator", project.id);
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const read = await requestJson(testApp!, `/jobs/${job.id}`, {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
expect(read.response.status).toBe(200);
|
||||
expect(read.body).toMatchObject({
|
||||
id: job.id,
|
||||
actorId: "seed-creator",
|
||||
projectId: project.id,
|
||||
status: "succeeded",
|
||||
targetType: "project",
|
||||
targetId: project.id
|
||||
});
|
||||
});
|
||||
|
||||
it("GET /jobs/:jobId rejects foreign job access", async () => {
|
||||
const project = await seedProject(prisma, "foreign-job", "seed-creator-other");
|
||||
const job = await seedCompletedProjectJob(prisma, "foreign-job", "seed-creator-other", project.id);
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const denied = await requestJson(testApp!, `/jobs/${job.id}`, {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toEqual({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects target project/version mismatch from the service layer before enqueue", async () => {
|
||||
const project = await seedProject(prisma, "mismatch-project", "seed-creator");
|
||||
const otherProject = await seedProject(prisma, "mismatch-other-project", "seed-creator");
|
||||
const otherVersion = await prisma.$transaction((tx) => seedDraftVersion(tx, otherProject.id, "mismatch-other"));
|
||||
|
||||
await expect(
|
||||
testApp!.jobApiService.enqueueScopedJobForActor({
|
||||
id: `${runId}-mismatch-job`,
|
||||
actorId: "seed-creator",
|
||||
projectId: project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "mismatch",
|
||||
target: { type: "version", id: otherVersion.id },
|
||||
payloadJson: { mismatch: true }
|
||||
})
|
||||
).rejects.toMatchObject({ code: "TARGET_PROJECT_MISMATCH" });
|
||||
|
||||
expect(testApp!.queue.messages).toHaveLength(0);
|
||||
await expect(prisma.job.findUnique({ where: { id: `${runId}-mismatch-job` } })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("writes job.enqueue_failed audit and keeps stable errorCode when queue enqueue fails", async () => {
|
||||
await testApp?.app.close();
|
||||
const failingQueue = new FailingQueue();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
})
|
||||
.overrideProvider(S1_QUEUE_ADAPTER)
|
||||
.useValue(failingQueue)
|
||||
.compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const server = await app.listen(0);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
|
||||
testApp = {
|
||||
app,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
jobApiService: moduleRef.get(JobApiService),
|
||||
queue: new RecordingQueue()
|
||||
};
|
||||
const project = await seedProject(prisma, "queue-failure", "seed-creator");
|
||||
const jobId = `${runId}-queue-failure-job`;
|
||||
|
||||
await expect(
|
||||
testApp.jobApiService.enqueueScopedJobForActor({
|
||||
id: jobId,
|
||||
actorId: "seed-creator",
|
||||
projectId: project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "queue-failure",
|
||||
target: { type: "project", id: project.id },
|
||||
payloadJson: { ok: true }
|
||||
})
|
||||
).rejects.toMatchObject({ code: "QUEUE_UNAVAILABLE" });
|
||||
|
||||
await expect(prisma.job.findUnique({ where: { id: jobId } })).resolves.toMatchObject({
|
||||
status: "pending_retry",
|
||||
errorCode: "QUEUE_ENQUEUE_FAILED"
|
||||
});
|
||||
await expect(prisma.auditLog.findFirst({ where: { targetId: jobId, action: "job.enqueue_failed" } })).resolves.toMatchObject({
|
||||
actorId: "seed-creator",
|
||||
targetType: "Job",
|
||||
eventJson: expect.objectContaining({ errorCode: "QUEUE_ENQUEUE_FAILED" })
|
||||
});
|
||||
await expect(prisma.auditLog.count({ where: { targetId: jobId, action: "job.enqueued" } })).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("writes job.enqueued audit only once for the idempotent winner after enqueue succeeds", async () => {
|
||||
await testApp?.app.close();
|
||||
testApp = await createTestApp(new CompletingQueue());
|
||||
const project = await seedProject(prisma, "duplicate-success", "seed-creator");
|
||||
const firstJobId = `${runId}-duplicate-success-a-job`;
|
||||
const secondJobId = `${runId}-duplicate-success-b-job`;
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
testApp!.jobApiService.enqueueScopedJobForActor({
|
||||
id: firstJobId,
|
||||
actorId: "seed-creator",
|
||||
projectId: project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "duplicate-success",
|
||||
target: { type: "project", id: project.id },
|
||||
payloadJson: { attempt: "a" }
|
||||
}),
|
||||
testApp!.jobApiService.enqueueScopedJobForActor({
|
||||
id: secondJobId,
|
||||
actorId: "seed-creator",
|
||||
projectId: project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "duplicate-success",
|
||||
target: { type: "project", id: project.id },
|
||||
payloadJson: { attempt: "b" }
|
||||
})
|
||||
]);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(testApp!.queue.messages).toHaveLength(1);
|
||||
await expect(prisma.auditLog.count({ where: { targetId: first.id, action: "job.enqueued" } })).resolves.toBe(1);
|
||||
await expect(prisma.auditLog.count({ where: { targetId: first.id, action: "job.enqueue_failed" } })).resolves.toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function acquireJobTestIsolationLock(): Promise<void> {
|
||||
// 仅测试隔离:本 spec 的 queue failure 会短暂产生 eligible pending_retry,需与 store claimNext 用例错开。
|
||||
lockClient = new pg.Client({ connectionString: databaseUrl });
|
||||
await lockClient.connect();
|
||||
await lockClient.query("SELECT pg_advisory_lock($1)", [jobTestIsolationLockKey]);
|
||||
}
|
||||
|
||||
async function releaseJobTestIsolationLock(): Promise<void> {
|
||||
if (!lockClient) return;
|
||||
try {
|
||||
await lockClient.query("SELECT pg_advisory_unlock($1)", [jobTestIsolationLockKey]);
|
||||
} finally {
|
||||
await lockClient.end();
|
||||
lockClient = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe("Task 8 queue provider factory", () => {
|
||||
it("uses memory adapter only when QUEUE_ADAPTER=memory", async () => {
|
||||
expect(createQueueAdapterFromEnv({ QUEUE_ADAPTER: "memory" })).toBeInstanceOf(InMemoryQueueAdapter);
|
||||
});
|
||||
|
||||
it("fails closed when QUEUE_ADAPTER is missing instead of falling back to memory", async () => {
|
||||
const adapter = createQueueAdapterFromEnv({});
|
||||
|
||||
expect(adapter).not.toBeInstanceOf(InMemoryQueueAdapter);
|
||||
await expect(adapter.enqueue({ jobId: "missing-adapter", type: "noop", payload: {} })).rejects.toMatchObject({
|
||||
code: "QUEUE_UNAVAILABLE"
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when QUEUE_ADAPTER is unsupported instead of falling back to memory", async () => {
|
||||
const adapter = createQueueAdapterFromEnv({ QUEUE_ADAPTER: "sidekiq" });
|
||||
|
||||
expect(adapter).not.toBeInstanceOf(InMemoryQueueAdapter);
|
||||
await expect(adapter.enqueue({ jobId: "unsupported-adapter", type: "noop", payload: {} })).rejects.toMatchObject({
|
||||
code: "QUEUE_UNAVAILABLE"
|
||||
});
|
||||
});
|
||||
|
||||
it("uses BullMQ when QUEUE_ADAPTER=bullmq and REDIS_URL is present", async () => {
|
||||
expect(createQueueAdapterFromEnv({ QUEUE_ADAPTER: "bullmq", REDIS_URL: "redis://localhost:6379" })).toBeInstanceOf(
|
||||
BullMqQueueAdapter
|
||||
);
|
||||
});
|
||||
|
||||
it("does not silently fallback to memory when QUEUE_ADAPTER=bullmq lacks REDIS_URL", async () => {
|
||||
const adapter = createQueueAdapterFromEnv({ QUEUE_ADAPTER: "bullmq" });
|
||||
|
||||
expect(adapter).not.toBeInstanceOf(InMemoryQueueAdapter);
|
||||
await expect(adapter.enqueue({ jobId: "missing-redis", type: "noop", payload: {} })).rejects.toMatchObject({
|
||||
code: "QUEUE_UNAVAILABLE"
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the wrapped queue adapter when the Nest app closes", async () => {
|
||||
const closeCountingQueue = new CloseCountingQueue();
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
{ provide: S1_QUEUE_ADAPTER_INNER, useValue: closeCountingQueue },
|
||||
{
|
||||
provide: S1_QUEUE_ADAPTER,
|
||||
useFactory: (queue: QueueAdapter) => new ManagedQueueAdapter(queue),
|
||||
inject: [S1_QUEUE_ADAPTER_INNER]
|
||||
}
|
||||
]
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
await app.close();
|
||||
|
||||
expect(closeCountingQueue.closeCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
660
apps/api/src/modules/jobs/jobs.spec.ts
Normal file
660
apps/api/src/modules/jobs/jobs.spec.ts
Normal file
@ -0,0 +1,660 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import pg from "pg";
|
||||
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
|
||||
import type { QueueAdapter, QueueMessage } from "../queue/index.js";
|
||||
import { StateTransitionService } from "../state-transition/index.js";
|
||||
import {
|
||||
JobExecutionError,
|
||||
JobExecutionStore,
|
||||
findJobExecutionBoundaryViolations,
|
||||
scanJobExecutionBoundary
|
||||
} from "./index.js";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task7-jobs-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const fixedNow = new Date("2026-06-01T09:00:00.000Z");
|
||||
const claimIsolationBase = new Date("2000-01-01T00:00:00.000Z");
|
||||
const jobTestIsolationLockKey = 810_008;
|
||||
let claimIsolationSequence = 0;
|
||||
let lockClient: pg.Client | undefined;
|
||||
|
||||
type DbClient = Prisma.TransactionClient;
|
||||
|
||||
class RecordingQueue implements QueueAdapter {
|
||||
readonly messages: QueueMessage[] = [];
|
||||
readonly observedJobs: Array<{ readonly id: string; readonly status: string }> = [];
|
||||
private readonly fail: boolean;
|
||||
private readonly db?: PrismaClient;
|
||||
|
||||
constructor(options: { fail?: boolean; db?: PrismaClient } = {}) {
|
||||
this.fail = options.fail ?? false;
|
||||
this.db = options.db;
|
||||
}
|
||||
|
||||
async enqueue(message: QueueMessage): Promise<void> {
|
||||
const observed = await this.db?.job.findUnique({ where: { id: message.jobId } });
|
||||
if (observed) this.observedJobs.push({ id: observed.id, status: observed.status });
|
||||
if (this.fail) throw new Error("QUEUE_DOWN");
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async process(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function seedProjectSet(db: DbClient, suffix: string) {
|
||||
const actor = await db.user.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-actor`,
|
||||
email: `${runId}-${suffix}-actor@example.test`,
|
||||
displayName: `${suffix} actor`
|
||||
}
|
||||
});
|
||||
const otherActor = await db.user.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-other-actor`,
|
||||
email: `${runId}-${suffix}-other-actor@example.test`,
|
||||
displayName: `${suffix} other actor`
|
||||
}
|
||||
});
|
||||
const project = await db.gameProject.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-project`,
|
||||
ownerId: actor.id,
|
||||
slug: `${runId}-${suffix}-project`,
|
||||
title: `${suffix} project`
|
||||
}
|
||||
});
|
||||
const otherProject = await db.gameProject.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-other-project`,
|
||||
ownerId: otherActor.id,
|
||||
slug: `${runId}-${suffix}-other-project`,
|
||||
title: `${suffix} other project`
|
||||
}
|
||||
});
|
||||
const versionService = new StateTransitionService({
|
||||
db,
|
||||
blockedAuditDb: db,
|
||||
harnessGate: {
|
||||
validateContract: async () => ({ ok: true, reasonCode: null }),
|
||||
validateTransition: async () => ({ ok: true, reasonCode: null })
|
||||
}
|
||||
});
|
||||
const version = await versionService.createDraftVersion({
|
||||
id: `${runId}-${suffix}-version`,
|
||||
projectId: project.id,
|
||||
versionNumber: 1,
|
||||
configJson: { suffix }
|
||||
});
|
||||
const otherVersion = await versionService.createDraftVersion({
|
||||
id: `${runId}-${suffix}-other-version`,
|
||||
projectId: otherProject.id,
|
||||
versionNumber: 1,
|
||||
configJson: { suffix, other: true }
|
||||
});
|
||||
|
||||
return { actor, otherActor, project, otherProject, version, otherVersion };
|
||||
}
|
||||
|
||||
function enqueueInput(seed: Awaited<ReturnType<typeof seedProjectSet>>, suffix: string) {
|
||||
return {
|
||||
id: `${runId}-${suffix}-job`,
|
||||
actorId: seed.actor.id,
|
||||
projectId: seed.project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "same-key",
|
||||
target: { type: "version" as const, id: seed.version.id },
|
||||
payloadJson: { suffix }
|
||||
};
|
||||
}
|
||||
|
||||
async function enqueueJob(store: JobExecutionStore, input: Parameters<JobExecutionStore["enqueueScopedJob"]>[0], queue: QueueAdapter) {
|
||||
return (await store.enqueueScopedJob(input, queue)).job;
|
||||
}
|
||||
|
||||
async function prepareClaimFixture(jobId: string): Promise<void> {
|
||||
const createdAt = new Date(claimIsolationBase.getTime() + claimIsolationSequence++);
|
||||
await prisma.job.update({ where: { id: jobId }, data: { createdAt } });
|
||||
await expectNoExternalEligibleJobs();
|
||||
}
|
||||
|
||||
async function expectNoExternalEligibleJobs(): Promise<void> {
|
||||
const externalJobs = await prisma.job.findMany({
|
||||
where: {
|
||||
id: { not: { startsWith: runId } },
|
||||
OR: [
|
||||
{ status: "queued" },
|
||||
{
|
||||
status: "pending_retry",
|
||||
OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: fixedNow } }]
|
||||
}
|
||||
]
|
||||
},
|
||||
select: { id: true, status: true, createdAt: true },
|
||||
take: 5,
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
|
||||
});
|
||||
|
||||
// claimNext 的业务语义是全局领取;测试隔离只能检测外部 eligible job,不能把其他 spec/dev 数据改成终态。
|
||||
expect(externalJobs, "external eligible jobs would make this claimNext test mutate data outside the current runId").toEqual([]);
|
||||
}
|
||||
|
||||
describe("JobExecutionStore", () => {
|
||||
beforeEach(async () => {
|
||||
await acquireJobTestIsolationLock();
|
||||
await cleanupTask7JobRows();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTask7JobRows();
|
||||
await releaseJobTestIsolationLock();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("scopes idempotency by actor/project/type/target/idempotencyKey", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "idempotency"));
|
||||
const queue = new RecordingQueue();
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
|
||||
|
||||
const first = await enqueueJob(store, enqueueInput(seed, "first"), queue);
|
||||
const duplicate = await enqueueJob(
|
||||
store,
|
||||
{ ...enqueueInput(seed, "duplicate"), id: `${runId}-idempotency-duplicate-job` },
|
||||
queue
|
||||
);
|
||||
const otherCreator = await enqueueJob(
|
||||
store,
|
||||
{
|
||||
...enqueueInput(seed, "other-creator"),
|
||||
id: `${runId}-idempotency-other-creator-job`,
|
||||
actorId: seed.otherActor.id
|
||||
},
|
||||
queue
|
||||
);
|
||||
|
||||
expect(duplicate.id).toBe(first.id);
|
||||
expect(otherCreator.id).not.toBe(first.id);
|
||||
expect(queue.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("rejects enqueueScopedJob when constructed with an outer transaction client", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "tx-enqueue-rejected"));
|
||||
const queue = new RecordingQueue();
|
||||
|
||||
await expect(
|
||||
prisma.$transaction(async (tx) => {
|
||||
const store = new JobExecutionStore({ db: tx, now: () => fixedNow });
|
||||
await store.enqueueScopedJob(enqueueInput(seed, "tx-enqueue-rejected"), queue);
|
||||
})
|
||||
).rejects.toMatchObject<JobExecutionError>({ code: "JOB_ENQUEUE_REQUIRES_ROOT_CLIENT" });
|
||||
|
||||
expect(queue.messages).toHaveLength(0);
|
||||
await expect(prisma.job.findUnique({ where: { id: `${runId}-tx-enqueue-rejected-job` } })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects target project mismatch before enqueue and leaves target unchanged when enqueue fails", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "enqueue-failure"));
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
|
||||
const queue = new RecordingQueue({ fail: true });
|
||||
|
||||
await expect(
|
||||
store.enqueueScopedJob(
|
||||
{
|
||||
id: `${runId}-mismatch-job`,
|
||||
actorId: seed.actor.id,
|
||||
projectId: seed.project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "mismatch",
|
||||
target: { type: "version", id: seed.otherVersion.id },
|
||||
payloadJson: {}
|
||||
},
|
||||
queue
|
||||
)
|
||||
).rejects.toMatchObject<JobExecutionError>({ code: "TARGET_PROJECT_MISMATCH" });
|
||||
expect(queue.messages).toHaveLength(0);
|
||||
|
||||
await expect(
|
||||
store.enqueueScopedJob(
|
||||
{
|
||||
id: `${runId}-failed-enqueue-job`,
|
||||
actorId: seed.actor.id,
|
||||
projectId: seed.project.id,
|
||||
type: "noop",
|
||||
idempotencyKey: "failed-enqueue",
|
||||
target: { type: "project", id: seed.project.id },
|
||||
payloadJson: {}
|
||||
},
|
||||
queue
|
||||
)
|
||||
).rejects.toThrow(/QUEUE_DOWN/);
|
||||
|
||||
await expect(prisma.job.findUnique({ where: { id: `${runId}-failed-enqueue-job` } })).resolves.toMatchObject({
|
||||
status: "pending_retry",
|
||||
errorCode: "QUEUE_ENQUEUE_FAILED"
|
||||
});
|
||||
await expect(prisma.gameProject.findUniqueOrThrow({ where: { id: seed.project.id } })).resolves.toMatchObject({
|
||||
title: "enqueue-failure project"
|
||||
});
|
||||
});
|
||||
|
||||
it("concurrent duplicate idempotency requests persist only one job while creator scopes stay isolated", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "concurrent-idempotency"));
|
||||
const queue = new RecordingQueue();
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
|
||||
|
||||
const [first, second, isolated] = await Promise.all([
|
||||
enqueueJob(store, enqueueInput(seed, "concurrent-a"), queue),
|
||||
enqueueJob(store, { ...enqueueInput(seed, "concurrent-b"), id: `${runId}-concurrent-b-job` }, queue),
|
||||
enqueueJob(
|
||||
store,
|
||||
{
|
||||
...enqueueInput(seed, "concurrent-other"),
|
||||
id: `${runId}-concurrent-other-job`,
|
||||
actorId: seed.otherActor.id
|
||||
},
|
||||
queue
|
||||
)
|
||||
]);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(isolated.id).not.toBe(first.id);
|
||||
await expect(
|
||||
prisma.job.count({
|
||||
where: {
|
||||
projectId: seed.project.id,
|
||||
type: "noop",
|
||||
targetScopeKey: `version:${seed.version.id}`,
|
||||
idempotencyKey: "same-key"
|
||||
}
|
||||
})
|
||||
).resolves.toBe(2);
|
||||
});
|
||||
|
||||
it("reports created only for the idempotent winner under concurrent duplicate requests", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "winner-outcome-once"));
|
||||
const queue = new RecordingQueue();
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
|
||||
const withDuplicateKey = (id: string) => ({
|
||||
...enqueueInput(seed, "winner-outcome-once"),
|
||||
id,
|
||||
idempotencyKey: "winner-outcome-once"
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
store.enqueueScopedJob(withDuplicateKey(`${runId}-winner-outcome-once-a-job`), queue),
|
||||
store.enqueueScopedJob(withDuplicateKey(`${runId}-winner-outcome-once-b-job`), queue)
|
||||
]);
|
||||
|
||||
expect(second.job.id).toBe(first.job.id);
|
||||
expect([first.created, second.created].filter(Boolean)).toHaveLength(1);
|
||||
expect([first.enqueued, second.enqueued].filter(Boolean)).toHaveLength(1);
|
||||
expect(queue.messages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("enqueues after DB commit and marks the job recoverable when enqueue fails", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "enqueue-after-commit"));
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, retryDelayMs: 15_000 });
|
||||
const queue = new RecordingQueue({ fail: true, db: prisma });
|
||||
|
||||
await expect(
|
||||
store.enqueueScopedJob(
|
||||
{
|
||||
...enqueueInput(seed, "enqueue-after-commit"),
|
||||
idempotencyKey: "enqueue-after-commit"
|
||||
},
|
||||
queue
|
||||
)
|
||||
).rejects.toThrow(/QUEUE_DOWN/);
|
||||
|
||||
expect(queue.observedJobs).toEqual([{ id: `${runId}-enqueue-after-commit-job`, status: "queued" }]);
|
||||
await expect(prisma.gameProject.findUniqueOrThrow({ where: { id: seed.project.id } })).resolves.toMatchObject({
|
||||
title: "enqueue-after-commit project"
|
||||
});
|
||||
await expect(prisma.job.findUniqueOrThrow({ where: { id: `${runId}-enqueue-after-commit-job` } })).resolves.toMatchObject({
|
||||
status: "pending_retry",
|
||||
errorCode: "QUEUE_ENQUEUE_FAILED",
|
||||
nextRetryAt: new Date(fixedNow.getTime() + 15_000)
|
||||
});
|
||||
});
|
||||
|
||||
it("claims exactly one eligible job and prevents concurrent workers from claiming the same row", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "claim"));
|
||||
const queue = new RecordingQueue();
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
|
||||
const job = await enqueueJob(store, enqueueInput(seed, "claim"), queue);
|
||||
await prepareClaimFixture(job.id);
|
||||
|
||||
const [first, second] = await Promise.all([store.claimNext("worker-a", fixedNow), store.claimNext("worker-b", fixedNow)]);
|
||||
const claimed = [first, second].filter((value) => value !== null);
|
||||
|
||||
expect(claimed).toHaveLength(1);
|
||||
expect(claimed[0]).toMatchObject({ id: job.id, status: "running", leasedBy: expect.stringMatching(/^worker-/) });
|
||||
expect(claimed[0]?.leaseToken).toEqual(expect.any(String));
|
||||
await expect(prisma.job.findUniqueOrThrow({ where: { id: job.id } })).resolves.toMatchObject({
|
||||
status: "running",
|
||||
lockVersion: 1
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale completion after lease/version changes", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "stale-complete"));
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, retryDelayMs: 1000 });
|
||||
const job = await enqueueJob(store, enqueueInput(seed, "stale-complete"), new RecordingQueue());
|
||||
await prepareClaimFixture(job.id);
|
||||
const claimed = await store.claimNext("worker-a", fixedNow);
|
||||
|
||||
expect(claimed?.id).toBe(job.id);
|
||||
await store.markTimedOut(job.id, new Date(fixedNow.getTime() + 31_000), "JOB_TIMEOUT");
|
||||
|
||||
await expect(store.markSucceeded(job.id, claimed?.leaseToken ?? "missing")).rejects.toMatchObject<JobExecutionError>({
|
||||
code: "STALE_JOB_LEASE"
|
||||
});
|
||||
});
|
||||
|
||||
it("ensures API cancel and worker completion cannot both win", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "cancel-complete"));
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow });
|
||||
const queued = await enqueueJob(store, enqueueInput(seed, "cancel-first"), new RecordingQueue());
|
||||
|
||||
await expect(store.cancelQueued(queued.id, seed.actor.id)).resolves.toMatchObject({ status: "canceled" });
|
||||
await expect(prisma.job.findUniqueOrThrow({ where: { id: queued.id } })).resolves.toMatchObject({ status: "canceled" });
|
||||
|
||||
const running = await enqueueJob(
|
||||
store,
|
||||
{ ...enqueueInput(seed, "complete-first"), id: `${runId}-complete-first-job`, idempotencyKey: "complete-first" },
|
||||
new RecordingQueue()
|
||||
);
|
||||
await prepareClaimFixture(running.id);
|
||||
const claimed = await store.claimNext("worker-b", fixedNow);
|
||||
expect(claimed?.id).toBe(running.id);
|
||||
|
||||
await expect(store.cancelQueued(running.id, seed.actor.id)).rejects.toMatchObject<JobExecutionError>({
|
||||
code: "JOB_CANCEL_NOT_ALLOWED"
|
||||
});
|
||||
await expect(store.markSucceeded(running.id, claimed?.leaseToken ?? "missing")).resolves.toMatchObject({
|
||||
status: "succeeded"
|
||||
});
|
||||
});
|
||||
|
||||
it("marks timeout and retry outcomes with attempts, nextRetryAt, and errorCode", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "retry-timeout"));
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, retryDelayMs: 60_000 });
|
||||
|
||||
const retryJob = await enqueueJob(
|
||||
store,
|
||||
{ ...enqueueInput(seed, "retry"), idempotencyKey: "retry", maxAttempts: 2 },
|
||||
new RecordingQueue()
|
||||
);
|
||||
await prepareClaimFixture(retryJob.id);
|
||||
const retryClaim = await store.claimNext("worker-retry", fixedNow);
|
||||
expect(retryClaim?.id).toBe(retryJob.id);
|
||||
await expect(store.markFailedOrRetry(retryJob.id, retryClaim?.leaseToken ?? "missing", "WORKER_FAILED")).resolves.toMatchObject({
|
||||
status: "pending_retry",
|
||||
attempts: 1,
|
||||
errorCode: "WORKER_FAILED",
|
||||
nextRetryAt: new Date(fixedNow.getTime() + 60_000)
|
||||
});
|
||||
|
||||
const timeoutJob = await enqueueJob(
|
||||
store,
|
||||
{ ...enqueueInput(seed, "timeout"), id: `${runId}-timeout-job`, idempotencyKey: "timeout", maxAttempts: 1 },
|
||||
new RecordingQueue()
|
||||
);
|
||||
await prepareClaimFixture(timeoutJob.id);
|
||||
const timeoutClaim = await store.claimNext("worker-timeout", fixedNow);
|
||||
expect(timeoutClaim?.id).toBe(timeoutJob.id);
|
||||
await expect(store.markTimedOut(timeoutJob.id, new Date(fixedNow.getTime() + 31_000), "JOB_TIMEOUT")).resolves.toMatchObject({
|
||||
status: "failed",
|
||||
attempts: 1,
|
||||
errorCode: "JOB_TIMEOUT",
|
||||
nextRetryAt: null
|
||||
});
|
||||
});
|
||||
|
||||
it("marks jobs timed out by persisted timeoutAt even when lease has not expired", async () => {
|
||||
const seed = await prisma.$transaction((tx) => seedProjectSet(tx, "persisted-timeout"));
|
||||
const store = new JobExecutionStore({ db: prisma, now: () => fixedNow, leaseMs: 60_000, retryDelayMs: 10_000 });
|
||||
const job = await enqueueJob(
|
||||
store,
|
||||
{
|
||||
...enqueueInput(seed, "persisted-timeout"),
|
||||
timeoutMs: -1,
|
||||
maxAttempts: 2
|
||||
},
|
||||
new RecordingQueue()
|
||||
);
|
||||
await prepareClaimFixture(job.id);
|
||||
const claim = await store.claimNext("worker-persisted-timeout", fixedNow);
|
||||
|
||||
expect(claim).toMatchObject({
|
||||
id: job.id,
|
||||
status: "running",
|
||||
leaseExpiresAt: new Date(fixedNow.getTime() + 60_000)
|
||||
});
|
||||
await expect(store.markTimedOut(job.id, fixedNow, "JOB_TIMEOUT")).resolves.toMatchObject({
|
||||
status: "pending_retry",
|
||||
attempts: 1,
|
||||
errorCode: "JOB_TIMEOUT",
|
||||
nextRetryAt: new Date(fixedNow.getTime() + 10_000)
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Job runtime field writes inside JobExecutionStore", async () => {
|
||||
expect(
|
||||
findJobExecutionBoundaryViolations([
|
||||
{
|
||||
path: "apps/api/src/modules/projects/project.service.ts",
|
||||
content: "await db.job.update({ where: { id }, data: { status: 'failed' } });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/comment-paren-job.service.ts",
|
||||
content: `
|
||||
await db.job.update({ // )
|
||||
where: { id },
|
||||
data: { status: "failed" }
|
||||
});
|
||||
`
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/jobs/index.ts",
|
||||
content:
|
||||
"export class JobExecutionStore { async ok(db: Prisma.TransactionClient) { await db.job.update({ where: { id }, data: { status: 'failed', attempts: { increment: 1 } } }); } }"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/jobs/index.ts",
|
||||
content: `
|
||||
export class JobExecutionStore {
|
||||
async ok(db: Prisma.TransactionClient): Promise<void> {
|
||||
await db.job.update({ where: { id: "ok" }, data: { status: "running" } });
|
||||
}
|
||||
}
|
||||
|
||||
export class JobApiService {
|
||||
async bad(db: Prisma.TransactionClient): Promise<void> {
|
||||
await db.job.update({ where: { id: "bad" }, data: { status: "failed" } });
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/jobs/bad.ts",
|
||||
content:
|
||||
"await db.job.create({ data: { projectId, actorId, status: 'queued', attempts: 0, nextRetryAt: null } });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/quoted-job.service.ts",
|
||||
content:
|
||||
"await db.job.update({ where: { id }, data: { \"status\": \"failed\", 'attempts': 1, [`nextRetryAt`]: null } });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/lease-token-job.service.ts",
|
||||
content: 'await db.job.update({ where: { id }, data: { leaseToken: "stolen" } });'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/timeout-at-job.service.ts",
|
||||
content: "await db.job.update({ where: { id }, data: { timeoutAt: new Date() } });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/computed-job.service.ts",
|
||||
content: 'const field = "status"; await db.job.update({ where: { id }, data: { [field]: "failed" } });'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/variable-job.service.ts",
|
||||
content: 'const data = { status: "failed" }; await db.job.update({ where: { id }, data });'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/spread-job.service.ts",
|
||||
content: 'const patch = { attempts: 1 }; await db.job.update({ where: { id }, data: { ...patch } });'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-status-job.service.ts",
|
||||
content: `await db.$executeRawUnsafe("UPDATE \\"Job\\" SET \\"status\\" = 'failed' WHERE id = $1", id);`
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-comment-paren-job.service.ts",
|
||||
content: `
|
||||
await db.$executeRawUnsafe( // )
|
||||
"UPDATE \\"Job\\" SET \\"status\\" = 'failed' WHERE id = $1",
|
||||
id
|
||||
);
|
||||
`
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-attempts-job.service.ts",
|
||||
content: 'await db.$executeRawUnsafe("UPDATE Job SET attempts = attempts + 1 WHERE id = $1", id);'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-next-retry-job.service.ts",
|
||||
content: 'await db.$executeRawUnsafe("UPDATE \\"Job\\" SET \\"nextRetryAt\\" = NULL WHERE id = $1", id);'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-insert-status-job.service.ts",
|
||||
content: 'await db.$executeRawUnsafe("INSERT INTO \\"Job\\" (\\"id\\", \\"status\\") VALUES ($1, \'queued\')", id);'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-lease-job.service.ts",
|
||||
content:
|
||||
'await db.$executeRawUnsafe("UPDATE \\"Job\\" SET \\"leaseToken\\" = $1, \\"lockVersion\\" = \\"lockVersion\\" + 1 WHERE id = $2", token, id);'
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-timeout-job.service.ts",
|
||||
content: 'await db.$executeRawUnsafe("INSERT INTO Job (id, timeoutAt) VALUES ($1, NOW())", id);'
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
path: "apps/api/src/modules/projects/project.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/comment-paren-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/jobs/index.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/jobs/bad.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/quoted-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/lease-token-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/timeout-at-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/computed-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/variable-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/spread-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-status-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-comment-paren-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-attempts-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-next-retry-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-insert-status-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-lease-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/projects/raw-timeout-job.service.ts",
|
||||
reason: "JOB_STATUS_WRITE_OUTSIDE_EXECUTION_STORE"
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(scanJobExecutionBoundary(process.cwd())).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("does not expose pre-enqueue mutation hooks from the Task 8 job runtime", async () => {
|
||||
const runtime = await readFile(new URL("./index.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(runtime).not.toMatch(/\bonJobCreated\b/);
|
||||
expect(runtime).not.toMatch(/\bmutateTarget\b/);
|
||||
});
|
||||
});
|
||||
|
||||
async function cleanupTask7JobRows(): Promise<void> {
|
||||
await prisma.job.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
await prisma.gameProject.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
await prisma.user.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
}
|
||||
|
||||
async function acquireJobTestIsolationLock(): Promise<void> {
|
||||
// 仅测试隔离:claimNext 是全局领取,跨 spec 并发时用 DB advisory lock 缩小测试窗口,不改写外部 Job。
|
||||
lockClient = new pg.Client({ connectionString: databaseUrl });
|
||||
await lockClient.connect();
|
||||
await lockClient.query("SELECT pg_advisory_lock($1)", [jobTestIsolationLockKey]);
|
||||
}
|
||||
|
||||
async function releaseJobTestIsolationLock(): Promise<void> {
|
||||
if (!lockClient) return;
|
||||
try {
|
||||
await lockClient.query("SELECT pg_advisory_unlock($1)", [jobTestIsolationLockKey]);
|
||||
} finally {
|
||||
await lockClient.end();
|
||||
lockClient = undefined;
|
||||
}
|
||||
}
|
||||
49
apps/api/src/modules/projects/api-runtime.spec.ts
Normal file
49
apps/api/src/modules/projects/api-runtime.spec.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Prisma } from "../../generated/prisma/client.js";
|
||||
import { JobExecutionError } from "../jobs/index.js";
|
||||
import { QueueUnavailableError } from "../queue/index.js";
|
||||
import { StateTransitionDomainError } from "../state-transition/index.js";
|
||||
import { mapExceptionToHttpError } from "./api-runtime.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("Structured API exception mapping", () => {
|
||||
it("maps Prisma unique and missing-row errors to stable structured statuses", () => {
|
||||
const unique = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "task8-test",
|
||||
meta: { target: ["slug"] }
|
||||
});
|
||||
const missing = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "task8-test"
|
||||
});
|
||||
|
||||
expect(mapExceptionToHttpError(unique, "req-1")).toEqual({
|
||||
status: 409,
|
||||
body: { code: "CONFLICT", message: expect.any(String), requestId: "req-1", details: expect.anything() }
|
||||
});
|
||||
expect(mapExceptionToHttpError(missing, "req-2")).toEqual({
|
||||
status: 404,
|
||||
body: { code: "NOT_FOUND", message: expect.any(String), requestId: "req-2", details: expect.anything() }
|
||||
});
|
||||
});
|
||||
|
||||
it("maps queue, job, and state-transition domain errors without leaking raw 500s", () => {
|
||||
expect(mapExceptionToHttpError(new QueueUnavailableError("redis unavailable"), null)).toEqual({
|
||||
status: 503,
|
||||
body: { code: "QUEUE_UNAVAILABLE", message: expect.any(String), requestId: null, details: expect.anything() }
|
||||
});
|
||||
expect(mapExceptionToHttpError(new JobExecutionError("TARGET_PROJECT_MISMATCH"), null)).toEqual({
|
||||
status: 422,
|
||||
body: { code: "TARGET_PROJECT_MISMATCH", message: expect.any(String), requestId: null, details: expect.anything() }
|
||||
});
|
||||
expect(mapExceptionToHttpError(new StateTransitionDomainError("HARNESS_INVALID_OUTPUT"), null)).toEqual({
|
||||
status: 422,
|
||||
body: {
|
||||
code: "STATE_TRANSITION_REJECTED",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.objectContaining({ reasonCode: "HARNESS_INVALID_OUTPUT" })
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
182
apps/api/src/modules/projects/api-runtime.ts
Normal file
182
apps/api/src/modules/projects/api-runtime.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { Catch, Global, HttpException, HttpStatus, Injectable, Module, type ArgumentsHost, type ExceptionFilter } from "@nestjs/common";
|
||||
import { APP_FILTER } from "@nestjs/core";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
|
||||
|
||||
export type StructuredApiError = {
|
||||
readonly code: string;
|
||||
readonly message: string;
|
||||
readonly requestId: string | null;
|
||||
readonly details: unknown;
|
||||
};
|
||||
|
||||
type HttpRequestLike = {
|
||||
readonly headers?: {
|
||||
readonly "x-request-id"?: string | readonly string[];
|
||||
};
|
||||
};
|
||||
|
||||
type HttpResponseLike = {
|
||||
status(statusCode: number): {
|
||||
json(body: StructuredApiError): void;
|
||||
};
|
||||
};
|
||||
|
||||
export type MappedHttpError = {
|
||||
readonly status: number;
|
||||
readonly body: StructuredApiError;
|
||||
};
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
|
||||
export function apiError(code: string, message: string, details: unknown = {}): StructuredApiError {
|
||||
return {
|
||||
code,
|
||||
message,
|
||||
requestId: null,
|
||||
details: details ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
function requestIdFrom(request: HttpRequestLike): string | null {
|
||||
const value = request.headers?.["x-request-id"];
|
||||
if (typeof value === "string" && value.trim() !== "") return value;
|
||||
if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim() !== "") return value[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
function isNamedDomainError(exception: unknown, name: string): boolean {
|
||||
return exception instanceof Error && exception.name === name;
|
||||
}
|
||||
|
||||
function errorCodeOf(exception: unknown): string | undefined {
|
||||
if (typeof exception === "object" && exception !== null && "code" in exception && typeof exception.code === "string") {
|
||||
return exception.code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reasonCodeOf(exception: unknown): string | undefined {
|
||||
if (typeof exception === "object" && exception !== null && "reasonCode" in exception && typeof exception.reasonCode === "string") {
|
||||
return exception.reasonCode;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mappedBody(code: string, message: string, requestId: string | null, details: unknown = {}): StructuredApiError {
|
||||
return { code, message, requestId, details: details ?? {} };
|
||||
}
|
||||
|
||||
function mapJobExecutionError(exception: unknown, requestId: string | null): MappedHttpError | null {
|
||||
if (!isNamedDomainError(exception, "JobExecutionError")) return null;
|
||||
|
||||
const code = errorCodeOf(exception) ?? "JOB_EXECUTION_ERROR";
|
||||
const status =
|
||||
code === "TARGET_NOT_FOUND" || code === "JOB_NOT_FOUND"
|
||||
? HttpStatus.NOT_FOUND
|
||||
: code === "TARGET_PROJECT_MISMATCH"
|
||||
? HttpStatus.UNPROCESSABLE_ENTITY
|
||||
: code === "JOB_ENQUEUE_REQUIRES_ROOT_CLIENT"
|
||||
? HttpStatus.INTERNAL_SERVER_ERROR
|
||||
: HttpStatus.CONFLICT;
|
||||
return {
|
||||
status,
|
||||
body: mappedBody(code, "Job execution boundary rejected the request", requestId, { code })
|
||||
};
|
||||
}
|
||||
|
||||
export function mapExceptionToHttpError(exception: unknown, requestId: string | null): MappedHttpError {
|
||||
if (exception instanceof HttpException) {
|
||||
const response = exception.getResponse();
|
||||
if (typeof response === "object" && response !== null && "code" in response) {
|
||||
const body = response as Partial<StructuredApiError>;
|
||||
return {
|
||||
status: exception.getStatus(),
|
||||
body: {
|
||||
code: typeof body.code === "string" ? body.code : "HTTP_ERROR",
|
||||
message: typeof body.message === "string" ? body.message : exception.message,
|
||||
requestId,
|
||||
details: body.details ?? {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: exception.getStatus(),
|
||||
body: mappedBody(exception.getStatus() === HttpStatus.NOT_FOUND ? "NOT_FOUND" : "HTTP_ERROR", exception.message, requestId)
|
||||
};
|
||||
}
|
||||
|
||||
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (exception.code === "P2002") {
|
||||
return {
|
||||
status: HttpStatus.CONFLICT,
|
||||
body: mappedBody("CONFLICT", "Unique constraint conflict", requestId, { prismaCode: exception.code, meta: exception.meta })
|
||||
};
|
||||
}
|
||||
if (exception.code === "P2025") {
|
||||
return {
|
||||
status: HttpStatus.NOT_FOUND,
|
||||
body: mappedBody("NOT_FOUND", "Requested record was not found", requestId, { prismaCode: exception.code })
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isNamedDomainError(exception, "QueueUnavailableError") || errorCodeOf(exception) === "QUEUE_UNAVAILABLE") {
|
||||
return {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
body: mappedBody("QUEUE_UNAVAILABLE", "Queue is unavailable", requestId, { queue: true })
|
||||
};
|
||||
}
|
||||
|
||||
const jobError = mapJobExecutionError(exception, requestId);
|
||||
if (jobError) return jobError;
|
||||
|
||||
if (isNamedDomainError(exception, "StateTransitionDomainError")) {
|
||||
const reasonCode = reasonCodeOf(exception) ?? "HARNESS_INVALID_OUTPUT";
|
||||
return {
|
||||
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
body: mappedBody("STATE_TRANSITION_REJECTED", "State transition rejected by harness", requestId, { reasonCode })
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
body: mappedBody("INTERNAL_ERROR", "Internal server error", requestId)
|
||||
};
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class StructuredApiExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const http = host.switchToHttp();
|
||||
const request = http.getRequest<HttpRequestLike>();
|
||||
const response = http.getResponse<HttpResponseLike>();
|
||||
const mapped = mapExceptionToHttpError(exception, requestIdFrom(request));
|
||||
response.status(mapped.status).json(mapped.body);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class S1PrismaClient extends PrismaClient {
|
||||
constructor() {
|
||||
super({ adapter: new PrismaPg({ connectionString: databaseUrl }) });
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
S1PrismaClient,
|
||||
{
|
||||
provide: APP_FILTER,
|
||||
useClass: StructuredApiExceptionFilter
|
||||
}
|
||||
],
|
||||
exports: [S1PrismaClient]
|
||||
})
|
||||
export class S1ApiRuntimeModule {}
|
||||
284
apps/api/src/modules/projects/index.ts
Normal file
284
apps/api/src/modules/projects/index.ts
Normal file
@ -0,0 +1,284 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Injectable,
|
||||
Module,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards
|
||||
} from "@nestjs/common";
|
||||
import { Prisma, type GameProject, type GameVersion } from "../../generated/prisma/client.js";
|
||||
import { AuthGuard, AuthModule, type AuthActor } from "../auth/index.js";
|
||||
import { AuditService } from "../audit/index.js";
|
||||
import { canReadReviewFoundationData, creatorOwnsResource, hasRole } from "../rbac/index.js";
|
||||
import { StateTransitionService } from "../state-transition/index.js";
|
||||
import { apiError, S1ApiRuntimeModule, S1PrismaClient } from "./api-runtime.js";
|
||||
|
||||
type RequestWithActor = {
|
||||
readonly actor: AuthActor;
|
||||
};
|
||||
|
||||
type CreateProjectBody = {
|
||||
readonly title?: unknown;
|
||||
readonly slug?: unknown;
|
||||
};
|
||||
|
||||
type CreateVersionBody = {
|
||||
readonly configJson?: unknown;
|
||||
};
|
||||
|
||||
export type ProjectDto = {
|
||||
readonly id: string;
|
||||
readonly ownerId: string;
|
||||
readonly slug: string;
|
||||
readonly title: string;
|
||||
readonly status: string;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
};
|
||||
|
||||
export type VersionDto = {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
readonly versionNumber: number;
|
||||
readonly status: string;
|
||||
readonly configJson: unknown;
|
||||
readonly createdAt: string;
|
||||
readonly updatedAt: string;
|
||||
};
|
||||
|
||||
function projectDto(project: GameProject): ProjectDto {
|
||||
return {
|
||||
id: project.id,
|
||||
ownerId: project.ownerId,
|
||||
slug: project.slug,
|
||||
title: project.title,
|
||||
status: project.status,
|
||||
createdAt: project.createdAt.toISOString(),
|
||||
updatedAt: project.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function versionDto(version: GameVersion): VersionDto {
|
||||
return {
|
||||
id: version.id,
|
||||
projectId: version.projectId,
|
||||
versionNumber: version.versionNumber,
|
||||
status: version.status,
|
||||
configJson: version.configJson,
|
||||
createdAt: version.createdAt.toISOString(),
|
||||
updatedAt: version.updatedAt.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function requireNonEmptyString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
throw new BadRequestException(apiError("INVALID_REQUEST", `${field} must be a non-empty string`, { field }));
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalSafeSlug(value: unknown, fallbackId: string): string {
|
||||
if (value === undefined) return `project-${fallbackId.slice(0, 8)}`;
|
||||
const slug = requireNonEmptyString(value, "slug");
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,80}$/.test(slug)) {
|
||||
throw new BadRequestException(apiError("INVALID_REQUEST", "slug contains unsafe characters", { field: "slug" }));
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
function inputJson(value: unknown): Prisma.InputJsonValue {
|
||||
if (value === undefined) return {};
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new BadRequestException(apiError("INVALID_REQUEST", "configJson must be a JSON object", { field: "configJson" }));
|
||||
}
|
||||
return value as Prisma.InputJsonObject;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ProjectsApiService {
|
||||
constructor(private readonly db: S1PrismaClient) {}
|
||||
|
||||
async createProject(actor: AuthActor, body: CreateProjectBody): Promise<ProjectDto> {
|
||||
if (!hasRole(actor, "creator")) {
|
||||
// S1 只有 creator 可以创建项目;operator/admin 在 Task 8 仅有 foundation read,不是管理 CRUD。
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Creator role is required to create a project", { requiredRole: "creator" }));
|
||||
}
|
||||
|
||||
const title = requireNonEmptyString(body.title, "title");
|
||||
const id = randomUUID();
|
||||
const slug = optionalSafeSlug(body.slug, id);
|
||||
|
||||
const project = await this.db.$transaction(async (tx) => {
|
||||
await this.ensureActorUser(tx, actor);
|
||||
const created = await tx.gameProject.create({
|
||||
data: {
|
||||
id,
|
||||
ownerId: actor.id,
|
||||
slug,
|
||||
title
|
||||
}
|
||||
});
|
||||
|
||||
await new AuditService({ db: tx }).append({
|
||||
id: randomUUID(),
|
||||
actorId: actor.id,
|
||||
action: "project.created",
|
||||
targetType: "GameProject",
|
||||
targetId: created.id,
|
||||
eventJson: {
|
||||
// 项目创建改变 ownership 边界,审计记录只写事实,不包含可变业务状态。
|
||||
ownerId: actor.id,
|
||||
slug: created.slug
|
||||
}
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return projectDto(project);
|
||||
}
|
||||
|
||||
async listProjects(actor: AuthActor): Promise<ProjectDto[]> {
|
||||
const where = canReadReviewFoundationData(actor, "project") ? {} : { ownerId: actor.id };
|
||||
const projects = await this.db.gameProject.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: "asc" }, { id: "asc" }]
|
||||
});
|
||||
return projects.map(projectDto);
|
||||
}
|
||||
|
||||
async getProject(actor: AuthActor, projectId: string): Promise<ProjectDto> {
|
||||
const project = await this.requireReadableProject(actor, projectId, "project");
|
||||
return projectDto(project);
|
||||
}
|
||||
|
||||
async createDraftVersion(actor: AuthActor, projectId: string, body: CreateVersionBody): Promise<VersionDto> {
|
||||
const project = await this.requireReadableProject(actor, projectId, "project");
|
||||
if (!creatorOwnsResource(actor, project)) {
|
||||
// 版本创建是 owner 写路径;operator/admin 在 S1 只有 foundation read,没有管理写权限。
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Only the owning creator can create a draft version", { projectId }));
|
||||
}
|
||||
|
||||
const configJson = inputJson(body.configJson);
|
||||
const version = await this.db.$transaction(async (tx) => {
|
||||
const latest = await tx.gameVersion.findFirst({
|
||||
where: { projectId },
|
||||
orderBy: { versionNumber: "desc" }
|
||||
});
|
||||
const service = new StateTransitionService({
|
||||
db: tx,
|
||||
blockedAuditDb: tx,
|
||||
harnessGate: {
|
||||
validateContract: async () => ({ ok: true, reasonCode: null }),
|
||||
validateTransition: async () => ({ ok: true, reasonCode: null })
|
||||
}
|
||||
});
|
||||
const created = await service.createDraftVersion({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
versionNumber: (latest?.versionNumber ?? 0) + 1,
|
||||
configJson
|
||||
});
|
||||
|
||||
await new AuditService({ db: tx }).append({
|
||||
id: randomUUID(),
|
||||
actorId: actor.id,
|
||||
action: "version.created",
|
||||
targetType: "GameVersion",
|
||||
targetId: created.id,
|
||||
eventJson: {
|
||||
// draft 状态写入必须走 StateTransitionService;审计只记录边界调用事实。
|
||||
projectId,
|
||||
versionNumber: created.versionNumber,
|
||||
status: created.status
|
||||
}
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return versionDto(version);
|
||||
}
|
||||
|
||||
async listVersions(actor: AuthActor, projectId: string): Promise<VersionDto[]> {
|
||||
await this.requireReadableProject(actor, projectId, "version");
|
||||
const versions = await this.db.gameVersion.findMany({
|
||||
where: { projectId },
|
||||
orderBy: [{ versionNumber: "asc" }, { id: "asc" }]
|
||||
});
|
||||
return versions.map(versionDto);
|
||||
}
|
||||
|
||||
private async requireReadableProject(
|
||||
actor: AuthActor,
|
||||
projectId: string,
|
||||
category: "project" | "version"
|
||||
): Promise<GameProject> {
|
||||
const project = await this.db.gameProject.findUnique({ where: { id: projectId } });
|
||||
if (!project) throw new NotFoundException(apiError("NOT_FOUND", "Project not found", { projectId }));
|
||||
if (creatorOwnsResource(actor, project) || canReadReviewFoundationData(actor, category)) return project;
|
||||
throw new ForbiddenException(apiError("FORBIDDEN", "Actor cannot access this project", { projectId }));
|
||||
}
|
||||
|
||||
private async ensureActorUser(tx: Prisma.TransactionClient, actor: AuthActor): Promise<void> {
|
||||
await tx.user.upsert({
|
||||
where: { id: actor.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: actor.id,
|
||||
email: actor.email,
|
||||
displayName: actor.displayName
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller()
|
||||
@UseGuards(AuthGuard)
|
||||
export class ProjectsController {
|
||||
constructor(private readonly service: ProjectsApiService) {}
|
||||
|
||||
@Post("projects")
|
||||
createProject(@Req() request: RequestWithActor, @Body() body: CreateProjectBody): Promise<ProjectDto> {
|
||||
return this.service.createProject(request.actor, body);
|
||||
}
|
||||
|
||||
@Get("projects")
|
||||
listProjects(@Req() request: RequestWithActor): Promise<ProjectDto[]> {
|
||||
return this.service.listProjects(request.actor);
|
||||
}
|
||||
|
||||
@Get("projects/:projectId")
|
||||
getProject(@Req() request: RequestWithActor, @Param("projectId") projectId: string): Promise<ProjectDto> {
|
||||
return this.service.getProject(request.actor, projectId);
|
||||
}
|
||||
|
||||
@Post("projects/:projectId/versions")
|
||||
createDraftVersion(
|
||||
@Req() request: RequestWithActor,
|
||||
@Param("projectId") projectId: string,
|
||||
@Body() body: CreateVersionBody
|
||||
): Promise<VersionDto> {
|
||||
return this.service.createDraftVersion(request.actor, projectId, body);
|
||||
}
|
||||
|
||||
@Get("projects/:projectId/versions")
|
||||
listVersions(@Req() request: RequestWithActor, @Param("projectId") projectId: string): Promise<VersionDto[]> {
|
||||
return this.service.listVersions(request.actor, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [S1ApiRuntimeModule, AuthModule],
|
||||
controllers: [ProjectsController],
|
||||
providers: [ProjectsApiService],
|
||||
exports: [ProjectsApiService]
|
||||
})
|
||||
export class ProjectsModule {}
|
||||
323
apps/api/src/modules/projects/projects.api.spec.ts
Normal file
323
apps/api/src/modules/projects/projects.api.spec.ts
Normal file
@ -0,0 +1,323 @@
|
||||
import { type INestApplication } from "@nestjs/common";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { AppModule } from "../../app.module.js";
|
||||
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
|
||||
import { StateTransitionService } from "../state-transition/index.js";
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task8-projects-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
type DbClient = Prisma.TransactionClient;
|
||||
|
||||
type TestApp = {
|
||||
readonly app: INestApplication;
|
||||
readonly baseUrl: string;
|
||||
};
|
||||
|
||||
async function createTestApp(): Promise<TestApp> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule]
|
||||
}).compile();
|
||||
const app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
const server = await app.listen(0);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") throw new Error("Nest test server did not expose a TCP port");
|
||||
return { app, baseUrl: `http://127.0.0.1:${address.port}` };
|
||||
}
|
||||
|
||||
async function requestJson(testApp: TestApp, path: string, init: RequestInit = {}) {
|
||||
const response = await fetch(`${testApp.baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...init.headers
|
||||
}
|
||||
});
|
||||
const body = (await response.json().catch(() => null)) as unknown;
|
||||
return { response, body };
|
||||
}
|
||||
|
||||
async function login(testApp: TestApp, email: string): Promise<string> {
|
||||
const response = await requestJson(testApp, "/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
expect(response.response.status).toBe(201);
|
||||
return (response.body as { token: string }).token;
|
||||
}
|
||||
|
||||
async function ensureSeedUsers(db: DbClient): Promise<void> {
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-creator" },
|
||||
update: {},
|
||||
create: { id: "seed-creator", email: "creator@example.test", displayName: "Seed Creator" }
|
||||
});
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-creator-other" },
|
||||
update: {},
|
||||
create: { id: "seed-creator-other", email: "creator-other@example.test", displayName: "Seed Other Creator" }
|
||||
});
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-operator" },
|
||||
update: {},
|
||||
create: { id: "seed-operator", email: "operator@example.test", displayName: "Seed Operator" }
|
||||
});
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-admin" },
|
||||
update: {},
|
||||
create: { id: "seed-admin", email: "admin@example.test", displayName: "Seed Admin" }
|
||||
});
|
||||
await db.user.upsert({
|
||||
where: { id: "seed-player" },
|
||||
update: {},
|
||||
create: { id: "seed-player", email: "player@example.test", displayName: "Seed Player" }
|
||||
});
|
||||
}
|
||||
|
||||
async function seedProject(db: DbClient, suffix: string, ownerId = "seed-creator") {
|
||||
return db.gameProject.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-project`,
|
||||
ownerId,
|
||||
slug: `${runId}-${suffix}`,
|
||||
title: `${suffix} project`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function seedDraftVersion(db: DbClient, projectId: string, suffix: string, versionNumber = 1) {
|
||||
const service = new StateTransitionService({
|
||||
db,
|
||||
blockedAuditDb: db,
|
||||
harnessGate: {
|
||||
validateContract: async () => ({ ok: true, reasonCode: null }),
|
||||
validateTransition: async () => ({ ok: true, reasonCode: null })
|
||||
}
|
||||
});
|
||||
return service.createDraftVersion({
|
||||
id: `${runId}-${suffix}-version`,
|
||||
projectId,
|
||||
versionNumber,
|
||||
configJson: { suffix }
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(): Promise<void> {
|
||||
await prisma.job.deleteMany({ where: { id: { startsWith: runId } } });
|
||||
await prisma.gameProject.deleteMany({ where: { OR: [{ id: { startsWith: runId } }, { slug: { startsWith: runId } }] } });
|
||||
}
|
||||
|
||||
describe("Project and version HTTP APIs", () => {
|
||||
let testApp: TestApp | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
await cleanup();
|
||||
await ensureSeedUsers(prisma);
|
||||
testApp = await createTestApp();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await testApp?.app.close();
|
||||
testApp = undefined;
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
it("POST /projects creates a creator-owned project and appends an audit fact", async () => {
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const created = await requestJson(testApp!, "/projects", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ title: "Task 8 Project", slug: `${runId}-created` })
|
||||
});
|
||||
|
||||
expect(created.response.status).toBe(201);
|
||||
expect(created.body).toMatchObject({
|
||||
slug: `${runId}-created`,
|
||||
title: "Task 8 Project",
|
||||
ownerId: "seed-creator",
|
||||
status: "active"
|
||||
});
|
||||
|
||||
const projectId = (created.body as { id: string }).id;
|
||||
await expect(prisma.gameProject.findUnique({ where: { id: projectId } })).resolves.toMatchObject({
|
||||
ownerId: "seed-creator"
|
||||
});
|
||||
await expect(prisma.auditLog.findFirst({ where: { targetId: projectId, action: "project.created" } })).resolves.toMatchObject({
|
||||
actorId: "seed-creator",
|
||||
targetType: "GameProject"
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["operator", "operator@example.test", "seed-operator"],
|
||||
["admin", "admin@example.test", "seed-admin"],
|
||||
["player", "player@example.test", "seed-player"]
|
||||
])("POST /projects rejects authenticated non-creator %s without project or audit mutation", async (_label, email, actorId) => {
|
||||
const token = await login(testApp!, email);
|
||||
const slug = `${runId}-non-creator-${actorId}`;
|
||||
const beforeAuditCount = await prisma.auditLog.count({
|
||||
where: { actorId, action: "project.created", targetType: "GameProject" }
|
||||
});
|
||||
|
||||
const denied = await requestJson(testApp!, "/projects", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ title: "Blocked Project", slug })
|
||||
});
|
||||
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toEqual({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
await expect(prisma.gameProject.count({ where: { slug } })).resolves.toBe(0);
|
||||
await expect(
|
||||
prisma.auditLog.count({ where: { actorId, action: "project.created", targetType: "GameProject" } })
|
||||
).resolves.toBe(beforeAuditCount);
|
||||
});
|
||||
|
||||
it("POST /projects rejects anonymous callers with structured 403 before mutation", async () => {
|
||||
const slug = `${runId}-anonymous-create`;
|
||||
const beforeAuditCount = await prisma.auditLog.count({ where: { action: "project.created", targetType: "GameProject" } });
|
||||
|
||||
const denied = await requestJson(testApp!, "/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: "Blocked Anonymous Project", slug })
|
||||
});
|
||||
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toEqual({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
await expect(prisma.gameProject.count({ where: { slug } })).resolves.toBe(0);
|
||||
await expect(prisma.auditLog.count({ where: { action: "project.created", targetType: "GameProject" } })).resolves.toBe(
|
||||
beforeAuditCount
|
||||
);
|
||||
});
|
||||
|
||||
it("GET /projects returns only owned projects for creators", async () => {
|
||||
await seedProject(prisma, "owned", "seed-creator");
|
||||
await seedProject(prisma, "foreign", "seed-creator-other");
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const listed = await requestJson(testApp!, "/projects", {
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
|
||||
expect(listed.response.status).toBe(200);
|
||||
const projects = listed.body as Array<{ id: string; ownerId: string }>;
|
||||
expect(projects).toContainEqual(expect.objectContaining({ id: `${runId}-owned-project`, ownerId: "seed-creator" }));
|
||||
expect(projects).not.toContainEqual(expect.objectContaining({ id: `${runId}-foreign-project` }));
|
||||
expect(projects.every((project) => project.ownerId === "seed-creator")).toBe(true);
|
||||
});
|
||||
|
||||
it("GET /projects/:projectId rejects foreign creators and allows operator/admin foundation reads", async () => {
|
||||
const foreignProject = await seedProject(prisma, "foreign-detail", "seed-creator-other");
|
||||
const creatorToken = await login(testApp!, "creator@example.test");
|
||||
const operatorToken = await login(testApp!, "operator@example.test");
|
||||
const adminToken = await login(testApp!, "admin@example.test");
|
||||
|
||||
const denied = await requestJson(testApp!, `/projects/${foreignProject.id}`, {
|
||||
headers: { authorization: `Bearer ${creatorToken}` }
|
||||
});
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toEqual({
|
||||
code: "FORBIDDEN",
|
||||
message: expect.any(String),
|
||||
requestId: null,
|
||||
details: expect.anything()
|
||||
});
|
||||
|
||||
const operatorRead = await requestJson(testApp!, `/projects/${foreignProject.id}`, {
|
||||
headers: { authorization: `Bearer ${operatorToken}` }
|
||||
});
|
||||
expect(operatorRead.response.status).toBe(200);
|
||||
expect(operatorRead.body).toMatchObject({ id: foreignProject.id, ownerId: "seed-creator-other" });
|
||||
|
||||
const adminRead = await requestJson(testApp!, `/projects/${foreignProject.id}`, {
|
||||
headers: { authorization: `Bearer ${adminToken}` }
|
||||
});
|
||||
expect(adminRead.response.status).toBe(200);
|
||||
expect(adminRead.body).toMatchObject({ id: foreignProject.id, ownerId: "seed-creator-other" });
|
||||
});
|
||||
|
||||
it("POST /projects/:projectId/versions creates a draft version for the owner through the state boundary", async () => {
|
||||
const project = await seedProject(prisma, "version-owner", "seed-creator");
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const created = await requestJson(testApp!, `/projects/${project.id}/versions`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ configJson: { source: "task8" } })
|
||||
});
|
||||
|
||||
expect(created.response.status).toBe(201);
|
||||
expect(created.body).toMatchObject({
|
||||
projectId: project.id,
|
||||
versionNumber: 1,
|
||||
status: "draft",
|
||||
configJson: { source: "task8" }
|
||||
});
|
||||
await expect(
|
||||
prisma.auditLog.findFirst({ where: { targetId: (created.body as { id: string }).id, action: "version.created" } })
|
||||
).resolves.toMatchObject({ actorId: "seed-creator", targetType: "GameVersion" });
|
||||
});
|
||||
|
||||
it("POST /projects/:projectId/versions rejects foreign creators without mutation", async () => {
|
||||
const project = await seedProject(prisma, "version-foreign", "seed-creator-other");
|
||||
const token = await login(testApp!, "creator@example.test");
|
||||
|
||||
const denied = await requestJson(testApp!, `/projects/${project.id}/versions`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ configJson: { blocked: true } })
|
||||
});
|
||||
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toMatchObject({ code: "FORBIDDEN", requestId: null, details: expect.anything() });
|
||||
await expect(prisma.gameVersion.count({ where: { projectId: project.id } })).resolves.toBe(0);
|
||||
await expect(prisma.auditLog.count({ where: { targetId: project.id } })).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it("GET /projects/:projectId/versions returns only authorized versions and allows operator/admin reads", async () => {
|
||||
const ownedProject = await seedProject(prisma, "versions-owned", "seed-creator");
|
||||
const foreignProject = await seedProject(prisma, "versions-foreign", "seed-creator-other");
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await seedDraftVersion(tx, ownedProject.id, "versions-owned", 1);
|
||||
await seedDraftVersion(tx, foreignProject.id, "versions-foreign", 1);
|
||||
});
|
||||
const creatorToken = await login(testApp!, "creator@example.test");
|
||||
const operatorToken = await login(testApp!, "operator@example.test");
|
||||
|
||||
const owned = await requestJson(testApp!, `/projects/${ownedProject.id}/versions`, {
|
||||
headers: { authorization: `Bearer ${creatorToken}` }
|
||||
});
|
||||
expect(owned.response.status).toBe(200);
|
||||
expect((owned.body as Array<{ id: string }>).map((version) => version.id)).toEqual([`${runId}-versions-owned-version`]);
|
||||
|
||||
const denied = await requestJson(testApp!, `/projects/${foreignProject.id}/versions`, {
|
||||
headers: { authorization: `Bearer ${creatorToken}` }
|
||||
});
|
||||
expect(denied.response.status).toBe(403);
|
||||
expect(denied.body).toMatchObject({ code: "FORBIDDEN", message: expect.any(String), requestId: null });
|
||||
|
||||
const operatorRead = await requestJson(testApp!, `/projects/${foreignProject.id}/versions`, {
|
||||
headers: { authorization: `Bearer ${operatorToken}` }
|
||||
});
|
||||
expect(operatorRead.response.status).toBe(200);
|
||||
expect(operatorRead.body).toEqual([
|
||||
expect.objectContaining({ id: `${runId}-versions-foreign-version`, projectId: foreignProject.id, status: "draft" })
|
||||
]);
|
||||
});
|
||||
});
|
||||
210
apps/api/src/modules/queue/index.ts
Normal file
210
apps/api/src/modules/queue/index.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
export type QueueMessage = {
|
||||
readonly jobId: string;
|
||||
readonly type: string;
|
||||
readonly payload: unknown;
|
||||
};
|
||||
|
||||
export type QueueHandler = (message: QueueMessage) => Promise<void> | void;
|
||||
|
||||
export interface QueueAdapter {
|
||||
enqueue(message: QueueMessage): Promise<void>;
|
||||
process(handler: QueueHandler): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class QueueUnavailableError extends Error {
|
||||
readonly code = "QUEUE_UNAVAILABLE";
|
||||
|
||||
constructor(message = "Queue is unavailable") {
|
||||
super(message);
|
||||
this.name = "QueueUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export class InMemoryQueueAdapter implements QueueAdapter {
|
||||
private readonly messages: QueueMessage[] = [];
|
||||
private closed = false;
|
||||
|
||||
async enqueue(message: QueueMessage): Promise<void> {
|
||||
if (this.closed) throw new QueueUnavailableError("In-memory queue is closed");
|
||||
this.messages.push(message);
|
||||
}
|
||||
|
||||
async process(handler: QueueHandler): Promise<void> {
|
||||
if (this.closed) throw new QueueUnavailableError("In-memory queue is closed");
|
||||
|
||||
while (this.messages.length > 0) {
|
||||
const message = this.messages.shift();
|
||||
if (message) await handler(message);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.messages.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export type BullMqQueueAdapterOptions = {
|
||||
readonly queueName: string;
|
||||
readonly redisUrl: string;
|
||||
readonly connectionTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type BullMqQueueInstance = {
|
||||
add(name: string, data: QueueMessage): Promise<unknown>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
type BullMqWorkerInstance = {
|
||||
close(): Promise<void>;
|
||||
on(event: string, listener: (error: Error) => void): unknown;
|
||||
};
|
||||
|
||||
type BullMqModule = {
|
||||
readonly Queue: new (name: string, options: { connection: unknown }) => BullMqQueueInstance;
|
||||
readonly Worker: new (
|
||||
name: string,
|
||||
handler: (job: { data: QueueMessage }) => Promise<void>,
|
||||
options: { connection: unknown }
|
||||
) => BullMqWorkerInstance;
|
||||
};
|
||||
|
||||
type IoredisModule = {
|
||||
readonly default: new (
|
||||
url: string,
|
||||
options: { maxRetriesPerRequest: null; enableReadyCheck: boolean; lazyConnect: boolean; connectTimeout: number }
|
||||
) => {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): void;
|
||||
};
|
||||
};
|
||||
|
||||
function timeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new QueueUnavailableError()), timeoutMs);
|
||||
promise.then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function importBullMq(): Promise<BullMqModule> {
|
||||
return (await import("bullmq")) as BullMqModule;
|
||||
}
|
||||
|
||||
async function importIoredis(): Promise<IoredisModule> {
|
||||
return (await import("ioredis")) as unknown as IoredisModule;
|
||||
}
|
||||
|
||||
export class BullMqQueueAdapter implements QueueAdapter {
|
||||
private readonly queueName: string;
|
||||
private readonly redisUrl: string;
|
||||
private readonly connectionTimeoutMs: number;
|
||||
private queue: BullMqQueueInstance | undefined;
|
||||
private worker: BullMqWorkerInstance | undefined;
|
||||
private connection:
|
||||
| {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): void;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
constructor(options: BullMqQueueAdapterOptions) {
|
||||
this.queueName = options.queueName;
|
||||
this.redisUrl = options.redisUrl;
|
||||
this.connectionTimeoutMs = options.connectionTimeoutMs ?? 1000;
|
||||
}
|
||||
|
||||
async enqueue(message: QueueMessage): Promise<void> {
|
||||
const queue = await this.getQueue();
|
||||
try {
|
||||
await timeout(queue.add(message.type, message), this.connectionTimeoutMs);
|
||||
} catch (error) {
|
||||
throw this.toUnavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async process(handler: QueueHandler): Promise<void> {
|
||||
try {
|
||||
const [{ Worker }, connection] = await Promise.all([importBullMq(), this.getConnection()]);
|
||||
this.worker = new Worker(
|
||||
this.queueName,
|
||||
async (job) => {
|
||||
await handler(job.data);
|
||||
},
|
||||
{ connection }
|
||||
);
|
||||
this.worker.on("error", () => undefined);
|
||||
} catch (error) {
|
||||
throw this.toUnavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.worker?.close().catch(() => undefined);
|
||||
await this.queue?.close().catch(() => undefined);
|
||||
this.connection?.disconnect();
|
||||
}
|
||||
|
||||
private async getQueue(): Promise<BullMqQueueInstance> {
|
||||
if (this.queue) return this.queue;
|
||||
|
||||
try {
|
||||
const [{ Queue }, connection] = await Promise.all([importBullMq(), this.getConnection()]);
|
||||
this.queue = new Queue(this.queueName, { connection });
|
||||
return this.queue;
|
||||
} catch (error) {
|
||||
throw this.toUnavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async getConnection(): Promise<NonNullable<BullMqQueueAdapter["connection"]>> {
|
||||
if (this.connection) return this.connection;
|
||||
|
||||
try {
|
||||
const { default: Redis } = await importIoredis();
|
||||
const connection = new Redis(this.redisUrl, {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
lazyConnect: true,
|
||||
connectTimeout: this.connectionTimeoutMs
|
||||
});
|
||||
await timeout(connection.connect(), this.connectionTimeoutMs);
|
||||
this.connection = connection;
|
||||
return connection;
|
||||
} catch (error) {
|
||||
throw this.toUnavailable(error);
|
||||
}
|
||||
}
|
||||
|
||||
private toUnavailable(error: unknown): QueueUnavailableError {
|
||||
if (error instanceof QueueUnavailableError) return error;
|
||||
const message = error instanceof Error ? error.message : "Queue is unavailable";
|
||||
return new QueueUnavailableError(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopQueueProcessor extends EventEmitter {
|
||||
private processed = 0;
|
||||
|
||||
async handle(message: QueueMessage): Promise<void> {
|
||||
// S1 worker 只消费 no-op 消息,不在 worker 内 claim/complete DB row,避免越过 API-owned JobExecutionStore。
|
||||
if (message.type !== "noop") throw new QueueUnavailableError(`Unsupported S1 job type: ${message.type}`);
|
||||
this.processed += 1;
|
||||
this.emit("processed", message);
|
||||
}
|
||||
|
||||
getProcessedCount(): number {
|
||||
return this.processed;
|
||||
}
|
||||
}
|
||||
32
apps/api/src/modules/queue/queue.spec.ts
Normal file
32
apps/api/src/modules/queue/queue.spec.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { BullMqQueueAdapter, InMemoryQueueAdapter, QueueUnavailableError } from "./index.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("QueueAdapter", () => {
|
||||
it("memory adapter processes a no-op job in tests", async () => {
|
||||
const adapter = new InMemoryQueueAdapter();
|
||||
const processed: string[] = [];
|
||||
|
||||
await adapter.enqueue({ jobId: "job-memory-1", type: "noop", payload: { ok: true } });
|
||||
await adapter.process(async (message) => {
|
||||
processed.push(message.jobId);
|
||||
});
|
||||
|
||||
expect(processed).toEqual(["job-memory-1"]);
|
||||
await adapter.close();
|
||||
});
|
||||
|
||||
it("BullMQ adapter reports QUEUE_UNAVAILABLE when Redis is unavailable", async () => {
|
||||
const adapter = new BullMqQueueAdapter({
|
||||
queueName: "task7-unavailable",
|
||||
redisUrl: "redis://127.0.0.1:1",
|
||||
connectionTimeoutMs: 50
|
||||
});
|
||||
|
||||
await expect(adapter.enqueue({ jobId: "job-bullmq-1", type: "noop", payload: {} })).rejects.toMatchObject<
|
||||
QueueUnavailableError
|
||||
>({
|
||||
code: "QUEUE_UNAVAILABLE"
|
||||
});
|
||||
await adapter.close();
|
||||
});
|
||||
});
|
||||
95
apps/api/src/modules/rbac/index.ts
Normal file
95
apps/api/src/modules/rbac/index.ts
Normal file
@ -0,0 +1,95 @@
|
||||
export type UserRoleName = "admin" | "operator" | "creator" | "player";
|
||||
export type ActorRoleName = UserRoleName | "anonymous";
|
||||
|
||||
export type AuthenticatedActor = {
|
||||
readonly id: string;
|
||||
readonly roles: readonly UserRoleName[];
|
||||
};
|
||||
|
||||
export type AnonymousActor = {
|
||||
readonly id: null;
|
||||
readonly roles: readonly ["anonymous"];
|
||||
};
|
||||
|
||||
export type RbacActor = AuthenticatedActor | AnonymousActor;
|
||||
|
||||
export type ReviewFoundationDataCategory =
|
||||
| "project"
|
||||
| "version"
|
||||
| "audit-log"
|
||||
| "review-record"
|
||||
| "lifecycle-event"
|
||||
| "management-crud";
|
||||
|
||||
const knownRoles = new Set<ActorRoleName>(["admin", "operator", "creator", "player", "anonymous"]);
|
||||
const reviewFoundationReadCategories = new Set<ReviewFoundationDataCategory>([
|
||||
"project",
|
||||
"version",
|
||||
"audit-log",
|
||||
"review-record",
|
||||
"lifecycle-event"
|
||||
]);
|
||||
|
||||
export class RbacForbiddenError extends Error {
|
||||
readonly statusCode = 403;
|
||||
readonly code = "FORBIDDEN";
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "RbacForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export function makeAnonymousActor(): AnonymousActor {
|
||||
return { id: null, roles: ["anonymous"] };
|
||||
}
|
||||
|
||||
export function isKnownRole(role: unknown): role is ActorRoleName {
|
||||
return typeof role === "string" && knownRoles.has(role as ActorRoleName);
|
||||
}
|
||||
|
||||
export function hasRole(actor: RbacActor, role: UserRoleName): boolean {
|
||||
return actor.roles.some((actorRole) => actorRole === role);
|
||||
}
|
||||
|
||||
export function isAuthenticated(actor: RbacActor): actor is AuthenticatedActor {
|
||||
return actor.id !== null;
|
||||
}
|
||||
|
||||
export function requireAuthenticated(actor: RbacActor): AuthenticatedActor {
|
||||
if (!isAuthenticated(actor)) {
|
||||
throw new Error("FORBIDDEN: anonymous actor is outside the S1 protected-route boundary");
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
export function creatorOwnsResource(
|
||||
actor: RbacActor,
|
||||
resource: { readonly ownerId?: string; readonly creatorId?: string; readonly actorId?: string }
|
||||
): boolean {
|
||||
if (!isAuthenticated(actor) || !hasRole(actor, "creator")) return false;
|
||||
|
||||
// direct-object 授权必须 fail closed:资源对象没有明确归属字段时,不推断项目/版本关系。
|
||||
return resource.ownerId === actor.id || resource.creatorId === actor.id || resource.actorId === actor.id;
|
||||
}
|
||||
|
||||
export function requireCreatorResourceOwnership(
|
||||
actor: RbacActor,
|
||||
resource: { readonly ownerId?: string; readonly creatorId?: string; readonly actorId?: string }
|
||||
): AuthenticatedActor {
|
||||
if (!creatorOwnsResource(actor, resource)) {
|
||||
// 直接对象访问必须先授权再执行写入;缺归属、跨 owner、匿名访问都统一 403,不做补偿式推断。
|
||||
throw new RbacForbiddenError("Creator cannot access this resource");
|
||||
}
|
||||
|
||||
return requireAuthenticated(actor);
|
||||
}
|
||||
|
||||
export function canReadReviewFoundationData(actor: RbacActor, category: ReviewFoundationDataCategory): boolean {
|
||||
if (!isAuthenticated(actor)) return false;
|
||||
if (!reviewFoundationReadCategories.has(category)) return false;
|
||||
|
||||
// S1 只开放审核所需基础读权限;后台管理 CRUD 留给后续明确端点单独授权。
|
||||
return hasRole(actor, "admin") || hasRole(actor, "operator");
|
||||
}
|
||||
66
apps/api/src/modules/rbac/rbac.spec.ts
Normal file
66
apps/api/src/modules/rbac/rbac.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canReadReviewFoundationData,
|
||||
creatorOwnsResource,
|
||||
isAuthenticated,
|
||||
isKnownRole,
|
||||
makeAnonymousActor,
|
||||
requireAuthenticated,
|
||||
requireCreatorResourceOwnership
|
||||
} from "./index.js";
|
||||
|
||||
describe("RBAC policy boundary", () => {
|
||||
it("recognizes admin/operator/creator/player/anonymous roles", () => {
|
||||
expect(isKnownRole("admin")).toBe(true);
|
||||
expect(isKnownRole("operator")).toBe(true);
|
||||
expect(isKnownRole("creator")).toBe(true);
|
||||
expect(isKnownRole("player")).toBe(true);
|
||||
expect(isKnownRole("anonymous")).toBe(true);
|
||||
expect(isKnownRole("owner")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats anonymous as unauthenticated until the S6 public feed behavior exists", () => {
|
||||
const anonymous = makeAnonymousActor();
|
||||
|
||||
expect(isAuthenticated(anonymous)).toBe(false);
|
||||
expect(() => requireAuthenticated(anonymous)).toThrow(/FORBIDDEN/);
|
||||
});
|
||||
|
||||
it("allows creator ownership on own resource-like objects and rejects foreign objects fail-closed", () => {
|
||||
const creator = { id: "creator-1", roles: ["creator"] as const };
|
||||
|
||||
expect(creatorOwnsResource(creator, { ownerId: "creator-1" })).toBe(true);
|
||||
expect(creatorOwnsResource(creator, { creatorId: "creator-1" })).toBe(true);
|
||||
expect(creatorOwnsResource(creator, { actorId: "creator-1" })).toBe(true);
|
||||
expect(creatorOwnsResource(creator, { ownerId: "creator-2" })).toBe(false);
|
||||
expect(creatorOwnsResource(creator, { projectId: "project-without-owner" })).toBe(false);
|
||||
expect(creatorOwnsResource(makeAnonymousActor(), { ownerId: "creator-1" })).toBe(false);
|
||||
});
|
||||
|
||||
it("fails direct-object access closed with 403 before mutation", () => {
|
||||
const creator = { id: "creator-1", roles: ["creator"] as const };
|
||||
const mutation = { applied: false };
|
||||
let caught: unknown;
|
||||
|
||||
try {
|
||||
requireCreatorResourceOwnership(creator, { ownerId: "creator-2" });
|
||||
mutation.applied = true;
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toMatchObject({ code: "FORBIDDEN", statusCode: 403 });
|
||||
expect(mutation.applied).toBe(false);
|
||||
});
|
||||
|
||||
it("allows only operator/admin to read review-oriented foundation data categories", () => {
|
||||
expect(canReadReviewFoundationData({ id: "admin-1", roles: ["admin"] }, "project")).toBe(true);
|
||||
expect(canReadReviewFoundationData({ id: "operator-1", roles: ["operator"] }, "version")).toBe(true);
|
||||
expect(canReadReviewFoundationData({ id: "operator-1", roles: ["operator"] }, "audit-log")).toBe(true);
|
||||
|
||||
expect(canReadReviewFoundationData({ id: "creator-1", roles: ["creator"] }, "project")).toBe(false);
|
||||
expect(canReadReviewFoundationData({ id: "player-1", roles: ["player"] }, "version")).toBe(false);
|
||||
expect(canReadReviewFoundationData(makeAnonymousActor(), "audit-log")).toBe(false);
|
||||
expect(canReadReviewFoundationData({ id: "operator-1", roles: ["operator"] }, "management-crud")).toBe(false);
|
||||
});
|
||||
});
|
||||
396
apps/api/src/modules/state-transition/index.ts
Normal file
396
apps/api/src/modules/state-transition/index.ts
Normal file
@ -0,0 +1,396 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Prisma, type GameVersion, type LifecycleEvent } from "../../generated/prisma/client.js";
|
||||
import type { HarnessClient, HarnessGateResult } from "../harness-gate/index.js";
|
||||
|
||||
export type ReviewDecisionEvent = "review_approved" | "review_rejected";
|
||||
|
||||
export type LifecycleEventDto = {
|
||||
readonly eventId: string;
|
||||
readonly event: ReviewDecisionEvent;
|
||||
readonly from: "pending_review";
|
||||
readonly to: "publish_ready" | "rejected";
|
||||
readonly actor: Prisma.InputJsonValue;
|
||||
readonly requiredRole: "operator";
|
||||
readonly requiredRecordRefs: Prisma.InputJsonValue;
|
||||
readonly auditEvent: "lifecycle.review_approved" | "lifecycle.review_rejected";
|
||||
readonly reasonCode: string;
|
||||
readonly occurredAt: string;
|
||||
readonly approval: "operator";
|
||||
readonly requiredRecords: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export type StateTransitionServiceOptions = {
|
||||
readonly db: Prisma.TransactionClient;
|
||||
// 失败审计必须能跨调用方事务回滚持久化,因此由 API composition root 注入非事务客户端。
|
||||
readonly blockedAuditDb: Pick<Prisma.TransactionClient, "auditLog">;
|
||||
readonly harnessGate: HarnessClient;
|
||||
readonly now?: () => Date;
|
||||
};
|
||||
|
||||
export type CreateDraftVersionInput = {
|
||||
readonly id: string;
|
||||
readonly projectId: string;
|
||||
readonly versionNumber: number;
|
||||
readonly configJson: Prisma.InputJsonValue;
|
||||
};
|
||||
|
||||
export type ApplyReviewDecisionInput = {
|
||||
readonly event: ReviewDecisionEvent;
|
||||
readonly gameVersionId: string;
|
||||
readonly reviewRecordId: string;
|
||||
readonly lifecycleEventId: string;
|
||||
readonly actorId: string;
|
||||
readonly actor: Prisma.InputJsonValue;
|
||||
readonly reasonCode: string;
|
||||
};
|
||||
|
||||
export type ApplyReviewDecisionResult = {
|
||||
readonly lifecycleEvent: LifecycleEventDto;
|
||||
};
|
||||
|
||||
export type StateBoundaryViolationReason =
|
||||
| "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
|
||||
| "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION"
|
||||
| "WORKER_DB_IMPORT_FORBIDDEN"
|
||||
| "WORKER_STATE_TRANSITION_IMPORT_FORBIDDEN";
|
||||
|
||||
export type StateBoundaryViolation = {
|
||||
readonly path: string;
|
||||
readonly reason: StateBoundaryViolationReason;
|
||||
};
|
||||
|
||||
type ScanFile = {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
};
|
||||
|
||||
const stateTransitionGuardSql = "SELECT set_config('app.state_transition_guard', 'on', true)";
|
||||
const stateTransitionModulePrefix = "apps/api/src/modules/state-transition/";
|
||||
const generatedPrismaPrefix = "apps/api/src/generated/prisma/";
|
||||
const prismaMigrationPrefix = "apps/api/prisma/migrations/";
|
||||
const historicalTestFiles = new Set(["apps/api/src/prisma.schema.spec.ts"]);
|
||||
const ignoredScanSegments = new Set(["node_modules", "dist", ".vite", "coverage", ".next"]);
|
||||
|
||||
export class StateTransitionDomainError extends Error {
|
||||
readonly statusCode = 422;
|
||||
readonly reasonCode: string;
|
||||
|
||||
constructor(reasonCode: string) {
|
||||
super(`State transition rejected by harness: ${reasonCode}`);
|
||||
this.name = "StateTransitionDomainError";
|
||||
this.reasonCode = reasonCode;
|
||||
}
|
||||
}
|
||||
|
||||
function eventSpec(event: ReviewDecisionEvent) {
|
||||
if (event === "review_approved") {
|
||||
return {
|
||||
auditEvent: "lifecycle.review_approved" as const,
|
||||
decision: "approved" as const,
|
||||
status: "approved" as const,
|
||||
to: "publish_ready" as const,
|
||||
requiredRecords: ["ReviewRecord.decision=approved", "ReviewRecord.reasonCode"]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
auditEvent: "lifecycle.review_rejected" as const,
|
||||
decision: "rejected" as const,
|
||||
status: "rejected" as const,
|
||||
to: "rejected" as const,
|
||||
requiredRecords: ["ReviewRecord.decision=rejected", "ReviewRecord.reasonCode"]
|
||||
};
|
||||
}
|
||||
|
||||
export function projectLifecycleEvent(row: Pick<
|
||||
LifecycleEvent,
|
||||
| "eventId"
|
||||
| "event"
|
||||
| "from"
|
||||
| "to"
|
||||
| "actorJson"
|
||||
| "requiredRole"
|
||||
| "requiredRecordRefsJson"
|
||||
| "auditEvent"
|
||||
| "reasonCode"
|
||||
| "occurredAt"
|
||||
| "approval"
|
||||
| "requiredRecordsJson"
|
||||
>): LifecycleEventDto {
|
||||
return {
|
||||
eventId: row.eventId,
|
||||
event: row.event as ReviewDecisionEvent,
|
||||
from: row.from as "pending_review",
|
||||
to: row.to as "publish_ready" | "rejected",
|
||||
actor: row.actorJson as Prisma.InputJsonValue,
|
||||
requiredRole: row.requiredRole as "operator",
|
||||
requiredRecordRefs: row.requiredRecordRefsJson as Prisma.InputJsonValue,
|
||||
auditEvent: row.auditEvent as "lifecycle.review_approved" | "lifecycle.review_rejected",
|
||||
reasonCode: row.reasonCode,
|
||||
occurredAt: row.occurredAt.toISOString(),
|
||||
approval: row.approval as "operator",
|
||||
requiredRecords: row.requiredRecordsJson as Prisma.InputJsonValue
|
||||
};
|
||||
}
|
||||
|
||||
export class StateTransitionService {
|
||||
private readonly db: Prisma.TransactionClient;
|
||||
private readonly blockedAuditDb: Pick<Prisma.TransactionClient, "auditLog">;
|
||||
private readonly harnessGate: HarnessClient;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(options: StateTransitionServiceOptions) {
|
||||
this.db = options.db;
|
||||
this.blockedAuditDb = options.blockedAuditDb;
|
||||
this.harnessGate = options.harnessGate;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async createDraftVersion(input: CreateDraftVersionInput): Promise<GameVersion> {
|
||||
return this.withStateTransitionGuard(() =>
|
||||
this.db.gameVersion.create({
|
||||
data: {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
versionNumber: input.versionNumber,
|
||||
status: "draft",
|
||||
configJson: input.configJson
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async applyReviewDecision(input: ApplyReviewDecisionInput): Promise<ApplyReviewDecisionResult> {
|
||||
const spec = eventSpec(input.event);
|
||||
const occurredAt = this.now();
|
||||
const lifecycleEvent: LifecycleEventDto = {
|
||||
eventId: input.lifecycleEventId,
|
||||
event: input.event,
|
||||
from: "pending_review",
|
||||
to: spec.to,
|
||||
actor: input.actor,
|
||||
requiredRole: "operator",
|
||||
requiredRecordRefs: [input.reviewRecordId],
|
||||
auditEvent: spec.auditEvent,
|
||||
reasonCode: input.reasonCode,
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
approval: "operator",
|
||||
requiredRecords: spec.requiredRecords
|
||||
};
|
||||
|
||||
const gate = await this.harnessGate.validateTransition(input.event, lifecycleEvent);
|
||||
if (!gate.ok) {
|
||||
await this.writeBlockedAudit(input, spec.auditEvent, gate);
|
||||
throw new StateTransitionDomainError(gate.reasonCode ?? "HARNESS_INVALID_OUTPUT");
|
||||
}
|
||||
|
||||
const contractGate = await this.harnessGate.validateContract("LifecycleEvent", lifecycleEvent);
|
||||
if (!contractGate.ok) {
|
||||
await this.writeBlockedAudit(input, spec.auditEvent, contractGate);
|
||||
throw new StateTransitionDomainError(contractGate.reasonCode ?? "HARNESS_INVALID_OUTPUT");
|
||||
}
|
||||
|
||||
await this.withStateTransitionGuard(async () => {
|
||||
await this.db.reviewRecord.create({
|
||||
data: {
|
||||
id: input.reviewRecordId,
|
||||
gameVersionId: input.gameVersionId,
|
||||
status: spec.status,
|
||||
decision: spec.decision,
|
||||
reasonCode: input.reasonCode,
|
||||
decidedById: input.actorId,
|
||||
decidedAt: occurredAt
|
||||
}
|
||||
});
|
||||
|
||||
await this.db.lifecycleEvent.create({
|
||||
data: {
|
||||
eventId: input.lifecycleEventId,
|
||||
gameVersionId: input.gameVersionId,
|
||||
event: input.event,
|
||||
from: lifecycleEvent.from,
|
||||
to: lifecycleEvent.to,
|
||||
actorJson: lifecycleEvent.actor,
|
||||
requiredRole: lifecycleEvent.requiredRole,
|
||||
requiredRecordRefsJson: lifecycleEvent.requiredRecordRefs,
|
||||
auditEvent: lifecycleEvent.auditEvent,
|
||||
reasonCode: lifecycleEvent.reasonCode,
|
||||
occurredAt,
|
||||
approval: lifecycleEvent.approval,
|
||||
requiredRecordsJson: lifecycleEvent.requiredRecords
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await this.db.auditLog.create({
|
||||
data: {
|
||||
id: `${input.lifecycleEventId}-audit`,
|
||||
actorId: input.actorId,
|
||||
action: spec.auditEvent,
|
||||
targetType: "GameVersion",
|
||||
targetId: input.gameVersionId,
|
||||
eventJson: {
|
||||
lifecycleEventId: input.lifecycleEventId,
|
||||
reviewRecordId: input.reviewRecordId,
|
||||
harness: gate
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { lifecycleEvent };
|
||||
}
|
||||
|
||||
private async withStateTransitionGuard<T>(callback: () => Promise<T>): Promise<T> {
|
||||
await this.db.$executeRawUnsafe(stateTransitionGuardSql);
|
||||
try {
|
||||
const result = await callback();
|
||||
await this.db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', '', true)");
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', '', true)");
|
||||
} catch {
|
||||
// 保留原始 DB/业务错误优先级;事务结束时 transaction-local guard 会释放。
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async writeBlockedAudit(
|
||||
input: ApplyReviewDecisionInput,
|
||||
action: string,
|
||||
gate: HarnessGateResult
|
||||
): Promise<void> {
|
||||
await this.blockedAuditDb.auditLog.create({
|
||||
data: {
|
||||
id: `${input.lifecycleEventId}-blocked-audit`,
|
||||
actorId: input.actorId,
|
||||
action: `${action}.blocked`,
|
||||
targetType: "GameVersion",
|
||||
targetId: input.gameVersionId,
|
||||
eventJson: {
|
||||
lifecycleEventId: input.lifecycleEventId,
|
||||
reviewRecordId: input.reviewRecordId,
|
||||
reasonCode: gate.reasonCode
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectFiles(root: string, relativeDir: string): Promise<ScanFile[]> {
|
||||
const absoluteDir = path.join(root, relativeDir);
|
||||
const entries = await readdir(absoluteDir, { withFileTypes: true }).catch(() => []);
|
||||
const files: ScanFile[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory() && ignoredScanSegments.has(entry.name)) continue;
|
||||
const relativePath = path.join(relativeDir, entry.name);
|
||||
const absolutePath = path.join(root, relativePath);
|
||||
if (entry.isDirectory()) files.push(...(await collectFiles(root, relativePath)));
|
||||
if (entry.isFile() && /\.(?:ts|tsx|js|mjs|cjs|json|sql)$/.test(entry.name)) {
|
||||
files.push({
|
||||
path: relativePath,
|
||||
content: await readFile(absolutePath, "utf8")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function isAllowedStateTransitionFile(filePath: string): boolean {
|
||||
return filePath.startsWith(stateTransitionModulePrefix);
|
||||
}
|
||||
|
||||
function isHistoricalTestFile(filePath: string): boolean {
|
||||
return historicalTestFiles.has(filePath);
|
||||
}
|
||||
|
||||
function isGeneratedPrismaFile(filePath: string): boolean {
|
||||
return filePath.startsWith(generatedPrismaPrefix);
|
||||
}
|
||||
|
||||
function isIgnoredScanPath(filePath: string): boolean {
|
||||
return filePath.split("/").some((segment) => ignoredScanSegments.has(segment));
|
||||
}
|
||||
|
||||
function hasGuardedPrismaWrite(content: string): boolean {
|
||||
const guardedModels = ["gameVersion", "reviewRecord", "lifecycleEvent"];
|
||||
const hasPrismaWrite = guardedModels.some((model) => {
|
||||
const createOrUpdate = new RegExp(`\\.${model}\\.(?:create|update|upsert|createMany|updateMany|delete|deleteMany)\\s*\\(`);
|
||||
return createOrUpdate.test(content);
|
||||
});
|
||||
const hasSqlWrite = /\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+"?(?:GameVersion|ReviewRecord|LifecycleEvent)"?/i.test(content);
|
||||
return hasPrismaWrite || hasSqlWrite;
|
||||
}
|
||||
|
||||
function hasRuntimeGuardSetter(content: string): boolean {
|
||||
return /app\.state_transition_guard|set_config\s*\(|SET\s+LOCAL/i.test(content);
|
||||
}
|
||||
|
||||
function stripSqlComments(content: string): string {
|
||||
return content
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/--.*$/, ""))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function hasForbiddenMigrationGuardSetter(content: string): boolean {
|
||||
const sql = stripSqlComments(content);
|
||||
return /set_config\s*\(|SET\s+LOCAL/i.test(sql);
|
||||
}
|
||||
|
||||
export function findStateBoundaryViolations(files: readonly ScanFile[]): StateBoundaryViolation[] {
|
||||
const violations: StateBoundaryViolation[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const normalizedPath = file.path.split(path.sep).join("/");
|
||||
if (isIgnoredScanPath(normalizedPath)) continue;
|
||||
if (isHistoricalTestFile(normalizedPath)) continue;
|
||||
if (isGeneratedPrismaFile(normalizedPath)) continue;
|
||||
|
||||
if (normalizedPath.startsWith("apps/worker/")) {
|
||||
if (/@prisma\/client|generated\/prisma|PrismaClient/.test(file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "WORKER_DB_IMPORT_FORBIDDEN" });
|
||||
}
|
||||
if (/state-transition|StateTransitionService|app\.state_transition_guard|set_config\s*\(|SET\s+LOCAL/i.test(file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "WORKER_STATE_TRANSITION_IMPORT_FORBIDDEN" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAllowedStateTransitionFile(normalizedPath)) continue;
|
||||
|
||||
if (normalizedPath.startsWith(prismaMigrationPrefix)) {
|
||||
if (hasForbiddenMigrationGuardSetter(file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION" });
|
||||
}
|
||||
if (hasGuardedPrismaWrite(file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION" });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasGuardedPrismaWrite(file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION" });
|
||||
}
|
||||
if (hasRuntimeGuardSetter(file.content)) {
|
||||
violations.push({ path: normalizedPath, reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION" });
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
export async function scanStateTransitionBoundary(repoRoot: string): Promise<StateBoundaryViolation[]> {
|
||||
const files = [
|
||||
...(await collectFiles(repoRoot, "apps/api/src")),
|
||||
...(await collectFiles(repoRoot, "apps/api/prisma/migrations")),
|
||||
...(await collectFiles(repoRoot, "apps/worker"))
|
||||
];
|
||||
|
||||
return findStateBoundaryViolations(files);
|
||||
}
|
||||
456
apps/api/src/modules/state-transition/state-transition.spec.ts
Normal file
456
apps/api/src/modules/state-transition/state-transition.spec.ts
Normal file
@ -0,0 +1,456 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Prisma, PrismaClient } from "../../generated/prisma/client.js";
|
||||
import { HarnessGateService } from "../harness-gate/index.js";
|
||||
import {
|
||||
StateTransitionDomainError,
|
||||
StateTransitionService,
|
||||
findStateBoundaryViolations,
|
||||
projectLifecycleEvent,
|
||||
scanStateTransitionBoundary
|
||||
} from "./index.js";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../../..");
|
||||
const harnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
|
||||
const rollbackToken = Symbol("state transition rollback");
|
||||
const runId = `task5-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const occurredAt = new Date("2026-06-01T06:00:00.000Z");
|
||||
|
||||
type DbClient = Prisma.TransactionClient;
|
||||
let savepointSequence = 0;
|
||||
|
||||
class OrdinaryVersionService {
|
||||
constructor(private readonly db: DbClient) {}
|
||||
|
||||
async forceStatus(versionId: string) {
|
||||
return this.db.gameVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { status: "active" }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function withRollback(callback: (tx: DbClient) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await callback(tx);
|
||||
throw rollbackToken;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== rollbackToken) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectPrismaRejectedInSavepoint(
|
||||
db: DbClient,
|
||||
action: () => Promise<unknown>,
|
||||
pattern: RegExp
|
||||
): Promise<void> {
|
||||
const savepointName = `task5_sp_${++savepointSequence}`;
|
||||
await db.$executeRawUnsafe(`SAVEPOINT ${savepointName}`);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
await db.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
await db.$executeRawUnsafe(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
|
||||
if (caught === undefined) throw new Error(`Expected Prisma action to reject with ${pattern}`);
|
||||
expect(() => {
|
||||
throw caught;
|
||||
}).toThrow(pattern);
|
||||
}
|
||||
|
||||
async function withTempJson<T>(value: unknown, callback: (payloadPath: string) => Promise<T>): Promise<T> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "huijing-state-transition-"));
|
||||
const payloadPath = path.join(dir, "payload.json");
|
||||
|
||||
try {
|
||||
await writeFile(payloadPath, JSON.stringify(value), "utf8");
|
||||
return await callback(payloadPath);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHarnessContract(contract: string, value: unknown): Promise<void> {
|
||||
await withTempJson(value, async (payloadPath) => {
|
||||
const result = await execFileAsync("node", [harnessCli, "--contract", contract, "--input", payloadPath], {
|
||||
cwd: repoRoot
|
||||
});
|
||||
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectHarnessTransition(transition: string, value: unknown): Promise<void> {
|
||||
await withTempJson(value, async (payloadPath) => {
|
||||
const result = await execFileAsync("node", [harnessCli, "--transition", transition, "--input", payloadPath], {
|
||||
cwd: repoRoot
|
||||
});
|
||||
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
}
|
||||
|
||||
async function seedProjectSet(db: DbClient, suffix: string) {
|
||||
const creator = await db.user.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-creator`,
|
||||
email: `${runId}-${suffix}-creator@example.test`,
|
||||
displayName: `${suffix} creator`
|
||||
}
|
||||
});
|
||||
|
||||
const operator = await db.user.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-operator`,
|
||||
email: `${runId}-${suffix}-operator@example.test`,
|
||||
displayName: `${suffix} operator`
|
||||
}
|
||||
});
|
||||
|
||||
const project = await db.gameProject.create({
|
||||
data: {
|
||||
id: `${runId}-${suffix}-project`,
|
||||
ownerId: creator.id,
|
||||
slug: `${runId}-${suffix}-project`,
|
||||
title: `${suffix} project`
|
||||
}
|
||||
});
|
||||
|
||||
const service = new StateTransitionService({ db, blockedAuditDb: db, harnessGate: new HarnessGateService() });
|
||||
const version = await service.createDraftVersion({
|
||||
id: `${runId}-${suffix}-version`,
|
||||
projectId: project.id,
|
||||
versionNumber: 1,
|
||||
configJson: { seed: suffix }
|
||||
});
|
||||
|
||||
return { creator, operator, project, version };
|
||||
}
|
||||
|
||||
describe("StateTransitionService", () => {
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("harness PASS allows review_approved and writes review, lifecycle, and audit facts", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "approve-pass");
|
||||
const service = new StateTransitionService({
|
||||
db: tx,
|
||||
blockedAuditDb: prisma,
|
||||
harnessGate: new HarnessGateService(),
|
||||
now: () => occurredAt
|
||||
});
|
||||
|
||||
const result = await service.applyReviewDecision({
|
||||
event: "review_approved",
|
||||
gameVersionId: seeded.version.id,
|
||||
reviewRecordId: `${runId}-approve-pass-review`,
|
||||
lifecycleEventId: `${runId}-approve-pass-lifecycle`,
|
||||
actorId: seeded.operator.id,
|
||||
actor: { role: "operator", operatorId: seeded.operator.id },
|
||||
reasonCode: "approved_for_publish"
|
||||
});
|
||||
|
||||
expect(result.lifecycleEvent).toEqual({
|
||||
eventId: `${runId}-approve-pass-lifecycle`,
|
||||
event: "review_approved",
|
||||
from: "pending_review",
|
||||
to: "publish_ready",
|
||||
actor: { role: "operator", operatorId: seeded.operator.id },
|
||||
requiredRole: "operator",
|
||||
requiredRecordRefs: [`${runId}-approve-pass-review`],
|
||||
auditEvent: "lifecycle.review_approved",
|
||||
reasonCode: "approved_for_publish",
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
approval: "operator",
|
||||
requiredRecords: ["ReviewRecord.decision=approved", "ReviewRecord.reasonCode"]
|
||||
});
|
||||
|
||||
const review = await tx.reviewRecord.findUniqueOrThrow({ where: { id: `${runId}-approve-pass-review` } });
|
||||
expect(review.status).toBe("approved");
|
||||
expect(review.decision).toBe("approved");
|
||||
expect(review.reasonCode).toBe("approved_for_publish");
|
||||
|
||||
const lifecycle = await tx.lifecycleEvent.findUniqueOrThrow({
|
||||
where: { eventId: `${runId}-approve-pass-lifecycle` }
|
||||
});
|
||||
const projected = projectLifecycleEvent(lifecycle);
|
||||
await expectHarnessContract("LifecycleEvent", projected);
|
||||
await expectHarnessTransition("review_approved", projected);
|
||||
|
||||
const audit = await tx.auditLog.findFirstOrThrow({
|
||||
where: { action: "lifecycle.review_approved", targetId: seeded.version.id }
|
||||
});
|
||||
expect(audit.eventJson).toMatchObject({
|
||||
lifecycleEventId: `${runId}-approve-pass-lifecycle`,
|
||||
reviewRecordId: `${runId}-approve-pass-review`,
|
||||
harness: { ok: true, reasonCode: null }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("harness FAIL bubbles across the transaction boundary and persists the blocked audit fact", async () => {
|
||||
const seeded = await prisma.$transaction((tx) => seedProjectSet(tx, "approve-fail"));
|
||||
|
||||
await expect(
|
||||
prisma.$transaction(async (tx) => {
|
||||
const service = new StateTransitionService({
|
||||
db: tx,
|
||||
blockedAuditDb: prisma,
|
||||
harnessGate: {
|
||||
validateContract: async () => ({ ok: true, reasonCode: null }),
|
||||
validateTransition: async () => ({ ok: false, reasonCode: "TRANSITION_REQUIRED_RECORD_MISMATCH" })
|
||||
},
|
||||
now: () => occurredAt
|
||||
});
|
||||
|
||||
await service.applyReviewDecision({
|
||||
event: "review_approved",
|
||||
gameVersionId: seeded.version.id,
|
||||
reviewRecordId: `${runId}-approve-fail-review`,
|
||||
lifecycleEventId: `${runId}-approve-fail-lifecycle`,
|
||||
actorId: seeded.operator.id,
|
||||
actor: { role: "operator", operatorId: seeded.operator.id },
|
||||
reasonCode: "approved_for_publish"
|
||||
});
|
||||
})
|
||||
).rejects.toMatchObject<StateTransitionDomainError>({
|
||||
statusCode: 422,
|
||||
reasonCode: "TRANSITION_REQUIRED_RECORD_MISMATCH"
|
||||
});
|
||||
|
||||
await expect(prisma.reviewRecord.findUnique({ where: { id: `${runId}-approve-fail-review` } })).resolves.toBeNull();
|
||||
await expect(
|
||||
prisma.lifecycleEvent.findUnique({ where: { eventId: `${runId}-approve-fail-lifecycle` } })
|
||||
).resolves.toBeNull();
|
||||
|
||||
const audit = await prisma.auditLog.findUniqueOrThrow({
|
||||
where: { id: `${runId}-approve-fail-lifecycle-blocked-audit` }
|
||||
});
|
||||
expect(audit).toMatchObject({
|
||||
action: "lifecycle.review_approved.blocked",
|
||||
targetId: seeded.version.id
|
||||
});
|
||||
expect(audit.eventJson).toMatchObject({
|
||||
reasonCode: "TRANSITION_REQUIRED_RECORD_MISMATCH",
|
||||
lifecycleEventId: `${runId}-approve-fail-lifecycle`,
|
||||
reviewRecordId: `${runId}-approve-fail-review`
|
||||
});
|
||||
});
|
||||
|
||||
it("ordinary services and direct Prisma cannot bypass guarded status or lifecycle writes", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "bypass");
|
||||
const ordinary = new OrdinaryVersionService(tx);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(tx, () => ordinary.forceStatus(seeded.version.id), /state transition guard is required/);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.gameVersion.create({
|
||||
data: {
|
||||
id: `${runId}-bypass-direct-version`,
|
||||
projectId: seeded.project.id,
|
||||
versionNumber: 2,
|
||||
status: "draft",
|
||||
configJson: {}
|
||||
}
|
||||
}),
|
||||
/state transition guard is required/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-bypass-direct-review`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "pending_review"
|
||||
}
|
||||
}),
|
||||
/state transition guard is required/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.lifecycleEvent.create({
|
||||
data: {
|
||||
eventId: `${runId}-bypass-direct-lifecycle`,
|
||||
gameVersionId: seeded.version.id,
|
||||
event: "review_rejected",
|
||||
from: "pending_review",
|
||||
to: "rejected",
|
||||
actorJson: { role: "operator", operatorId: seeded.operator.id },
|
||||
requiredRole: "operator",
|
||||
requiredRecordRefsJson: [`${runId}-bypass-direct-review`],
|
||||
auditEvent: "lifecycle.review_rejected",
|
||||
reasonCode: "needs_design_changes",
|
||||
occurredAt,
|
||||
approval: "operator",
|
||||
requiredRecordsJson: ["ReviewRecord.decision=rejected", "ReviewRecord.reasonCode"]
|
||||
}
|
||||
}),
|
||||
/state transition guard is required/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("DB review/lifecycle facts project to S0 review_rejected transition input and LifecycleEvent contract", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "reject-pass");
|
||||
const service = new StateTransitionService({
|
||||
db: tx,
|
||||
blockedAuditDb: prisma,
|
||||
harnessGate: new HarnessGateService(),
|
||||
now: () => occurredAt
|
||||
});
|
||||
|
||||
await service.applyReviewDecision({
|
||||
event: "review_rejected",
|
||||
gameVersionId: seeded.version.id,
|
||||
reviewRecordId: `${runId}-reject-pass-review`,
|
||||
lifecycleEventId: `${runId}-reject-pass-lifecycle`,
|
||||
actorId: seeded.operator.id,
|
||||
actor: { role: "operator", operatorId: seeded.operator.id },
|
||||
reasonCode: "needs_design_changes"
|
||||
});
|
||||
|
||||
const review = await tx.reviewRecord.findUniqueOrThrow({ where: { id: `${runId}-reject-pass-review` } });
|
||||
expect(review.status).toBe("rejected");
|
||||
expect(review.decision).toBe("rejected");
|
||||
|
||||
const lifecycle = await tx.lifecycleEvent.findUniqueOrThrow({
|
||||
where: { eventId: `${runId}-reject-pass-lifecycle` }
|
||||
});
|
||||
const projected = projectLifecycleEvent(lifecycle);
|
||||
await expectHarnessContract("LifecycleEvent", projected);
|
||||
await expectHarnessTransition("review_rejected", projected);
|
||||
});
|
||||
});
|
||||
|
||||
it("static scan enforces runtime state-transition and worker DB boundaries without scanning historical Task 4 tests", async () => {
|
||||
await expect(scanStateTransitionBoundary(repoRoot)).resolves.toEqual([]);
|
||||
|
||||
expect(
|
||||
findStateBoundaryViolations([
|
||||
{
|
||||
path: "apps/api/src/modules/projects/project.service.ts",
|
||||
content: "await prisma.gameVersion.update({ where: { id }, data: { status: 'active' } });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/reviews/review.service.ts",
|
||||
content: "await db.$executeRawUnsafe(\"SELECT set_config('app.state_transition_guard', 'on', true)\");"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/src/index.ts",
|
||||
content: "import { PrismaClient } from '@prisma/client';"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/src/review-worker.ts",
|
||||
content: "import { StateTransitionService } from '../../api/src/modules/state-transition/index.js';"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/package.json",
|
||||
content: "{\"dependencies\":{\"@prisma/client\":\"7.8.0\"}}"
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
path: "apps/api/src/modules/projects/project.service.ts",
|
||||
reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
|
||||
},
|
||||
{
|
||||
path: "apps/api/src/modules/reviews/review.service.ts",
|
||||
reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/src/index.ts",
|
||||
reason: "WORKER_DB_IMPORT_FORBIDDEN"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/src/review-worker.ts",
|
||||
reason: "WORKER_STATE_TRANSITION_IMPORT_FORBIDDEN"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/package.json",
|
||||
reason: "WORKER_DB_IMPORT_FORBIDDEN"
|
||||
}
|
||||
]);
|
||||
|
||||
expect(
|
||||
findStateBoundaryViolations([
|
||||
{
|
||||
path: "apps/api/src/modules/state-transition/index.ts",
|
||||
content:
|
||||
"await tx.$executeRawUnsafe(\"SELECT set_config('app.state_transition_guard', 'on', true)\"); await tx.lifecycleEvent.create({ data });"
|
||||
},
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql",
|
||||
content: await readFile(path.join(repoRoot, "apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql"), "utf8")
|
||||
}
|
||||
])
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
findStateBoundaryViolations([
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
|
||||
content: "SELECT set_config('app.state_transition_guard', 'on', true);"
|
||||
},
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
|
||||
content: "UPDATE \"GameVersion\" SET \"status\" = 'active';"
|
||||
},
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
|
||||
content: "INSERT INTO \"LifecycleEvent\" (\"eventId\") VALUES ('x');"
|
||||
}
|
||||
])
|
||||
).toEqual([
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
|
||||
reason: "STATE_GUARD_SETTER_OUTSIDE_STATE_TRANSITION"
|
||||
},
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
|
||||
reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
|
||||
},
|
||||
{
|
||||
path: "apps/api/prisma/migrations/20260601999999_bad/migration.sql",
|
||||
reason: "GUARDED_PRISMA_WRITE_OUTSIDE_STATE_TRANSITION"
|
||||
}
|
||||
]);
|
||||
|
||||
expect(
|
||||
findStateBoundaryViolations([
|
||||
{
|
||||
path: "apps/api/src/prisma.schema.spec.ts",
|
||||
content: "await db.$executeRawUnsafe(\"SELECT set_config('app.state_transition_guard', 'on', true)\");"
|
||||
},
|
||||
{
|
||||
path: "apps/worker/dist/index.js",
|
||||
content: "const { PrismaClient } = require('@prisma/client');"
|
||||
}
|
||||
])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
190
apps/api/src/modules/storage/index.ts
Normal file
190
apps/api/src/modules/storage/index.ts
Normal file
@ -0,0 +1,190 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { lstat, mkdir, realpath, rename, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export type PutObjectInput = {
|
||||
readonly ownerId: string;
|
||||
readonly projectId: string;
|
||||
readonly objectName: string;
|
||||
readonly mimeType: string;
|
||||
readonly bytes: Buffer | Uint8Array;
|
||||
readonly expectedSha256: string;
|
||||
};
|
||||
|
||||
export type PutObjectResult = {
|
||||
readonly storageKey: string;
|
||||
readonly absolutePath: string;
|
||||
readonly byteSize: number;
|
||||
readonly sha256: string;
|
||||
readonly mimeType: string;
|
||||
};
|
||||
|
||||
export type PlanObjectInput = Omit<PutObjectInput, "bytes"> & {
|
||||
readonly byteSize: number;
|
||||
};
|
||||
|
||||
export type PlanObjectResult = Omit<PutObjectResult, "absolutePath">;
|
||||
|
||||
export type LocalStorageAdapterOptions = {
|
||||
readonly root: string;
|
||||
readonly allowedMimeTypes: readonly string[];
|
||||
readonly maxBytes: number;
|
||||
};
|
||||
|
||||
export type StorageBoundaryErrorCode =
|
||||
| "INVALID_STORAGE_KEY"
|
||||
| "STORAGE_ROOT_ESCAPE"
|
||||
| "MIME_NOT_ALLOWED"
|
||||
| "FILE_TOO_LARGE"
|
||||
| "CHECKSUM_MISMATCH";
|
||||
|
||||
export class StorageBoundaryError extends Error {
|
||||
readonly code: StorageBoundaryErrorCode;
|
||||
|
||||
constructor(code: StorageBoundaryErrorCode, message = code) {
|
||||
super(message);
|
||||
this.name = "StorageBoundaryError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer | Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function isSafeSegment(segment: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment) && segment !== "." && segment !== "..";
|
||||
}
|
||||
|
||||
function rejectUnsafeValue(value: string): void {
|
||||
if (value.includes("\u0000")) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
if (/%(?:2f|5c|00|2e)/i.test(value)) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
}
|
||||
|
||||
function normalizeObjectName(objectName: string): string {
|
||||
rejectUnsafeValue(objectName);
|
||||
if (path.isAbsolute(objectName)) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
|
||||
const normalized = path.posix.normalize(objectName);
|
||||
if (normalized.startsWith("../") || normalized === ".." || normalized.includes("/../")) {
|
||||
throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
}
|
||||
|
||||
const segments = normalized.split("/");
|
||||
if (segments.some((segment) => !isSafeSegment(segment))) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
return segments.join("/");
|
||||
}
|
||||
|
||||
async function assertInsideRoot(rootReal: string, candidate: string): Promise<void> {
|
||||
const parentReal = await realpath(path.dirname(candidate));
|
||||
const relative = path.relative(rootReal, parentReal);
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
|
||||
throw new StorageBoundaryError("STORAGE_ROOT_ESCAPE");
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
||||
}
|
||||
|
||||
function assertRealPathInsideRoot(rootReal: string, candidateReal: string): void {
|
||||
const relative = path.relative(rootReal, candidateReal);
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
|
||||
throw new StorageBoundaryError("STORAGE_ROOT_ESCAPE");
|
||||
}
|
||||
|
||||
async function assertSafeExistingDirectory(rootReal: string, candidate: string): Promise<void> {
|
||||
const stat = await lstat(candidate);
|
||||
if (stat.isSymbolicLink()) throw new StorageBoundaryError("STORAGE_ROOT_ESCAPE");
|
||||
if (!stat.isDirectory()) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
assertRealPathInsideRoot(rootReal, await realpath(candidate));
|
||||
}
|
||||
|
||||
async function ensureSafeDirectory(root: string, rootReal: string, relativeSegments: readonly string[]): Promise<void> {
|
||||
let current = root;
|
||||
for (const segment of relativeSegments) {
|
||||
current = path.join(current, segment);
|
||||
try {
|
||||
await assertSafeExistingDirectory(rootReal, current);
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error;
|
||||
|
||||
// 逐级创建目录前已确认父级不是 symlink 且 realpath 仍在 root 内,避免 recursive mkdir 穿过 root 内 symlink 污染外部目录。
|
||||
await mkdir(current);
|
||||
await assertSafeExistingDirectory(rootReal, current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalStorageAdapter {
|
||||
private readonly root: string;
|
||||
private readonly allowedMimeTypes: Set<string>;
|
||||
private readonly maxBytes: number;
|
||||
|
||||
constructor(options: LocalStorageAdapterOptions) {
|
||||
this.root = path.resolve(options.root);
|
||||
this.allowedMimeTypes = new Set(options.allowedMimeTypes);
|
||||
this.maxBytes = options.maxBytes;
|
||||
}
|
||||
|
||||
planObject(input: PlanObjectInput): PlanObjectResult {
|
||||
if (!this.allowedMimeTypes.has(input.mimeType)) throw new StorageBoundaryError("MIME_NOT_ALLOWED");
|
||||
if (input.byteSize > this.maxBytes) throw new StorageBoundaryError("FILE_TOO_LARGE");
|
||||
if (!Number.isInteger(input.byteSize) || input.byteSize <= 0) throw new StorageBoundaryError("FILE_TOO_LARGE");
|
||||
|
||||
rejectUnsafeValue(input.ownerId);
|
||||
rejectUnsafeValue(input.projectId);
|
||||
if (!isSafeSegment(input.ownerId) || !isSafeSegment(input.projectId)) throw new StorageBoundaryError("INVALID_STORAGE_KEY");
|
||||
|
||||
const expected = input.expectedSha256.toLowerCase();
|
||||
if (!/^[a-f0-9]{64}$/.test(expected)) throw new StorageBoundaryError("CHECKSUM_MISMATCH");
|
||||
|
||||
return {
|
||||
// presign 和实际 putObject 共用 adapter-owned key 构造,避免 API 层复制 path/MIME/size 规则后漂移。
|
||||
storageKey: `owners/${input.ownerId}/projects/${input.projectId}/${normalizeObjectName(input.objectName)}`,
|
||||
byteSize: input.byteSize,
|
||||
sha256: expected,
|
||||
mimeType: input.mimeType
|
||||
};
|
||||
}
|
||||
|
||||
async putObject(input: PutObjectInput): Promise<PutObjectResult> {
|
||||
const planned = this.planObject({
|
||||
ownerId: input.ownerId,
|
||||
projectId: input.projectId,
|
||||
objectName: input.objectName,
|
||||
mimeType: input.mimeType,
|
||||
byteSize: input.bytes.byteLength,
|
||||
expectedSha256: input.expectedSha256
|
||||
});
|
||||
const expected = input.expectedSha256.toLowerCase();
|
||||
const actual = sha256(input.bytes);
|
||||
if (actual !== expected) throw new StorageBoundaryError("CHECKSUM_MISMATCH");
|
||||
|
||||
await mkdir(this.root, { recursive: true });
|
||||
const rootReal = await realpath(this.root);
|
||||
const finalPath = path.join(this.root, planned.storageKey);
|
||||
const finalDirSegments = planned.storageKey.split("/").slice(0, -1);
|
||||
const tempPath = path.join(this.root, `.upload-${randomUUID()}.tmp`);
|
||||
|
||||
try {
|
||||
await assertSafeExistingDirectory(rootReal, this.root);
|
||||
await ensureSafeDirectory(this.root, rootReal, finalDirSegments);
|
||||
await assertInsideRoot(rootReal, finalPath);
|
||||
await writeFile(tempPath, input.bytes);
|
||||
await assertInsideRoot(rootReal, tempPath);
|
||||
await rename(tempPath, finalPath);
|
||||
await assertInsideRoot(rootReal, finalPath);
|
||||
return {
|
||||
storageKey: planned.storageKey,
|
||||
absolutePath: finalPath,
|
||||
byteSize: input.bytes.byteLength,
|
||||
sha256: actual,
|
||||
mimeType: input.mimeType
|
||||
};
|
||||
} catch (error) {
|
||||
await rm(tempPath, { force: true }).catch(() => undefined);
|
||||
if (error instanceof StorageBoundaryError) throw error;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
apps/api/src/modules/storage/storage.spec.ts
Normal file
132
apps/api/src/modules/storage/storage.spec.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { mkdir, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { LocalStorageAdapter, StorageBoundaryError } from "./index.js";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
let root: string;
|
||||
let outside: string;
|
||||
|
||||
function sha256(buffer: Buffer): string {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function adapter(options: { maxBytes?: number; mimeTypes?: string[] } = {}) {
|
||||
return new LocalStorageAdapter({
|
||||
root,
|
||||
allowedMimeTypes: options.mimeTypes ?? ["text/plain", "image/png"],
|
||||
maxBytes: options.maxBytes ?? 1024
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = path.join(os.tmpdir(), `huijing-storage-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(root, { recursive: true });
|
||||
outside = path.join(os.tmpdir(), `huijing-storage-outside-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
await mkdir(outside, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("LocalStorageAdapter", () => {
|
||||
it("builds owner/project scoped object keys and stores uploads under S1_STORAGE_ROOT", async () => {
|
||||
const data = Buffer.from("hello", "utf8");
|
||||
const stored = await adapter().putObject({
|
||||
ownerId: "creator-1",
|
||||
projectId: "project-1",
|
||||
objectName: "cover.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: data,
|
||||
expectedSha256: sha256(data)
|
||||
});
|
||||
|
||||
expect(stored.storageKey).toBe("owners/creator-1/projects/project-1/cover.txt");
|
||||
expect(stored.absolutePath.startsWith(root)).toBe(true);
|
||||
await expect(readFile(stored.absolutePath, "utf8")).resolves.toBe("hello");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["path traversal", "../escape.txt"],
|
||||
["absolute paths", "/tmp/escape.txt"],
|
||||
["encoded separators", "folder%2Fescape.txt"],
|
||||
["encoded traversal", "%2e%2e/escape.txt"],
|
||||
["NUL bytes", "evil\u0000name.txt"]
|
||||
])("rejects %s", async (_label, objectName) => {
|
||||
const data = Buffer.from("blocked", "utf8");
|
||||
|
||||
await expect(
|
||||
adapter().putObject({
|
||||
ownerId: "creator-1",
|
||||
projectId: "project-1",
|
||||
objectName,
|
||||
mimeType: "text/plain",
|
||||
bytes: data,
|
||||
expectedSha256: sha256(data)
|
||||
})
|
||||
).rejects.toMatchObject<StorageBoundaryError>({ code: "INVALID_STORAGE_KEY" });
|
||||
});
|
||||
|
||||
it("rejects symlink and realpath escapes outside S1_STORAGE_ROOT", async () => {
|
||||
await mkdir(path.join(outside, "target"), { recursive: true });
|
||||
await symlink(path.join(outside, "target"), path.join(root, "owners"));
|
||||
const data = Buffer.from("escape", "utf8");
|
||||
|
||||
await expect(
|
||||
adapter().putObject({
|
||||
ownerId: "creator-1",
|
||||
projectId: "project-1",
|
||||
objectName: "escape.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: data,
|
||||
expectedSha256: sha256(data)
|
||||
})
|
||||
).rejects.toMatchObject<StorageBoundaryError>({ code: "STORAGE_ROOT_ESCAPE" });
|
||||
|
||||
await expect(readdir(path.join(outside, "target"))).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("enforces MIME allowlist, max size, checksum validation, and temporary cleanup", async () => {
|
||||
const data = Buffer.from("hello", "utf8");
|
||||
|
||||
await expect(
|
||||
adapter({ mimeTypes: ["image/png"] }).putObject({
|
||||
ownerId: "creator-1",
|
||||
projectId: "project-1",
|
||||
objectName: "file.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: data,
|
||||
expectedSha256: sha256(data)
|
||||
})
|
||||
).rejects.toMatchObject<StorageBoundaryError>({ code: "MIME_NOT_ALLOWED" });
|
||||
|
||||
await expect(
|
||||
adapter({ maxBytes: 2 }).putObject({
|
||||
ownerId: "creator-1",
|
||||
projectId: "project-1",
|
||||
objectName: "large.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: data,
|
||||
expectedSha256: sha256(data)
|
||||
})
|
||||
).rejects.toMatchObject<StorageBoundaryError>({ code: "FILE_TOO_LARGE" });
|
||||
|
||||
await expect(
|
||||
adapter().putObject({
|
||||
ownerId: "creator-1",
|
||||
projectId: "project-1",
|
||||
objectName: "bad-checksum.txt",
|
||||
mimeType: "text/plain",
|
||||
bytes: data,
|
||||
expectedSha256: "0".repeat(64)
|
||||
})
|
||||
).rejects.toMatchObject<StorageBoundaryError>({ code: "CHECKSUM_MISMATCH" });
|
||||
|
||||
await expect(readFile(path.join(root, "owners/creator-1/projects/project-1/bad-checksum.txt"))).rejects.toThrow();
|
||||
await writeFile(path.join(root, "leftover.tmp"), "manual");
|
||||
await expect(readFile(path.join(root, "leftover.tmp"), "utf8")).resolves.toBe("manual");
|
||||
});
|
||||
});
|
||||
800
apps/api/src/prisma.schema.spec.ts
Normal file
800
apps/api/src/prisma.schema.spec.ts
Normal file
@ -0,0 +1,800 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { Prisma, PrismaClient } from "./generated/prisma/client.js";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const databaseUrl = process.env.DATABASE_URL ?? "postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public";
|
||||
const adapter = new PrismaPg({ connectionString: databaseUrl });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const runId = `task4-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const rollbackToken = Symbol("rollback test transaction");
|
||||
type DbClient = Prisma.TransactionClient;
|
||||
let savepointSequence = 0;
|
||||
|
||||
const workspaceRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const harnessCli = path.join(workspaceRoot, "harness/scripts/validate-harness.mjs");
|
||||
|
||||
// S0 harness 的 AgentTask 校验依赖 fixture context,这里使用 fixture 兼容 ID 单独验证投影。
|
||||
const s0SessionSeed = {
|
||||
id: "session-main-creation-001",
|
||||
creatorId: "creator-demo-001",
|
||||
projectId: "project-s0-simulation-001",
|
||||
versionId: "version-s0-simulation-001",
|
||||
status: "routing_internal_tasks",
|
||||
contextSummary: "Creator asked for a lightweight simulation game with short sessions and mobile-first controls.",
|
||||
createdAt: new Date("2026-06-01T01:55:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T02:00:00.000Z")
|
||||
};
|
||||
|
||||
const s0TaskSeed = {
|
||||
id: "agent-task-requirement-clarifier-001",
|
||||
sessionId: "session-main-creation-001",
|
||||
taskType: "requirement_clarifier",
|
||||
subagentId: "subagent-requirement-clarifier-001",
|
||||
inputRef: "prompt-record-s0-001",
|
||||
outputRef: "requirement-brief-simulation-001",
|
||||
status: "succeeded",
|
||||
timeoutAt: new Date("2026-06-01T02:10:00.000Z"),
|
||||
errorCode: null,
|
||||
auditLogId: "audit-agent-task-requirement-clarifier-001",
|
||||
createdAt: new Date("2026-06-01T01:56:00.000Z"),
|
||||
updatedAt: new Date("2026-06-01T01:58:00.000Z")
|
||||
};
|
||||
|
||||
function iso(value: Date): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
// Prisma row 不能直接作为 S0 DTO 暴露;投影函数只输出 S0 schema 允许字段。
|
||||
function projectSession(row: {
|
||||
id: string;
|
||||
creatorId: string;
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
status: string;
|
||||
contextSummary: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
creatorId: row.creatorId,
|
||||
projectId: row.projectId,
|
||||
versionId: row.versionId,
|
||||
status: row.status,
|
||||
contextSummary: row.contextSummary,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function projectTask(row: {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
taskType: string;
|
||||
subagentId: string;
|
||||
inputRef: string;
|
||||
outputRef: string | null;
|
||||
status: string;
|
||||
timeoutAt: Date;
|
||||
errorCode: string | null;
|
||||
auditLogId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.sessionId,
|
||||
taskType: row.taskType,
|
||||
subagentId: row.subagentId,
|
||||
inputRef: row.inputRef,
|
||||
outputRef: row.outputRef,
|
||||
status: row.status,
|
||||
timeoutAt: iso(row.timeoutAt),
|
||||
errorCode: row.errorCode,
|
||||
auditLogId: row.auditLogId,
|
||||
createdAt: iso(row.createdAt),
|
||||
updatedAt: iso(row.updatedAt)
|
||||
};
|
||||
}
|
||||
|
||||
function projectLifecycle(row: {
|
||||
eventId: string;
|
||||
event: string;
|
||||
from: string;
|
||||
to: string;
|
||||
actorJson: Prisma.JsonValue;
|
||||
requiredRole: string;
|
||||
requiredRecordRefsJson: Prisma.JsonValue;
|
||||
auditEvent: string;
|
||||
reasonCode: string;
|
||||
occurredAt: Date;
|
||||
approval: string;
|
||||
requiredRecordsJson: Prisma.JsonValue;
|
||||
}) {
|
||||
return {
|
||||
eventId: row.eventId,
|
||||
event: row.event,
|
||||
from: row.from,
|
||||
to: row.to,
|
||||
actor: row.actorJson,
|
||||
requiredRole: row.requiredRole,
|
||||
requiredRecordRefs: row.requiredRecordRefsJson,
|
||||
auditEvent: row.auditEvent,
|
||||
reasonCode: row.reasonCode,
|
||||
occurredAt: iso(row.occurredAt),
|
||||
approval: row.approval,
|
||||
requiredRecords: row.requiredRecordsJson
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempJson<T>(value: unknown, run: (file: string) => Promise<T>): Promise<T> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "huijing-prisma-"));
|
||||
const file = path.join(dir, "payload.json");
|
||||
await writeFile(file, JSON.stringify(value, null, 2), "utf8");
|
||||
try {
|
||||
return await run(file);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHarnessContract(contract: string, value: unknown): Promise<void> {
|
||||
await withTempJson(value, async (file) => {
|
||||
const result = await execFileAsync("node", [harnessCli, "--contract", contract, "--input", file], {
|
||||
cwd: workspaceRoot
|
||||
});
|
||||
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectHarnessTransition(transition: string, value: unknown): Promise<void> {
|
||||
await withTempJson(value, async (file) => {
|
||||
const result = await execFileAsync("node", [harnessCli, "--transition", transition, "--input", file], {
|
||||
cwd: workspaceRoot
|
||||
});
|
||||
expect(JSON.parse(result.stdout)).toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
}
|
||||
|
||||
async function expectPrismaRejectedInSavepoint(
|
||||
db: DbClient,
|
||||
action: () => Promise<unknown>,
|
||||
pattern: RegExp
|
||||
): Promise<void> {
|
||||
const savepointName = `task4_sp_${++savepointSequence}`;
|
||||
await db.$executeRawUnsafe(`SAVEPOINT ${savepointName}`);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
await db.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${savepointName}`);
|
||||
await db.$executeRawUnsafe(`RELEASE SAVEPOINT ${savepointName}`);
|
||||
|
||||
if (caught === undefined) {
|
||||
throw new Error(`Expected Prisma action to reject with ${pattern}`);
|
||||
}
|
||||
expect(() => {
|
||||
throw caught;
|
||||
}).toThrow(pattern);
|
||||
}
|
||||
|
||||
// Task 4 只允许测试 setup 使用事务本地 guard,不提供生产 state-transition helper。
|
||||
async function withStateGuard<T>(db: DbClient, callback: () => Promise<T>): Promise<T> {
|
||||
await db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', 'on', true)");
|
||||
const result = await callback();
|
||||
await db.$executeRawUnsafe("SELECT set_config('app.state_transition_guard', '', true)");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function withRollback(callback: (tx: DbClient) => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await callback(tx);
|
||||
throw rollbackToken;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error !== rollbackToken) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function seedProjectSet(db: DbClient, prefix: string) {
|
||||
const scopedPrefix = `${runId}-${prefix}`;
|
||||
const creator = await db.user.create({
|
||||
data: {
|
||||
id: `${scopedPrefix}-creator`,
|
||||
email: `${scopedPrefix}@example.test`,
|
||||
displayName: `${prefix} creator`
|
||||
}
|
||||
});
|
||||
|
||||
const operator = await db.user.create({
|
||||
data: {
|
||||
id: `${scopedPrefix}-operator`,
|
||||
email: `${scopedPrefix}-operator@example.test`,
|
||||
displayName: `${prefix} operator`
|
||||
}
|
||||
});
|
||||
|
||||
const project = await db.gameProject.create({
|
||||
data: {
|
||||
id: `${scopedPrefix}-project`,
|
||||
ownerId: creator.id,
|
||||
slug: `${scopedPrefix}-project`,
|
||||
title: `${prefix} project`
|
||||
}
|
||||
});
|
||||
|
||||
const version = await insertGameVersion(db, project.id, `${scopedPrefix}-version`, 1);
|
||||
|
||||
return { creator, operator, project, version };
|
||||
}
|
||||
|
||||
async function seedS0FixtureProjectSet(db: DbClient) {
|
||||
const creator = await db.user.create({
|
||||
data: {
|
||||
id: s0SessionSeed.creatorId,
|
||||
email: `${runId}-creator-demo-001@example.test`,
|
||||
displayName: "S0 fixture creator"
|
||||
}
|
||||
});
|
||||
|
||||
const project = await db.gameProject.create({
|
||||
data: {
|
||||
id: s0SessionSeed.projectId,
|
||||
ownerId: creator.id,
|
||||
slug: `${runId}-${s0SessionSeed.projectId}`,
|
||||
title: "S0 simulation project"
|
||||
}
|
||||
});
|
||||
|
||||
const version = await insertGameVersion(db, project.id, s0SessionSeed.versionId, 1);
|
||||
|
||||
return { creator, project, version };
|
||||
}
|
||||
|
||||
async function insertGameVersion(db: DbClient, projectId: string, id: string, versionNumber: number) {
|
||||
return withStateGuard(db, () =>
|
||||
db.gameVersion.create({
|
||||
data: {
|
||||
id,
|
||||
projectId,
|
||||
versionNumber,
|
||||
status: "draft",
|
||||
configJson: { seed: id }
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
describe("Prisma core database model", () => {
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("stores S0-compatible session and task anchors and passes the S0 CLI projection checks", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
await seedS0FixtureProjectSet(tx);
|
||||
const session = await tx.mainCreationAgentSession.create({
|
||||
data: {
|
||||
...s0SessionSeed
|
||||
}
|
||||
});
|
||||
|
||||
const task = await tx.agentTask.create({
|
||||
data: {
|
||||
...s0TaskSeed,
|
||||
sessionId: session.id
|
||||
}
|
||||
});
|
||||
|
||||
await expectHarnessContract("MainCreationAgentSession", projectSession(session));
|
||||
await expectHarnessContract("AgentTask", projectTask(task));
|
||||
});
|
||||
});
|
||||
|
||||
it("anchors arbitrary DB session and task rows to their actual owner, project, version, and parent session", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "relationship-integrity");
|
||||
const session = await tx.mainCreationAgentSession.create({
|
||||
data: {
|
||||
id: `${runId}-relationship-integrity-session`,
|
||||
creatorId: seeded.creator.id,
|
||||
projectId: seeded.project.id,
|
||||
versionId: seeded.version.id,
|
||||
status: "routing_internal_tasks",
|
||||
contextSummary: "Arbitrary relationship integrity anchor"
|
||||
}
|
||||
});
|
||||
|
||||
const task = await tx.agentTask.create({
|
||||
data: {
|
||||
id: `${runId}-relationship-integrity-task`,
|
||||
sessionId: session.id,
|
||||
taskType: "requirement_clarifier",
|
||||
subagentId: "subagent-requirement-clarifier-001",
|
||||
inputRef: "relationship-integrity-input",
|
||||
outputRef: null,
|
||||
status: "queued",
|
||||
timeoutAt: new Date("2026-06-01T04:00:00.000Z"),
|
||||
auditLogId: `${runId}-audit-relationship-integrity-task`
|
||||
}
|
||||
});
|
||||
|
||||
const anchoredSession = await tx.mainCreationAgentSession.findUniqueOrThrow({
|
||||
where: { id: session.id },
|
||||
include: {
|
||||
creator: true,
|
||||
project: true,
|
||||
version: true,
|
||||
tasks: true
|
||||
}
|
||||
});
|
||||
|
||||
expect(anchoredSession.creator.id).toBe(seeded.creator.id);
|
||||
expect(anchoredSession.project.id).toBe(seeded.project.id);
|
||||
expect(anchoredSession.version.id).toBe(seeded.version.id);
|
||||
expect(anchoredSession.tasks.map((item) => item.id)).toEqual([task.id]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects session anchors when projectId and versionId point at different projects", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const first = await seedProjectSet(tx, "session-first");
|
||||
const second = await seedProjectSet(tx, "session-second");
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.mainCreationAgentSession.create({
|
||||
data: {
|
||||
id: `${runId}-session-project-mismatch`,
|
||||
creatorId: first.creator.id,
|
||||
projectId: first.project.id,
|
||||
versionId: second.version.id,
|
||||
status: "routing_internal_tasks",
|
||||
contextSummary: "Mismatched session anchor"
|
||||
}
|
||||
}),
|
||||
/Foreign key constraint violated/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces enums, task allowlist, timeout, failure, and audit anchor fields at the DB boundary", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "task-policy");
|
||||
const session = await tx.mainCreationAgentSession.create({
|
||||
data: {
|
||||
id: `${runId}-task-policy-session`,
|
||||
creatorId: seeded.creator.id,
|
||||
projectId: seeded.project.id,
|
||||
versionId: seeded.version.id,
|
||||
status: "routing_internal_tasks",
|
||||
contextSummary: "Task policy anchor"
|
||||
}
|
||||
});
|
||||
|
||||
const failedTask = await tx.agentTask.create({
|
||||
data: {
|
||||
id: `${runId}-task-policy-failed-task`,
|
||||
sessionId: session.id,
|
||||
taskType: "game_design_draft_generator",
|
||||
subagentId: "subagent-game-draft-001",
|
||||
inputRef: "requirement-brief-simulation-001",
|
||||
outputRef: null,
|
||||
status: "failed",
|
||||
timeoutAt: new Date("2026-06-01T04:30:00.000Z"),
|
||||
errorCode: "SUBAGENT_TIMEOUT",
|
||||
auditLogId: `${runId}-audit-task-policy-failed`
|
||||
}
|
||||
});
|
||||
|
||||
expect(failedTask.errorCode).toBe("SUBAGENT_TIMEOUT");
|
||||
expect(failedTask.auditLogId).toBe(`${runId}-audit-task-policy-failed`);
|
||||
expect(failedTask.timeoutAt.toISOString()).toBe("2026-06-01T04:30:00.000Z");
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.agentTask.create({
|
||||
data: {
|
||||
id: `${runId}-task-policy-invalid-pair`,
|
||||
sessionId: session.id,
|
||||
taskType: "requirement_clarifier",
|
||||
subagentId: "subagent-game-draft-001",
|
||||
inputRef: "prompt-record-s0-001",
|
||||
outputRef: null,
|
||||
status: "queued",
|
||||
timeoutAt: new Date("2026-06-01T04:40:00.000Z"),
|
||||
auditLogId: `${runId}-audit-task-policy-invalid`
|
||||
}
|
||||
}),
|
||||
/AgentTask_task_subagent_allowlist_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.$executeRawUnsafe(
|
||||
`INSERT INTO "GameVersion" ("id", "projectId", "versionNumber", "status", "configJson")
|
||||
VALUES ('${runId}-task-policy-bad-status', '${seeded.project.id}', 99, 'pending_review', '{}'::jsonb)`
|
||||
),
|
||||
/invalid input value for enum/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid Job target shapes and enforces scoped idempotency", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const first = await seedProjectSet(tx, "job-first");
|
||||
const second = await seedProjectSet(tx, "job-second");
|
||||
|
||||
await tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-project-valid`,
|
||||
actorId: first.creator.id,
|
||||
projectId: first.project.id,
|
||||
type: "build_project",
|
||||
idempotencyKey: "idem-1",
|
||||
targetType: "project",
|
||||
targetId: first.project.id,
|
||||
targetScopeKey: `project:${first.project.id}`,
|
||||
gameProjectId: first.project.id,
|
||||
payloadJson: { ok: true }
|
||||
}
|
||||
});
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-project-duplicate`,
|
||||
actorId: first.creator.id,
|
||||
projectId: first.project.id,
|
||||
type: "build_project",
|
||||
idempotencyKey: "idem-1",
|
||||
targetType: "project",
|
||||
targetId: first.project.id,
|
||||
targetScopeKey: `project:${first.project.id}`,
|
||||
gameProjectId: first.project.id,
|
||||
payloadJson: { ok: true }
|
||||
}
|
||||
}),
|
||||
/Unique constraint failed/
|
||||
);
|
||||
|
||||
await tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-project-same-key-other-actor`,
|
||||
actorId: second.creator.id,
|
||||
projectId: first.project.id,
|
||||
type: "build_project",
|
||||
idempotencyKey: "idem-1",
|
||||
targetType: "project",
|
||||
targetId: first.project.id,
|
||||
targetScopeKey: `project:${first.project.id}`,
|
||||
gameProjectId: first.project.id,
|
||||
payloadJson: { ok: true }
|
||||
}
|
||||
});
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-project-xor-invalid`,
|
||||
actorId: first.creator.id,
|
||||
projectId: first.project.id,
|
||||
type: "build_project",
|
||||
idempotencyKey: "idem-xor",
|
||||
targetType: "project",
|
||||
targetId: first.project.id,
|
||||
targetScopeKey: `project:${first.project.id}`,
|
||||
gameProjectId: first.project.id,
|
||||
gameVersionId: first.version.id,
|
||||
payloadJson: { invalid: true }
|
||||
}
|
||||
}),
|
||||
/Job_target_xor_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-project-targetid-invalid`,
|
||||
actorId: first.creator.id,
|
||||
projectId: first.project.id,
|
||||
type: "build_project",
|
||||
idempotencyKey: "idem-targetid",
|
||||
targetType: "project",
|
||||
targetId: second.project.id,
|
||||
targetScopeKey: `project:${first.project.id}`,
|
||||
gameProjectId: first.project.id,
|
||||
payloadJson: { invalid: true }
|
||||
}
|
||||
}),
|
||||
/Job_target_id_matches_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-project-scope-invalid`,
|
||||
actorId: first.creator.id,
|
||||
projectId: second.project.id,
|
||||
type: "build_project",
|
||||
idempotencyKey: "idem-scope",
|
||||
targetType: "project",
|
||||
targetId: first.project.id,
|
||||
targetScopeKey: `project:${first.project.id}`,
|
||||
gameProjectId: first.project.id,
|
||||
payloadJson: { invalid: true }
|
||||
}
|
||||
}),
|
||||
/Job_project_scope_matches_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.job.create({
|
||||
data: {
|
||||
id: `${runId}-job-version-scope-invalid`,
|
||||
actorId: first.creator.id,
|
||||
projectId: second.project.id,
|
||||
type: "build_version",
|
||||
idempotencyKey: "idem-version-scope",
|
||||
targetType: "version",
|
||||
targetId: first.version.id,
|
||||
targetScopeKey: `version:${first.version.id}`,
|
||||
gameVersionId: first.version.id,
|
||||
payloadJson: { invalid: true }
|
||||
}
|
||||
}),
|
||||
/Foreign key constraint violated/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces ReviewRecord decision evidence for approved, rejected, pending, and canceled states", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "review-evidence");
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
withStateGuard(tx, () =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-review-approved-missing-decision`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "approved",
|
||||
reasonCode: "approved_for_publish",
|
||||
decidedById: seeded.operator.id,
|
||||
decidedAt: new Date("2026-06-01T05:01:00.000Z")
|
||||
}
|
||||
})
|
||||
),
|
||||
/ReviewRecord_decision_reason_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
withStateGuard(tx, () =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-review-rejected-missing-evidence`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "rejected",
|
||||
decision: "rejected",
|
||||
reasonCode: " ",
|
||||
decidedById: seeded.operator.id,
|
||||
decidedAt: new Date("2026-06-01T05:02:00.000Z")
|
||||
}
|
||||
})
|
||||
),
|
||||
/ReviewRecord_decision_reason_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
withStateGuard(tx, () =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-review-pending-with-decision`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "pending_review",
|
||||
decision: "approved",
|
||||
reasonCode: "approved_for_publish",
|
||||
decidedById: seeded.operator.id,
|
||||
decidedAt: new Date("2026-06-01T05:03:00.000Z")
|
||||
}
|
||||
})
|
||||
),
|
||||
/ReviewRecord_decision_reason_check/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
withStateGuard(tx, () =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-review-canceled-with-decision`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "canceled",
|
||||
decision: "rejected",
|
||||
reasonCode: "needs_design_changes",
|
||||
decidedById: seeded.operator.id,
|
||||
decidedAt: new Date("2026-06-01T05:04:00.000Z")
|
||||
}
|
||||
})
|
||||
),
|
||||
/ReviewRecord_decision_reason_check/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks direct guarded state writes, lifecycle deletes, and audit mutations without the transaction guard", async () => {
|
||||
await withRollback(async (tx) => {
|
||||
const seeded = await seedProjectSet(tx, "guard-policy");
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.gameVersion.create({
|
||||
data: {
|
||||
id: `${runId}-guard-policy-direct-version`,
|
||||
projectId: seeded.project.id,
|
||||
versionNumber: 2,
|
||||
status: "draft",
|
||||
configJson: {}
|
||||
}
|
||||
}),
|
||||
/state transition guard is required/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.gameVersion.update({
|
||||
where: { id: seeded.version.id },
|
||||
data: { status: "candidate" }
|
||||
}),
|
||||
/state transition guard is required/
|
||||
);
|
||||
|
||||
const review = await withStateGuard(tx, () =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-guard-policy-review-approved`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "approved",
|
||||
decision: "approved",
|
||||
reasonCode: "approved_for_publish",
|
||||
decidedById: seeded.operator.id,
|
||||
decidedAt: new Date("2026-06-01T05:00:00.000Z")
|
||||
}
|
||||
})
|
||||
);
|
||||
expect(review.reasonCode).toBe("approved_for_publish");
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
tx.reviewRecord.update({
|
||||
where: { id: review.id },
|
||||
data: { status: "rejected", decision: "rejected", reasonCode: "needs_fix" }
|
||||
}),
|
||||
/state transition guard is required/
|
||||
);
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() =>
|
||||
withStateGuard(tx, () =>
|
||||
tx.reviewRecord.create({
|
||||
data: {
|
||||
id: `${runId}-guard-policy-review-missing-reason`,
|
||||
gameVersionId: seeded.version.id,
|
||||
status: "approved",
|
||||
decision: "approved",
|
||||
reasonCode: null,
|
||||
decidedById: seeded.operator.id,
|
||||
decidedAt: new Date("2026-06-01T05:01:00.000Z")
|
||||
}
|
||||
})
|
||||
),
|
||||
/ReviewRecord_decision_reason_check/
|
||||
);
|
||||
|
||||
const lifecycle = await withStateGuard(tx, () =>
|
||||
tx.lifecycleEvent.create({
|
||||
data: {
|
||||
eventId: `${runId}-guard-policy-review-approved-event`,
|
||||
gameVersionId: seeded.version.id,
|
||||
event: "review_approved",
|
||||
from: "pending_review",
|
||||
to: "publish_ready",
|
||||
actorJson: { role: "operator", operatorId: seeded.operator.id },
|
||||
requiredRole: "operator",
|
||||
requiredRecordRefsJson: [review.id],
|
||||
auditEvent: "lifecycle.review_approved",
|
||||
reasonCode: "approved_for_publish",
|
||||
approval: "operator",
|
||||
requiredRecordsJson: ["ReviewRecord.decision=approved", "ReviewRecord.reasonCode"],
|
||||
occurredAt: new Date("2026-06-01T05:02:00.000Z")
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await expectHarnessContract("LifecycleEvent", projectLifecycle(lifecycle));
|
||||
await expectHarnessTransition("review_approved", projectLifecycle(lifecycle));
|
||||
|
||||
const rejectedLifecycle = await withStateGuard(tx, () =>
|
||||
tx.lifecycleEvent.create({
|
||||
data: {
|
||||
eventId: `${runId}-guard-policy-review-rejected-event`,
|
||||
gameVersionId: seeded.version.id,
|
||||
event: "review_rejected",
|
||||
from: "pending_review",
|
||||
to: "rejected",
|
||||
actorJson: { role: "operator", operatorId: seeded.operator.id },
|
||||
requiredRole: "operator",
|
||||
requiredRecordRefsJson: [`${runId}-review-rejected-001`],
|
||||
auditEvent: "lifecycle.review_rejected",
|
||||
reasonCode: "needs_design_changes",
|
||||
approval: "operator",
|
||||
requiredRecordsJson: ["ReviewRecord.decision=rejected", "ReviewRecord.reasonCode"],
|
||||
occurredAt: new Date("2026-06-01T05:03:00.000Z")
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await expectHarnessTransition("review_rejected", projectLifecycle(rejectedLifecycle));
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() => tx.lifecycleEvent.delete({ where: { eventId: lifecycle.eventId } }),
|
||||
/state transition guard is required/
|
||||
);
|
||||
|
||||
const audit = await tx.auditLog.create({
|
||||
data: {
|
||||
id: `${runId}-guard-policy-audit`,
|
||||
actorId: seeded.operator.id,
|
||||
action: "lifecycle.review_approved",
|
||||
targetType: "GameVersion",
|
||||
targetId: seeded.version.id,
|
||||
eventJson: { reviewRecordId: review.id }
|
||||
}
|
||||
});
|
||||
|
||||
await expectPrismaRejectedInSavepoint(
|
||||
tx,
|
||||
() => tx.auditLog.update({ where: { id: audit.id }, data: { action: "changed" } }),
|
||||
/AuditLog is append-only/
|
||||
);
|
||||
await expectPrismaRejectedInSavepoint(tx, () => tx.auditLog.delete({ where: { id: audit.id } }), /AuditLog is append-only/);
|
||||
});
|
||||
});
|
||||
});
|
||||
14
apps/api/tsconfig.json
Normal file
14
apps/api/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||
}
|
||||
6
apps/web/next-env.d.ts
vendored
Normal file
6
apps/web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
5
apps/web/next.config.mjs
Normal file
5
apps/web/next.config.mjs
Normal file
@ -0,0 +1,5 @@
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@huijing/shared-contracts"]
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@huijing/web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "next build",
|
||||
"dev:smoke": "next build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@huijing/shared-contracts": "workspace:*",
|
||||
"next": "^16.2.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
11
apps/web/src/app/layout.tsx
Normal file
11
apps/web/src/app/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { createElement } from "react";
|
||||
|
||||
export const metadata = {
|
||||
description: "Huijing AI app foundation",
|
||||
title: "Huijing AI"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { readonly children: ReactNode }) {
|
||||
return createElement("html", { lang: "en" }, createElement("body", null, children));
|
||||
}
|
||||
11
apps/web/src/app/page.tsx
Normal file
11
apps/web/src/app/page.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { createElement } from "react";
|
||||
import { S1_PACKAGE_NAMES } from "@huijing/shared-contracts";
|
||||
|
||||
export default function HomePage() {
|
||||
return createElement(
|
||||
"main",
|
||||
{ "data-package": S1_PACKAGE_NAMES.web },
|
||||
createElement("h1", null, "Huijing AI App Foundation"),
|
||||
createElement("p", null, `${S1_PACKAGE_NAMES.web} is ready.`)
|
||||
);
|
||||
}
|
||||
25
apps/web/src/smoke.test.ts
Normal file
25
apps/web/src/smoke.test.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import RootLayout from "./app/layout";
|
||||
import HomePage from "./app/page";
|
||||
|
||||
describe("@huijing/web smoke", () => {
|
||||
it("returns an App Router root layout with html and body elements", () => {
|
||||
const layout = RootLayout({ children: "content" }) as unknown as ReactElement<{
|
||||
children: ReactElement<{ children: ReactNode }>;
|
||||
lang: string;
|
||||
}>;
|
||||
|
||||
expect(layout.type).toBe("html");
|
||||
expect(layout.props.lang).toBe("en");
|
||||
expect(layout.props.children.type).toBe("body");
|
||||
expect(layout.props.children.props.children).toBe("content");
|
||||
});
|
||||
|
||||
it("renders the web package identity on the home page", () => {
|
||||
const page = HomePage() as ReactElement<{ "data-package": string }>;
|
||||
|
||||
expect(page.type).toBe("main");
|
||||
expect(page.props["data-package"]).toBe("@huijing/web");
|
||||
});
|
||||
});
|
||||
13
apps/web/tsconfig.json
Normal file
13
apps/web/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"jsx": "preserve",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"noEmit": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"types": ["node", "react", "react-dom"]
|
||||
},
|
||||
"include": ["next-env.d.ts", ".next/types/**/*.ts", "src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
5
apps/worker/.env.example
Normal file
5
apps/worker/.env.example
Normal file
@ -0,0 +1,5 @@
|
||||
DATABASE_URL=postgresql://huijing:huijing@localhost:5432/huijing_dev?schema=public
|
||||
REDIS_URL=redis://localhost:6379
|
||||
QUEUE_ADAPTER=bullmq
|
||||
S1_STORAGE_ROOT=.local/storage
|
||||
HARNESS_CLI_TIMEOUT_MS=5000
|
||||
16
apps/worker/package.json
Normal file
16
apps/worker/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@huijing/worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev:smoke": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
40
apps/worker/src/index.ts
Normal file
40
apps/worker/src/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export interface WorkerSmokeStatus {
|
||||
readonly packageName: "@huijing/worker";
|
||||
readonly queue: "none" | "memory";
|
||||
readonly status: "idle";
|
||||
}
|
||||
|
||||
export interface WorkerQueueMessage {
|
||||
readonly jobId: string;
|
||||
readonly type: "noop";
|
||||
readonly payload: unknown;
|
||||
}
|
||||
|
||||
export interface WorkerNoopSmokeResult {
|
||||
readonly processed: number;
|
||||
readonly jobTypes: readonly string[];
|
||||
}
|
||||
|
||||
export function getWorkerSmokeStatus(): WorkerSmokeStatus {
|
||||
return {
|
||||
packageName: "@huijing/worker",
|
||||
queue: "none",
|
||||
status: "idle"
|
||||
};
|
||||
}
|
||||
|
||||
export async function runNoopWorkerSmoke(): Promise<WorkerNoopSmokeResult> {
|
||||
const messages: WorkerQueueMessage[] = [{ jobId: "worker-smoke-noop", type: "noop", payload: {} }];
|
||||
const processed: string[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
// S1 worker smoke 只证明 no-op queue handler 可运行;DB claim/complete 由 API JobExecutionStore 统一拥有。
|
||||
if (message.type !== "noop") continue;
|
||||
processed.push(message.type);
|
||||
}
|
||||
|
||||
return {
|
||||
processed: processed.length,
|
||||
jobTypes: processed
|
||||
};
|
||||
}
|
||||
19
apps/worker/src/worker.smoke.spec.ts
Normal file
19
apps/worker/src/worker.smoke.spec.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getWorkerSmokeStatus, runNoopWorkerSmoke } from "./index";
|
||||
|
||||
describe("@huijing/worker smoke", () => {
|
||||
it("reports an idle no-op worker status", () => {
|
||||
expect(getWorkerSmokeStatus()).toEqual({
|
||||
packageName: "@huijing/worker",
|
||||
queue: "none",
|
||||
status: "idle"
|
||||
});
|
||||
});
|
||||
|
||||
it("processes one no-op queue message without importing Prisma or mutating guarded state", async () => {
|
||||
await expect(runNoopWorkerSmoke()).resolves.toEqual({
|
||||
processed: 1,
|
||||
jobTypes: ["noop"]
|
||||
});
|
||||
});
|
||||
});
|
||||
10
apps/worker/tsconfig.json
Normal file
10
apps/worker/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||
}
|
||||
81
docs/memorys/2026-06-01-S1Task1工作区初始化门禁.md
Normal file
81
docs/memorys/2026-06-01-S1Task1工作区初始化门禁.md
Normal file
@ -0,0 +1,81 @@
|
||||
# 2026-06-01 S1 Task 1 工作区初始化门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 1 `Initialize Workspace` 已完成实现,并通过两轮 fresh review gate:
|
||||
|
||||
- spec compliance review:PASS,无 blocking finding。
|
||||
- quality / feasibility review:PASS,无 blocking finding。
|
||||
|
||||
可以进入 S1 Task 2。
|
||||
|
||||
## 前置基线
|
||||
|
||||
Task 1 开始前,工作树已有 `.idea/`、`docs-design/`、`docs/`、`harness/` 变更或未跟踪内容。它们是前置 dirty baseline,不属于 Task 1 delta;Task 1 未回退、清理或重写这些内容。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 1 delta 仅包含:
|
||||
|
||||
- `.gitignore`
|
||||
- `package.json`
|
||||
- `pnpm-workspace.yaml`
|
||||
- `tsconfig.base.json`
|
||||
- `eslint.config.mjs`
|
||||
- `scripts/check-workspace-scripts.mjs`
|
||||
- `pnpm-lock.yaml`
|
||||
|
||||
未创建:
|
||||
|
||||
- `apps/**`
|
||||
- `packages/**`
|
||||
- `scripts/check-s1-scope.mjs`
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Reviewer ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e80cd-cec9-7631-b131-7f93954b2808` | DONE | 无 |
|
||||
| spec compliance initial | `019e80d3-9538-7a32-9ec9-c231ed27e478` | FAIL | 误把前置 dirty baseline 计入 Task 1 scope |
|
||||
| spec compliance re-review | `019e80d9-075d-70e3-bd45-dad329715740` | PASS | 无 |
|
||||
| quality / feasibility | `019e80de-5705-74f0-a04d-e29d0de4e659` | PASS | 无 |
|
||||
|
||||
初次 spec review 的 Critical 是证据上下文问题,不是实现缺陷;补充 Task 1 前置 `git status --short` baseline 后,fresh re-review 判定 Task 1 delta 合规。
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
node --check scripts/check-workspace-scripts.mjs
|
||||
pnpm check:workspace-scripts
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec eslint --print-config apps/example.ts >/dev/null
|
||||
test ! -e scripts/check-s1-scope.mjs && test ! -d apps && test ! -d packages
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `node --check scripts/check-workspace-scripts.mjs`:PASS。
|
||||
- `pnpm check:workspace-scripts`:PASS;当前没有 workspace packages。
|
||||
- `pnpm install --frozen-lockfile`:PASS;lockfile up to date。
|
||||
- `pnpm exec eslint --print-config apps/example.ts >/dev/null`:PASS;flat config 可解析。
|
||||
- forbidden path check:PASS;`apps/`、`packages/`、`scripts/check-s1-scope.mjs` 不存在。
|
||||
|
||||
未运行完整 `pnpm lint`、`pnpm typecheck`、`pnpm test`、`pnpm build`,因为 Task 2 才创建 workspace packages 和占位测试。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `.gitignore` | `6dcd1666bf43e6f3abb664ad4aa511502a89f3500904fd059912dec73e516ae6` |
|
||||
| `package.json` | `0e3d6edb70fe2317497ce883710d961eaa3df9b5b37cd0e2b53873633dabe92b` |
|
||||
| `pnpm-workspace.yaml` | `08d75840c97ab0e72d1d9b5b84a17e47a2e06cb159a5fbec5ee0a6a56682dad7` |
|
||||
| `tsconfig.base.json` | `0080d9633209d16b5328cfad0c9a4d4b63de70ffd65115bf5ef89a3c185f34c8` |
|
||||
| `eslint.config.mjs` | `d055a4c012addf1b74db19811630b5a51d453dcf10d5366faf4d4aa0499c7a11` |
|
||||
| `scripts/check-workspace-scripts.mjs` | `c46c16a1c07d8afbce8c4afd6bcee9804413298651729966f46f9f5109742eb7` |
|
||||
| `pnpm-lock.yaml` | `3410b404ec3eab910cf0c7fb6aa74b6b63296f634fce2762e0ec3ce908b38eb9` |
|
||||
|
||||
## 当前限制
|
||||
|
||||
`check:s1-scope` 仍指向未来的 `scripts/check-s1-scope.mjs`;这是 S1 plan 的预期安排,Task 9 才创建并验证该脚本。
|
||||
135
docs/memorys/2026-06-01-S1Task2应用骨架门禁.md
Normal file
135
docs/memorys/2026-06-01-S1Task2应用骨架门禁.md
Normal file
@ -0,0 +1,135 @@
|
||||
# 2026-06-01 S1 Task 2 应用骨架门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 2 `Scaffold Web, API, Worker, Shared Packages` 已完成实现,并通过两轮 fresh review gate:
|
||||
|
||||
- spec compliance review:PASS,无 blocking finding。
|
||||
- quality / feasibility review:首次 FAIL,修复后 PASS,无 blocking finding。
|
||||
|
||||
可以进入 S1 Task 3。
|
||||
|
||||
## 前置基线
|
||||
|
||||
Task 2 开始前,工作树已有:
|
||||
|
||||
- `.idea/`、`docs-design/`、`docs/`、`harness/` 前置 dirty baseline。
|
||||
- S1 Task 1 根 workspace 文件。
|
||||
- `docs/memorys/2026-06-01-S1Task1工作区初始化门禁.md`。
|
||||
|
||||
这些不属于 Task 2 delta;Task 2 未回退、清理或重写这些内容。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 2 delta 包含:
|
||||
|
||||
- `apps/web/**`
|
||||
- `apps/api/**`
|
||||
- `apps/worker/**`
|
||||
- `packages/shared-contracts/**`
|
||||
- `packages/harness-client/**`
|
||||
- `pnpm-lock.yaml`
|
||||
|
||||
保留的生成文件:
|
||||
|
||||
- `apps/web/next-env.d.ts`:Next TypeScript build 支持文件。
|
||||
|
||||
未创建:
|
||||
|
||||
- DB / Prisma schema。
|
||||
- Docker / dev infra。
|
||||
- auth / project modules。
|
||||
- worker queue implementation。
|
||||
- harness CLI integration。
|
||||
- `scripts/check-s1-scope.mjs`。
|
||||
- game generation / runtime / WebPlatformAdapter / storage / RBAC 实现。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Reviewer ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e80e5-9503-7d31-aecc-f9cb4232db24` | DONE_WITH_CONCERNS -> DONE | pnpm ignored build scripts 作为后续 Prisma 风险记录 |
|
||||
| spec compliance initial | `019e80fb-c82b-7a21-98c0-9a557bae20c0` | PASS | 无 |
|
||||
| quality / feasibility initial | `019e8102-eadf-7673-ba2f-c47372a13216` | FAIL | API ESM build artifact 不能被 Node load |
|
||||
| spec compliance re-review | `019e8112-3f05-7de3-95ed-96ce7e8ec144` | PASS | 无 |
|
||||
| quality / feasibility re-review | `019e8118-6796-74d0-ae4e-f24b4083b6d6` | PASS | 无 |
|
||||
|
||||
## 关键修复
|
||||
|
||||
初次 quality review 发现 `@huijing/api` 在 ESM + extensionless import 下,`pnpm build` 能成功但 `node apps/api/dist/main.js` 失败,报 `ERR_MODULE_NOT_FOUND`。
|
||||
|
||||
已修复:
|
||||
|
||||
- `apps/api/tsconfig.json` 使用 `module: "NodeNext"` 和 `moduleResolution: "NodeNext"`。
|
||||
- API 相对 import 改为 `.js` specifier。
|
||||
- `apps/api/src/main.ts` 只在直接执行时启动 Nest server;被 import 时不会启动长运行进程。
|
||||
- `apps/api/package.json` 的 `build` 加入 `node -e "await import('./dist/main.js')"`,确保 API 编译产物可被 Node bounded load。
|
||||
|
||||
## Controller Verification
|
||||
|
||||
控制器在临时镜像 `/tmp/games-s1-task2-controller.*` 中运行完整验证,避免在原工作区留下 build artifacts:
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm check:workspace-scripts
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
pnpm dev:smoke
|
||||
cd apps/api && node -e "await import('./dist/main.js')"
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `pnpm install --frozen-lockfile`:PASS。
|
||||
- `pnpm check:workspace-scripts`:PASS。
|
||||
- `pnpm lint`:PASS。
|
||||
- `pnpm typecheck`:PASS。
|
||||
- `pnpm test`:PASS,5 个 test files / 7 个 tests。
|
||||
- `pnpm build`:PASS,包含 API dist import-load check 和 Next build。
|
||||
- `pnpm dev:smoke`:PASS,bounded Vitest / Next build smoke。
|
||||
- `node -e "await import('./dist/main.js')"`:PASS。
|
||||
|
||||
安装时 pnpm 仍提示 ignored build scripts:`@nestjs/core`、`@prisma/engines`、`prisma`、`sharp`。Task 2 review 判定这不是当前阻塞;Task 4/Prisma 接入前必须显式验证 Prisma CLI/engine 行为。
|
||||
|
||||
原工作区 build hygiene:
|
||||
|
||||
- `apps/**` / `packages/**` 下无 `dist`、`.next`、`coverage`、`.turbo`、`tsconfig.tsbuildinfo` 残留。
|
||||
- `node_modules` 存在于 pnpm 安装面,但已被 `.gitignore` 忽略。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/package.json` | `c196bf57ce0620678bba85bb120560ac57c328ce939feab81b2c20fe2236cece` |
|
||||
| `apps/api/tsconfig.json` | `3bd816db2ad470383908e05caeaa6d09f70a442faa048f5fa37a048a3f833dba` |
|
||||
| `apps/api/src/main.ts` | `fcefc7c057d211b11f890477a9a0017ea4a3d4ed41c34e93dc1be271f8212d58` |
|
||||
| `apps/api/src/app.module.ts` | `801f76ed55d6a68c16b602ab1de2383102d3360ebc0a6d4187381e7972475056` |
|
||||
| `apps/api/src/health.controller.ts` | `148a766072bdc8fd82196d0e12a570d819ef73e3790e486b8382423820f12640` |
|
||||
| `apps/api/src/health.controller.spec.ts` | `55951223829eee0337b7f2cdbcdbc59e91dc3d16e7f5c8b2cb23bff5d6a31818` |
|
||||
| `apps/web/package.json` | `8f98a78d0ae0568784eb5d2030d969245e5557726716ff4b754bf475a6548e28` |
|
||||
| `apps/web/tsconfig.json` | `aea53dc64ec13851d4ae402eecdc72877e1dbe49683b0f766eb6b22418b0725f` |
|
||||
| `apps/web/next.config.mjs` | `bbd5d4d14444fa08710ea17b600f194281abc880700bfd66cf088477a7f54c16` |
|
||||
| `apps/web/next-env.d.ts` | `7b550dda9686c16f36a17bf9051d5dbf31e98555b30d114ac49fc49a1e712651` |
|
||||
| `apps/web/src/app/layout.tsx` | `d895c441c61e964e0c59d68cfb6f6d596bd80b0430676496b2c4dfd1d77e97ba` |
|
||||
| `apps/web/src/app/page.tsx` | `476da146bb7fa7164404af6bf24d6b4b65bdcd1a8c92a7b9a9488a6a59fdfb61` |
|
||||
| `apps/web/src/smoke.test.ts` | `0c26e88a463147d8542f2ab59485907a8b396ea53a9c7349b99ed22a7c17fd87` |
|
||||
| `apps/worker/package.json` | `5094f400f8a169a4375c392802402454fcc333576a026d491d58b5f79a8c1cf2` |
|
||||
| `apps/worker/tsconfig.json` | `2f4be30e3b5a16e3912d3da11f257c8cce21c77af144655ce8cc20e17400cd25` |
|
||||
| `apps/worker/src/index.ts` | `66e0389a61773e34d6b33b75dc24646785a5da16e73b30b49f065cd09cf24360` |
|
||||
| `apps/worker/src/worker.smoke.spec.ts` | `081a4ad6d4a4ca2941faa895c0c4afd4a5cff5a2e66ba9656dd9c41824c3b91a` |
|
||||
| `packages/shared-contracts/package.json` | `3f4b7503422a02075111877f69a62d5683c3c051acba744bc724a0600c6987bf` |
|
||||
| `packages/shared-contracts/tsconfig.json` | `2f4be30e3b5a16e3912d3da11f257c8cce21c77af144655ce8cc20e17400cd25` |
|
||||
| `packages/shared-contracts/src/index.ts` | `bacedef4146ed83fb306d52184d1a6373b121fc9aa1bb5a20ed1fbc398b23758` |
|
||||
| `packages/shared-contracts/src/index.spec.ts` | `05fdb16cb210d7c5a03f8af530b902bf9522ffcccc4cc7892bbdd1a2407cb6a2` |
|
||||
| `packages/harness-client/package.json` | `dae5f12bde880da0400c9023a6aa44519b96a33c2c011a6c38ed6e01d251e8bb` |
|
||||
| `packages/harness-client/tsconfig.json` | `2f4be30e3b5a16e3912d3da11f257c8cce21c77af144655ce8cc20e17400cd25` |
|
||||
| `packages/harness-client/src/index.ts` | `aa435bdb7f9027241968e6e04fc095cb73c32ee78ddd527f88193895eb5654f8` |
|
||||
| `packages/harness-client/src/index.spec.ts` | `13c651235c0fe2571d6ed9209fcb15c55a37d98c94817652b5994f509364994a` |
|
||||
| `pnpm-lock.yaml` | `9182ae05154e589711137d21b1a58afa54a28ecde5e0e7cb6d23cac3db69a4ab` |
|
||||
|
||||
## 后续注意
|
||||
|
||||
- Task 4/Prisma 前必须处理或验证 pnpm ignored build scripts 对 Prisma engine 的影响。
|
||||
- `@huijing/harness-client` 当前是 fail-closed placeholder;Task 5 才接入 S0 CLI,不得提前把 placeholder 当作真实 validator。
|
||||
78
docs/memorys/2026-06-01-S1Task3开发基础设施门禁.md
Normal file
78
docs/memorys/2026-06-01-S1Task3开发基础设施门禁.md
Normal file
@ -0,0 +1,78 @@
|
||||
# 2026-06-01 S1 Task 3 开发基础设施门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 3 `Add Development Infrastructure` 已完成实现,并通过两轮 fresh review gate:
|
||||
|
||||
- spec compliance review:PASS,无 blocking finding。
|
||||
- quality / feasibility review:PASS,无 blocking finding。
|
||||
|
||||
可以进入 S1 Task 4。
|
||||
|
||||
## 前置基线
|
||||
|
||||
Task 3 开始前,工作树已有:
|
||||
|
||||
- `.idea/`、`docs-design/`、`docs/`、`harness/` 前置 dirty baseline。
|
||||
- S1 Task 1 / Task 2 workspace 与 app skeleton 文件。
|
||||
- S1 Task 1 / Task 2 memory 留痕。
|
||||
|
||||
这些不属于 Task 3 delta;Task 3 未回退、清理或重写这些内容。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 3 delta 仅包含:
|
||||
|
||||
- `infra/docker-compose.dev.yml`
|
||||
- `apps/api/.env.example`
|
||||
- `apps/worker/.env.example`
|
||||
|
||||
未创建:
|
||||
|
||||
- DB schema / Prisma migration。
|
||||
- dev scripts / README / docs。
|
||||
- app code / queue / storage implementation。
|
||||
- 业务模块。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Reviewer ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e812c-3746-7e42-ad95-5be412def299` | DONE | 无 |
|
||||
| spec compliance | `019e8132-4244-7f43-a275-63ab2e84b113` | PASS | 无 |
|
||||
| quality / feasibility | `019e8134-d6d2-7d90-ae33-f27a642a0e1e` | PASS | 无 |
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
docker compose -f infra/docker-compose.dev.yml ps
|
||||
docker compose -f infra/docker-compose.dev.yml exec -T postgres pg_isready -U huijing -d huijing_dev
|
||||
docker compose -f infra/docker-compose.dev.yml exec -T postgres psql -U huijing -d huijing_dev -tAc 'select 1'
|
||||
docker compose -f infra/docker-compose.dev.yml exec -T redis redis-cli ping
|
||||
POSTGRES_PORT=15432 REDIS_PORT=16379 docker compose -f infra/docker-compose.dev.yml config
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `infra-postgres-1`:`Up ... (healthy)`,端口 `0.0.0.0:5432->5432/tcp`。
|
||||
- `infra-redis-1`:`Up ... (healthy)`,端口 `0.0.0.0:6379->6379/tcp`。
|
||||
- `pg_isready`:`/var/run/postgresql:5432 - accepting connections`。
|
||||
- `psql select 1`:返回 `1`。
|
||||
- `redis-cli ping`:返回 `PONG`。
|
||||
- 端口覆盖配置可解析:`POSTGRES_PORT=15432` 映射到 target `5432`,`REDIS_PORT=16379` 映射到 target `6379`。
|
||||
|
||||
容器保持运行,供 Task 4 Prisma migration 使用。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `infra/docker-compose.dev.yml` | `0bd44a47ed724c2a80ed0fc6e6923e2ed9d8800461027ee1261e6df0d5ebc67f` |
|
||||
| `apps/api/.env.example` | `7a3101a09f29d3be684bdc2f7990c98b4d7e737af6055993d9a536bd028344ef` |
|
||||
| `apps/worker/.env.example` | `7a3101a09f29d3be684bdc2f7990c98b4d7e737af6055993d9a536bd028344ef` |
|
||||
|
||||
## 后续注意
|
||||
|
||||
Task 4 可直接复用当前运行中的 Postgres / Redis。若本机端口后续冲突,按 plan 使用 `POSTGRES_PORT` / `REDIS_PORT` 覆盖并记录验证证据。
|
||||
131
docs/memorys/2026-06-01-S1Task4数据库模型门禁.md
Normal file
131
docs/memorys/2026-06-01-S1Task4数据库模型门禁.md
Normal file
@ -0,0 +1,131 @@
|
||||
# 2026-06-01 S1 Task 4 数据库模型门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 4 `Implement Core Database Model` 已完成实现,并通过两轮 fresh review gate:
|
||||
|
||||
- spec compliance review:最终 PASS,无 blocking finding。
|
||||
- quality / feasibility review:最终 PASS,无 blocking finding。
|
||||
|
||||
可以进入 S1 Task 5。
|
||||
|
||||
## 前置基线
|
||||
|
||||
Task 4 开始前,工作树已有:
|
||||
|
||||
- `.idea/`、`docs-design/`、`docs/`、`harness/` 前置 dirty baseline。
|
||||
- S1 Task 1-3 workspace / app / infra 文件。
|
||||
- S1 Task 4 plan amendment:`docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md`。
|
||||
- S1 Task 4 plan amendment memory:`docs/memorys/2026-06-01-S1Task4计划修正门禁.md`。
|
||||
|
||||
这些不属于 Task 4 implementation delta;Task 4 未回退、清理或重写这些内容。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 4 delta 包含:
|
||||
|
||||
- `apps/api/prisma/schema.prisma`
|
||||
- `apps/api/prisma.config.ts`
|
||||
- `apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql`
|
||||
- `apps/api/prisma/migrations/20260601050132_s1_task4_quality_fixes/migration.sql`
|
||||
- `apps/api/prisma/migrations/migration_lock.toml`
|
||||
- `apps/api/src/generated/prisma/**`
|
||||
- `apps/api/src/prisma.schema.spec.ts`
|
||||
- `apps/api/package.json`
|
||||
- `pnpm-lock.yaml`
|
||||
|
||||
未创建:
|
||||
|
||||
- service / API endpoint。
|
||||
- JobExecutionStore。
|
||||
- state-transition service / helper module。
|
||||
- worker Prisma import / DB writer。
|
||||
- auth / project / storage / queue business module。
|
||||
- game generation / prompt routing / subagent execution / GameIR compilation。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Reviewer ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation attempt | `019e813b-ad1b-7003-8899-80d6508f18a5` | NEEDS_CONTEXT | Prisma 7 plan 缺口 |
|
||||
| plan amendment re-review coherence | `019e814b-1fbf-7263-a5ee-764e8efd6ee0` | PASS | 无 |
|
||||
| plan amendment re-review feasibility | `019e814b-2053-70f3-bceb-ab84ecf4bd3f` | PASS | 无 |
|
||||
| implementation | `019e8151-c119-7812-a7fd-848549897826` | DONE_WITH_CONCERNS -> DONE | pg warning 非阻塞 |
|
||||
| spec compliance initial | `019e8166-b443-7270-83b4-52287f806636` | FAIL | Session S0 投影测试覆盖了 DB row 字段 |
|
||||
| spec compliance re-review | `019e817b-0403-7013-a3d7-57ef40549ef6` | PASS | 无 |
|
||||
| quality initial | `019e8180-6923-7912-8e36-1d6a700150bd` | FAIL | 普通测试 destructive;ReviewRecord CHECK 不完整 |
|
||||
| spec compliance after quality fixes | `019e8192-7d94-7791-ba83-816a5f6e91f1` | PASS | 无 |
|
||||
| quality re-review | `019e8196-d916-7591-a6ff-e0d535ce9836` | FAIL | unused helper 导致 lint 失败 |
|
||||
| final spec compliance | `019e81a2-2ce0-7db1-bbcb-561839c2d4a7` | PASS | 无 |
|
||||
| final quality / feasibility | `019e81a6-01a7-7ce3-8f40-c2592d194343` | PASS | 无 |
|
||||
|
||||
## 关键修复
|
||||
|
||||
- Prisma 7 plan 修正:
|
||||
- 新增 `apps/api/prisma.config.ts`。
|
||||
- 使用 `prisma-client` generator,output 为 `apps/api/src/generated/prisma`。
|
||||
- direct tests 通过 `@prisma/adapter-pg` / `pg` 连接 Postgres。
|
||||
- Session S0 projection:
|
||||
- S0 fixture ID 真实写入 DB 关系。
|
||||
- `MainCreationAgentSession` CLI 校验直接使用 `projectSession(session)`,不再覆盖 `creatorId/projectId/versionId`。
|
||||
- 另有 arbitrary relationship integrity 测试。
|
||||
- Test safety:
|
||||
- 移除 `TRUNCATE` / `RESTART IDENTITY` / broad reset。
|
||||
- 普通 Prisma tests 使用 transaction rollback + savepoint。
|
||||
- ReviewRecord invariant:
|
||||
- `pending_review` / `canceled` 必须没有 decision evidence。
|
||||
- `approved` / `rejected` 必须有 matching `decision`、非空 `reasonCode`、`decidedById`、`decidedAt`。
|
||||
- Session/version same-project:
|
||||
- `MainCreationAgentSession(versionId, projectId)` 复合 FK 指向 `GameVersion(id, projectId)`。
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api lint
|
||||
pnpm --filter @huijing/api test -- prisma
|
||||
pnpm --filter @huijing/api build
|
||||
docker compose -f infra/docker-compose.dev.yml ps
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- Prisma schema:valid。
|
||||
- Prisma migrate status:2 migrations found,database schema up to date。
|
||||
- API lint:PASS。
|
||||
- API Prisma tests:PASS,2 files / 8 tests。
|
||||
- API build:PASS。
|
||||
- Docker:
|
||||
- `infra-postgres-1` healthy on `5432`。
|
||||
- `infra-redis-1` healthy on `6379`。
|
||||
- Build outputs 已清理,`apps/**` / `packages/**` 下无 `dist`、`.next`、`coverage`、`tsconfig.tsbuildinfo` 残留。
|
||||
- destructive scan:普通 app/package/script surface 中未发现 `TRUNCATE`、`RESTART IDENTITY`、`migrate reset`、`DROP DATABASE`、`DROP TABLE`、`DELETE FROM`。
|
||||
|
||||
## Residual Non-blocking Notes
|
||||
|
||||
- `pnpm test` 已在 quality review 中通过;`check:s1-scope` 仍指向未来的 `scripts/check-s1-scope.mjs`,这是 Task 9 预期交付,不是 Task 4 blocker。
|
||||
- Prisma test 当前会输出 `pg` deprecation warning;现有 `pg` range 仍在 v8,命令 exit 0。升级 Prisma adapter / `pg` 时需复查 savepoint 测试方式。
|
||||
- pnpm ignored build scripts 仍可能列出 `@prisma/engines` / `prisma`,但本轮 validate / migrate / generate / tests / build 均通过。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/package.json` | `678617fe3796b710a24f72230df9805f47879df2ca0f3f2b907bd68b1941bc17` |
|
||||
| `apps/api/prisma.config.ts` | `eb72dc13ebfd7bf6e74036ac3c2ecc7add69be52e374b2c47411307b0c9381be` |
|
||||
| `apps/api/prisma/schema.prisma` | `c418f59a6bcd10a87e7f68af336bffdedace5ba506a793ac7f2fb82f220996d9` |
|
||||
| `apps/api/prisma/migrations/20260601040253_s1_app_foundation/migration.sql` | `00ce17a7f11a9c041679977a3c81cc5d8f4d0aece6681d793cb9acae4bad500a` |
|
||||
| `apps/api/prisma/migrations/20260601050132_s1_task4_quality_fixes/migration.sql` | `666807cdfe9f549a34638f60c07cf1631f8ccd1ca43b796f346db2122e965e3b` |
|
||||
| `apps/api/prisma/migrations/migration_lock.toml` | `99836963713b4f5b269ad49af0ed3d7b0b2e336115c2f92dc9ac683d139d0900` |
|
||||
| `apps/api/src/prisma.schema.spec.ts` | `b98d3f82d0c500930c1a95f6648ce0c3c2894c3414da8c3067a612754b67a9a2` |
|
||||
| `pnpm-lock.yaml` | `c85b03575467a508b0e85dbe81a7ab51c0ea6f754e122567b0671c2f3b12b576` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
|
||||
Generated Prisma client output under `apps/api/src/generated/prisma/**` is part of Task 4 output; individual file hashes were computed during controller verification and can be regenerated with:
|
||||
|
||||
```bash
|
||||
find apps/api/src/generated/prisma -type f | sort | xargs shasum -a 256
|
||||
```
|
||||
63
docs/memorys/2026-06-01-S1Task4计划修正门禁.md
Normal file
63
docs/memorys/2026-06-01-S1Task4计划修正门禁.md
Normal file
@ -0,0 +1,63 @@
|
||||
# 2026-06-01 S1 Task 4 计划修正门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 4 在实现前发现 Prisma 7 工具链与原计划 allowed files 不匹配。已修正 Task 4 plan,并通过 fresh amendment review gate。
|
||||
|
||||
可以重新派 fresh implementation subagent 执行 S1 Task 4。
|
||||
|
||||
## 阻塞事实
|
||||
|
||||
原 Task 4 plan 只允许创建 `apps/api/prisma/schema.prisma`、`apps/api/prisma/migrations/**` 和测试相关 `apps/api/src/**` 文件。实现子代理在进入实现前验证到:
|
||||
|
||||
- `pnpm --filter @huijing/api exec prisma --version`:Prisma CLI 为 `7.8.0`,`@prisma/client: Not found`。
|
||||
- Prisma 7 不允许 `schema.prisma` 使用 `datasource.url = env("DATABASE_URL")`。
|
||||
- `migrate dev --schema prisma/schema.prisma` 需要 `prisma.config.ts` 提供 datasource URL。
|
||||
- 直接 Prisma tests / `prisma generate` 需要允许安装 Prisma 7 client/runtime 依赖并更新 lockfile。
|
||||
|
||||
因此,原计划在不越权的情况下不可实现。
|
||||
|
||||
## Plan Amendment
|
||||
|
||||
已修改 `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md`:
|
||||
|
||||
- Task 4 allowed files 增加:
|
||||
- `apps/api/prisma.config.ts`
|
||||
- `apps/api/package.json`
|
||||
- `pnpm-lock.yaml`
|
||||
- Dependency Matrix 中 `@huijing/api` 增加:
|
||||
- runtime:`@prisma/client`、`@prisma/adapter-pg`、`pg`
|
||||
- dev:`@types/pg`
|
||||
- Task 4 Step 2 明确:
|
||||
- Prisma 7 datasource URL 放在 `apps/api/prisma.config.ts`。
|
||||
- `schema.prisma` 不写 `url = env("DATABASE_URL")`。
|
||||
- 使用 Prisma 7 `prisma-client` generator。
|
||||
- generated client explicit output 为 `apps/api/src/generated/prisma`。
|
||||
- direct Prisma tests 从该 output path import generated client,并使用 PostgreSQL driver adapter。
|
||||
- 不依赖 install-time postinstall;仍以显式 `prisma generate` 作为验收证据。
|
||||
|
||||
未修改 S1 DB 合同、guarded-state/audit/job target 约束,也未把 Task 4 范围扩大到 service/API/Task 5+。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Reviewer ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation attempt | `019e813b-ad1b-7003-8899-80d6508f18a5` | NEEDS_CONTEXT | Prisma 7 config/client scope 缺口 |
|
||||
| amendment review 1 | `019e8143-d916-7351-a413-c59a2e1c05eb` | FAIL | package/lockfile scope 与 client strategy 不足 |
|
||||
| amendment review 2 | `019e8143-d992-71b3-ad37-caab35c72e0d` | FAIL | `@prisma/client` / generator strategy / lockfile scope 缺口 |
|
||||
| amendment re-review coherence/scope | `019e814b-1fbf-7263-a5ee-764e8efd6ee0` | PASS | 无 |
|
||||
| amendment re-review feasibility | `019e814b-2053-70f3-bceb-ab84ecf4bd3f` | PASS | 无 |
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
|
||||
## 后续执行要求
|
||||
|
||||
- S1 Task 4 必须使用新的 fresh implementation subagent,不复用已返回 `NEEDS_CONTEXT` 的子代理。
|
||||
- Task 4 review 必须检查 `prisma.config.ts`、generated client output、PostgreSQL adapter、`@prisma/client` 依赖、`pnpm-lock.yaml`、迁移 SQL 约束和 direct Prisma tests。
|
||||
- pnpm ignored build scripts 仍存在;只有在 migrate/generate 实际失败时,才引入 `pnpm.onlyBuiltDependencies` / rebuild 作为 scoped fix。
|
||||
143
docs/memorys/2026-06-01-S1Task5状态边界门禁.md
Normal file
143
docs/memorys/2026-06-01-S1Task5状态边界门禁.md
Normal file
@ -0,0 +1,143 @@
|
||||
# 2026-06-01 S1 Task 5 状态边界门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 5 `Implement Harness Client And State Transition Boundary` 已完成实现,并通过 mandatory fresh review gate:
|
||||
|
||||
- spec compliance review:最终 PASS,无 Critical / Important / Minor finding。
|
||||
- quality / feasibility review:最终 PASS,无 Critical / Important;剩余 1 个 Minor 风险,不阻塞进入 Task 6。
|
||||
|
||||
可以进入 S1 Task 6。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 5 delta 包含:
|
||||
|
||||
- `packages/harness-client/package.json`
|
||||
- `packages/harness-client/src/index.ts`
|
||||
- `packages/harness-client/src/internal.ts`
|
||||
- `packages/harness-client/src/index.spec.ts`
|
||||
- `apps/api/src/modules/harness-gate/index.ts`
|
||||
- `apps/api/src/modules/harness-gate/harness-gate.spec.ts`
|
||||
- `apps/api/src/modules/state-transition/index.ts`
|
||||
- `apps/api/src/modules/state-transition/state-transition.spec.ts`
|
||||
- `apps/api/package.json`
|
||||
- `pnpm-lock.yaml`
|
||||
|
||||
未实现:
|
||||
|
||||
- Task 6 auth / RBAC。
|
||||
- Task 7 queue / worker / storage / audit service。
|
||||
- Task 8 project / version / review endpoint。
|
||||
- Task 9 `scripts/check-s1-scope.mjs`。
|
||||
- game generation / prompt routing / asset pipeline / publish pipeline。
|
||||
|
||||
## 关键实现事实
|
||||
|
||||
- `@huijing/harness-client` package root 只导出 Task 5 指定的两个 type:
|
||||
- `HarnessGateResult`
|
||||
- `HarnessClient`
|
||||
- `@huijing/harness-client/runtime` 是受控 runtime subpath,供 API 复用同一套 S0 CLI shell-out 实现,避免 API 复制子进程、临时文件、JSON parse 和 timeout 逻辑。
|
||||
- Harness client runtime:
|
||||
- shell out 到 `node harness/scripts/validate-harness.mjs`。
|
||||
- 从 repo root resolve S0 CLI path,不依赖当前 cwd。
|
||||
- payload 写入临时 JSON,成功、失败、timeout 后清理。
|
||||
- 默认 timeout 为 `5000ms`,API 侧通过 `HARNESS_CLI_TIMEOUT_MS` 解析注入。
|
||||
- stdout / stderr 有捕获上限。
|
||||
- strict JSON-only parse,不使用 substring 判断。
|
||||
- timeout 返回 `HARNESS_TIMEOUT`。
|
||||
- invalid output / spawn ambiguity 返回 `HARNESS_INVALID_OUTPUT`。
|
||||
- S0 CLI 返回 `{ ok:false, reasonCode }` 时保留 S0 reasonCode。
|
||||
- `HarnessGateService`:
|
||||
- 通过 `@huijing/harness-client/runtime` 创建 runtime client。
|
||||
- 只作为 API 内部 S0 gate facade,不复制 harness runtime 核心逻辑。
|
||||
- `StateTransitionService`:
|
||||
- 是 S1 当前唯一 API-owned guarded state transition boundary。
|
||||
- 允许 S1 draft version creation,但不把 draft creation 标记为 S0 high-risk transition。
|
||||
- `review_approved` / `review_rejected` 会先过 S0 transition 和 `LifecycleEvent` contract gate。
|
||||
- PASS 路径设置事务本地 guard,写入 `ReviewRecord`、`LifecycleEvent`、`AuditLog`。
|
||||
- FAIL 路径返回 422-style `StateTransitionDomainError(reasonCode)`,不写 guarded state。
|
||||
- FAIL blocked audit 使用独立 `blockedAuditDb` 写入通道;测试已覆盖外层 transaction rollback 后 blocked audit 仍持久化。
|
||||
- guard cleanup 保留原始错误优先级,不让 cleanup failure 覆盖业务 / DB 原错。
|
||||
- 静态边界扫描:
|
||||
- 拦截 state-transition module 外的 guarded Prisma writes。
|
||||
- 拦截 state-transition module 外的 runtime guard setter。
|
||||
- migration 不再整体 allowlist;只允许 trigger / current_setting 定义,不允许 guard setter 或 protected table DML。
|
||||
- 扫描 `apps/worker/**`,忽略 `node_modules`、`dist`、`.vite`、`coverage`、`.next` 等生成目录。
|
||||
- 明确不误伤 Task 4 `apps/api/src/prisma.schema.spec.ts` 的 test-only guard helper。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e81b2-b8fd-7c00-983b-04ff43dcaabe` | DONE | 初版完成 |
|
||||
| spec compliance initial | `019e81cc-2106-7541-a604-c4e8b413f2c3` | FAIL | public contract 外泄;API timeout 未接 env config;scanner allowlist 过宽 |
|
||||
| implementation fix 1 | `019e81b2-b8fd-7c00-983b-04ff43dcaabe` | DONE | 修复 spec findings |
|
||||
| spec compliance re-review | `019e81f0-52e0-76d2-8ca9-5f4b4db015f1` | PASS | 无 |
|
||||
| quality / feasibility initial | `019e81fb-497e-7230-9ede-5aa017d3757a` | FAIL | blocked audit 可随 transaction rollback 丢失;API 复制 harness runtime |
|
||||
| implementation fix 2 | `019e81b2-b8fd-7c00-983b-04ff43dcaabe` | DONE | 修复 quality findings |
|
||||
| spec compliance safety recheck | `019e8215-c68e-7361-97ca-f8a2ca107c07` | PASS | 无 |
|
||||
| quality / feasibility re-review | `019e821b-2026-77f3-8563-97515277b471` | PASS | 无 Critical / Important;1 个 Minor |
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @huijing/harness-client test
|
||||
pnpm --filter @huijing/api test -- harness-gate state-transition
|
||||
pnpm --filter @huijing/harness-client lint
|
||||
pnpm --filter @huijing/harness-client typecheck
|
||||
pnpm --filter @huijing/api lint
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm --filter @huijing/harness-client build
|
||||
pnpm --filter @huijing/api build
|
||||
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
|
||||
node harness/scripts/validate-harness.mjs
|
||||
pnpm check:workspace-scripts
|
||||
pnpm --filter @huijing/api test -- prisma
|
||||
docker compose -f infra/docker-compose.dev.yml ps
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- Harness client tests:PASS,1 file / 10 tests。
|
||||
- API harness-gate / state-transition tests:PASS,4 files / 19 tests。
|
||||
- Harness client lint / typecheck / build:PASS。
|
||||
- API lint / typecheck / build:PASS。
|
||||
- Prisma validate:PASS。
|
||||
- Prisma migrate status:2 migrations found,database schema up to date。
|
||||
- S0 full harness validation:PASS,checks=1259,validFixtures=45,invalidFixtures=51,invalidCoverage=51/51。
|
||||
- Workspace script check:PASS。
|
||||
- API Prisma tests:PASS,4 files / 19 tests。
|
||||
- Docker:
|
||||
- `infra-postgres-1` healthy on `5432`。
|
||||
- `infra-redis-1` healthy on `6379`。
|
||||
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist`、`.next`、`coverage`、`tsconfig.tsbuildinfo` 残留。
|
||||
|
||||
## Residual Non-blocking Notes
|
||||
|
||||
- Quality reviewer 记录 1 个 Minor:同一 `lifecycleEventId` 的 blocked transition 如果重试,固定 `blocked-audit` id 可能撞主键,原始 harness reasonCode 可能被 Prisma unique error 覆盖。后续 API composition root 接入时建议用 `createMany skipDuplicates`、`upsert`,或显式复用已有 blocked audit 后继续返回 `StateTransitionDomainError`。
|
||||
- `StateTransitionServiceOptions.blockedAuditDb` 依赖调用方注入非事务客户端;当前注释和测试表达了边界,后续 composition root 应用工厂封装,避免业务方直接 `new StateTransitionService` 时误传 transaction client。
|
||||
- API / Prisma tests 仍输出既有 `pg` deprecation warning;命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
|
||||
- `check:s1-scope` 仍指向未来的 `scripts/check-s1-scope.mjs`,这是 Task 9 交付项,不是 Task 5 blocker。
|
||||
- 当前 git status 仍包含 `.idea/`、`docs-design/` 和大量 untracked workspace surface,这是前置工作面;Task 5 未回退或清理这些内容。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `packages/harness-client/package.json` | `94bb54022628ded5c934816f2a0f5c9b47129e6090d5770718821ba5b3c6a733` |
|
||||
| `packages/harness-client/src/index.ts` | `9936030f93e145baab3b701609442834be871b7f6177117ff2de3bf20464fa80` |
|
||||
| `packages/harness-client/src/internal.ts` | `54deaba53a418a1a3b8d76366cf3f9c1f071c808489e1481c26525dc63cd8d59` |
|
||||
| `packages/harness-client/src/index.spec.ts` | `d6b150b00098e4efceaf9ac9a8ab6d29887e6a50a169ac0ef53fc3f8f5da8ef1` |
|
||||
| `apps/api/src/modules/harness-gate/index.ts` | `dfb683ce8b5e500aae271a8daa8e97592fdc2e2e39e50544d86c2fecc72de673` |
|
||||
| `apps/api/src/modules/harness-gate/harness-gate.spec.ts` | `7f936d954add5a40c755abae987fdfd845c120b1ba281362afe174781aada3ac` |
|
||||
| `apps/api/src/modules/state-transition/index.ts` | `c3c08bf59728f6e07160d821772a3a37201005ab38710583b6cab45e0938c065` |
|
||||
| `apps/api/src/modules/state-transition/state-transition.spec.ts` | `cf8ae70448541185980ac716cc8118c3f6b84a1c0b1109e1fb4f36898b327dd2` |
|
||||
| `apps/api/package.json` | `6697372caa5a2ae69c45995ca1ad66b92c656b548d581c1d4276f7e0fcd5902a` |
|
||||
| `pnpm-lock.yaml` | `d1e6463b4b56b7f63ed35b4df7bf5ab43fd28a1df31adb21beb608c2f834ed26` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
99
docs/memorys/2026-06-01-S1Task6权限边界门禁.md
Normal file
99
docs/memorys/2026-06-01-S1Task6权限边界门禁.md
Normal file
@ -0,0 +1,99 @@
|
||||
# 2026-06-01 S1 Task 6 权限边界门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 6 `Implement Auth And RBAC Boundary` 已完成实现,并通过 mandatory fresh review gate:
|
||||
|
||||
- spec compliance review:PASS,无 Critical / Important / Minor finding。
|
||||
- quality / feasibility review:PASS,无 Critical / Important;剩余 3 个 Minor 风险,不阻塞进入 Task 7。
|
||||
|
||||
可以进入 S1 Task 7。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 6 delta 包含:
|
||||
|
||||
- `apps/api/src/app.module.ts`
|
||||
- `apps/api/src/modules/auth/index.ts`
|
||||
- `apps/api/src/modules/auth/auth.spec.ts`
|
||||
- `apps/api/src/modules/rbac/index.ts`
|
||||
- `apps/api/src/modules/rbac/rbac.spec.ts`
|
||||
|
||||
未实现:
|
||||
|
||||
- project / version / asset / job / audit endpoints。
|
||||
- queue / worker / storage / audit service。
|
||||
- Prisma schema / migration 变更。
|
||||
- 外部 auth provider、OAuth、复杂 JWT。
|
||||
- `scripts/check-s1-scope.mjs`。
|
||||
|
||||
## 关键实现事实
|
||||
|
||||
- `AuthModule` 已注册到 `AppModule`。
|
||||
- `AuthController` 暴露:
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/logout`
|
||||
- `GET /me`
|
||||
- S1 auth 使用本地内存 session store 和 deterministic seed users,满足 local MVP / test boundary;不作为生产多实例认证方案。
|
||||
- token 使用 Node `crypto.randomBytes` 生成,不是固定 token。
|
||||
- `AuthGuard` 在服务端解析 Bearer token,并对缺失 / 未知 token fail closed。
|
||||
- logout 会删除当前 token;同 token 后续 `GET /me` 失败。
|
||||
- RBAC policy 覆盖:
|
||||
- `admin`
|
||||
- `operator`
|
||||
- `creator`
|
||||
- `player`
|
||||
- `anonymous`
|
||||
- `creatorOwnsResource` 作为 resource-like ownership predicate,不调用 project/version API。
|
||||
- `canReadReviewFoundationData` 允许 operator/admin 读审核基础数据类别,不创建管理 CRUD。
|
||||
- `assertDirectObjectAccess` 对 anonymous / 非 owner fail closed,返回 403-style error。
|
||||
- Task 6 没有提前创建 project/version/asset/job/audit HTTP endpoint;endpoint-level RBAC 留给 Task 8。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e8234-9c8d-7280-85d3-f817b9796ebe` | DONE | 无 |
|
||||
| spec compliance review | `019e823f-89e9-7380-b2fb-ce259be9e8e7` | PASS | 无 |
|
||||
| quality / feasibility review | `019e8245-5205-7ec3-b37b-4b1e686c17f8` | PASS | 无 Critical / Important;3 个 Minor |
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @huijing/api test -- auth rbac
|
||||
pnpm --filter @huijing/api lint
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm --filter @huijing/api build
|
||||
find apps packages -maxdepth 4 \( -name dist -o -name .next -o -name tsconfig.tsbuildinfo -o -name coverage \) -print
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- API auth/RBAC tests:PASS,6 files / 27 tests。
|
||||
- API lint:PASS。
|
||||
- API typecheck:PASS。
|
||||
- API build:PASS。
|
||||
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist`、`.next`、`coverage`、`tsconfig.tsbuildinfo` 残留。
|
||||
|
||||
## Residual Non-blocking Notes
|
||||
|
||||
- Quality reviewer 记录 Minor:AuthGuard protected-route test 只覆盖 anonymous rejection,尚未覆盖 authenticated token success path 和 `request.actor` 注入。Task 8 接真实 endpoint 前建议补一条 Bearer token 成功访问用例。
|
||||
- Quality reviewer 记录 Minor:seed users 位于 runtime code;符合 local MVP,但后续真实认证前应加 local-only / env-gated provider,避免演示环境误认为生产认证。
|
||||
- Quality reviewer 记录 Minor:`creatorOwnsResource` 接受 `ownerId` / `creatorId` / `actorId`,后续 Task 8 endpoint 接入时应优先使用明确 owner field;只有 job 这类 `actorId` 语义等于请求方资源时才使用 `actorId`。
|
||||
- API tests 仍输出既有 `pg` deprecation warning;命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
|
||||
- 当前 git status 仍包含 `.idea/`、`docs-design/` 和大量 untracked workspace surface,这是前置工作面;Task 6 未回退或清理这些内容。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/src/app.module.ts` | `6c4072e3f6e65e6759e9b55ee6ba5acc43e0dc4b3a2351fafdf5fd0fc80fa1b8` |
|
||||
| `apps/api/src/modules/auth/index.ts` | `63597418c1e2402957f448dfbfaa221ab87a93d550ad8c3361b04ca62420c160` |
|
||||
| `apps/api/src/modules/auth/auth.spec.ts` | `69a7e7df869116ffb0d6eff51fa8503c791867aea9f6358d6640f1120a522ebc` |
|
||||
| `apps/api/src/modules/rbac/index.ts` | `cf923d0f787dd5ea73ac3e9408f757e30c80be9fe84ef0f83ccd9972b5c984b1` |
|
||||
| `apps/api/src/modules/rbac/rbac.spec.ts` | `8f58871b6ce1ed8b12999215953a4129de62edebd1b1342e5c20a128ddc84bac` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
168
docs/memorys/2026-06-01-S1Task7队列存储审计门禁.md
Normal file
168
docs/memorys/2026-06-01-S1Task7队列存储审计门禁.md
Normal file
@ -0,0 +1,168 @@
|
||||
# 2026-06-01 S1 Task 7 队列存储审计门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 7 `Implement Queue, Worker, Storage, And Audit Boundaries` 已完成实现,并通过 mandatory fresh review gate:
|
||||
|
||||
- spec compliance review:最终 PASS,无 Critical / Important。
|
||||
- quality / feasibility review:最终 PASS,无 Critical / Important。
|
||||
|
||||
可以进入 S1 Task 8。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 7 delta 包含:
|
||||
|
||||
- `apps/api/prisma/schema.prisma`
|
||||
- `apps/api/prisma/migrations/20260601165239_s1_task7_job_lease_fields/migration.sql`
|
||||
- `apps/api/src/generated/prisma/**`
|
||||
- `apps/api/src/modules/jobs/index.ts`
|
||||
- `apps/api/src/modules/jobs/jobs.spec.ts`
|
||||
- `apps/api/src/modules/queue/index.ts`
|
||||
- `apps/api/src/modules/queue/queue.spec.ts`
|
||||
- `apps/api/src/modules/storage/index.ts`
|
||||
- `apps/api/src/modules/storage/storage.spec.ts`
|
||||
- `apps/api/src/modules/audit/index.ts`
|
||||
- `apps/api/src/modules/audit/audit.spec.ts`
|
||||
- `apps/api/package.json`
|
||||
- `apps/worker/package.json`
|
||||
- `apps/worker/src/index.ts`
|
||||
- `apps/worker/src/worker.smoke.spec.ts`
|
||||
- `pnpm-lock.yaml`
|
||||
|
||||
未实现:
|
||||
|
||||
- Project / version / asset / job / audit HTTP endpoints。
|
||||
- Audit admin UI。
|
||||
- Real build / conversion / generation worker business。
|
||||
- Cloud storage SDK。
|
||||
- `scripts/check-s1-scope.mjs`。
|
||||
|
||||
## 关键实现事实
|
||||
|
||||
- `Job` 新增持久运行态字段:
|
||||
- `leaseToken`
|
||||
- `leasedBy`
|
||||
- `leaseExpiresAt`
|
||||
- `lockVersion`
|
||||
- Task 7 migration 增加:
|
||||
- Job lease fields。
|
||||
- `Job_status_nextRetryAt_createdAt_idx`。
|
||||
- `Job_leaseToken_idx`。
|
||||
- `Job_running_lease_check`。
|
||||
- `Job_lock_version_check`。
|
||||
- `JobExecutionStore` 是 API-owned Job 状态写入口,覆盖:
|
||||
- `enqueueScopedJob`
|
||||
- `claimNext`
|
||||
- `markSucceeded`
|
||||
- `markFailedOrRetry`
|
||||
- `markTimedOut`
|
||||
- `cancelQueued`
|
||||
- `enqueueScopedJob` 必须使用 root Prisma client;如果用外层 transaction client 调用,会抛 `JOB_ENQUEUE_REQUIRES_ROOT_CLIENT`,并且不会执行 `mutateTarget`、不会 enqueue、不会持久化 Job。
|
||||
- 同 scope/idempotencyKey 并发请求只有 winner 会执行 `mutateTarget`。
|
||||
- `queue.enqueue` 已移出 DB transaction,在 Job commit 后执行。
|
||||
- `queue.enqueue` 失败后,Job 会进入 `pending_retry`,写入 `QUEUE_ENQUEUE_FAILED` 和 `nextRetryAt`,避免保持普通 `queued`。
|
||||
- `markTimedOut` 同时支持 persisted `timeoutAt <= now` 和 `leaseExpiresAt <= now`。
|
||||
- `QueueAdapter` 包含 `enqueue(job)`、`process(handler)`、`close()`。
|
||||
- `InMemoryQueueAdapter` 用于测试 / explicit local fallback。
|
||||
- `BullMqQueueAdapter` 在 Redis 不可用时显式返回 `QUEUE_UNAVAILABLE`,不静默 fallback 到 memory。
|
||||
- Worker 仍是 S1 no-op smoke,不 import Prisma,不写 DB,不更新 guarded state。
|
||||
- Local storage adapter:
|
||||
- 使用 `S1_STORAGE_ROOT` root isolation。
|
||||
- 生成 owner/project scoped asset keys。
|
||||
- 拒绝 path traversal、absolute path、encoded separators、NUL bytes。
|
||||
- 逐级 `lstat` / `realpath` 拒绝 symlink escape,拒绝后不会在 root 外创建目录或文件。
|
||||
- 校验 MIME、size、sha256 checksum。
|
||||
- 失败路径清理临时文件。
|
||||
- Audit service:
|
||||
- 只提供 append create path。
|
||||
- service-layer update/delete attempt 被拒绝。
|
||||
- direct Prisma auditLog update/delete 仍由 DB trigger 拒绝。
|
||||
- static scan 会发现 S1 app code 中的 `auditLog.update/delete`。
|
||||
- high-risk blocked facts 可写 audit,不变更 target state。
|
||||
- Task 7 没有引入 Task 8 HTTP endpoints;当前生产 controller 仍只有 health 和 auth。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE_WITH_CONCERNS | BullMQ happy path 未测;既有 pg warning |
|
||||
| spec compliance initial | `019e8270-5e5e-72e3-8d5c-ca9745de85bc` | FAIL | storage symlink escape 先污染 root 外目录;Job timeout 未使用 persisted `timeoutAt` |
|
||||
| implementation fix 1 | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE | 修复 storage / timeout findings |
|
||||
| spec compliance re-review | `019e828b-3b2c-78d3-baa1-e5989d5191b1` | PASS | 无 Critical / Important;2 个 Minor |
|
||||
| quality / feasibility initial | `019e8297-bf65-7302-9321-a7dded6147fd` | FAIL | duplicate idempotency 可重复 `mutateTarget`;`queue.enqueue` 在 DB transaction 内 |
|
||||
| implementation fix 2 | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE | 修复 winner-only mutation 和 post-commit enqueue |
|
||||
| spec compliance safety recheck | `019e82a9-1aeb-74d3-ba0c-919e788dba34` | PASS | 无 |
|
||||
| quality / feasibility re-review | `019e82b2-e49e-7b60-89b1-db47760b1551` | FAIL | 外层 transaction client 仍可导致 queue before outer commit |
|
||||
| implementation fix 3 | `019e8252-50f2-7311-8ddd-235f17d52a31` | DONE | `enqueueScopedJob` 要求 root Prisma client |
|
||||
| spec compliance safety recheck 2 | `019e82cc-459e-7d90-977f-c06894022a28` | PASS | 无 |
|
||||
| quality / feasibility re-review 2 | `019e82f2-2382-7dc0-9cc3-8912b42c07ef` | PASS | 无 Critical / Important |
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @huijing/api test -- jobs queue storage audit
|
||||
pnpm --filter @huijing/worker test
|
||||
pnpm --filter @huijing/api lint
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm --filter @huijing/worker lint
|
||||
pnpm --filter @huijing/worker typecheck
|
||||
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api exec prisma generate --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api build
|
||||
pnpm --filter @huijing/worker build
|
||||
docker compose -f infra/docker-compose.dev.yml ps
|
||||
find apps packages -maxdepth 4 \( -name dist -o -name .next -o -name tsconfig.tsbuildinfo -o -name coverage \) -print
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- API jobs / queue / storage / audit tests:PASS,10 files / 53 tests。
|
||||
- Worker tests:PASS,1 file / 2 tests。
|
||||
- API lint / typecheck / build:PASS。
|
||||
- Worker lint / typecheck / build:PASS。
|
||||
- Prisma validate:PASS。
|
||||
- Prisma migrate status:3 migrations found,database schema up to date。
|
||||
- Prisma generate:PASS。
|
||||
- Docker:
|
||||
- `infra-postgres-1` healthy on `5432`。
|
||||
- `infra-redis-1` healthy on `6379`。
|
||||
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist`、`.next`、`coverage`、`tsconfig.tsbuildinfo` 残留。
|
||||
|
||||
## Residual Non-blocking Notes
|
||||
|
||||
- `pnpm check:s1-scope` 仍失败,因为 `scripts/check-s1-scope.mjs` 不存在;这是 Task 9 交付项,不是 Task 7 blocker。
|
||||
- Storage cleanup 测试仍主要覆盖 pre-write failure;spec re-review 记录 Minor:可后续补一个写入 temp 后让 rename 失败的测试,断言 `.upload-*` 不残留。
|
||||
- Worker package 未声明 dependency matrix 中的 `bullmq`、`ioredis`、`zod`、`@huijing/shared-contracts`;当前 queue runtime 依赖在 API package,worker 保持 no-op smoke。后续若 worker 真正消费 queue,再补 worker runtime dependencies。
|
||||
- BullMQ worker runtime error signal 目前较弱;后续真实 worker 接入时应提供 `onError` / logger / status hook。
|
||||
- Storage symlink / realpath 检查不是原子 nofollow 策略;S1 本地单进程 root 下可接受。后续上传 API 接入前可补目录替换并发测试或更强 nofollow/open/rename 校验。
|
||||
- `mutateTarget` 当前在 enqueue 成功后执行;S1 worker 仍是 no-op。后续真实 worker 接入时,需要明确 worker 只以 committed job / DB claim 为准,不把 target mutation 当作已完成业务前置条件。
|
||||
- `pending_retry` recovery 当前在 `JobExecutionStore.claimNext` 层清晰,但还没有真实 retry scheduler / DB polling worker 端到端链路;符合 S1 no-op worker 范围。
|
||||
- API tests 仍输出既有 `pg` deprecation warning;命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
|
||||
- 当前 git status 仍包含 `.idea/`、`docs-design/` 和大量 untracked workspace surface,这是前置工作面;Task 7 未回退或清理这些内容。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/prisma/schema.prisma` | `e0a536208fe789d0c83ca89c081381a906d8e8520f63625335816c7df6b82c06` |
|
||||
| `apps/api/prisma/migrations/20260601165239_s1_task7_job_lease_fields/migration.sql` | `389d32966131c98520493bac8ee6938217275133a6c4e5d5fd4083616936277a` |
|
||||
| `apps/api/src/modules/jobs/index.ts` | `b2e4dd3709a053537e24884aa40f77d140d446a5df66d8f746339d760a1d3175` |
|
||||
| `apps/api/src/modules/jobs/jobs.spec.ts` | `c236eb5d78a68d111e68c328af4b26ec8cebae02aa85ddaa063020c02eef2d29` |
|
||||
| `apps/api/src/modules/queue/index.ts` | `de86c64863bf6d9ffd4c963b70d24f3e8478f80b41440f2cc4fed7c8c4a4c6c4` |
|
||||
| `apps/api/src/modules/queue/queue.spec.ts` | `9eb1cc2fb1052e19f3f2d38c6a04193bb4da6b043aa015205bc7b16605bb44ce` |
|
||||
| `apps/api/src/modules/storage/index.ts` | `efba0aa32fae28b0b21de6c842ef8ee19274a6c77f27ffcbcdbb2fbd7aede34b` |
|
||||
| `apps/api/src/modules/storage/storage.spec.ts` | `117c21d29afbba42213b0bf90b7b781baf5e3e3ae6a4df21d08750233e07241c` |
|
||||
| `apps/api/src/modules/audit/index.ts` | `42382d6d233d58e54b4535499d43f94f2a2ef561264a5ee3ba7b8da6c96c93bb` |
|
||||
| `apps/api/src/modules/audit/audit.spec.ts` | `b5f8bf06014c29bff2604369fcc0f9a9ed0c0a896b15c591f8287971b705e458` |
|
||||
| `apps/api/package.json` | `5ac20498af83cd24527da598bfee8e28dda9e6a2a93012a29759b135c2d98b63` |
|
||||
| `apps/worker/package.json` | `5094f400f8a169a4375c392802402454fcc333576a026d491d58b5f79a8c1cf2` |
|
||||
| `apps/worker/src/index.ts` | `23f99cb50abbd66599d10e6851473f9c03ae47fc24f3cd66c05902fcf134e980` |
|
||||
| `apps/worker/src/worker.smoke.spec.ts` | `510ded34b3204fcb466937a9298e69eba0ce18c644fa50e55b6668b499166510` |
|
||||
| `pnpm-lock.yaml` | `293cd73e9a8f692e6444152a983664c3520f1f8d1d956151b453a213b79069eb` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
158
docs/memorys/2026-06-01-S1Task8API门禁.md
Normal file
158
docs/memorys/2026-06-01-S1Task8API门禁.md
Normal file
@ -0,0 +1,158 @@
|
||||
# 2026-06-01 S1 Task 8 API 门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 8 `Implement Project, Version, Asset, Job, And Audit APIs` 已完成实现,并通过 mandatory fresh review gate:
|
||||
|
||||
- spec compliance review:最终 PASS,无 Critical / Important。
|
||||
- quality / feasibility review:最终 PASS,无 Critical / Important。
|
||||
|
||||
可以进入 S1 Task 9。Task 9 之前仍不能运行 `pnpm check:s1-scope` 作为通过门禁,因为 `scripts/check-s1-scope.mjs` 是 Task 9 交付项。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 8 delta 包含:
|
||||
|
||||
- `apps/api/src/app.module.ts`
|
||||
- `apps/api/src/main.ts`
|
||||
- `apps/api/src/modules/projects/index.ts`
|
||||
- `apps/api/src/modules/projects/api-runtime.ts`
|
||||
- `apps/api/src/modules/projects/projects.api.spec.ts`
|
||||
- `apps/api/src/modules/projects/api-runtime.spec.ts`
|
||||
- `apps/api/src/modules/assets/index.ts`
|
||||
- `apps/api/src/modules/assets/assets.api.spec.ts`
|
||||
- `apps/api/src/modules/jobs/index.ts`
|
||||
- `apps/api/src/modules/jobs/jobs.spec.ts`
|
||||
- `apps/api/src/modules/jobs/jobs.api.spec.ts`
|
||||
- `apps/api/src/modules/audit/index.ts`
|
||||
- `apps/api/src/modules/audit/audit.api.spec.ts`
|
||||
|
||||
未实现:
|
||||
|
||||
- Task 9 `scripts/check-s1-scope.mjs`。
|
||||
- S2+ creation agent、GameIR editor、runtime、conversion、publish/feed/telemetry API。
|
||||
- 新 schema / migration。
|
||||
- audit admin UI。
|
||||
|
||||
## 关键实现事实
|
||||
|
||||
- S1 HTTP API surface 包含:
|
||||
- `POST /auth/login`
|
||||
- `POST /auth/logout`
|
||||
- `GET /me`
|
||||
- `POST /projects`
|
||||
- `GET /projects`
|
||||
- `GET /projects/:projectId`
|
||||
- `POST /projects/:projectId/versions`
|
||||
- `GET /projects/:projectId/versions`
|
||||
- `POST /assets/presign`
|
||||
- `GET /jobs/:jobId`
|
||||
- `GET /audit-logs`
|
||||
- `POST /projects` 仅 creator 可创建;operator/admin/player/anonymous 会被拒绝且不产生 project/audit mutation。
|
||||
- creator 只能读写自有 project/version/asset/job;operator/admin 只在列出的 foundation read endpoints 读取审核基础数据。
|
||||
- draft version 创建通过 `StateTransitionService.createDraftVersion` 写入 `draft`,没有调用无关 S0 review transition。
|
||||
- asset presign 复用 `LocalStorageAdapter.planObject()`,保持 Task 7 storage boundary。
|
||||
- queue provider 由 `createQueueAdapterFromEnv()` 决定:
|
||||
- `QUEUE_ADAPTER=memory` 才使用 `InMemoryQueueAdapter`。
|
||||
- `QUEUE_ADAPTER=bullmq` 且 `REDIS_URL` 存在时使用 `BullMqQueueAdapter`。
|
||||
- 缺失 / unsupported `QUEUE_ADAPTER` 或 bullmq 缺 `REDIS_URL` 时 fail closed 为 `QUEUE_UNAVAILABLE`,不静默 fallback memory。
|
||||
- `ManagedQueueAdapter` 实现 `onModuleDestroy()`,Nest `app.close()` 会关闭底层 queue adapter。
|
||||
- `JobExecutionStore.enqueueScopedJob()` 返回 `{ job, created, enqueued }`,用于区分 create winner、duplicate 和 enqueue success。
|
||||
- S1 generic job runtime 不暴露 `mutateTarget` 或 `onJobCreated` pre-enqueue DB hook;避免 queue failure 后留下半成品 target。
|
||||
- `queue.enqueue` 仍在 DB commit 后执行。
|
||||
- `job.enqueued` 只在 queue side effect 成功后追加。
|
||||
- queue failure 后 Job 进入 `pending_retry`,写入 `QUEUE_ENQUEUE_FAILED`,并追加 `job.enqueue_failed`;不会追加 `job.enqueued`,也不会修改 target。
|
||||
- 全局异常过滤器稳定映射 Prisma `P2002/P2025`、`JobExecutionError`、`QueueUnavailableError`、`StateTransitionDomainError`,并保持 `{ code, message, requestId, details }` 响应形状。
|
||||
- Task8 API specs 按 runId 清理自己的 audit/project/job/asset 测试数据;`task8-*` leftover 已验证为 0。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| implementation | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 初始实现通过后进入 review |
|
||||
| spec compliance initial | `019e830f-0800-77b3-8108-942901a4ae7f` | FAIL | `POST /projects` 允许非 creator;asset presign 未复用 storage boundary |
|
||||
| implementation fix 1 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 修复 creator-only project creation 与 storage planner delegation |
|
||||
| spec compliance re-review | `019e8319-5514-7ba3-8e9b-29c9799fa789` | PASS | 无 |
|
||||
| quality / feasibility initial | `019e831d-c3d9-7253-bb4e-f777ebfd7fb6` | FAIL | queue provider 忽略 env;job audit 幂等 / queue failure 边界;异常映射不足 |
|
||||
| implementation fix 2 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 增加 queue env factory、异常映射、job failure audit |
|
||||
| spec compliance safety recheck | `019e8335-b8c9-7d10-8292-4117256468b2` | PASS | 无 |
|
||||
| quality / feasibility re-review | `019e833d-5ccf-70c3-aa3c-662f6af21b44` | FAIL | `QUEUE_ADAPTER` 缺省 fallback memory;enqueue 后 target mutation;queue adapter lifecycle |
|
||||
| implementation fix 3 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 显式 queue adapter、ManagedQueueAdapter、移动 target mutation |
|
||||
| spec compliance recheck 2 | `019e834f-befd-7903-83a9-659f348bcf2a` | FAIL | queue failure 后 target mutation 已提交 |
|
||||
| implementation fix 4 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 移除 generic `mutateTarget` |
|
||||
| spec compliance recheck 3 | `019e835b-fbb1-7951-98f3-d691c4806454` | PASS | 无 |
|
||||
| quality / feasibility re-review 2 | `019e8361-4709-7bc3-b4de-ad504dbf43aa` | FAIL | `job.enqueued` 早于 queue success;`onJobCreated` pre-enqueue DB hook 风险 |
|
||||
| implementation fix 5 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 移除 `onJobCreated`;`job.enqueued` 移到 queue success 后 |
|
||||
| spec compliance recheck 4 | `019e8374-0515-7832-b402-9882aa8076fc` | FAIL | Job tests 并行污染,`claimNext()` 可领取其他 spec fixture |
|
||||
| implementation fix 6 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | 修复 Job tests 隔离,不改 runtime `claimNext()` 语义 |
|
||||
| spec compliance recheck 5 | `019e8389-c240-7d41-9416-ab6eeac2ed1b` | PASS | 无 |
|
||||
| quality / feasibility re-review 3 | `019e839a-aaef-7ca3-a64e-bd8a00461631` | FAIL | 测试隔离会改写非本 runId Job;Task8 audit leftovers |
|
||||
| implementation fix 7 | `019e82fc-f61d-73e0-a9a6-b5895a49db4d` | DONE | advisory lock 隔离;只清理本 runId / task8 测试数据;leftover 清零 |
|
||||
| spec compliance final | `019e83cb-4f97-7e93-b583-1d7a8f5afdbf` | PASS | 无 |
|
||||
| quality / feasibility final attempt | `019e83d2-a012-77c1-87e7-d250193c5405` | TIMEOUT | 超时关闭,无结果 |
|
||||
| quality / feasibility final | `019e859d-3c8f-7b02-99b2-51c632f41469` | PASS | 无 Critical / Important;2 个 Minor |
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已运行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @huijing/api test -- projects assets jobs audit
|
||||
pnpm --filter @huijing/api test -- auth rbac jobs queue storage audit state-transition
|
||||
pnpm --filter @huijing/api lint
|
||||
pnpm --filter @huijing/api typecheck
|
||||
pnpm --filter @huijing/api build
|
||||
rm -rf apps/api/dist
|
||||
pnpm --filter @huijing/api exec prisma validate --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api exec prisma migrate status --schema prisma/schema.prisma
|
||||
pnpm check:workspace-scripts
|
||||
docker compose -f infra/docker-compose.dev.yml ps
|
||||
find apps packages -maxdepth 5 \( -name dist -o -name coverage -o -name .next -o -name tsconfig.tsbuildinfo \) -print
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- API Task8 tests:PASS,15 files / 82 tests。
|
||||
- API auth/rbac/jobs/queue/storage/audit/state-transition tests:PASS,15 files / 82 tests。
|
||||
- API lint / typecheck / build:PASS。
|
||||
- Prisma validate:PASS。
|
||||
- Prisma migrate status:3 migrations found,database schema up to date。
|
||||
- `pnpm check:workspace-scripts`:PASS。
|
||||
- Docker:
|
||||
- `infra-postgres-1` healthy on `5432`。
|
||||
- `infra-redis-1` healthy on `6379`。
|
||||
- Build outputs 已清理;`apps/**` / `packages/**` 下无 `dist`、`.next`、`coverage`、`tsconfig.tsbuildinfo` 残留。
|
||||
- Task8 DB leftover 查询:
|
||||
- `audit_task8_leftovers=0`
|
||||
- `job_task8_leftovers=0`
|
||||
- `project_task8_leftovers=0`
|
||||
|
||||
## Residual Non-blocking Notes
|
||||
|
||||
- `pnpm check:s1-scope` 仍失败,因为 `scripts/check-s1-scope.mjs` 不存在;这是 Task 9 交付项,不是 Task 8 blocker。
|
||||
- `GET /audit-logs` 当前返回全局 latest 200。官方单进程 / 顺序门禁通过;两个完整 Vitest 进程同时打同一个 dev DB 时,audit API 测试可能被其他进程新 audit 挤出 latest 200。Quality final 评定为 Minor,不阻塞 Task8。后续建议使用 per-process DB/schema,或禁止完整 Vitest 并发共享 dev DB。
|
||||
- API tests 仍输出既有 `pg` deprecation warning;命令 exit 0。升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
|
||||
- Task7 memory 中关于 `mutateTarget` 的 residual note 已过时;Task8 最新事实是 runtime 不再暴露 `mutateTarget` / `onJobCreated` pre-enqueue hook。
|
||||
- 当前 git status 仍包含 `.idea/`、`docs-design/` 和大量 untracked workspace surface,这是前置工作面;Task8 未回退或清理这些内容。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `apps/api/src/app.module.ts` | `8d2b322de359888a470ba863f6cee488a72b8cb6261b66fd9bf14d6f17d073fe` |
|
||||
| `apps/api/src/main.ts` | `fcefc7c057d211b11f890477a9a0017ea4a3d4ed41c34e93dc1be271f8212d58` |
|
||||
| `apps/api/src/modules/projects/index.ts` | `fb33104864df22c829eb5beeb1015890f0fd71900f6e32230531fc7ea50d2233` |
|
||||
| `apps/api/src/modules/projects/api-runtime.ts` | `a4ebdcbba9e25f1a49fc4cc0156147daf8836fec3df1a522b89e5c3d3305713b` |
|
||||
| `apps/api/src/modules/projects/projects.api.spec.ts` | `c941170e73bf293926283147aa5fe384c515c742035ab7b77181742053f87c09` |
|
||||
| `apps/api/src/modules/projects/api-runtime.spec.ts` | `96e1e1ea4b47363c4af6238a944e10136033b321c1206cc3fe581a76a4709d3b` |
|
||||
| `apps/api/src/modules/assets/index.ts` | `df8236d27715f98e2cea9a2abbd40677afc2863293e09682b80bc83ec766092c` |
|
||||
| `apps/api/src/modules/assets/assets.api.spec.ts` | `4b260fe97ce42cf27dca2aba0c8abc0573091d966a7692d5f504e54e28a8b8a3` |
|
||||
| `apps/api/src/modules/jobs/index.ts` | `300563b079c80edf2ce64c4035f12446b0b7edb73ba56809f1938128a56f391a` |
|
||||
| `apps/api/src/modules/jobs/jobs.spec.ts` | `8188d8d52fa3da5b354b89f01d723c87758010356ddbe5abc0f5ae644cd63fa9` |
|
||||
| `apps/api/src/modules/jobs/jobs.api.spec.ts` | `15275a7298eff7092e2888986c782c070edd29afa66f3100c4320f1ef8222331` |
|
||||
| `apps/api/src/modules/audit/index.ts` | `d815b6e143f39ccf9525b4cd931f13d003299ba36063946861a546eb3ec97c18` |
|
||||
| `apps/api/src/modules/audit/audit.api.spec.ts` | `262eb16e9423ab82119aaf2a4d2115c06669d2f518205236128ab6fce8a64640` |
|
||||
| `apps/api/prisma/schema.prisma` | `e0a536208fe789d0c83ca89c081381a906d8e8520f63625335816c7df6b82c06` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
54
docs/memorys/2026-06-01-S1规格计划门禁.md
Normal file
54
docs/memorys/2026-06-01-S1规格计划门禁.md
Normal file
@ -0,0 +1,54 @@
|
||||
# 2026-06-01 S1 规格计划门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 App Foundation spec/plan review gate 已通过。四个 fresh reviewer 均为 `gpt-5.5 + xhigh`,本轮无 Critical / Important finding,可以进入 S1 Task 1 实现。
|
||||
|
||||
## Reviewers
|
||||
|
||||
| Role | Reviewer ID | Gate | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| coherence/scope | `019e80c3-ff72-7d10-bc0b-58bea7bccf9e` | PASS | 无 |
|
||||
| feasibility | `019e80c4-0003-7a43-926b-ef25efdd7308` | PASS | 无 |
|
||||
| security | `019e80c4-023c-7373-8548-adeef0dee226` | PASS | 无 |
|
||||
| adversarial | `019e80c4-0350-7b72-b115-84e71c0cebe5` | PASS | 无 |
|
||||
|
||||
## Reviewed Files And SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `25591c2ec52cde6651dca8fbadbdb6ad13fad051b0319e366f8393c6d1cef71a` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
| `harness/scripts/validate-harness.mjs` | `4e40acad9383548a24389c060c9ed71d134b8b2285147ac2bc2aad14d40808da` |
|
||||
| `harness/schemas/main-creation-agent-session.schema.json` | `6748fefc0fdc49fa9812f63ef66df5b44b2bb025ef3b2459efdcec2f177b4fa2` |
|
||||
| `harness/schemas/agent-task.schema.json` | `b26d91750c3651690b9b3f1567255960fc326d23e24cb48386b4fa99b8b46ba2` |
|
||||
| `harness/schemas/lifecycle-event.schema.json` | `b1a4737a25516a04eb8a2ddc6951766219b420a6eb735c8d6bdfb81fb70b789b` |
|
||||
| `harness/lifecycle.yaml` | `6e2400aa678ea9feed957ebea56a4f98d50508e03c396dc943e42e4cf802e79e` |
|
||||
| `docs/memorys/2026-06-01-S0阶段通过门禁.md` | `dce65992dd97fdfd6d2ce1474a180fe95ca0f5635c0b94038a594a1f48b406f5` |
|
||||
|
||||
## Worktree State
|
||||
|
||||
`git status --short` 显示 `docs/` 与 `harness/` 仍为 untracked surface;`git ls-files --stage` 对上述 reviewed docs/harness 文件无输出,因此这些 reviewed files 当前均为 untracked。`.idea/` 与 `docs-design/` 存在既有 staged/modified 内容,S1 gate 未修改或回滚这些文件。
|
||||
|
||||
## Fixed Blocking Findings
|
||||
|
||||
本轮通过前已修复以下阻塞类问题:
|
||||
|
||||
- S1 spec/plan 起始事实改为已有 S0 `harness/`,S1 固定消费 `harness/scripts/validate-harness.mjs`。
|
||||
- `MainCreationAgentSession` / `AgentTask` 对齐 S0 schema,并要求 exact DTO projection 与 S0 CLI 校验。
|
||||
- `ReviewRecord` / `LifecycleEvent` 补齐 S0 lifecycle transition 证据字段。
|
||||
- Task 4 验收收窄为 DB schema / raw SQL / direct Prisma,不再提前要求 Task 7/8 的 service/API/concurrency。
|
||||
- Job target 增加 DB 级 same-project invariant,Job 状态由 API-owned `JobExecutionStore` 单写入口管理。
|
||||
- Worker 明确不 import Prisma、不直写 DB、不写 guarded state。
|
||||
- Guarded state / audit append-only 通过 DB trigger、static scanner、direct Prisma negative tests 约束。
|
||||
- Final verification 纳入 Docker Postgres/Redis、migration deploy/generate、real DB/queue integration。
|
||||
- Review gate protocol 要求 evidence note 记录 reviewer ids、roles、SHA-256、`git status --short` 和 tracked/untracked 状态。
|
||||
|
||||
## Non-blocking Implementation Notes
|
||||
|
||||
- `review_rejected` 可用构造 payload 或新增 S0 fixture 验证;不要误以为当前只存在 `review_approved` fixture 就跳过 rejected path。
|
||||
- `LifecycleEvent --contract` 只能证明必填字段存在,审核状态的关键约束仍必须用 `--transition review_approved/review_rejected` 验证。
|
||||
- `JobExecutionStore` 实现要使用 `leaseToken`、`leasedBy`、`leaseExpiresAt`、`lockVersion` 或等价持久字段支撑 claim/lease/concurrency。
|
||||
- Scope scanner 实现时应固化 denylist、path allowlist、正负 self-test fixture,避免靠自然语言判断。
|
||||
- Harness stdout/stderr bound、storage MIME allowlist 和 max size 必须给出具体上限;review 时不接受无界或过宽实现。
|
||||
205
docs/memorys/2026-06-02-S1Task9最终验证门禁.md
Normal file
205
docs/memorys/2026-06-02-S1Task9最终验证门禁.md
Normal file
@ -0,0 +1,205 @@
|
||||
# 2026-06-02 S1 Task 9 最终验证门禁
|
||||
|
||||
## 结论
|
||||
|
||||
S1 Task 9 `Final S1 Verification` 已完成,并通过 mandatory fresh review gate 与 S1 阶段最终 read-only review gate:
|
||||
|
||||
- Task 9 scope scanner spec compliance review:最终 PASS,无 Critical / Important。
|
||||
- Task 9 scope scanner quality / feasibility review:最终 PASS,无 Critical / Important。
|
||||
- Task 9 full verification:PASS。
|
||||
- S1 final read-only review:PASS,无 Critical / Important,可以进入 S2。
|
||||
|
||||
## 实现范围
|
||||
|
||||
Task 9 delta 包含:
|
||||
|
||||
- `scripts/check-s1-scope.mjs`
|
||||
- `apps/api/src/modules/jobs/index.ts`
|
||||
|
||||
Task 9 未实现:
|
||||
|
||||
- S2+ creation agent / 游戏需求确认 / draft 生成 / draft 修改。
|
||||
- Web runtime、WebPlatformAdapter、小游戏转换、发布流、feed、analytics。
|
||||
- 新业务 API、schema model 或 migration。
|
||||
|
||||
## 关键实现事实
|
||||
|
||||
- `scripts/check-s1-scope.mjs` 扫描 `apps/**` 与 `packages/**`,忽略 `node_modules`、`dist`、`.next`、`.vite`、`coverage`、generated Prisma。
|
||||
- scanner 会拒绝 S2-S8 路径、路由、模型、符号泄漏,包括:
|
||||
- `creation-agent/messages`
|
||||
- `compile-game-ir`
|
||||
- `web-runtime`
|
||||
- `mini-game-conversion`
|
||||
- `AIGameDesignDraft`
|
||||
- `GameIRArtifact`
|
||||
- `GameIR`
|
||||
- `ConversionReport`
|
||||
- `ValidationReport`
|
||||
- `MiniGameProject`
|
||||
- `MiniGameCodeConversion`
|
||||
- `FeedItem`
|
||||
- `GameDailyStats`
|
||||
- `CreatorDailyStats`
|
||||
- `/events/batch`
|
||||
- `MainCreationAgentSession`、`AgentTask`、`ReviewRecord`、`LifecycleEvent` 只允许作为 S1 schema/state/audit anchor,不允许作为业务实现泄漏。
|
||||
- `packages/harness-client/**` 只在明确 `validateContract(...)` 或 harness CLI `--contract` 参数断言上下文放行 S0 contract 名称。
|
||||
- `JobExecutionStore` 是唯一 Job runtime state writer;worker 不 import Prisma/DB。
|
||||
- scanner 会拒绝 Job runtime field 的 Prisma direct write、bracket write、optional chaining write、delegate alias write,以及 raw SQL 写。
|
||||
- scanner 会拒绝 `AuditLog` update/delete/upsert、raw SQL update/delete/truncate/drop/disable trigger/function tamper。
|
||||
- scanner 会拒绝 guarded state `GameVersion` / `ReviewRecord` / `LifecycleEvent` 在 state-transition boundary 外直接写。
|
||||
- scanner 会拒绝 runtime guard setter:`set_config`、`SET LOCAL`、`app.state_transition_guard`。
|
||||
- raw SQL scanner 覆盖本轮 review 发现的绕过:
|
||||
- `db.$executeRawUnsafe?.call(...)`
|
||||
- `db.$executeRawUnsafe?.bind(db)(...)`
|
||||
- `const execRaw = db.$executeRawUnsafe.bind(db); await execRaw(...)`
|
||||
- `db. $executeRawUnsafe(...)`
|
||||
- `db["$executeRawUnsafe"](...)`
|
||||
- `db?.$executeRawUnsafe(...)`
|
||||
- `db?.["$executeRawUnsafe"](...)`
|
||||
- bracket / optional bracket 的 `call`、`bind`、bound alias
|
||||
- no-semicolon / ASI bound alias
|
||||
- dynamic raw SQL write identifier fail closed;dynamic read-only `SELECT` 放行。
|
||||
- `apps/api/src/modules/jobs/index.ts` 的 Task9 后续 lint 修复只是 `let cursor` -> `const cursor`,未改变 Job runtime 或 scanner 行为。
|
||||
|
||||
## Review Gate
|
||||
|
||||
| Gate | Agent ID | Result | Blocking findings |
|
||||
| --- | --- | --- | --- |
|
||||
| scanner implementation remediation | `019e87ab-d507-7bd0-ae38-5d537bbba64b` | DONE | 修复 Prisma optional chaining、delegate alias、raw optional/call/bind 基础覆盖 |
|
||||
| scanner spec compliance | `019e87bb-5f8c-79c0-bdee-64bce4584492` | PASS | 无 |
|
||||
| scanner quality / feasibility | `019e87c0-c594-7c61-8348-30ebe6332a30` | FAIL | raw SQL `?.call` / `?.bind` / bound alias 仍可绕过 |
|
||||
| scanner implementation fix 1 | `019e87c7-6a70-72c0-add2-e3ae885fe774` | DONE | 修复 optional call/bind/bound alias |
|
||||
| scanner spec compliance recheck 1 | `019e87d1-1965-74f3-9b22-c976be68d8ac` | PASS | 无 |
|
||||
| scanner quality / feasibility recheck 1 | `019e87d5-9210-7611-b41d-162d50adb479` | FAIL | raw executor access 漏掉 dot whitespace、static bracket、optional receiver |
|
||||
| scanner implementation fix 2 | `019e87e0-57e2-70b1-a272-4fa5604c8390` | DONE | 扩展 raw executor access matching |
|
||||
| scanner spec compliance recheck 2 | `019e87e6-2508-7931-809b-7a5238233ca8` | FAIL | no-semicolon / ASI bound alias 漏报 |
|
||||
| scanner implementation fix 3 | `019e87ea-a3f0-7273-9a02-877b378b5426` | DONE | 修复 no-semicolon / ASI bound alias |
|
||||
| scanner spec compliance final | `019e8817-261a-70a3-af70-e358e55156ca` | PASS | 无 |
|
||||
| scanner quality / feasibility final | `019e881e-d4ea-70b1-aa23-87febde47014` | PASS | 无 |
|
||||
| lint implementation fix | `019e882a-e2ca-7093-b23e-4c1c603f14aa` | DONE | 修复 `prefer-const` |
|
||||
| lint fix spec compliance | `019e8835-22cc-7782-82f6-abcc9e4e2699` | PASS | 无 |
|
||||
| lint fix quality / feasibility | `019e8838-65a7-7d61-8b25-adfa4187208d` | PASS | 无 |
|
||||
| S1 final read-only review | `019e883c-91a6-7542-84c1-d1c4464e831e` | PASS | 无 Critical / Important,可以进入 S2 |
|
||||
|
||||
## Controller Verification
|
||||
|
||||
已串行运行:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm check:workspace-scripts
|
||||
export POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
export REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
export DATABASE_URL="postgresql://huijing:huijing@localhost:${POSTGRES_PORT}/huijing_dev?schema=public"
|
||||
export REDIS_URL="redis://localhost:${REDIS_PORT}"
|
||||
docker compose -f infra/docker-compose.dev.yml up -d --wait
|
||||
docker compose -f infra/docker-compose.dev.yml exec -T postgres pg_isready -U huijing -d huijing_dev
|
||||
docker compose -f infra/docker-compose.dev.yml exec -T redis redis-cli ping
|
||||
pnpm --filter @huijing/api exec prisma migrate deploy --schema prisma/schema.prisma
|
||||
pnpm --filter @huijing/api exec prisma generate --schema prisma/schema.prisma
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
QUEUE_ADAPTER=bullmq pnpm test
|
||||
pnpm build
|
||||
pnpm dev:smoke
|
||||
pnpm check:s1-scope
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `pnpm install`:PASS。
|
||||
- 非阻塞提示:pnpm `10.33.0 -> 11.5.0` update notice。
|
||||
- 非阻塞 warning:`msgpackr-extract@3.0.4` ignored build scripts。
|
||||
- `pnpm check:workspace-scripts`:PASS。
|
||||
- Docker dev services:
|
||||
- `infra-postgres-1` healthy on `5432`。
|
||||
- `infra-redis-1` healthy on `6379`。
|
||||
- `pg_isready`:PASS。
|
||||
- `redis-cli ping`:`PONG`。
|
||||
- Prisma migrate deploy:PASS,3 migrations found,No pending migrations。
|
||||
- Prisma generate:PASS,Prisma Client 7.8.0 generated。
|
||||
- `pnpm lint`:初次失败于 `apps/api/src/modules/jobs/index.ts:703 prefer-const`;fresh implementer 修复并通过两轮 review 后,重跑 PASS。
|
||||
- `pnpm typecheck`:PASS。
|
||||
- `QUEUE_ADAPTER=bullmq pnpm test`:PASS。
|
||||
- worker:1 file / 2 tests。
|
||||
- shared-contracts:1 file / 2 tests。
|
||||
- harness-client:1 file / 10 tests。
|
||||
- web:1 file / 2 tests。
|
||||
- api:15 files / 82 tests。
|
||||
- 非阻塞 warning:`pg@9` deprecation warning。
|
||||
- `pnpm build`:PASS。
|
||||
- `pnpm dev:smoke`:PASS。
|
||||
- API smoke 中仍出现同一个 `pg@9` deprecation warning。
|
||||
- `pnpm check:s1-scope`:PASS,`S1 scope check passed.`。
|
||||
|
||||
控制器额外探针:
|
||||
|
||||
- optional raw call AuditLog delete:scanner failed as expected。
|
||||
- optional raw bind Job status update:scanner failed as expected。
|
||||
- bound raw alias AuditLog delete:scanner failed as expected。
|
||||
- dot whitespace raw AuditLog delete:scanner failed as expected。
|
||||
- bracket raw AuditLog delete:scanner failed as expected。
|
||||
- optional receiver / optional bracket raw write:scanner failed as expected。
|
||||
- no-semicolon dot / optional dot / bracket / optional bracket bound alias:scanner failed as expected。
|
||||
- dynamic read-only `SELECT`:scanner passed as expected。
|
||||
|
||||
## Cleanup / Runtime State
|
||||
|
||||
- build/dev-smoke 产生的输出已清理:
|
||||
- `apps/web/.next`
|
||||
- `apps/api/dist`
|
||||
- `apps/worker/dist`
|
||||
- `packages/harness-client/dist`
|
||||
- `packages/shared-contracts/dist`
|
||||
- `apps/**` / `packages/**` 下无 `dist`、`.next` 构建输出残留。
|
||||
- `/tmp` 下无 `huijing-s1-scope-*`、`huijing-s1-probe-*`、`huijing-audit-scan-*`、`huijing-controller-s1-scope-*` 探针目录残留。
|
||||
- Docker dev services 保持运行且 healthy;未在本轮关闭。
|
||||
|
||||
## Residual Non-blocking Notes
|
||||
|
||||
- 当前仓库大量 S1 workspace surface 仍是 untracked;`git diff` 对这些文件不能代表实际变更范围。
|
||||
- `.idea/`、`docs-design/` 和大量 untracked workspace surface 是前置工作面;本轮未回退或清理这些内容。
|
||||
- `pg@9` deprecation warning 仍存在,但所有相关测试命令 exit 0;升级 `pg@9` 前需要复查 Prisma adapter / 并发测试方式。
|
||||
- `msgpackr-extract@3.0.4` ignored build scripts warning 仍存在;当前验证未受影响。
|
||||
- Final reviewer 未重跑完整 `pnpm test/build`,避免重新写构建产物或扰动 dev DB;其基于控制器 full verification 与只读 spot-check 给出 PASS。
|
||||
|
||||
## SHA-256
|
||||
|
||||
| Path | SHA-256 |
|
||||
| --- | --- |
|
||||
| `scripts/check-s1-scope.mjs` | `e47eee55977f28b1d711f1bc7f4e1a330c1d51291d87f6a1fd2a95d80aa738fc` |
|
||||
| `apps/api/src/modules/jobs/index.ts` | `dac1176331d0adc6ba7c79f0f7788ef9efd147492d3f36d1155902784820ae32` |
|
||||
| `apps/api/src/modules/assets/assets.api.spec.ts` | `815109ff2eb27cba10837af862702908c6ea06556b1ae4e75ed51a46fdaed6d0` |
|
||||
| `apps/api/src/modules/audit/audit.api.spec.ts` | `efa91e6e71851bc5d8ddab0bf6b18e8c1dd4a433712529ef8d39cd877506098d` |
|
||||
| `apps/api/src/modules/jobs/jobs.api.spec.ts` | `5358ac8a1bd9d66821b7c7cafd2de57a79ceeb4585b90ae9e9d06d366663fe90` |
|
||||
| `apps/api/src/modules/projects/projects.api.spec.ts` | `1facfbac8e6ddb8d9bb3bed3faf69af050efafb3d7ff48c46d6a58a60d5aab32` |
|
||||
| `docs/superpowers/plans/2026-05-31-mvp-S1-app-foundation.md` | `868dc63fee64e254cf267d63db472aa46abf15977f1999785cfaea6fa1956a0a` |
|
||||
| `docs/superpowers/specs/2026-05-31-mvp-S1-app-foundation-design.md` | `f514de72c36c5fd1ee6f3e87999b911f33d392549dbb76c6ee5d538dac774cdb` |
|
||||
| `docs/superpowers/plans/2026-06-01-implementation-review-gate-protocol.md` | `c8726acc5457ab87fb8f26c2e439e1d1b98df0255af78e4d6e8d9636dc518478` |
|
||||
|
||||
## Git Status Snapshot
|
||||
|
||||
`git status --short` 显示:
|
||||
|
||||
```text
|
||||
A .idea/.gitignore
|
||||
A .idea/games-development-ai.iml
|
||||
A .idea/misc.xml
|
||||
A .idea/modules.xml
|
||||
A .idea/vcs.xml
|
||||
A docs-design/*
|
||||
AM docs-design/Deployment for AI.md
|
||||
?? .gitignore
|
||||
?? apps/
|
||||
?? docs/
|
||||
?? eslint.config.mjs
|
||||
?? harness/
|
||||
?? infra/
|
||||
?? package.json
|
||||
?? packages/
|
||||
?? pnpm-lock.yaml
|
||||
?? pnpm-workspace.yaml
|
||||
?? scripts/
|
||||
?? tsconfig.base.json
|
||||
```
|
||||
|
||||
24
eslint.config.mjs
Normal file
24
eslint.config.mjs
Normal file
@ -0,0 +1,24 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
// 仅覆盖 S1 工作区源码;根脚本和未来特殊目录由各自配置单独处理。
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/.next/**",
|
||||
"**/.turbo/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ["apps/**/*.{ts,tsx}", "packages/**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module"
|
||||
}
|
||||
}
|
||||
);
|
||||
23
infra/docker-compose.dev.yml
Normal file
23
infra/docker-compose.dev.yml
Normal file
@ -0,0 +1,23 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: huijing
|
||||
POSTGRES_PASSWORD: huijing
|
||||
POSTGRES_DB: huijing_dev
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U huijing -d huijing_dev"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
redis:
|
||||
image: redis:7
|
||||
ports:
|
||||
- "${REDIS_PORT:-6379}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"lint": "pnpm check:workspace-scripts && pnpm -r lint",
|
||||
"typecheck": "pnpm check:workspace-scripts && pnpm -r typecheck",
|
||||
"test": "pnpm check:workspace-scripts && pnpm -r test",
|
||||
"build": "pnpm check:workspace-scripts && pnpm -r build",
|
||||
"dev": "pnpm -r --parallel --if-present dev",
|
||||
"dev:smoke": "pnpm check:workspace-scripts && pnpm -r dev:smoke",
|
||||
"check:workspace-scripts": "node scripts/check-workspace-scripts.mjs",
|
||||
"check:s1-scope": "node scripts/check-s1-scope.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"eslint": "^10.4.1",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.0"
|
||||
}
|
||||
}
|
||||
26
packages/harness-client/package.json
Normal file
26
packages/harness-client/package.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@huijing/harness-client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./runtime": {
|
||||
"types": "./src/internal.ts",
|
||||
"default": "./src/internal.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev:smoke": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
201
packages/harness-client/src/index.spec.ts
Normal file
201
packages/harness-client/src/index.spec.ts
Normal file
@ -0,0 +1,201 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
HARNESS_INVALID_OUTPUT,
|
||||
HARNESS_TIMEOUT,
|
||||
createHarnessClient,
|
||||
type ChildProcessRunner
|
||||
} from "./internal";
|
||||
|
||||
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = path.resolve(packageRoot, "../..");
|
||||
const fixturePath = (...segments: string[]) => path.join(repoRoot, "harness", "fixtures", ...segments);
|
||||
const realHarnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
|
||||
|
||||
async function readFixture(...segments: string[]): Promise<unknown> {
|
||||
return JSON.parse(await readFile(fixturePath(...segments), "utf8"));
|
||||
}
|
||||
|
||||
async function expectMissing(file: string): Promise<void> {
|
||||
await expect(stat(file)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
}
|
||||
|
||||
describe("@huijing/harness-client", () => {
|
||||
const originalCwd = process.cwd();
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(originalCwd);
|
||||
});
|
||||
|
||||
it("通过真实 S0 fixture 调用 contract CLI,并在执行后删除临时 JSON 输入文件", async () => {
|
||||
let observedInput: string | null = null;
|
||||
const runner: ChildProcessRunner = async (file, args, options) => {
|
||||
expect(file).toBe("node");
|
||||
expect(args.slice(0, 2)).toEqual([realHarnessCli, "--contract"]);
|
||||
expect(args[2]).toBe("GameIR");
|
||||
expect(args[3]).toBe("--input");
|
||||
observedInput = args[4] ?? null;
|
||||
expect(options.cwd).toBe(repoRoot);
|
||||
expect(options.timeoutMs).toBe(5_000);
|
||||
expect(options.maxOutputBytes).toBeGreaterThan(0);
|
||||
expect(JSON.parse(await readFile(observedInput ?? "", "utf8"))).toEqual(
|
||||
await readFixture("mvp", "valid", "simulation-game-ir-valid.json")
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
execFile(file, args, { cwd: options.cwd }, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
timedOut: false,
|
||||
exitCode: typeof error?.code === "number" ? error.code : 0,
|
||||
stdout,
|
||||
stderr
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const result = await createHarnessClient({ runner }).validateContract(
|
||||
"GameIR",
|
||||
await readFixture("mvp", "valid", "simulation-game-ir-valid.json")
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, reasonCode: null });
|
||||
expect(observedInput).toBeTruthy();
|
||||
await expectMissing(observedInput ?? "");
|
||||
});
|
||||
|
||||
it("通过真实 S0 failing fixture 解析非零退出 stdout JSON,并返回 fail-closed reasonCode", async () => {
|
||||
const client = createHarnessClient();
|
||||
|
||||
await expect(
|
||||
client.validateContract(
|
||||
"ValidationReport",
|
||||
await readFixture("mvp", "invalid", "game-logic-validation-report-web-only-sdk-invalid.json")
|
||||
)
|
||||
).resolves.toEqual({ ok: false, reasonCode: "WEB_ONLY_SDK_USAGE" });
|
||||
});
|
||||
|
||||
it("调用 transition CLI,使用真实 S0 review_approved fixture", async () => {
|
||||
const client = createHarnessClient();
|
||||
|
||||
await expect(
|
||||
client.validateTransition(
|
||||
"review_approved",
|
||||
await readFixture("lifecycle", "publish-approved-valid.json")
|
||||
)
|
||||
).resolves.toEqual({ ok: true, reasonCode: null });
|
||||
});
|
||||
|
||||
it("repo root 解析独立于当前工作目录", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "huijing-cwd-"));
|
||||
try {
|
||||
process.chdir(tempDir);
|
||||
const client = createHarnessClient();
|
||||
|
||||
await expect(
|
||||
client.validateContract("GameIR", await readFixture("mvp", "valid", "simulation-game-ir-valid.json"))
|
||||
).resolves.toEqual({ ok: true, reasonCode: null });
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("timeout 返回稳定 HARNESS_TIMEOUT 并清理临时文件", async () => {
|
||||
let observedInput: string | null = null;
|
||||
const runner: ChildProcessRunner = async (_file, args) => {
|
||||
observedInput = args[4] ?? null;
|
||||
return {
|
||||
timedOut: true,
|
||||
stdout: "{\"ok\":true,\"reasonCode\":null}",
|
||||
stderr: ""
|
||||
};
|
||||
};
|
||||
|
||||
const result = await createHarnessClient({ runner, timeoutMs: 1 }).validateContract("GameIR", { id: "x" });
|
||||
|
||||
expect(result).toEqual({ ok: false, reasonCode: HARNESS_TIMEOUT });
|
||||
await expectMissing(observedInput ?? "");
|
||||
});
|
||||
|
||||
it("invalid JSON 或 invalid result shape 返回稳定 HARNESS_INVALID_OUTPUT", async () => {
|
||||
const invalidJsonRunner: ChildProcessRunner = async () => ({
|
||||
timedOut: false,
|
||||
stdout: "not json",
|
||||
stderr: ""
|
||||
});
|
||||
const invalidShapeRunner: ChildProcessRunner = async () => ({
|
||||
timedOut: false,
|
||||
stdout: JSON.stringify({ ok: "yes", reasonCode: null }),
|
||||
stderr: ""
|
||||
});
|
||||
|
||||
await expect(createHarnessClient({ runner: invalidJsonRunner }).validateContract("GameIR", {})).resolves.toEqual({
|
||||
ok: false,
|
||||
reasonCode: HARNESS_INVALID_OUTPUT
|
||||
});
|
||||
await expect(createHarnessClient({ runner: invalidShapeRunner }).validateContract("GameIR", {})).resolves.toEqual({
|
||||
ok: false,
|
||||
reasonCode: HARNESS_INVALID_OUTPUT
|
||||
});
|
||||
});
|
||||
|
||||
it("stdout/stderr 超过上限时 fail closed,避免无界捕获子进程输出", async () => {
|
||||
const runner: ChildProcessRunner = async () => ({
|
||||
timedOut: false,
|
||||
stdout: `${JSON.stringify({ ok: true, reasonCode: null })}x`,
|
||||
stderr: "too much"
|
||||
});
|
||||
|
||||
await expect(createHarnessClient({ runner, maxOutputBytes: 16 }).validateContract("GameIR", {})).resolves.toEqual({
|
||||
ok: false,
|
||||
reasonCode: HARNESS_INVALID_OUTPUT
|
||||
});
|
||||
});
|
||||
|
||||
it("spawn error 不泄露为成功,返回稳定 HARNESS_INVALID_OUTPUT", async () => {
|
||||
const runner: ChildProcessRunner = async () => {
|
||||
throw new Error("spawn failed");
|
||||
};
|
||||
|
||||
await expect(createHarnessClient({ runner }).validateContract("GameIR", {})).resolves.toEqual({
|
||||
ok: false,
|
||||
reasonCode: HARNESS_INVALID_OUTPUT
|
||||
});
|
||||
});
|
||||
|
||||
it("源码不 import 或复制 S0 validator 内部实现", async () => {
|
||||
const source = await readFile(path.join(packageRoot, "src", "internal.ts"), "utf8");
|
||||
|
||||
expect(source).not.toMatch(/from\s+["'].*validate-harness\.mjs["']/);
|
||||
expect(source).not.toMatch(/import\s*\(["'].*validate-harness\.mjs["']\)/);
|
||||
expect(source).not.toContain("validateAgainstSchema");
|
||||
expect(source).not.toContain("customContractRejection");
|
||||
expect(source).not.toContain("transitionList");
|
||||
});
|
||||
|
||||
it("包根只暴露 Task 5 指定的两个 public type 合同", async () => {
|
||||
const source = await readFile(path.join(packageRoot, "src", "index.ts"), "utf8");
|
||||
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")) as {
|
||||
exports: Record<string, unknown>;
|
||||
};
|
||||
|
||||
expect(source).toContain("export type HarnessGateResult");
|
||||
expect(source).toContain("export type HarnessClient");
|
||||
expect(source).not.toMatch(/export\s+(const|function|class|interface)\s+/);
|
||||
expect(source).not.toContain("createHarnessClient");
|
||||
expect(source).not.toContain("HARNESS_TIMEOUT");
|
||||
expect(source).not.toContain("ChildProcessRunner");
|
||||
expect(packageJson.exports["."]).toEqual({
|
||||
types: "./src/index.ts",
|
||||
default: "./src/index.ts"
|
||||
});
|
||||
expect(packageJson.exports["./runtime"]).toEqual({
|
||||
types: "./src/internal.ts",
|
||||
default: "./src/internal.ts"
|
||||
});
|
||||
});
|
||||
});
|
||||
6
packages/harness-client/src/index.ts
Normal file
6
packages/harness-client/src/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type HarnessGateResult = { ok: boolean; reasonCode: string | null };
|
||||
|
||||
export type HarnessClient = {
|
||||
validateContract(contract: string, payload: unknown): Promise<HarnessGateResult>;
|
||||
validateTransition(event: string, payload: unknown): Promise<HarnessGateResult>;
|
||||
};
|
||||
181
packages/harness-client/src/internal.ts
Normal file
181
packages/harness-client/src/internal.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { HarnessClient, HarnessGateResult } from "./index.js";
|
||||
|
||||
export const HARNESS_TIMEOUT = "HARNESS_TIMEOUT" as const;
|
||||
export const HARNESS_INVALID_OUTPUT = "HARNESS_INVALID_OUTPUT" as const;
|
||||
|
||||
export type ChildProcessResult = {
|
||||
readonly timedOut: boolean;
|
||||
readonly exitCode?: number | null;
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
};
|
||||
|
||||
export type ChildProcessRunnerOptions = {
|
||||
readonly cwd: string;
|
||||
readonly timeoutMs: number;
|
||||
readonly maxOutputBytes: number;
|
||||
};
|
||||
|
||||
export type ChildProcessRunner = (
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: ChildProcessRunnerOptions
|
||||
) => Promise<ChildProcessResult>;
|
||||
|
||||
export type HarnessClientOptions = {
|
||||
readonly repoRoot?: string;
|
||||
readonly timeoutMs?: number;
|
||||
readonly maxOutputBytes?: number;
|
||||
readonly runner?: ChildProcessRunner;
|
||||
};
|
||||
|
||||
const defaultTimeoutMs = 5_000;
|
||||
const defaultMaxOutputBytes = 64 * 1024;
|
||||
const currentFile = fileURLToPath(import.meta.url);
|
||||
const packageRoot = path.resolve(path.dirname(currentFile), "..");
|
||||
const defaultRepoRoot = path.resolve(packageRoot, "../..");
|
||||
|
||||
function byteLength(value: string): number {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
|
||||
function appendBounded(current: string, chunk: Buffer, maxOutputBytes: number): string {
|
||||
const next = current + chunk.toString("utf8");
|
||||
if (byteLength(next) <= maxOutputBytes) return next;
|
||||
return next.slice(0, maxOutputBytes + 1);
|
||||
}
|
||||
|
||||
async function defaultRunner(
|
||||
file: string,
|
||||
args: readonly string[],
|
||||
options: ChildProcessRunnerOptions
|
||||
): Promise<ChildProcessResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(file, [...args], {
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGKILL");
|
||||
}, options.timeoutMs);
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout = appendBounded(stdout, chunk, options.maxOutputBytes);
|
||||
if (byteLength(stdout) > options.maxOutputBytes) child.kill("SIGKILL");
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr = appendBounded(stderr, chunk, options.maxOutputBytes);
|
||||
if (byteLength(stderr) > options.maxOutputBytes) child.kill("SIGKILL");
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on("close", (exitCode) => {
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
timedOut,
|
||||
exitCode,
|
||||
stdout,
|
||||
stderr
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseHarnessOutput(result: ChildProcessResult, maxOutputBytes: number): HarnessGateResult {
|
||||
if (result.timedOut) return { ok: false, reasonCode: HARNESS_TIMEOUT };
|
||||
if (byteLength(result.stdout) > maxOutputBytes || byteLength(result.stderr) > maxOutputBytes) {
|
||||
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
}
|
||||
|
||||
const value = parsed as { ok?: unknown; reasonCode?: unknown };
|
||||
if (typeof value.ok !== "boolean") return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
if (value.reasonCode !== null && typeof value.reasonCode !== "string") {
|
||||
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
}
|
||||
if (!value.ok && value.reasonCode === null) return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
if (value.ok && result.exitCode !== undefined && result.exitCode !== null && result.exitCode !== 0) {
|
||||
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: value.ok,
|
||||
reasonCode: value.reasonCode
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempJson<T>(payload: unknown, run: (file: string) => Promise<T>): Promise<T> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "huijing-harness-"));
|
||||
const file = path.join(dir, "payload.json");
|
||||
|
||||
try {
|
||||
await writeFile(file, JSON.stringify(payload), "utf8");
|
||||
return await run(file);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function createHarnessClient(options: HarnessClientOptions = {}): HarnessClient {
|
||||
const repoRoot = options.repoRoot ?? defaultRepoRoot;
|
||||
const timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
|
||||
const maxOutputBytes = options.maxOutputBytes ?? defaultMaxOutputBytes;
|
||||
const runner = options.runner ?? defaultRunner;
|
||||
const harnessCli = path.join(repoRoot, "harness", "scripts", "validate-harness.mjs");
|
||||
|
||||
async function validate(kind: "contract" | "transition", name: string, payload: unknown): Promise<HarnessGateResult> {
|
||||
return withTempJson(payload, async (inputFile) => {
|
||||
const args =
|
||||
kind === "contract"
|
||||
? [harnessCli, "--contract", name, "--input", inputFile]
|
||||
: [harnessCli, "--transition", name, "--input", inputFile];
|
||||
|
||||
try {
|
||||
const result = await runner("node", args, {
|
||||
cwd: repoRoot,
|
||||
timeoutMs,
|
||||
maxOutputBytes
|
||||
});
|
||||
return parseHarnessOutput(result, maxOutputBytes);
|
||||
} catch {
|
||||
// 子进程启动失败时无法证明 S0 gate 通过,必须 fail closed。
|
||||
return { ok: false, reasonCode: HARNESS_INVALID_OUTPUT };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
validateContract(contract: string, payload: unknown) {
|
||||
return validate("contract", contract, payload);
|
||||
},
|
||||
validateTransition(event: string, payload: unknown) {
|
||||
return validate("transition", event, payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
10
packages/harness-client/tsconfig.json
Normal file
10
packages/harness-client/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||
}
|
||||
22
packages/shared-contracts/package.json
Normal file
22
packages/shared-contracts/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@huijing/shared-contracts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev:smoke": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.1",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
20
packages/shared-contracts/src/index.spec.ts
Normal file
20
packages/shared-contracts/src/index.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { S1_PACKAGE_NAMES, S1_WORKSPACE_KEYS, isS1PackageName } from "./index";
|
||||
|
||||
describe("@huijing/shared-contracts S1 exports", () => {
|
||||
it("exports stable S1 package names", () => {
|
||||
expect(S1_PACKAGE_NAMES).toEqual({
|
||||
api: "@huijing/api",
|
||||
harnessClient: "@huijing/harness-client",
|
||||
sharedContracts: "@huijing/shared-contracts",
|
||||
web: "@huijing/web",
|
||||
worker: "@huijing/worker"
|
||||
});
|
||||
});
|
||||
|
||||
it("checks whether a value is an S1 package name", () => {
|
||||
expect(S1_WORKSPACE_KEYS).toEqual(["web", "api", "worker", "sharedContracts", "harnessClient"]);
|
||||
expect(isS1PackageName("@huijing/api")).toBe(true);
|
||||
expect(isS1PackageName("@huijing/missing")).toBe(false);
|
||||
});
|
||||
});
|
||||
18
packages/shared-contracts/src/index.ts
Normal file
18
packages/shared-contracts/src/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export const S1_PACKAGE_NAMES = {
|
||||
api: "@huijing/api",
|
||||
harnessClient: "@huijing/harness-client",
|
||||
sharedContracts: "@huijing/shared-contracts",
|
||||
web: "@huijing/web",
|
||||
worker: "@huijing/worker"
|
||||
} as const;
|
||||
|
||||
export const S1_WORKSPACE_KEYS = ["web", "api", "worker", "sharedContracts", "harnessClient"] as const;
|
||||
|
||||
export type S1WorkspaceKey = (typeof S1_WORKSPACE_KEYS)[number];
|
||||
export type S1PackageName = (typeof S1_PACKAGE_NAMES)[S1WorkspaceKey];
|
||||
|
||||
const S1_PACKAGE_NAME_SET: ReadonlySet<string> = new Set(Object.values(S1_PACKAGE_NAMES));
|
||||
|
||||
export function isS1PackageName(value: string): value is S1PackageName {
|
||||
return S1_PACKAGE_NAME_SET.has(value);
|
||||
}
|
||||
10
packages/shared-contracts/tsconfig.json
Normal file
10
packages/shared-contracts/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
|
||||
}
|
||||
4053
pnpm-lock.yaml
generated
Normal file
4053
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- "apps/*"
|
||||
- "packages/*"
|
||||
2975
scripts/check-s1-scope.mjs
Normal file
2975
scripts/check-s1-scope.mjs
Normal file
File diff suppressed because it is too large
Load Diff
95
scripts/check-workspace-scripts.mjs
Normal file
95
scripts/check-workspace-scripts.mjs
Normal file
@ -0,0 +1,95 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const requiredScripts = ["lint", "typecheck", "test", "build", "dev:smoke"];
|
||||
|
||||
// 只支持当前仓库声明的一层通配符,例如 apps/* 和 packages/*。
|
||||
function parseWorkspacePatterns(source) {
|
||||
return source
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.startsWith("- "))
|
||||
.map((line) => line.slice(2).trim().replace(/^["']|["']$/g, ""));
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await readFile(filePath, "utf8");
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function findWorkspaceManifests(patterns) {
|
||||
const manifests = [];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
if (!pattern.endsWith("/*")) {
|
||||
throw new Error(`不支持的 workspace pattern: ${pattern}`);
|
||||
}
|
||||
|
||||
const baseDir = path.join(rootDir, pattern.slice(0, -2));
|
||||
try {
|
||||
const entries = await readdir(baseDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifestPath = path.join(baseDir, entry.name, "package.json");
|
||||
if (await pathExists(manifestPath)) {
|
||||
manifests.push(manifestPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && error.code === "ENOENT") {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return manifests;
|
||||
}
|
||||
|
||||
async function readManifest(manifestPath) {
|
||||
const raw = await readFile(manifestPath, "utf8");
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`${path.relative(rootDir, manifestPath)} 不是有效 JSON: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceConfigPath = path.join(rootDir, "pnpm-workspace.yaml");
|
||||
const workspaceConfig = await readFile(workspaceConfigPath, "utf8");
|
||||
const manifests = await findWorkspaceManifests(parseWorkspacePatterns(workspaceConfig));
|
||||
const failures = [];
|
||||
|
||||
for (const manifestPath of manifests) {
|
||||
const manifest = await readManifest(manifestPath);
|
||||
const missingScripts = requiredScripts.filter((scriptName) => !manifest.scripts?.[scriptName]);
|
||||
|
||||
if (missingScripts.length > 0) {
|
||||
failures.push({
|
||||
packageName: manifest.name || path.relative(rootDir, path.dirname(manifestPath)),
|
||||
manifestPath,
|
||||
missingScripts
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("以下 workspace package 缺少必需脚本:");
|
||||
for (const failure of failures) {
|
||||
console.error(
|
||||
`- ${failure.packageName} (${path.relative(rootDir, failure.manifestPath)}): ${failure.missingScripts.join(", ")}`
|
||||
);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
19
tsconfig.base.json
Normal file
19
tsconfig.base.json
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user