Publish DevRunbook source
Managed validation / full (push) Successful in 3m18s

This commit is contained in:
DevRunbook release export
2026-09-03 04:09:17 +02:00
commit cfd2804e27
928 changed files with 161642 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
schema: './src/schema.ts',
out: './migrations',
dbCredentials: { url: process.env.DATABASE_URL ?? '' },
strict: true,
verbose: true,
})
@@ -0,0 +1,511 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
--> statement-breakpoint
CREATE TABLE "audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"actor_user_id" uuid,
"workspace_id" uuid,
"action" text NOT NULL,
"resource_type" text NOT NULL,
"resource_id" text,
"request_id" text,
"outcome" text NOT NULL,
"metadata_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT "audit_events_outcome_check" CHECK ("audit_events"."outcome" in ('success', 'denied', 'failed'))
);
--> statement-breakpoint
CREATE TABLE "auth_sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
"idle_expires_at" timestamp with time zone NOT NULL,
"absolute_expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
"source_ip_hash" text,
"user_agent_summary" text,
CONSTRAINT "auth_sessions_token_hash_uq" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "collection_items" (
"collection_id" uuid NOT NULL,
"playbook_id" uuid NOT NULL,
"position" integer DEFAULT 0 NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "collection_items_pkey" PRIMARY KEY("collection_id","playbook_id")
);
--> statement-breakpoint
CREATE TABLE "collections" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"name" text NOT NULL,
"description" text DEFAULT '' NOT NULL,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "collections_workspace_name_uq" UNIQUE("workspace_id","name")
);
--> statement-breakpoint
CREATE TABLE "composition_drafts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"playbook_version_id" uuid NOT NULL,
"repository_profile_revision_id" uuid,
"input_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"scope_override_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"autonomy_level" text NOT NULL,
"work_mode" text NOT NULL,
"last_render_digest" text,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "evaluation_cases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"playbook_version_id" uuid NOT NULL,
"logical_case_id" text NOT NULL,
"fixture_version" text NOT NULL,
"case_json" jsonb NOT NULL,
"case_digest" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "evaluation_cases_identity_uq" UNIQUE("playbook_version_id","logical_case_id","fixture_version")
);
--> statement-breakpoint
CREATE TABLE "evaluation_results" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"evaluation_case_id" uuid NOT NULL,
"environment_json" jsonb NOT NULL,
"status" text NOT NULL,
"dimension_scores_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"evidence_artifact_id" uuid,
"executed_by" uuid,
"executed_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "evaluation_results_status_check" CHECK ("evaluation_results"."status" in ('passed', 'failed', 'error', 'skipped', 'stale'))
);
--> statement-breakpoint
CREATE TABLE "favorites" (
"workspace_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"playbook_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "favorites_pkey" PRIMARY KEY("workspace_id","user_id","playbook_id")
);
--> statement-breakpoint
CREATE TABLE "generated_artifacts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"artifact_type" text NOT NULL,
"storage_key" text NOT NULL,
"filename" text NOT NULL,
"media_type" text NOT NULL,
"size_bytes" bigint NOT NULL,
"sha256" text NOT NULL,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "generated_artifacts_storage_key_uq" UNIQUE("storage_key"),
CONSTRAINT "generated_artifacts_type_check" CHECK ("generated_artifacts"."artifact_type" in ('prompt_text', 'markdown', 'run_pack_zip', 'agents_suggestion', 'support_bundle')),
CONSTRAINT "generated_artifacts_size_check" CHECK ("generated_artifacts"."size_bytes" >= 0)
);
--> statement-breakpoint
CREATE TABLE "generated_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"source_draft_id" uuid,
"playbook_version_id" uuid NOT NULL,
"playbook_snapshot_json" jsonb NOT NULL,
"repository_profile_snapshot_json" jsonb,
"normalized_input_json" jsonb NOT NULL,
"policy_snapshot_json" jsonb NOT NULL,
"provenance_json" jsonb NOT NULL,
"lint_result_json" jsonb NOT NULL,
"rendered_prompt" text NOT NULL,
"render_digest" text NOT NULL,
"idempotency_key" text,
"generated_by" uuid NOT NULL,
"generated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "generated_runs_workspace_idempotency_uq" UNIQUE("workspace_id","idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "instance_settings" (
"singleton" boolean PRIMARY KEY DEFAULT true NOT NULL,
"setup_completed_at" timestamp with time zone,
"owner_user_id" uuid,
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"config_digest" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "instance_settings_singleton_check" CHECK ("instance_settings"."singleton")
);
--> statement-breakpoint
CREATE TABLE "integration_secrets" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"integration_id" uuid NOT NULL,
"secret_kind" text NOT NULL,
"envelope_version" integer NOT NULL,
"key_version" text NOT NULL,
"nonce" bytea NOT NULL,
"ciphertext" bytea NOT NULL,
"auth_tag" bytea NOT NULL,
"last_four" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"rotated_at" timestamp with time zone,
CONSTRAINT "integration_secrets_integration_kind_uq" UNIQUE("integration_id","secret_kind")
);
--> statement-breakpoint
CREATE TABLE "integrations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"type" text NOT NULL,
"display_name" text NOT NULL,
"base_url" text NOT NULL,
"status" text DEFAULT 'configured' NOT NULL,
"capabilities_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"last_checked_at" timestamp with time zone,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "integrations_workspace_type_base_url_uq" UNIQUE("workspace_id","type","base_url"),
CONSTRAINT "integrations_type_check" CHECK ("integrations"."type" in ('gitea')),
CONSTRAINT "integrations_status_check" CHECK ("integrations"."status" in ('configured', 'healthy', 'degraded', 'disabled'))
);
--> statement-breakpoint
CREATE TABLE "invitations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"token_hash" text NOT NULL,
"instance_role" text NOT NULL,
"workspace_id" uuid,
"workspace_role" text,
"expires_at" timestamp with time zone NOT NULL,
"accepted_at" timestamp with time zone,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "invitations_token_hash_uq" UNIQUE("token_hash"),
CONSTRAINT "invitations_instance_role_check" CHECK ("invitations"."instance_role" in ('instance_admin', 'user')),
CONSTRAINT "invitations_workspace_role_check" CHECK ("invitations"."workspace_role" is null or "invitations"."workspace_role" in ('owner', 'editor', 'viewer'))
);
--> statement-breakpoint
CREATE TABLE "jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid,
"type" text NOT NULL,
"state" text NOT NULL,
"idempotency_key" text,
"payload_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"progress_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"attempt_count" integer DEFAULT 0 NOT NULL,
"max_attempts" integer DEFAULT 3 NOT NULL,
"lease_owner" text,
"lease_expires_at" timestamp with time zone,
"available_at" timestamp with time zone DEFAULT now() NOT NULL,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"error_code" text,
"error_detail_redacted" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "jobs_state_check" CHECK ("jobs"."state" in ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
CONSTRAINT "jobs_attempt_count_check" CHECK ("jobs"."attempt_count" >= 0),
CONSTRAINT "jobs_max_attempts_check" CHECK ("jobs"."max_attempts" > 0)
);
--> statement-breakpoint
CREATE TABLE "password_reset_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"used_at" timestamp with time zone,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "password_reset_tokens_token_hash_uq" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "playbook_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"playbook_id" uuid NOT NULL,
"semantic_version" text NOT NULL,
"lifecycle" text NOT NULL,
"package_api_version" text NOT NULL,
"title" text NOT NULL,
"summary" text NOT NULL,
"category" text NOT NULL,
"risk_tier" text NOT NULL,
"package_json" jsonb NOT NULL,
"template_text" text NOT NULL,
"content_digest" text NOT NULL,
"search_document" tsvector,
"published_at" timestamp with time zone,
"supersedes_version_id" uuid,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "playbook_versions_playbook_semver_uq" UNIQUE("playbook_id","semantic_version"),
CONSTRAINT "playbook_versions_lifecycle_check" CHECK ("playbook_versions"."lifecycle" in ('draft', 'reviewed', 'validated', 'battle-tested', 'deprecated')),
CONSTRAINT "playbook_versions_risk_tier_check" CHECK ("playbook_versions"."risk_tier" in ('low', 'moderate', 'high', 'critical'))
);
--> statement-breakpoint
CREATE TABLE "playbooks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid,
"logical_id" text NOT NULL,
"slug" text NOT NULL,
"namespace" text NOT NULL,
"source_type" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "playbooks_namespace_logical_id_uq" UNIQUE("namespace","logical_id"),
CONSTRAINT "playbooks_namespace_slug_uq" UNIQUE("namespace","slug"),
CONSTRAINT "playbooks_source_type_check" CHECK ("playbooks"."source_type" in ('built_in', 'private', 'imported', 'remote_registry'))
);
--> statement-breakpoint
CREATE TABLE "repositories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"workspace_id" uuid NOT NULL,
"display_name" text NOT NULL,
"source_type" text NOT NULL,
"external_owner" text,
"external_name" text,
"external_id" text,
"integration_id" uuid,
"default_branch" text,
"archived" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "repositories_source_type_check" CHECK ("repositories"."source_type" in ('manual', 'gitea'))
);
--> statement-breakpoint
CREATE TABLE "repository_findings" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"snapshot_id" uuid NOT NULL,
"rule_id" text NOT NULL,
"severity" text NOT NULL,
"title" text NOT NULL,
"rationale" text NOT NULL,
"evidence_pointer" text NOT NULL,
"recommended_playbook_slug" text,
"status" text DEFAULT 'open' NOT NULL,
"resolution_note" text,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "repository_findings_evidence_uq" UNIQUE("snapshot_id","rule_id","evidence_pointer"),
CONSTRAINT "repository_findings_severity_check" CHECK ("repository_findings"."severity" in ('info', 'low', 'medium', 'high', 'critical')),
CONSTRAINT "repository_findings_status_check" CHECK ("repository_findings"."status" in ('open', 'dismissed', 'resolved'))
);
--> statement-breakpoint
CREATE TABLE "repository_profile_revisions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"repository_id" uuid NOT NULL,
"revision_number" integer NOT NULL,
"profile_json" jsonb NOT NULL,
"source_snapshot_id" uuid,
"content_digest" text NOT NULL,
"created_by" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "repository_profile_revisions_repository_number_uq" UNIQUE("repository_id","revision_number"),
CONSTRAINT "repository_profile_revisions_repository_digest_uq" UNIQUE("repository_id","content_digest")
);
--> statement-breakpoint
CREATE TABLE "repository_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"repository_id" uuid NOT NULL,
"integration_id" uuid,
"state" text NOT NULL,
"captured_at" timestamp with time zone,
"capability_snapshot_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"evidence_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"evidence_digest" text,
"sync_job_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "repository_snapshots_state_check" CHECK ("repository_snapshots"."state" in ('collecting', 'complete', 'failed', 'cancelled'))
);
--> statement-breakpoint
CREATE TABLE "run_feedback" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"run_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"rating" text,
"notes" text DEFAULT '' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "run_feedback_run_user_uq" UNIQUE("run_id","user_id"),
CONSTRAINT "run_feedback_rating_check" CHECK ("run_feedback"."rating" is null or "run_feedback"."rating" in ('helpful', 'mixed', 'unhelpful'))
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"display_name" text NOT NULL,
"password_hash" text NOT NULL,
"instance_role" text NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"password_changed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "users_instance_role_check" CHECK ("users"."instance_role" in ('instance_owner', 'instance_admin', 'user')),
CONSTRAINT "users_status_check" CHECK ("users"."status" in ('active', 'disabled', 'pending_deletion'))
);
--> statement-breakpoint
CREATE TABLE "workspace_memberships" (
"workspace_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"role" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "workspace_memberships_pkey" PRIMARY KEY("workspace_id","user_id"),
CONSTRAINT "workspace_memberships_role_check" CHECK ("workspace_memberships"."role" in ('owner', 'editor', 'viewer'))
);
--> statement-breakpoint
CREATE TABLE "workspaces" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"type" text DEFAULT 'personal' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "workspaces_type_check" CHECK ("workspaces"."type" in ('personal', 'team'))
);
--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_actor_user_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth_sessions" ADD CONSTRAINT "auth_sessions_user_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_items" ADD CONSTRAINT "collection_items_collection_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_items" ADD CONSTRAINT "collection_items_playbook_fk" FOREIGN KEY ("playbook_id") REFERENCES "public"."playbooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_playbook_version_fk" FOREIGN KEY ("playbook_version_id") REFERENCES "public"."playbook_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_profile_revision_fk" FOREIGN KEY ("repository_profile_revision_id") REFERENCES "public"."repository_profile_revisions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD CONSTRAINT "evaluation_cases_playbook_version_fk" FOREIGN KEY ("playbook_version_id") REFERENCES "public"."playbook_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD CONSTRAINT "evaluation_results_case_fk" FOREIGN KEY ("evaluation_case_id") REFERENCES "public"."evaluation_cases"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD CONSTRAINT "evaluation_results_artifact_fk" FOREIGN KEY ("evidence_artifact_id") REFERENCES "public"."generated_artifacts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD CONSTRAINT "evaluation_results_executed_by_fk" FOREIGN KEY ("executed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_user_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "favorites" ADD CONSTRAINT "favorites_playbook_fk" FOREIGN KEY ("playbook_id") REFERENCES "public"."playbooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_artifacts" ADD CONSTRAINT "generated_artifacts_run_fk" FOREIGN KEY ("run_id") REFERENCES "public"."generated_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_runs" ADD CONSTRAINT "generated_runs_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_runs" ADD CONSTRAINT "generated_runs_source_draft_fk" FOREIGN KEY ("source_draft_id") REFERENCES "public"."composition_drafts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_runs" ADD CONSTRAINT "generated_runs_playbook_version_fk" FOREIGN KEY ("playbook_version_id") REFERENCES "public"."playbook_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "generated_runs" ADD CONSTRAINT "generated_runs_generated_by_fk" FOREIGN KEY ("generated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "instance_settings" ADD CONSTRAINT "instance_settings_owner_user_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_integration_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integrations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "invitations" ADD CONSTRAINT "invitations_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "jobs" ADD CONSTRAINT "jobs_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD CONSTRAINT "playbook_versions_playbook_fk" FOREIGN KEY ("playbook_id") REFERENCES "public"."playbooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD CONSTRAINT "playbook_versions_supersedes_fk" FOREIGN KEY ("supersedes_version_id") REFERENCES "public"."playbook_versions"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD CONSTRAINT "playbook_versions_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "playbooks" ADD CONSTRAINT "playbooks_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repositories" ADD CONSTRAINT "repositories_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repositories" ADD CONSTRAINT "repositories_integration_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_findings" ADD CONSTRAINT "repository_findings_snapshot_fk" FOREIGN KEY ("snapshot_id") REFERENCES "public"."repository_snapshots"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_profile_revisions" ADD CONSTRAINT "repository_profile_revisions_repository_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_profile_revisions" ADD CONSTRAINT "repository_profile_revisions_snapshot_fk" FOREIGN KEY ("source_snapshot_id") REFERENCES "public"."repository_snapshots"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_profile_revisions" ADD CONSTRAINT "repository_profile_revisions_created_by_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_snapshots" ADD CONSTRAINT "repository_snapshots_repository_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_snapshots" ADD CONSTRAINT "repository_snapshots_integration_fk" FOREIGN KEY ("integration_id") REFERENCES "public"."integrations"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_snapshots" ADD CONSTRAINT "repository_snapshots_job_fk" FOREIGN KEY ("sync_job_id") REFERENCES "public"."jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_feedback" ADD CONSTRAINT "run_feedback_run_fk" FOREIGN KEY ("run_id") REFERENCES "public"."generated_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "run_feedback" ADD CONSTRAINT "run_feedback_user_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_memberships" ADD CONSTRAINT "workspace_memberships_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "workspace_memberships" ADD CONSTRAINT "workspace_memberships_user_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "audit_events_workspace_time_idx" ON "audit_events" USING btree ("workspace_id","occurred_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "audit_events_action_time_idx" ON "audit_events" USING btree ("action","occurred_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "auth_sessions_user_active_idx" ON "auth_sessions" USING btree ("user_id","absolute_expires_at") WHERE "auth_sessions"."revoked_at" is null;--> statement-breakpoint
CREATE INDEX "composition_drafts_workspace_updated_idx" ON "composition_drafts" USING btree ("workspace_id","updated_at" DESC NULLS LAST);--> statement-breakpoint
CREATE UNIQUE INDEX "generated_runs_digest_actor_uq" ON "generated_runs" USING btree ("workspace_id","generated_by","render_digest","generated_at");--> statement-breakpoint
CREATE INDEX "generated_runs_workspace_time_idx" ON "generated_runs" USING btree ("workspace_id","generated_at" DESC NULLS LAST);--> statement-breakpoint
CREATE UNIQUE INDEX "jobs_workspace_type_idempotency_uq" ON "jobs" USING btree ("workspace_id","type","idempotency_key") WHERE "jobs"."workspace_id" is not null and "jobs"."idempotency_key" is not null;--> statement-breakpoint
CREATE UNIQUE INDEX "jobs_global_type_idempotency_uq" ON "jobs" USING btree ("type","idempotency_key") WHERE "jobs"."workspace_id" is null and "jobs"."idempotency_key" is not null;--> statement-breakpoint
CREATE INDEX "jobs_claim_idx" ON "jobs" USING btree ("state","available_at","created_at") WHERE "jobs"."state" = 'queued';--> statement-breakpoint
CREATE INDEX "jobs_lease_idx" ON "jobs" USING btree ("state","lease_expires_at") WHERE "jobs"."state" = 'running';--> statement-breakpoint
CREATE INDEX "playbook_versions_search_idx" ON "playbook_versions" USING gin ("search_document");--> statement-breakpoint
CREATE INDEX "playbook_versions_filters_idx" ON "playbook_versions" USING btree ("category","risk_tier","lifecycle","published_at" DESC NULLS LAST);--> statement-breakpoint
CREATE INDEX "playbooks_workspace_idx" ON "playbooks" USING btree ("workspace_id");--> statement-breakpoint
CREATE UNIQUE INDEX "repositories_external_uq" ON "repositories" USING btree ("workspace_id","integration_id","external_id") WHERE "repositories"."external_id" is not null;--> statement-breakpoint
CREATE INDEX "repository_snapshots_repo_time_idx" ON "repository_snapshots" USING btree ("repository_id","captured_at" DESC NULLS LAST);--> statement-breakpoint
CREATE UNIQUE INDEX "users_email_ci_uq" ON "users" USING btree (lower("email")) WHERE "users"."deleted_at" is null;--> statement-breakpoint
CREATE INDEX "workspace_memberships_user_idx" ON "workspace_memberships" USING btree ("user_id");
--> statement-breakpoint
-- The singleton exists before setup so concurrent callers have a stable row to
-- inspect after taking the transaction-scoped advisory lock.
INSERT INTO "instance_settings" ("singleton") VALUES (true)
ON CONFLICT ("singleton") DO NOTHING;
--> statement-breakpoint
CREATE FUNCTION devrunbook_try_setup_advisory_lock()
RETURNS boolean
LANGUAGE sql
VOLATILE
PARALLEL UNSAFE
AS $$
SELECT pg_try_advisory_xact_lock(hashtextextended('devrunbook:first-run-setup', 0));
$$;
--> statement-breakpoint
COMMENT ON FUNCTION devrunbook_try_setup_advisory_lock() IS
'Acquire the transaction-scoped first-run setup lock; call inside the setup transaction.';
--> statement-breakpoint
CREATE FUNCTION devrunbook_reject_immutable_update()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
-- Allow PostgreSQL referential actions (for example ON DELETE SET NULL) to
-- preserve the deletion contract while rejecting direct application writes.
IF pg_trigger_depth() > 1 THEN
RETURN NEW;
END IF;
RAISE EXCEPTION USING
ERRCODE = '55000',
MESSAGE = format('%I is immutable', TG_TABLE_NAME);
END;
$$;
--> statement-breakpoint
CREATE FUNCTION devrunbook_reject_append_only_mutation()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
IF pg_trigger_depth() > 1 THEN
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END IF;
RAISE EXCEPTION USING
ERRCODE = '55000',
MESSAGE = format('%I is append-only', TG_TABLE_NAME);
END;
$$;
--> statement-breakpoint
CREATE TRIGGER playbook_versions_published_immutable_trg
BEFORE UPDATE ON "playbook_versions"
FOR EACH ROW
WHEN (OLD."published_at" IS NOT NULL)
EXECUTE FUNCTION devrunbook_reject_immutable_update();
--> statement-breakpoint
CREATE TRIGGER repository_profile_revisions_immutable_trg
BEFORE UPDATE ON "repository_profile_revisions"
FOR EACH ROW
EXECUTE FUNCTION devrunbook_reject_immutable_update();
--> statement-breakpoint
CREATE TRIGGER repository_snapshots_complete_immutable_trg
BEFORE UPDATE ON "repository_snapshots"
FOR EACH ROW
WHEN (OLD."state" = 'complete')
EXECUTE FUNCTION devrunbook_reject_immutable_update();
--> statement-breakpoint
CREATE TRIGGER generated_runs_immutable_trg
BEFORE UPDATE ON "generated_runs"
FOR EACH ROW
EXECUTE FUNCTION devrunbook_reject_immutable_update();
--> statement-breakpoint
CREATE TRIGGER evaluation_results_immutable_trg
BEFORE UPDATE ON "evaluation_results"
FOR EACH ROW
EXECUTE FUNCTION devrunbook_reject_immutable_update();
--> statement-breakpoint
CREATE TRIGGER audit_events_append_only_trg
BEFORE UPDATE OR DELETE ON "audit_events"
FOR EACH ROW
EXECUTE FUNCTION devrunbook_reject_append_only_mutation();
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "email_verified" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "image" text;
@@ -0,0 +1,3 @@
CREATE INDEX "repositories_workspace_updated_idx" ON "repositories" USING btree ("workspace_id","archived","updated_at" DESC NULLS LAST,"id");--> statement-breakpoint
ALTER TABLE "repository_profile_revisions" ADD CONSTRAINT "repository_profile_revisions_revision_positive_check" CHECK ("repository_profile_revisions"."revision_number" > 0);--> statement-breakpoint
ALTER TABLE "repository_profile_revisions" ADD CONSTRAINT "repository_profile_revisions_content_digest_check" CHECK ("repository_profile_revisions"."content_digest" ~ '^[0-9a-f]{64}$');
@@ -0,0 +1,11 @@
ALTER TABLE "composition_drafts" ADD COLUMN "policy_override_json" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD COLUMN "output_format" text DEFAULT 'prompt' NOT NULL;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD COLUMN "revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_revision_positive_check" CHECK ("composition_drafts"."revision" > 0);--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_autonomy_check" CHECK ("composition_drafts"."autonomy_level" in ('observe', 'diagnose', 'plan', 'implement', 'verify', 'repair'));--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_work_mode_check" CHECK ("composition_drafts"."work_mode" in ('inspect', 'plan', 'guided', 'execute', 'recovery'));--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_output_format_check" CHECK ("composition_drafts"."output_format" in ('prompt', 'markdown', 'run-pack'));--> statement-breakpoint
ALTER TABLE "composition_drafts" ADD CONSTRAINT "composition_drafts_last_render_digest_check" CHECK ("composition_drafts"."last_render_digest" is null or "composition_drafts"."last_render_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "generated_runs" ADD CONSTRAINT "generated_runs_render_digest_check" CHECK ("generated_runs"."render_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "generated_runs" ALTER COLUMN "idempotency_key" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "generated_runs" ADD CONSTRAINT "generated_runs_idempotency_key_check" CHECK (length("generated_runs"."idempotency_key") between 1 and 255 and btrim("generated_runs"."idempotency_key") = "generated_runs"."idempotency_key");
@@ -0,0 +1,19 @@
ALTER TABLE "integrations" ADD COLUMN "allow_private_http" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "integrations" ADD COLUMN "request_timeout_ms" integer DEFAULT 15000 NOT NULL;--> statement-breakpoint
ALTER TABLE "integrations" ADD COLUMN "server_version" text;--> statement-breakpoint
ALTER TABLE "integrations" ADD COLUMN "remote_identity_id" text;--> statement-breakpoint
ALTER TABLE "integrations" ADD COLUMN "remote_identity_login" text;--> statement-breakpoint
ALTER TABLE "integrations" ADD COLUMN "health_code" text;--> statement-breakpoint
CREATE INDEX "integrations_workspace_status_idx" ON "integrations" USING btree ("workspace_id","status","updated_at" DESC NULLS LAST);--> statement-breakpoint
CREATE UNIQUE INDEX "repository_snapshots_sync_job_uq" ON "repository_snapshots" USING btree ("sync_job_id") WHERE "repository_snapshots"."sync_job_id" is not null;--> statement-breakpoint
CREATE INDEX "repository_snapshots_integration_state_idx" ON "repository_snapshots" USING btree ("integration_id","state","created_at" DESC NULLS LAST);--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_envelope_version_check" CHECK ("integration_secrets"."envelope_version" = 1);--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_key_version_check" CHECK (length(btrim("integration_secrets"."key_version")) between 1 and 64);--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_secret_kind_check" CHECK ("integration_secrets"."secret_kind" = 'access_token');--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_nonce_length_check" CHECK (octet_length("integration_secrets"."nonce") = 12);--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_auth_tag_length_check" CHECK (octet_length("integration_secrets"."auth_tag") = 16);--> statement-breakpoint
ALTER TABLE "integration_secrets" ADD CONSTRAINT "integration_secrets_last_four_check" CHECK ("integration_secrets"."last_four" is null or length("integration_secrets"."last_four") = 4);--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_request_timeout_check" CHECK ("integrations"."request_timeout_ms" between 1000 and 60000);--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_remote_identity_check" CHECK (("integrations"."remote_identity_id" is null) = ("integrations"."remote_identity_login" is null));--> statement-breakpoint
ALTER TABLE "integrations" ADD CONSTRAINT "integrations_health_code_check" CHECK ("integrations"."health_code" is null or "integrations"."health_code" in ('AUTH_INVALID', 'PERMISSION_MISSING', 'CAPABILITY_UNSUPPORTED', 'RATE_LIMITED', 'NETWORK_BLOCKED', 'TLS_ERROR', 'REMOTE_UNAVAILABLE', 'CONTENT_TOO_LARGE'));--> statement-breakpoint
ALTER TABLE "repository_snapshots" ADD CONSTRAINT "repository_snapshots_complete_integrity_check" CHECK ("repository_snapshots"."state" <> 'complete' or ("repository_snapshots"."captured_at" is not null and "repository_snapshots"."evidence_digest" ~ '^[0-9a-f]{64}$'));
@@ -0,0 +1,74 @@
CREATE TABLE "playbook_package_files" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"playbook_version_id" uuid NOT NULL,
"path" text NOT NULL,
"role" text NOT NULL,
"content" "bytea" NOT NULL,
"size_bytes" bigint NOT NULL,
"sha256" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "playbook_package_files_version_path_uq" UNIQUE("playbook_version_id","path"),
CONSTRAINT "playbook_package_files_path_check" CHECK (length("playbook_package_files"."path") between 1 and 512 and "playbook_package_files"."path" = btrim("playbook_package_files"."path") and "playbook_package_files"."path" !~ '(^/|\\|//|(^|/)\.\.?(/|$))'),
CONSTRAINT "playbook_package_files_role_check" CHECK ("playbook_package_files"."role" in ('manifest', 'template', 'partial', 'documentation', 'changelog', 'example', 'evaluation', 'resource', 'run-pack-resource')),
CONSTRAINT "playbook_package_files_size_check" CHECK ("playbook_package_files"."size_bytes" between 0 and 5242880 and octet_length("playbook_package_files"."content") = "playbook_package_files"."size_bytes"),
CONSTRAINT "playbook_package_files_sha256_check" CHECK ("playbook_package_files"."sha256" ~ '^[0-9a-f]{64}$' and encode(digest("playbook_package_files"."content", 'sha256'), 'hex') = "playbook_package_files"."sha256")
);
--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD COLUMN "draft_revision" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD COLUMN "draft_digest" text DEFAULT repeat('0', 64) NOT NULL;--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD COLUMN "draft_validation_json" jsonb DEFAULT '{"valid":true,"issues":[]}'::jsonb NOT NULL;--> statement-breakpoint
DROP TRIGGER "playbook_versions_published_immutable_trg" ON "playbook_versions";--> statement-breakpoint
UPDATE "playbook_versions" SET "draft_digest" = "content_digest";--> statement-breakpoint
CREATE TRIGGER playbook_versions_published_immutable_trg
BEFORE UPDATE ON "playbook_versions"
FOR EACH ROW
WHEN (OLD."published_at" IS NOT NULL)
EXECUTE FUNCTION devrunbook_reject_immutable_update();--> statement-breakpoint
ALTER TABLE "playbook_package_files" ADD CONSTRAINT "playbook_package_files_version_fk" FOREIGN KEY ("playbook_version_id") REFERENCES "public"."playbook_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "playbook_package_files_version_idx" ON "playbook_package_files" USING btree ("playbook_version_id");--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD CONSTRAINT "playbook_versions_draft_revision_check" CHECK ("playbook_versions"."draft_revision" > 0);--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD CONSTRAINT "playbook_versions_draft_digest_check" CHECK ("playbook_versions"."draft_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "playbook_versions" ADD CONSTRAINT "playbook_versions_draft_validation_check" CHECK (jsonb_typeof("playbook_versions"."draft_validation_json") = 'object');--> statement-breakpoint
CREATE FUNCTION normalize_playbook_version_draft_digest() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
IF NEW.draft_digest = repeat('0', 64) THEN
NEW.draft_digest := NEW.content_digest;
END IF;
RETURN NEW;
END;
$$;--> statement-breakpoint
CREATE TRIGGER playbook_versions_normalize_draft_digest
BEFORE INSERT ON playbook_versions
FOR EACH ROW EXECUTE FUNCTION normalize_playbook_version_draft_digest();--> statement-breakpoint
CREATE FUNCTION reject_published_playbook_file_mutation() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE
target_version_id uuid;
target_published_at timestamptz;
BEGIN
target_version_id := CASE WHEN TG_OP = 'DELETE' THEN OLD.playbook_version_id ELSE NEW.playbook_version_id END;
SELECT published_at INTO target_published_at
FROM playbook_versions
WHERE id = target_version_id
FOR SHARE;
IF target_published_at IS NOT NULL THEN
RAISE EXCEPTION 'published playbook package files are immutable'
USING ERRCODE = '55000';
END IF;
IF TG_OP = 'UPDATE' AND OLD.playbook_version_id <> NEW.playbook_version_id THEN
SELECT published_at INTO target_published_at
FROM playbook_versions
WHERE id = OLD.playbook_version_id
FOR SHARE;
IF target_published_at IS NOT NULL THEN
RAISE EXCEPTION 'published playbook package files are immutable'
USING ERRCODE = '55000';
END IF;
END IF;
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
END;
$$;--> statement-breakpoint
CREATE TRIGGER playbook_package_files_immutable_when_published
BEFORE INSERT OR UPDATE OR DELETE ON playbook_package_files
FOR EACH ROW EXECUTE FUNCTION reject_published_playbook_file_mutation();
@@ -0,0 +1,38 @@
CREATE TABLE "playbook_review_attestations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"playbook_version_id" uuid NOT NULL,
"reviewed_by" uuid NOT NULL,
"attested_digest" text NOT NULL,
"schema_and_semantic_validation_passed" boolean NOT NULL,
"blocking_lint_finding_count" integer NOT NULL,
"limitations_documented" boolean NOT NULL,
"unresolved_safety_regression" boolean NOT NULL,
"review_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"reviewed_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "playbook_review_attestations_lint_count_check" CHECK ("playbook_review_attestations"."blocking_lint_finding_count" >= 0),
CONSTRAINT "playbook_review_attestations_digest_check" CHECK ("playbook_review_attestations"."attested_digest" ~ '^[0-9a-f]{64}$')
);
--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD COLUMN "case_version" text;--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD COLUMN "target_digest" text;--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD COLUMN "fixture_id" text;--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD COLUMN "fixture_digest" text;--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD COLUMN "environment_digest" text;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD COLUMN "target_digest" text;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD COLUMN "fixture_digest" text;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD COLUMN "environment_digest" text;--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD COLUMN "result_json" jsonb;--> statement-breakpoint
ALTER TABLE "playbook_review_attestations" ADD CONSTRAINT "playbook_review_attestations_version_fk" FOREIGN KEY ("playbook_version_id") REFERENCES "public"."playbook_versions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "playbook_review_attestations" ADD CONSTRAINT "playbook_review_attestations_reviewer_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "playbook_review_attestations_version_time_idx" ON "playbook_review_attestations" USING btree ("playbook_version_id","reviewed_at" DESC NULLS LAST);--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD CONSTRAINT "evaluation_cases_target_digest_check" CHECK ("evaluation_cases"."target_digest" is null or "evaluation_cases"."target_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD CONSTRAINT "evaluation_cases_fixture_digest_check" CHECK ("evaluation_cases"."fixture_digest" is null or "evaluation_cases"."fixture_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "evaluation_cases" ADD CONSTRAINT "evaluation_cases_environment_digest_check" CHECK ("evaluation_cases"."environment_digest" is null or "evaluation_cases"."environment_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD CONSTRAINT "evaluation_results_target_digest_check" CHECK ("evaluation_results"."target_digest" is null or "evaluation_results"."target_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD CONSTRAINT "evaluation_results_fixture_digest_check" CHECK ("evaluation_results"."fixture_digest" is null or "evaluation_results"."fixture_digest" ~ '^[0-9a-f]{64}$');--> statement-breakpoint
ALTER TABLE "evaluation_results" ADD CONSTRAINT "evaluation_results_environment_digest_check" CHECK ("evaluation_results"."environment_digest" is null or "evaluation_results"."environment_digest" ~ '^[0-9a-f]{64}$');
--> statement-breakpoint
CREATE TRIGGER playbook_review_attestations_immutable_trg
BEFORE UPDATE OR DELETE ON "playbook_review_attestations"
FOR EACH ROW
EXECUTE FUNCTION devrunbook_reject_append_only_mutation();
@@ -0,0 +1,5 @@
ALTER TABLE "collections" DROP CONSTRAINT "collections_workspace_name_uq";--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_owner_name_uq" UNIQUE("workspace_id","created_by","name");--> statement-breakpoint
ALTER TABLE "collection_items" ADD CONSTRAINT "collection_items_position_check" CHECK ("collection_items"."position" >= 0);--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_name_check" CHECK (char_length("collections"."name") between 1 and 80 and "collections"."name" = btrim("collections"."name") and "collections"."name" !~ '[[:cntrl:]]');--> statement-breakpoint
ALTER TABLE "collections" ADD CONSTRAINT "collections_description_check" CHECK (char_length("collections"."description") <= 500);
@@ -0,0 +1,15 @@
CREATE TABLE "repository_preferences" (
"workspace_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"repository_id" uuid NOT NULL,
"favorite" boolean DEFAULT false NOT NULL,
"last_used_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "repository_preferences_pkey" PRIMARY KEY("workspace_id","user_id","repository_id")
);
--> statement-breakpoint
ALTER TABLE "repository_preferences" ADD CONSTRAINT "repository_preferences_workspace_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspaces"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_preferences" ADD CONSTRAINT "repository_preferences_user_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "repository_preferences" ADD CONSTRAINT "repository_preferences_repository_fk" FOREIGN KEY ("repository_id") REFERENCES "public"."repositories"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "repository_preferences_user_rank_idx" ON "repository_preferences" USING btree ("workspace_id","user_id","favorite","last_used_at" DESC NULLS LAST);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1785110204324,
"tag": "0000_jittery_wind_dancer",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785111368997,
"tag": "0001_daily_mystique",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785131801172,
"tag": "0002_wild_wraith",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785137671077,
"tag": "0003_polite_kronos",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785146174147,
"tag": "0004_gitea_persistence_hardening",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785152317539,
"tag": "0005_luxuriant_changeling",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1785153820590,
"tag": "0006_worried_prodigy",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1785159158743,
"tag": "0007_lean_jack_power",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1785359005075,
"tag": "0008_third_menace",
"breakpoints": true
}
]
}
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@devrunbook/db",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./schema": "./src/schema.ts"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/migrate.ts",
"db:status": "tsx src/status.ts",
"lint": "eslint src drizzle.config.ts --max-warnings=0",
"test": "vitest run --passWithNoTests",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@devrunbook/application": "workspace:*",
"@devrunbook/domain": "workspace:*",
"@devrunbook/repository-intel": "workspace:*",
"drizzle-orm": "0.45.2",
"postgres": "3.4.9"
},
"devDependencies": {
"@types/node": "24.13.3",
"drizzle-kit": "0.31.10",
"tsx": "4.20.6",
"typescript": "5.9.3",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,87 @@
import type { GeneratedArtifactMetadata } from '@devrunbook/application'
import { describe, expect, it } from 'vitest'
import {
DrizzleGeneratedArtifactStore,
type GeneratedArtifactTransaction,
type GeneratedArtifactTransactionRunner,
} from './generated-artifact-store'
const artifact: GeneratedArtifactMetadata = {
id: '00000000-0000-4000-8000-000000000101',
workspaceId: '00000000-0000-4000-8000-000000000102',
runId: '00000000-0000-4000-8000-000000000103',
artifactType: 'markdown',
storageKey: 'a'.repeat(64),
filename: 'run.md',
mediaType: 'text/markdown',
sizeBytes: 6n,
sha256: 'b'.repeat(64),
expiresAt: null,
createdAt: '2026-07-27T12:00:00.000Z',
}
class MemoryRunner implements GeneratedArtifactTransactionRunner {
stored: GeneratedArtifactMetadata | null = null
runExists = true
storageOwner: string | null = null
locks: string[] = []
async run<T>(
work: (transaction: GeneratedArtifactTransaction) => Promise<T>,
): Promise<T> {
return work({
acquireIdempotencyLock: async (id) => {
this.locks.push(id)
},
findById: async (id) => (this.stored?.id === id ? this.stored : null),
findIdByStorageKey: async () => this.storageOwner,
runBelongsToWorkspace: async () => this.runExists,
insert: async (candidate) => {
this.stored = candidate
return candidate
},
})
}
}
describe('DrizzleGeneratedArtifactStore', () => {
it('serializes creation and returns an exact retry', async () => {
const runner = new MemoryRunner()
const store = new DrizzleGeneratedArtifactStore(runner, {} as never)
await expect(store.createIdempotently(artifact)).resolves.toEqual({
artifact,
created: true,
})
await expect(store.createIdempotently(artifact)).resolves.toEqual({
artifact,
created: false,
})
expect(runner.locks).toEqual([artifact.id, artifact.id])
})
it('rejects changed retries and cross-workspace run references', async () => {
const runner = new MemoryRunner()
const store = new DrizzleGeneratedArtifactStore(runner, {} as never)
await store.createIdempotently(artifact)
await expect(
store.createIdempotently({ ...artifact, filename: 'changed.md' }),
).rejects.toMatchObject({ code: 'generated_artifact_idempotency_conflict' })
runner.stored = null
runner.runExists = false
await expect(store.createIdempotently(artifact)).rejects.toMatchObject({
code: 'generated_artifact_run_not_found',
})
})
it('rejects reuse of a storage key by another artifact', async () => {
const runner = new MemoryRunner()
runner.storageOwner = '00000000-0000-4000-8000-000000000999'
const store = new DrizzleGeneratedArtifactStore(runner, {} as never)
await expect(store.createIdempotently(artifact)).rejects.toMatchObject({
code: 'generated_artifact_storage_key_conflict',
})
})
})
@@ -0,0 +1,240 @@
import type {
GeneratedArtifactMetadata,
GeneratedArtifactMetadataStore,
StoreGeneratedArtifactResult,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { and, asc, eq, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import { generatedArtifacts, generatedRuns } from '../schema'
type GeneratedArtifactRow = typeof generatedArtifacts.$inferSelect
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
function mapRow(
row: GeneratedArtifactRow,
workspaceId: string,
): GeneratedArtifactMetadata {
return {
id: row.id,
workspaceId,
runId: row.runId,
artifactType: row.artifactType as GeneratedArtifactMetadata['artifactType'],
storageKey: row.storageKey,
filename: row.filename,
mediaType: row.mediaType,
sizeBytes: row.sizeBytes,
sha256: row.sha256,
expiresAt: row.expiresAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
}
}
function sameLogicalArtifact(
left: GeneratedArtifactMetadata,
right: GeneratedArtifactMetadata,
): boolean {
return (
left.id === right.id &&
left.workspaceId === right.workspaceId &&
left.runId === right.runId &&
left.artifactType === right.artifactType &&
left.storageKey === right.storageKey &&
left.filename === right.filename &&
left.mediaType === right.mediaType &&
left.sizeBytes === right.sizeBytes &&
left.sha256 === right.sha256
)
}
export interface GeneratedArtifactTransaction {
acquireIdempotencyLock(artifactId: string): Promise<void>
findById(id: string): Promise<GeneratedArtifactMetadata | null>
findIdByStorageKey(storageKey: string): Promise<string | null>
runBelongsToWorkspace(runId: string, workspaceId: string): Promise<boolean>
insert(
artifact: GeneratedArtifactMetadata,
): Promise<GeneratedArtifactMetadata>
}
export interface GeneratedArtifactTransactionRunner {
run<T>(
work: (transaction: GeneratedArtifactTransaction) => Promise<T>,
): Promise<T>
}
class DrizzleGeneratedArtifactTransaction implements GeneratedArtifactTransaction {
constructor(private readonly transaction: Transaction) {}
async acquireIdempotencyLock(artifactId: string): Promise<void> {
await this.transaction.execute(sql`
select pg_advisory_xact_lock(
hashtextextended(${`devrunbook:generated-artifact:${artifactId}`}, 0)
)
`)
}
async findById(id: string): Promise<GeneratedArtifactMetadata | null> {
const [row] = await this.transaction
.select({
artifact: generatedArtifacts,
workspaceId: generatedRuns.workspaceId,
})
.from(generatedArtifacts)
.innerJoin(generatedRuns, eq(generatedArtifacts.runId, generatedRuns.id))
.where(eq(generatedArtifacts.id, id))
.limit(1)
return row ? mapRow(row.artifact, row.workspaceId) : null
}
async findIdByStorageKey(storageKey: string): Promise<string | null> {
const [row] = await this.transaction
.select({ id: generatedArtifacts.id })
.from(generatedArtifacts)
.where(eq(generatedArtifacts.storageKey, storageKey))
.limit(1)
return row?.id ?? null
}
async runBelongsToWorkspace(
runId: string,
workspaceId: string,
): Promise<boolean> {
const [row] = await this.transaction
.select({ id: generatedRuns.id })
.from(generatedRuns)
.where(
and(
eq(generatedRuns.id, runId),
eq(generatedRuns.workspaceId, workspaceId),
),
)
.limit(1)
return Boolean(row)
}
async insert(
artifact: GeneratedArtifactMetadata,
): Promise<GeneratedArtifactMetadata> {
const [row] = await this.transaction
.insert(generatedArtifacts)
.values({
id: artifact.id,
runId: artifact.runId,
artifactType: artifact.artifactType,
storageKey: artifact.storageKey,
filename: artifact.filename,
mediaType: artifact.mediaType,
sizeBytes: artifact.sizeBytes,
sha256: artifact.sha256,
expiresAt: artifact.expiresAt ? new Date(artifact.expiresAt) : null,
createdAt: new Date(artifact.createdAt),
})
.returning()
if (!row) throw new Error('Generated-artifact insert did not return a row')
return mapRow(row, artifact.workspaceId)
}
}
export class DrizzleGeneratedArtifactTransactionRunner implements GeneratedArtifactTransactionRunner {
constructor(private readonly database: Database = getDatabase()) {}
run<T>(
work: (transaction: GeneratedArtifactTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction((transaction) =>
work(new DrizzleGeneratedArtifactTransaction(transaction)),
)
}
}
export class DrizzleGeneratedArtifactStore implements GeneratedArtifactMetadataStore {
constructor(
private readonly runner: GeneratedArtifactTransactionRunner = new DrizzleGeneratedArtifactTransactionRunner(),
private readonly database: Database = getDatabase(),
) {}
createIdempotently(
artifact: GeneratedArtifactMetadata,
): Promise<StoreGeneratedArtifactResult> {
return this.runner.run(async (transaction) => {
await transaction.acquireIdempotencyLock(artifact.id)
const existing = await transaction.findById(artifact.id)
if (existing) {
if (!sameLogicalArtifact(existing, artifact)) {
throw new DomainError(
'generated_artifact_idempotency_conflict',
'Artifact identifier is already associated with different immutable input',
)
}
return { artifact: existing, created: false }
}
if (
!(await transaction.runBelongsToWorkspace(
artifact.runId,
artifact.workspaceId,
))
) {
throw new DomainError(
'generated_artifact_run_not_found',
'Generated run was not found in the authorized workspace',
)
}
const storageOwner = await transaction.findIdByStorageKey(
artifact.storageKey,
)
if (storageOwner && storageOwner !== artifact.id) {
throw new DomainError(
'generated_artifact_storage_key_conflict',
'Artifact storage key is already assigned',
)
}
return { artifact: await transaction.insert(artifact), created: true }
})
}
async findByIdInWorkspace(
id: string,
workspaceId: string,
): Promise<GeneratedArtifactMetadata | null> {
const [row] = await this.database
.select({
artifact: generatedArtifacts,
workspaceId: generatedRuns.workspaceId,
})
.from(generatedArtifacts)
.innerJoin(generatedRuns, eq(generatedArtifacts.runId, generatedRuns.id))
.where(
and(
eq(generatedArtifacts.id, id),
eq(generatedRuns.workspaceId, workspaceId),
),
)
.limit(1)
return row ? mapRow(row.artifact, row.workspaceId) : null
}
async listByRunInWorkspace(
runId: string,
workspaceId: string,
): Promise<readonly GeneratedArtifactMetadata[]> {
const rows = await this.database
.select({
artifact: generatedArtifacts,
workspaceId: generatedRuns.workspaceId,
})
.from(generatedArtifacts)
.innerJoin(generatedRuns, eq(generatedArtifacts.runId, generatedRuns.id))
.where(
and(
eq(generatedArtifacts.runId, runId),
eq(generatedRuns.workspaceId, workspaceId),
),
)
.orderBy(asc(generatedArtifacts.createdAt), asc(generatedArtifacts.id))
.limit(100)
return rows.map((row) => mapRow(row.artifact, row.workspaceId))
}
}
+141
View File
@@ -0,0 +1,141 @@
import type {
AuthPersistence,
AuthSessionRecord,
AuthUserRecord,
CreateAuthSessionRecord,
} from '@devrunbook/application'
import { and, eq, ilike, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import { authSessions, users } from '../schema'
type Database = ReturnType<typeof getDatabase>
function mapUser(row: typeof users.$inferSelect): AuthUserRecord {
if (
row.status !== 'active' &&
row.status !== 'disabled' &&
row.status !== 'pending_deletion'
) {
throw new Error('Stored user has an invalid status')
}
return {
id: row.id,
email: row.email,
displayName: row.displayName,
passwordHash: row.passwordHash,
emailVerified: row.emailVerified,
image: row.image,
status: row.status,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function mapSession(row: typeof authSessions.$inferSelect): AuthSessionRecord {
return row
}
export class DrizzleAuthPersistence implements AuthPersistence {
constructor(private readonly database: Database = getDatabase()) {}
async findUserById(id: string): Promise<AuthUserRecord | null> {
const [row] = await this.database
.select()
.from(users)
.where(and(eq(users.id, id), isNull(users.deletedAt)))
.limit(1)
return row ? mapUser(row) : null
}
async findUserByEmail(email: string): Promise<AuthUserRecord | null> {
const [row] = await this.database
.select()
.from(users)
.where(and(ilike(users.email, email), isNull(users.deletedAt)))
.limit(1)
return row ? mapUser(row) : null
}
async updateUser(
id: string,
update: Partial<
Pick<
AuthUserRecord,
'displayName' | 'email' | 'emailVerified' | 'image' | 'passwordHash'
>
>,
): Promise<AuthUserRecord | null> {
const [row] = await this.database
.update(users)
.set({ ...update, updatedAt: new Date() })
.where(and(eq(users.id, id), isNull(users.deletedAt)))
.returning()
return row ? mapUser(row) : null
}
async createSession(
input: CreateAuthSessionRecord,
): Promise<AuthSessionRecord> {
const [row] = await this.database
.insert(authSessions)
.values(input)
.returning()
if (!row) throw new Error('Session insert did not return a row')
return mapSession(row)
}
async findSessionByTokenHash(
tokenHash: string,
): Promise<AuthSessionRecord | null> {
const [row] = await this.database
.select()
.from(authSessions)
.where(eq(authSessions.tokenHash, tokenHash))
.limit(1)
return row ? mapSession(row) : null
}
async touchSession(
id: string,
input: { lastSeenAt: Date; idleExpiresAt: Date },
): Promise<AuthSessionRecord | null> {
const [row] = await this.database
.update(authSessions)
.set(input)
.where(and(eq(authSessions.id, id), isNull(authSessions.revokedAt)))
.returning()
return row ? mapSession(row) : null
}
async revokeSessionByTokenHash(
tokenHash: string,
revokedAt: Date,
): Promise<boolean> {
const rows = await this.database
.update(authSessions)
.set({ revokedAt })
.where(
and(
eq(authSessions.tokenHash, tokenHash),
isNull(authSessions.revokedAt),
),
)
.returning({ id: authSessions.id })
return rows.length > 0
}
async revokeSessionsForUser(
userId: string,
revokedAt: Date,
): Promise<number> {
const rows = await this.database
.update(authSessions)
.set({ revokedAt })
.where(
and(eq(authSessions.userId, userId), isNull(authSessions.revokedAt)),
)
.returning({ id: authSessions.id })
return rows.length
}
}
@@ -0,0 +1,118 @@
import { randomUUID } from 'node:crypto'
import {
acceptInvitation,
issueInvitation,
TokenDigester,
} from '@devrunbook/application'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzleInvitationStore } from './invitation-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)('invitation persistence', () => {
const ownerId = randomUUID()
const teamWorkspaceId = randomUUID()
const email = `invite-${randomUUID()}@example.invalid`
const rawToken = 'database-invitation-token-with-enough-entropy'
const digester = new TokenDigester(Buffer.alloc(32, 5))
let store: DrizzleInvitationStore
let invitedUserId: string | undefined
let personalWorkspaceId: string | undefined
beforeAll(async () => {
store = new DrizzleInvitationStore()
const sql = getSqlClient()
await sql`
insert into users (id, email, display_name, password_hash, instance_role, status)
values (${ownerId}, ${`owner-${ownerId}@example.invalid`}, 'Owner', 'test-hash', 'instance_owner', 'active')
`
await sql`
insert into workspaces (id, name, type)
values (${teamWorkspaceId}, 'Invitation team', 'team')
`
await sql`
insert into workspace_memberships (workspace_id, user_id, role)
values (${teamWorkspaceId}, ${ownerId}, 'owner')
`
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from invitations where created_by = ${ownerId}`
if (invitedUserId) await sql`delete from users where id = ${invitedUserId}`
await sql`delete from users where id = ${ownerId}`
if (personalWorkspaceId) {
await sql`delete from workspaces where id = ${personalWorkspaceId}`
}
await sql`delete from workspaces where id = ${teamWorkspaceId}`
await closeDatabase()
})
it('atomically accepts once with bound memberships and safe audits', async () => {
const issued = await issueInvitation(
{
store,
digester,
publicBaseUrl: 'https://runbook.example.test',
now: () => new Date('2026-07-27T12:00:00.000Z'),
generateToken: () => rawToken,
},
{
actorUserId: ownerId,
email,
instanceRole: 'user',
workspaceId: teamWorkspaceId,
workspaceRole: 'viewer',
},
)
expect(issued.inviteUrl).toContain('#token=')
const sql = getSqlClient()
const [stored] = await sql<{ token_hash: string }[]>`
select token_hash from invitations where id = ${issued.id}
`
expect(stored?.token_hash).toBe(digester.digest(rawToken))
expect(stored?.token_hash).not.toContain(rawToken)
const accepted = await acceptInvitation(
{ store, digester, now: () => new Date('2026-07-27T13:00:00.000Z') },
{
rawToken,
displayName: 'Invited User',
passwordHash: 'test-password-hash',
},
)
invitedUserId = accepted.userId
const memberships = await sql<{ workspace_id: string; role: string }[]>`
select workspace_id, role from workspace_memberships
where user_id = ${accepted.userId} order by role
`
expect(memberships).toHaveLength(2)
expect(memberships).toContainEqual({
workspace_id: teamWorkspaceId,
role: 'viewer',
})
personalWorkspaceId = memberships.find(
(item) => item.role === 'owner',
)?.workspace_id
await expect(
acceptInvitation(
{ store, digester, now: () => new Date('2026-07-27T13:01:00.000Z') },
{ rawToken, displayName: 'Replay', passwordHash: 'another-hash' },
),
).rejects.toMatchObject({ code: 'invalid_invitation' })
const audits = await sql<{ action: string }[]>`
select action from audit_events
where resource_id in (${issued.id}, ${accepted.userId}) order by action
`
expect(audits.map((event) => event.action)).toEqual([
'invitation.accepted',
'invitation.created',
'user.created',
])
})
})
+198
View File
@@ -0,0 +1,198 @@
import type {
InvitationStore,
InvitationTransaction,
} from '@devrunbook/application'
import { InvitationError } from '@devrunbook/application'
import { and, eq, gt, ilike, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
invitations,
users,
workspaceMemberships,
workspaces,
} from '../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
class DrizzleInvitationTransaction implements InvitationTransaction {
constructor(private readonly transaction: Transaction) {}
async create(input: Parameters<InvitationTransaction['create']>[0]) {
const [actor] = await this.transaction
.select({ role: users.instanceRole })
.from(users)
.where(
and(
eq(users.id, input.actorUserId),
eq(users.status, 'active'),
isNull(users.deletedAt),
),
)
.limit(1)
if (!actor || !['instance_owner', 'instance_admin'].includes(actor.role)) {
throw new InvitationError('invalid_invitation')
}
if (input.workspaceId) {
const [membership] = await this.transaction
.select({ role: workspaceMemberships.role })
.from(workspaceMemberships)
.innerJoin(
workspaces,
eq(workspaces.id, workspaceMemberships.workspaceId),
)
.where(
and(
eq(workspaceMemberships.userId, input.actorUserId),
eq(workspaceMemberships.workspaceId, input.workspaceId),
eq(workspaceMemberships.role, 'owner'),
isNull(workspaces.deletedAt),
),
)
.limit(1)
if (!membership) throw new InvitationError('invalid_invitation')
}
const [existingUser] = await this.transaction
.select({ id: users.id })
.from(users)
.where(and(ilike(users.email, input.email), isNull(users.deletedAt)))
.limit(1)
if (existingUser) throw new InvitationError('invitation_conflict')
const [row] = await this.transaction
.insert(invitations)
.values({
email: input.email,
tokenHash: input.tokenHash,
instanceRole: input.instanceRole,
workspaceId: input.workspaceId,
workspaceRole: input.workspaceRole,
expiresAt: input.expiresAt,
createdBy: input.actorUserId,
})
.returning({
id: invitations.id,
email: invitations.email,
expiresAt: invitations.expiresAt,
})
if (!row) throw new Error('Invitation insert did not return a row')
await this.transaction.insert(auditEvents).values({
actorUserId: input.actorUserId,
workspaceId: input.workspaceId,
action: 'invitation.created',
resourceType: 'invitation',
resourceId: row.id,
outcome: 'success',
metadataJson: { instanceRole: input.instanceRole },
})
return row
}
async consume(input: Parameters<InvitationTransaction['consume']>[0]) {
const [invitation] = await this.transaction
.select()
.from(invitations)
.where(
and(
eq(invitations.tokenHash, input.tokenHash),
isNull(invitations.acceptedAt),
gt(invitations.expiresAt, input.acceptedAt),
),
)
.limit(1)
.for('update')
if (!invitation) return null
const [existingUser] = await this.transaction
.select({ id: users.id })
.from(users)
.where(and(ilike(users.email, invitation.email), isNull(users.deletedAt)))
.limit(1)
if (existingUser) throw new InvitationError('invitation_conflict')
const [user] = await this.transaction
.insert(users)
.values({
email: invitation.email,
displayName: input.displayName,
passwordHash: input.passwordHash,
emailVerified: true,
instanceRole: invitation.instanceRole,
status: 'active',
})
.returning({ id: users.id })
if (!user) throw new Error('Invitation user insert did not return a row')
const [personalWorkspace] = await this.transaction
.insert(workspaces)
.values({ name: `${input.displayName}'s workspace`, type: 'personal' })
.returning({ id: workspaces.id })
if (!personalWorkspace) {
throw new Error('Invitation workspace insert did not return a row')
}
await this.transaction.insert(workspaceMemberships).values({
userId: user.id,
workspaceId: personalWorkspace.id,
role: 'owner',
})
if (invitation.workspaceId && invitation.workspaceRole) {
await this.transaction.insert(workspaceMemberships).values({
userId: user.id,
workspaceId: invitation.workspaceId,
role: invitation.workspaceRole,
})
}
const accepted = await this.transaction
.update(invitations)
.set({ acceptedAt: input.acceptedAt })
.where(
and(eq(invitations.id, invitation.id), isNull(invitations.acceptedAt)),
)
.returning({ id: invitations.id })
if (accepted.length !== 1) return null
await this.transaction.insert(auditEvents).values([
{
actorUserId: user.id,
workspaceId: invitation.workspaceId,
action: 'invitation.accepted',
resourceType: 'invitation',
resourceId: invitation.id,
outcome: 'success',
metadataJson: {},
},
{
actorUserId: user.id,
workspaceId: personalWorkspace.id,
action: 'user.created',
resourceType: 'user',
resourceId: user.id,
outcome: 'success',
metadataJson: { source: 'invitation' },
},
])
return { userId: user.id }
}
}
export class DrizzleInvitationStore implements InvitationStore {
constructor(private readonly database: Database = getDatabase()) {}
async isConsumable(tokenHash: string, now: Date): Promise<boolean> {
const [row] = await this.database
.select({ id: invitations.id })
.from(invitations)
.where(
and(
eq(invitations.tokenHash, tokenHash),
isNull(invitations.acceptedAt),
gt(invitations.expiresAt, now),
),
)
.limit(1)
return Boolean(row)
}
transaction<T>(work: (transaction: InvitationTransaction) => Promise<T>) {
return this.database.transaction((transaction) =>
work(new DrizzleInvitationTransaction(transaction)),
)
}
}
+66
View File
@@ -0,0 +1,66 @@
import type {
InstanceRole,
OperationsActor,
OperationsActorLookup,
WorkspaceRole,
} from '@devrunbook/application'
import { and, asc, eq, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import { users, workspaceMemberships, workspaces } from '../schema'
type Database = ReturnType<typeof getDatabase>
export class DrizzleOperationsActorLookup implements OperationsActorLookup {
constructor(private readonly database: Database = getDatabase()) {}
async findOperationsActor(userId: string): Promise<OperationsActor | null> {
const [row] = await this.database
.select({
userId: users.id,
instanceRole: users.instanceRole,
workspaceId: workspaces.id,
workspaceRole: workspaceMemberships.role,
})
.from(users)
.leftJoin(workspaceMemberships, eq(workspaceMemberships.userId, users.id))
.leftJoin(
workspaces,
and(
eq(workspaces.id, workspaceMemberships.workspaceId),
isNull(workspaces.deletedAt),
),
)
.where(
and(
eq(users.id, userId),
eq(users.status, 'active'),
isNull(users.deletedAt),
),
)
.orderBy(asc(workspaces.createdAt), asc(workspaces.id))
.limit(1)
if (!row) return null
if (
row.instanceRole !== 'instance_owner' &&
row.instanceRole !== 'instance_admin' &&
row.instanceRole !== 'user'
) {
return null
}
if (
row.workspaceRole !== null &&
row.workspaceRole !== 'owner' &&
row.workspaceRole !== 'editor' &&
row.workspaceRole !== 'viewer'
) {
return null
}
return {
userId: row.userId,
instanceRole: row.instanceRole as InstanceRole,
workspaceId: row.workspaceId,
workspaceRole: row.workspaceRole as WorkspaceRole | null,
}
}
}
@@ -0,0 +1,167 @@
import type {
PasswordResetStore,
PasswordResetTransaction,
} from '@devrunbook/application'
import { and, eq, gt, ilike, isNull } from 'drizzle-orm'
import { getDatabase } from '../../index'
import {
auditEvents,
authSessions,
passwordResetTokens,
users,
} from '../../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
class DrizzlePasswordResetTransaction implements PasswordResetTransaction {
constructor(private readonly transaction: Transaction) {}
async findActiveUserByEmail(email: string) {
const [user] = await this.transaction
.select({ id: users.id, email: users.email })
.from(users)
.where(
and(
ilike(users.email, email),
eq(users.status, 'active'),
isNull(users.deletedAt),
),
)
.for('update')
.limit(1)
return user ?? null
}
async revokeUnusedTokens(userId: string, revokedAt: Date): Promise<number> {
const rows = await this.transaction
.update(passwordResetTokens)
.set({ usedAt: revokedAt })
.where(
and(
eq(passwordResetTokens.userId, userId),
isNull(passwordResetTokens.usedAt),
),
)
.returning({ id: passwordResetTokens.id })
return rows.length
}
async createToken(input: {
userId: string
tokenHash: string
expiresAt: Date
createdBy: string | null
createdAt: Date
}): Promise<{ id: string }> {
const [token] = await this.transaction
.insert(passwordResetTokens)
.values(input)
.returning({ id: passwordResetTokens.id })
if (!token) throw new Error('Password reset token insert returned no row')
return token
}
async hasConsumableToken(input: {
tokenHash: string
checkedAt: Date
}): Promise<boolean> {
const [token] = await this.transaction
.select({ id: passwordResetTokens.id })
.from(passwordResetTokens)
.where(
and(
eq(passwordResetTokens.tokenHash, input.tokenHash),
isNull(passwordResetTokens.usedAt),
gt(passwordResetTokens.expiresAt, input.checkedAt),
),
)
.limit(1)
return token !== undefined
}
async consumeValidToken(input: {
tokenHash: string
consumedAt: Date
}): Promise<{ id: string; userId: string } | null> {
const [token] = await this.transaction
.update(passwordResetTokens)
.set({ usedAt: input.consumedAt })
.where(
and(
eq(passwordResetTokens.tokenHash, input.tokenHash),
isNull(passwordResetTokens.usedAt),
gt(passwordResetTokens.expiresAt, input.consumedAt),
),
)
.returning({
id: passwordResetTokens.id,
userId: passwordResetTokens.userId,
})
return token ?? null
}
async updatePassword(input: {
userId: string
passwordHash: string
changedAt: Date
}): Promise<boolean> {
const rows = await this.transaction
.update(users)
.set({
passwordHash: input.passwordHash,
passwordChangedAt: input.changedAt,
updatedAt: input.changedAt,
})
.where(
and(
eq(users.id, input.userId),
eq(users.status, 'active'),
isNull(users.deletedAt),
),
)
.returning({ id: users.id })
return rows.length === 1
}
async revokeSessions(userId: string, revokedAt: Date): Promise<number> {
const rows = await this.transaction
.update(authSessions)
.set({ revokedAt })
.where(
and(eq(authSessions.userId, userId), isNull(authSessions.revokedAt)),
)
.returning({ id: authSessions.id })
return rows.length
}
async appendAuditEvent(input: {
actorUserId: string | null
action: 'user.password_reset.issued' | 'user.password_reset.completed'
resourceId: string
metadata: Readonly<Record<string, string | number>>
}): Promise<void> {
await this.transaction.insert(auditEvents).values({
actorUserId: input.actorUserId,
workspaceId: null,
action: input.action,
resourceType: 'user',
resourceId: input.resourceId,
outcome: 'success',
metadataJson: input.metadata,
})
}
}
export class DrizzlePasswordResetStore implements PasswordResetStore {
constructor(private readonly database: Database = getDatabase()) {}
transaction<T>(
work: (transaction: PasswordResetTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction((transaction) =>
work(new DrizzlePasswordResetTransaction(transaction)),
)
}
}
@@ -0,0 +1,89 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzlePersonalDataStore } from './personal-data-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)('personal data lifecycle', () => {
const userId = randomUUID()
const workspaceId = randomUUID()
const sessionId = randomUUID()
let store: DrizzlePersonalDataStore
beforeAll(async () => {
store = new DrizzlePersonalDataStore()
const sql = getSqlClient()
await sql`
insert into users (id, email, display_name, password_hash, instance_role, status)
values (${userId}, ${`personal-${userId}@example.invalid`}, 'Personal User', 'private-password-hash', 'user', 'active')
`
await sql`insert into workspaces (id, name, type) values (${workspaceId}, 'Personal data', 'personal')`
await sql`
insert into workspace_memberships (workspace_id, user_id, role)
values (${workspaceId}, ${userId}, 'owner')
`
await sql`
insert into auth_sessions (
id, user_id, token_hash, idle_expires_at, absolute_expires_at
) values (
${sessionId}, ${userId}, ${`hmac-sha256:v1:${'a'.repeat(64)}`},
now() + interval '1 day', now() + interval '30 days'
)
`
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from users where id = ${userId}`
await sql`delete from workspaces where id = ${workspaceId}`
await closeDatabase()
})
it('exports safe data then irreversibly anonymizes identity and sessions', async () => {
const exported = await store.exportForUser(userId)
expect(exported).toMatchObject({
schemaVersion: 'devrunbook.personal-data/v1',
profile: { id: userId, displayName: 'Personal User' },
})
const serialized = JSON.stringify(exported)
expect(serialized).not.toContain('private-password-hash')
expect(serialized).not.toContain('token_hash')
await expect(
store.anonymizeUser({
userId,
requestId: 'personal-data-integration',
now: new Date('2026-07-27T14:00:00.000Z'),
}),
).resolves.toBe(true)
const sql = getSqlClient()
const [identity] = await sql<
{
email: string
display_name: string
status: string
deleted_at: Date
}[]
>`select email, display_name, status, deleted_at from users where id = ${userId}`
expect(identity).toMatchObject({
email: `deleted-${userId}@deleted.invalid`,
display_name: 'Deleted user',
status: 'pending_deletion',
})
const [session] = await sql<{ revoked_at: Date | null }[]>`
select revoked_at from auth_sessions where id = ${sessionId}
`
expect(session?.revoked_at).not.toBeNull()
const [audit] = await sql<{ action: string; metadata_json: unknown }[]>`
select action, metadata_json from audit_events
where resource_id = ${userId} and action = 'user.personal_data.deleted'
`
expect(audit).toMatchObject({ action: 'user.personal_data.deleted' })
await expect(store.exportForUser(userId)).resolves.toBeNull()
})
})
+162
View File
@@ -0,0 +1,162 @@
import { and, eq, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
authSessions,
collections,
compositionDrafts,
favorites,
generatedRuns,
runFeedback,
users,
workspaceMemberships,
workspaces,
} from '../schema'
type Database = ReturnType<typeof getDatabase>
export class DrizzlePersonalDataStore {
constructor(private readonly database: Database = getDatabase()) {}
async authenticationRecord(userId: string) {
const [row] = await this.database
.select({
passwordHash: users.passwordHash,
instanceRole: users.instanceRole,
})
.from(users)
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
.limit(1)
return row ?? null
}
async exportForUser(userId: string) {
const [profile] = await this.database
.select({
id: users.id,
email: users.email,
displayName: users.displayName,
instanceRole: users.instanceRole,
status: users.status,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(and(eq(users.id, userId), isNull(users.deletedAt)))
.limit(1)
if (!profile) return null
const [memberships, favoriteRows, collectionRows, drafts, runs, feedback] =
await Promise.all([
this.database
.select({
workspaceId: workspaceMemberships.workspaceId,
workspaceName: workspaces.name,
role: workspaceMemberships.role,
createdAt: workspaceMemberships.createdAt,
})
.from(workspaceMemberships)
.innerJoin(
workspaces,
eq(workspaces.id, workspaceMemberships.workspaceId),
)
.where(eq(workspaceMemberships.userId, userId)),
this.database
.select()
.from(favorites)
.where(eq(favorites.userId, userId)),
this.database
.select({
id: collections.id,
workspaceId: collections.workspaceId,
name: collections.name,
description: collections.description,
createdAt: collections.createdAt,
updatedAt: collections.updatedAt,
})
.from(collections)
.where(eq(collections.createdBy, userId)),
this.database
.select()
.from(compositionDrafts)
.where(eq(compositionDrafts.createdBy, userId)),
this.database
.select()
.from(generatedRuns)
.where(eq(generatedRuns.generatedBy, userId)),
this.database
.select()
.from(runFeedback)
.where(eq(runFeedback.userId, userId)),
])
return {
schemaVersion: 'devrunbook.personal-data/v1',
exportedAt: new Date().toISOString(),
profile,
memberships,
favorites: favoriteRows,
collections: collectionRows,
compositionDrafts: drafts,
generatedRuns: runs,
runFeedback: feedback,
}
}
async anonymizeUser(input: {
readonly userId: string
readonly requestId: string
readonly now?: Date
}): Promise<boolean> {
const now = input.now ?? new Date()
return this.database.transaction(async (transaction) => {
const [user] = await transaction
.select({ instanceRole: users.instanceRole })
.from(users)
.where(and(eq(users.id, input.userId), isNull(users.deletedAt)))
.limit(1)
.for('update')
if (!user || user.instanceRole === 'instance_owner') return false
await transaction
.delete(favorites)
.where(eq(favorites.userId, input.userId))
await transaction
.delete(runFeedback)
.where(eq(runFeedback.userId, input.userId))
await transaction
.update(authSessions)
.set({ revokedAt: now })
.where(
and(
eq(authSessions.userId, input.userId),
isNull(authSessions.revokedAt),
),
)
const rows = await transaction
.update(users)
.set({
email: `deleted-${input.userId}@deleted.invalid`,
displayName: 'Deleted user',
passwordHash: `deleted:${crypto.randomUUID()}`,
emailVerified: false,
image: null,
status: 'pending_deletion',
deletedAt: now,
updatedAt: now,
})
.where(and(eq(users.id, input.userId), isNull(users.deletedAt)))
.returning({ id: users.id })
if (rows.length !== 1) return false
await transaction.insert(auditEvents).values({
actorUserId: input.userId,
workspaceId: null,
action: 'user.personal_data.deleted',
resourceType: 'user',
resourceId: input.userId,
requestId: input.requestId,
outcome: 'success',
metadataJson: { mode: 'identity_anonymization' },
})
return true
})
}
}
@@ -0,0 +1,78 @@
import { and, desc, eq, gt, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import { auditEvents, authSessions } from '../schema'
type Database = ReturnType<typeof getDatabase>
export interface ManagedAuthSession {
readonly id: string
readonly createdAt: Date
readonly lastSeenAt: Date
readonly idleExpiresAt: Date
readonly absoluteExpiresAt: Date
readonly userAgentSummary: string | null
}
export class DrizzleSessionManagementStore {
constructor(private readonly database: Database = getDatabase()) {}
listActiveForUser(
userId: string,
now = new Date(),
): Promise<ManagedAuthSession[]> {
return this.database
.select({
id: authSessions.id,
createdAt: authSessions.createdAt,
lastSeenAt: authSessions.lastSeenAt,
idleExpiresAt: authSessions.idleExpiresAt,
absoluteExpiresAt: authSessions.absoluteExpiresAt,
userAgentSummary: authSessions.userAgentSummary,
})
.from(authSessions)
.where(
and(
eq(authSessions.userId, userId),
isNull(authSessions.revokedAt),
gt(authSessions.idleExpiresAt, now),
gt(authSessions.absoluteExpiresAt, now),
),
)
.orderBy(desc(authSessions.lastSeenAt))
}
async revokeOwned(input: {
readonly actorUserId: string
readonly sessionId: string
readonly requestId: string
readonly now?: Date
}): Promise<boolean> {
const now = input.now ?? new Date()
return this.database.transaction(async (transaction) => {
const rows = await transaction
.update(authSessions)
.set({ revokedAt: now })
.where(
and(
eq(authSessions.id, input.sessionId),
eq(authSessions.userId, input.actorUserId),
isNull(authSessions.revokedAt),
),
)
.returning({ id: authSessions.id })
if (rows.length !== 1) return false
await transaction.insert(auditEvents).values({
actorUserId: input.actorUserId,
workspaceId: null,
action: 'auth.session.revoked',
resourceType: 'auth_session',
resourceId: input.sessionId,
requestId: input.requestId,
outcome: 'success',
metadataJson: {},
})
return true
})
}
}
@@ -0,0 +1,68 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { describe, expect, it } from 'vitest'
import * as schema from '../schema'
import {
buildAuthorizedWorkspaceSelectionQuery,
buildDeterministicActiveWorkspaceQuery,
buildWorkspaceAuthorizationQuery,
} from './workspace-authorization'
describe('workspace authorization query', () => {
it('binds actor and target IDs while joining active, non-deleted ownership records', () => {
const database = drizzle.mock({ schema })
const userId = '00000000-0000-4000-8000-000000000001'
const workspaceId = '00000000-0000-4000-8000-000000000002'
const query = buildWorkspaceAuthorizationQuery(
database,
userId,
workspaceId,
).toSQL()
expect(query.sql).toContain('from "users"')
expect(query.sql).toContain('inner join "workspace_memberships"')
expect(query.sql).toContain('inner join "workspaces"')
expect(query.sql).toContain('"users"."deleted_at" is null')
expect(query.sql).toContain('"workspaces"."deleted_at" is null')
expect(query.sql).toContain('"users"."status" = $')
expect(query.params).toEqual(
expect.arrayContaining([userId, workspaceId, 'active']),
)
})
it('selects one active membership in stable workspace order', () => {
const database = drizzle.mock({ schema })
const userId = '00000000-0000-4000-8000-000000000001'
const query = buildDeterministicActiveWorkspaceQuery(
database,
userId,
).toSQL()
expect(query.sql).toContain('inner join "workspace_memberships"')
expect(query.sql).toContain('"users"."status" = $')
expect(query.sql).toContain('"users"."deleted_at" is null')
expect(query.sql).toContain('"workspaces"."deleted_at" is null')
expect(query.sql).toContain(
'order by "workspaces"."created_at" asc, "workspaces"."id" asc',
)
expect(query.params).toEqual(expect.arrayContaining([userId, 'active']))
})
it('lists only active non-deleted memberships in stable display order', () => {
const database = drizzle.mock({ schema })
const userId = '00000000-0000-4000-8000-000000000001'
const query = buildAuthorizedWorkspaceSelectionQuery(
database,
userId,
).toSQL()
expect(query.sql).toContain('inner join "workspace_memberships"')
expect(query.sql).toContain('"users"."status" = $')
expect(query.sql).toContain('"users"."deleted_at" is null')
expect(query.sql).toContain('"workspaces"."deleted_at" is null')
expect(query.sql).toContain(
'order by "workspaces"."name" asc, "workspaces"."id" asc',
)
expect(query.params).toEqual(expect.arrayContaining([userId, 'active']))
})
})
@@ -0,0 +1,194 @@
import type {
ActiveWorkspaceLookup,
InstanceRole,
WorkspaceAuthorizationLookup,
WorkspaceAuthorizationRecord,
WorkspaceRole,
WorkspaceSelectionLookup,
WorkspaceSelectionOption,
} from '@devrunbook/application'
import { and, asc, eq, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import { users, workspaceMemberships, workspaces } from '../schema'
type Database = ReturnType<typeof getDatabase>
const validInstanceRoles = new Set<InstanceRole>([
'instance_owner',
'instance_admin',
'user',
])
const validWorkspaceRoles = new Set<WorkspaceRole>([
'viewer',
'editor',
'owner',
])
export function buildWorkspaceAuthorizationQuery(
database: Database,
userId: string,
workspaceId: string,
) {
return database
.select({
userId: users.id,
instanceRole: users.instanceRole,
userStatus: users.status,
workspaceId: workspaces.id,
workspaceRole: workspaceMemberships.role,
})
.from(users)
.innerJoin(
workspaceMemberships,
and(
eq(workspaceMemberships.userId, users.id),
eq(workspaceMemberships.workspaceId, workspaceId),
),
)
.innerJoin(
workspaces,
and(
eq(workspaces.id, workspaceMemberships.workspaceId),
isNull(workspaces.deletedAt),
),
)
.where(
and(
eq(users.id, userId),
eq(users.status, 'active'),
isNull(users.deletedAt),
eq(workspaces.id, workspaceId),
),
)
.limit(1)
}
export function buildDeterministicActiveWorkspaceQuery(
database: Database,
userId: string,
) {
return database
.select({ workspaceId: workspaceMemberships.workspaceId })
.from(users)
.innerJoin(workspaceMemberships, eq(workspaceMemberships.userId, users.id))
.innerJoin(
workspaces,
and(
eq(workspaces.id, workspaceMemberships.workspaceId),
isNull(workspaces.deletedAt),
),
)
.where(
and(
eq(users.id, userId),
eq(users.status, 'active'),
isNull(users.deletedAt),
),
)
.orderBy(asc(workspaces.createdAt), asc(workspaces.id))
.limit(1)
}
export function buildAuthorizedWorkspaceSelectionQuery(
database: Database,
userId: string,
) {
return database
.select({
id: workspaces.id,
name: workspaces.name,
type: workspaces.type,
role: workspaceMemberships.role,
})
.from(users)
.innerJoin(workspaceMemberships, eq(workspaceMemberships.userId, users.id))
.innerJoin(
workspaces,
and(
eq(workspaces.id, workspaceMemberships.workspaceId),
isNull(workspaces.deletedAt),
),
)
.where(
and(
eq(users.id, userId),
eq(users.status, 'active'),
isNull(users.deletedAt),
),
)
.orderBy(asc(workspaces.name), asc(workspaces.id))
}
export class DrizzleActiveWorkspaceLookup implements ActiveWorkspaceLookup {
constructor(private readonly database: Database = getDatabase()) {}
async findDeterministicActiveWorkspaceId(
userId: string,
): Promise<string | null> {
const [row] = await buildDeterministicActiveWorkspaceQuery(
this.database,
userId,
)
return row?.workspaceId ?? null
}
}
export class DrizzleWorkspaceSelectionLookup implements WorkspaceSelectionLookup {
constructor(private readonly database: Database = getDatabase()) {}
async listAuthorizedWorkspaces(
userId: string,
): Promise<readonly WorkspaceSelectionOption[]> {
const rows = await buildAuthorizedWorkspaceSelectionQuery(
this.database,
userId,
)
return rows.flatMap((row) => {
if (
(row.type !== 'personal' && row.type !== 'team') ||
!validWorkspaceRoles.has(row.role as WorkspaceRole)
) {
return []
}
return [
{
id: row.id,
name: row.name,
type: row.type,
role: row.role as WorkspaceRole,
},
]
})
}
}
export class DrizzleWorkspaceAuthorizationLookup implements WorkspaceAuthorizationLookup {
constructor(private readonly database: Database = getDatabase()) {}
async findWorkspaceAuthorization(
userId: string,
workspaceId: string,
): Promise<WorkspaceAuthorizationRecord | null> {
const [row] = await buildWorkspaceAuthorizationQuery(
this.database,
userId,
workspaceId,
)
if (
!row ||
row.userStatus !== 'active' ||
!validInstanceRoles.has(row.instanceRole as InstanceRole) ||
!validWorkspaceRoles.has(row.workspaceRole as WorkspaceRole)
) {
return null
}
return {
userId: row.userId,
instanceRole: row.instanceRole as InstanceRole,
userStatus: 'active',
workspaceId: row.workspaceId,
workspaceRole: row.workspaceRole as WorkspaceRole,
}
}
}
@@ -0,0 +1,214 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzleCompositionDraftStore } from './composition-draft-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)(
'DrizzleCompositionDraftStore integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
let playbookVersionA: string
let playbookVersionB: string
let profileRevisionA: string
let profileRevisionB: string
let store: DrizzleCompositionDraftStore
async function createPlaybookVersion(workspaceId: string, suffix: string) {
const sql = getSqlClient()
const [playbook] = await sql<{ id: string }[]>`
insert into playbooks (
workspace_id, logical_id, slug, namespace, source_type
) values (
${workspaceId}, ${`draft-${suffix}`}, ${`draft-${suffix}`},
${`private-${workspaceId}`}, 'private'
) returning id
`
const [version] = await sql<{ id: string }[]>`
insert into playbook_versions (
playbook_id, semantic_version, lifecycle, package_api_version,
title, summary, category, risk_tier, package_json, template_text,
content_digest, created_by
) values (
${playbook!.id}, '1.0.0', 'validated', 'devrunbook.io/v1alpha1',
'Draft fixture', 'Draft fixture', 'testing', 'low', '{}'::jsonb,
'fixture', ${'a'.repeat(64)}, ${userId}
) returning id
`
return version!.id
}
async function createProfileRevision(workspaceId: string, suffix: string) {
const sql = getSqlClient()
const [repository] = await sql<{ id: string }[]>`
insert into repositories (workspace_id, display_name, source_type)
values (${workspaceId}, ${`Draft repository ${suffix}`}, 'manual')
returning id
`
const [revision] = await sql<{ id: string }[]>`
insert into repository_profile_revisions (
repository_id, revision_number, profile_json, content_digest, created_by
) values (
${repository!.id}, 1, '{}'::jsonb, ${'b'.repeat(64)}, ${userId}
) returning id
`
return revision!.id
}
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`draft-${userId}@example.invalid`}, 'Draft integration',
'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type)
values
(${workspaceA}, 'Draft integration A', 'team'),
(${workspaceB}, 'Draft integration B', 'team')
`
playbookVersionA = await createPlaybookVersion(workspaceA, randomUUID())
playbookVersionB = await createPlaybookVersion(workspaceB, randomUUID())
profileRevisionA = await createProfileRevision(workspaceA, 'A')
profileRevisionB = await createProfileRevision(workspaceB, 'B')
store = new DrizzleCompositionDraftStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('creates only with same-workspace immutable references and scopes reads', async () => {
const created = await store.create({
workspaceId: workspaceA,
createdBy: userId,
playbookVersionId: playbookVersionA,
repositoryProfileRevisionId: profileRevisionA,
inputs: { request: 'Bounded change' },
scopeOverrides: {},
policyOverrides: { preserveTests: true },
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
})
expect(created).toMatchObject({
revision: 1,
policyOverrides: { preserveTests: true },
})
await expect(
store.findByIdForWorkspace(workspaceA, created!.id),
).resolves.toMatchObject({ id: created!.id })
await expect(
store.findByIdForWorkspace(workspaceB, created!.id),
).resolves.toBeNull()
await expect(
store.create({
workspaceId: workspaceA,
createdBy: userId,
playbookVersionId: playbookVersionB,
repositoryProfileRevisionId: null,
inputs: {},
scopeOverrides: {},
policyOverrides: {},
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
}),
).resolves.toBeNull()
await expect(
store.create({
workspaceId: workspaceA,
createdBy: userId,
playbookVersionId: playbookVersionA,
repositoryProfileRevisionId: profileRevisionB,
inputs: {},
scopeOverrides: {},
policyOverrides: {},
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
}),
).resolves.toBeNull()
})
it('serializes concurrent autosaves and preserves a monotonic revision', async () => {
const created = await store.create({
workspaceId: workspaceA,
createdBy: userId,
playbookVersionId: playbookVersionA,
repositoryProfileRevisionId: null,
inputs: { request: 'Before' },
scopeOverrides: {},
policyOverrides: {},
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
})
const results = await Promise.allSettled([
store.patchWithRevision({
workspaceId: workspaceA,
draftId: created!.id,
expectedRevision: 1,
inputs: { request: 'First writer' },
}),
store.patchWithRevision({
workspaceId: workspaceA,
draftId: created!.id,
expectedRevision: 1,
inputs: { request: 'Second writer' },
}),
])
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1)
expect(
results.filter((result) => result.status === 'rejected'),
).toHaveLength(1)
const current = await store.findByIdForWorkspace(workspaceA, created!.id)
expect(current?.revision).toBe(2)
await expect(
store.patchWithRevision({
workspaceId: workspaceA,
draftId: created!.id,
expectedRevision: 2,
inputs: current!.inputs,
}),
).resolves.toMatchObject({ changed: false, draft: { revision: 2 } })
})
it('enforces digest and positive-revision checks in PostgreSQL', async () => {
const sql = getSqlClient()
await expect(sql`
insert into composition_drafts (
workspace_id, playbook_version_id, input_json, scope_override_json,
policy_override_json, autonomy_level, work_mode, output_format,
last_render_digest, revision, created_by
) values (
${workspaceA}, ${playbookVersionA}, '{}'::jsonb, '{}'::jsonb,
'{}'::jsonb, 'verify', 'execute', 'prompt', 'INVALID', 0, ${userId}
)
`).rejects.toBeDefined()
})
},
)
@@ -0,0 +1,178 @@
import type {
CompositionDraft,
CreateCompositionDraftStoreRequest,
} from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import {
DrizzleCompositionDraftStore,
type CompositionDraftTransaction,
type CompositionDraftTransactionRunner,
} from './composition-draft-store'
const workspaceId = '00000000-0000-4000-8000-000000000001'
function draft(overrides: Partial<CompositionDraft> = {}): CompositionDraft {
return {
id: '00000000-0000-4000-8000-000000000002',
workspaceId,
playbookVersionId: '00000000-0000-4000-8000-000000000003',
repositoryProfileRevisionId: null,
inputs: { request: 'Bounded work' },
scopeOverrides: {},
policyOverrides: {},
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
revision: 1,
createdBy: '00000000-0000-4000-8000-000000000004',
createdAt: '2026-07-27T12:00:00.000Z',
updatedAt: '2026-07-27T12:00:00.000Z',
...overrides,
}
}
const createRequest: CreateCompositionDraftStoreRequest = {
workspaceId,
createdBy: '00000000-0000-4000-8000-000000000004',
playbookVersionId: '00000000-0000-4000-8000-000000000003',
repositoryProfileRevisionId: null,
inputs: { request: 'Bounded work' },
scopeOverrides: {},
policyOverrides: {},
autonomyLevel: 'verify',
workMode: 'execute',
outputFormat: 'prompt',
lastRenderDigest: null,
}
function runner(overrides: Partial<CompositionDraftTransaction> = {}) {
const transaction: CompositionDraftTransaction = {
referencesAreAccessible: vi.fn(async () => true),
insert: vi.fn(async () => draft()),
lockByIdForWorkspace: vi.fn(async () => draft()),
update: vi.fn(async (current) =>
draft({
...current,
revision: current.revision + 1,
updatedAt: '2026-07-27T12:01:00.000Z',
}),
),
...overrides,
}
const transactionRunner: CompositionDraftTransactionRunner = {
run: async (work) => work(transaction),
}
return { transaction, transactionRunner }
}
describe('DrizzleCompositionDraftStore', () => {
it('checks immutable reference visibility before atomic creation', async () => {
const target = runner()
const store = new DrizzleCompositionDraftStore(
{} as never,
target.transactionRunner,
)
await expect(store.create(createRequest)).resolves.toEqual(draft())
expect(target.transaction.referencesAreAccessible).toHaveBeenCalledWith(
workspaceId,
createRequest.playbookVersionId,
null,
)
expect(target.transaction.insert).toHaveBeenCalledWith(createRequest)
})
it('conceals an inaccessible playbook or profile and does not insert', async () => {
const target = runner({
referencesAreAccessible: vi.fn(async () => false),
})
const store = new DrizzleCompositionDraftStore(
{} as never,
target.transactionRunner,
)
await expect(store.create(createRequest)).resolves.toBeNull()
expect(target.transaction.insert).not.toHaveBeenCalled()
})
it('increments one revision for a changed patch', async () => {
const target = runner()
const store = new DrizzleCompositionDraftStore(
{} as never,
target.transactionRunner,
() => new Date('2026-07-27T12:01:00.000Z'),
)
const result = await store.patchWithRevision({
workspaceId,
draftId: draft().id,
expectedRevision: 1,
outputFormat: 'markdown',
})
expect(result).toMatchObject({ changed: true, draft: { revision: 2 } })
expect(target.transaction.update).toHaveBeenCalledOnce()
})
it('suppresses semantic no-ops without changing revision or timestamp', async () => {
const target = runner()
const store = new DrizzleCompositionDraftStore(
{} as never,
target.transactionRunner,
)
const result = await store.patchWithRevision({
workspaceId,
draftId: draft().id,
expectedRevision: 1,
outputFormat: 'prompt',
inputs: { request: 'Bounded work' },
})
expect(result).toEqual({ draft: draft(), changed: false })
expect(target.transaction.update).not.toHaveBeenCalled()
})
it('returns a recoverable conflict before applying stale input', async () => {
const target = runner({
lockByIdForWorkspace: vi.fn(async () => draft({ revision: 4 })),
})
const store = new DrizzleCompositionDraftStore(
{} as never,
target.transactionRunner,
)
await expect(
store.patchWithRevision({
workspaceId,
draftId: draft().id,
expectedRevision: 3,
inputs: {},
}),
).rejects.toMatchObject({
code: 'composition_draft_conflict',
details: {
currentRevision: 4,
currentEtag: '"draft:4"',
recovery: 'reload-and-review',
},
})
expect(target.transaction.update).not.toHaveBeenCalled()
})
it('conceals cross-workspace draft IDs', async () => {
const target = runner({ lockByIdForWorkspace: vi.fn(async () => null) })
const store = new DrizzleCompositionDraftStore(
{} as never,
target.transactionRunner,
)
await expect(
store.patchWithRevision({
workspaceId,
draftId: draft().id,
expectedRevision: 1,
inputs: {},
}),
).resolves.toBeNull()
})
})
@@ -0,0 +1,359 @@
import type {
CompositionDraft,
CompositionDraftStore,
CompositionJsonObject,
CreateCompositionDraftStoreRequest,
PatchCompositionDraftStoreRequest,
PatchCompositionDraftStoreResult,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { and, eq, isNull, or } from 'drizzle-orm'
import { isDeepStrictEqual } from 'node:util'
import { getDatabase } from '../index'
import {
compositionDrafts,
playbooks,
playbookVersions,
repositories,
repositoryProfileRevisions,
} from '../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
type DraftRow = typeof compositionDrafts.$inferSelect
export interface CompositionDraftTransaction {
referencesAreAccessible(
workspaceId: string,
playbookVersionId: string,
repositoryProfileRevisionId: string | null,
): Promise<boolean>
insert(request: CreateCompositionDraftStoreRequest): Promise<CompositionDraft>
lockByIdForWorkspace(
workspaceId: string,
draftId: string,
): Promise<CompositionDraft | null>
update(
current: CompositionDraft,
request: PatchCompositionDraftStoreRequest,
updatedAt: Date,
): Promise<CompositionDraft>
}
export interface CompositionDraftTransactionRunner {
run<T>(
work: (transaction: CompositionDraftTransaction) => Promise<T>,
): Promise<T>
}
function isJsonObject(value: unknown): value is CompositionJsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function mapRow(row: DraftRow): CompositionDraft {
if (
!Number.isSafeInteger(row.revision) ||
row.revision < 1 ||
!['observe', 'diagnose', 'plan', 'implement', 'verify', 'repair'].includes(
row.autonomyLevel,
) ||
!['inspect', 'plan', 'guided', 'execute', 'recovery'].includes(
row.workMode,
) ||
!['prompt', 'markdown', 'run-pack'].includes(row.outputFormat) ||
(row.lastRenderDigest !== null &&
!/^[a-f0-9]{64}$/u.test(row.lastRenderDigest)) ||
!isJsonObject(row.inputJson) ||
!isJsonObject(row.scopeOverrideJson) ||
!isJsonObject(row.policyOverrideJson)
) {
throw new Error('Stored composition draft failed integrity validation')
}
return {
id: row.id,
workspaceId: row.workspaceId,
playbookVersionId: row.playbookVersionId,
repositoryProfileRevisionId: row.repositoryProfileRevisionId,
inputs: row.inputJson,
scopeOverrides: row.scopeOverrideJson,
policyOverrides: row.policyOverrideJson,
autonomyLevel: row.autonomyLevel as CompositionDraft['autonomyLevel'],
workMode: row.workMode as CompositionDraft['workMode'],
outputFormat: row.outputFormat as CompositionDraft['outputFormat'],
lastRenderDigest: row.lastRenderDigest,
revision: row.revision,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
function mergedDraft(
current: CompositionDraft,
patch: PatchCompositionDraftStoreRequest,
): CompositionDraft {
return {
...current,
...('repositoryProfileRevisionId' in patch
? {
repositoryProfileRevisionId:
patch.repositoryProfileRevisionId ?? null,
}
: {}),
...(patch.inputs === undefined ? {} : { inputs: patch.inputs }),
...(patch.scopeOverrides === undefined
? {}
: { scopeOverrides: patch.scopeOverrides }),
...(patch.policyOverrides === undefined
? {}
: { policyOverrides: patch.policyOverrides }),
...(patch.autonomyLevel === undefined
? {}
: { autonomyLevel: patch.autonomyLevel }),
...(patch.workMode === undefined ? {} : { workMode: patch.workMode }),
...(patch.outputFormat === undefined
? {}
: { outputFormat: patch.outputFormat }),
...('lastRenderDigest' in patch
? { lastRenderDigest: patch.lastRenderDigest ?? null }
: {}),
}
}
function sameMutableState(
left: CompositionDraft,
right: CompositionDraft,
): boolean {
return isDeepStrictEqual(
{
repositoryProfileRevisionId: left.repositoryProfileRevisionId,
inputs: left.inputs,
scopeOverrides: left.scopeOverrides,
policyOverrides: left.policyOverrides,
autonomyLevel: left.autonomyLevel,
workMode: left.workMode,
outputFormat: left.outputFormat,
lastRenderDigest: left.lastRenderDigest,
},
{
repositoryProfileRevisionId: right.repositoryProfileRevisionId,
inputs: right.inputs,
scopeOverrides: right.scopeOverrides,
policyOverrides: right.policyOverrides,
autonomyLevel: right.autonomyLevel,
workMode: right.workMode,
outputFormat: right.outputFormat,
lastRenderDigest: right.lastRenderDigest,
},
)
}
class DrizzleCompositionDraftTransaction implements CompositionDraftTransaction {
constructor(private readonly transaction: Transaction) {}
async referencesAreAccessible(
workspaceId: string,
playbookVersionId: string,
repositoryProfileRevisionId: string | null,
): Promise<boolean> {
const [playbook] = await this.transaction
.select({ id: playbookVersions.id })
.from(playbookVersions)
.innerJoin(playbooks, eq(playbookVersions.playbookId, playbooks.id))
.where(
and(
eq(playbookVersions.id, playbookVersionId),
or(
and(
isNull(playbooks.workspaceId),
eq(playbooks.sourceType, 'built_in'),
),
eq(playbooks.workspaceId, workspaceId),
),
),
)
.limit(1)
if (!playbook) return false
if (repositoryProfileRevisionId === null) return true
const [profile] = await this.transaction
.select({ id: repositoryProfileRevisions.id })
.from(repositoryProfileRevisions)
.innerJoin(
repositories,
eq(repositoryProfileRevisions.repositoryId, repositories.id),
)
.where(
and(
eq(repositoryProfileRevisions.id, repositoryProfileRevisionId),
eq(repositories.workspaceId, workspaceId),
),
)
.limit(1)
return Boolean(profile)
}
async insert(
request: CreateCompositionDraftStoreRequest,
): Promise<CompositionDraft> {
const [row] = await this.transaction
.insert(compositionDrafts)
.values({
workspaceId: request.workspaceId,
playbookVersionId: request.playbookVersionId,
repositoryProfileRevisionId: request.repositoryProfileRevisionId,
inputJson: request.inputs,
scopeOverrideJson: request.scopeOverrides,
policyOverrideJson: request.policyOverrides,
autonomyLevel: request.autonomyLevel,
workMode: request.workMode,
outputFormat: request.outputFormat,
lastRenderDigest: request.lastRenderDigest,
revision: 1,
createdBy: request.createdBy,
})
.returning()
if (!row) throw new Error('Composition draft insert did not return a row')
return mapRow(row)
}
async lockByIdForWorkspace(
workspaceId: string,
draftId: string,
): Promise<CompositionDraft | null> {
const [row] = await this.transaction
.select()
.from(compositionDrafts)
.where(
and(
eq(compositionDrafts.workspaceId, workspaceId),
eq(compositionDrafts.id, draftId),
),
)
.limit(1)
.for('update')
return row ? mapRow(row) : null
}
async update(
current: CompositionDraft,
request: PatchCompositionDraftStoreRequest,
updatedAt: Date,
): Promise<CompositionDraft> {
const merged = mergedDraft(current, request)
const [row] = await this.transaction
.update(compositionDrafts)
.set({
repositoryProfileRevisionId: merged.repositoryProfileRevisionId,
inputJson: merged.inputs,
scopeOverrideJson: merged.scopeOverrides,
policyOverrideJson: merged.policyOverrides,
autonomyLevel: merged.autonomyLevel,
workMode: merged.workMode,
outputFormat: merged.outputFormat,
lastRenderDigest: merged.lastRenderDigest,
revision: current.revision + 1,
updatedAt,
})
.where(
and(
eq(compositionDrafts.workspaceId, current.workspaceId),
eq(compositionDrafts.id, current.id),
eq(compositionDrafts.revision, current.revision),
),
)
.returning()
if (!row)
throw new Error('Locked composition draft update lost its CAS revision')
return mapRow(row)
}
}
export class DrizzleCompositionDraftTransactionRunner implements CompositionDraftTransactionRunner {
constructor(private readonly database: Database = getDatabase()) {}
run<T>(
work: (transaction: CompositionDraftTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction((transaction) =>
work(new DrizzleCompositionDraftTransaction(transaction)),
)
}
}
export class DrizzleCompositionDraftStore implements CompositionDraftStore {
constructor(
private readonly database: Database = getDatabase(),
private readonly runner: CompositionDraftTransactionRunner = new DrizzleCompositionDraftTransactionRunner(
database,
),
private readonly now: () => Date = () => new Date(),
) {}
create(
request: CreateCompositionDraftStoreRequest,
): Promise<CompositionDraft | null> {
return this.runner.run(async (transaction) => {
const accessible = await transaction.referencesAreAccessible(
request.workspaceId,
request.playbookVersionId,
request.repositoryProfileRevisionId,
)
return accessible ? transaction.insert(request) : null
})
}
async findByIdForWorkspace(
workspaceId: string,
draftId: string,
): Promise<CompositionDraft | null> {
const [row] = await this.database
.select()
.from(compositionDrafts)
.where(
and(
eq(compositionDrafts.workspaceId, workspaceId),
eq(compositionDrafts.id, draftId),
),
)
.limit(1)
return row ? mapRow(row) : null
}
patchWithRevision(
request: PatchCompositionDraftStoreRequest,
): Promise<PatchCompositionDraftStoreResult | null> {
return this.runner.run(async (transaction) => {
const current = await transaction.lockByIdForWorkspace(
request.workspaceId,
request.draftId,
)
if (!current) return null
if (current.revision !== request.expectedRevision) {
throw new DomainError(
'composition_draft_conflict',
'Composition draft changed since it was read',
{
currentRevision: current.revision,
currentEtag: `"draft:${current.revision}"`,
recovery: 'reload-and-review',
},
)
}
const candidate = mergedDraft(current, request)
if (sameMutableState(current, candidate)) {
return { draft: current, changed: false }
}
const referencesAccessible = await transaction.referencesAreAccessible(
request.workspaceId,
current.playbookVersionId,
candidate.repositoryProfileRevisionId,
)
if (!referencesAccessible) return null
return {
draft: await transaction.update(current, request, this.now()),
changed: true,
}
})
}
}
@@ -0,0 +1,228 @@
import {
applyRepositoryProfileServerMetadata,
type RepositoryProfile,
} from '@devrunbook/repository-intel'
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzleCompositionSourceReader } from './composition-source-reader'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function profile(name: string): RepositoryProfile {
return applyRepositoryProfileServerMetadata(
{
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: { name, revision: 1, source: 'manual' },
spec: {
repositoryType: 'single-app',
stack: {
languages: ['TypeScript'],
frameworks: [],
packageManagers: ['pnpm'],
databases: ['PostgreSQL'],
deploymentTypes: ['Docker'],
testFrameworks: ['Vitest'],
},
commands: [],
paths: {
applicationRoots: ['apps/web'],
testRoots: ['tests'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['runtime'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'reversible-only',
documentationRequired: true,
networkAccess: 'forbidden',
productionDataAccess: 'forbidden',
},
},
},
1,
)
}
describe.skipIf(!databaseIntegration)(
'DrizzleCompositionSourceReader integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
const suffix = randomUUID()
const builtInSlug = `composition-built-in-${suffix}`
const privateSlug = `composition-private-${suffix}`
const draftSlug = `composition-draft-${suffix}`
let builtInPlaybookId: string
let builtInVersionId: string
let privateVersionId: string
let profileRevisionId: string
let corruptRevisionId: string
let reader: DrizzleCompositionSourceReader
async function insertPlaybook(input: {
workspaceId: string | null
slug: string
source: 'built_in' | 'private'
published: boolean
}) {
const sql = getSqlClient()
const [playbook] = await sql<{ id: string }[]>`
insert into playbooks (
workspace_id, logical_id, slug, namespace, source_type
) values (
${input.workspaceId}, ${input.slug}, ${input.slug},
${input.source === 'built_in' ? 'devrunbook' : `private-${input.workspaceId}`},
${input.source}
) returning id
`
const manifest = {
metadata: {
slug: input.slug,
version: '1.0.0',
title: 'Composition source fixture',
},
spec: { intent: { outcome: 'Verify source visibility.' } },
}
const [version] = await sql<{ id: string }[]>`
insert into playbook_versions (
playbook_id, semantic_version, lifecycle, package_api_version,
title, summary, category, risk_tier, package_json, template_text,
content_digest, published_at, created_by
) values (
${playbook!.id}, '1.0.0', 'validated', 'devrunbook.io/v1alpha1',
'Composition fixture', 'Composition fixture', 'testing', 'low',
${JSON.stringify(manifest)}::jsonb, '# Task', ${'a'.repeat(64)},
${input.published ? '2026-07-27T12:00:00.000Z' : null},
${userId}
) returning id
`
return { playbookId: playbook!.id, versionId: version!.id }
}
async function insertProfile(
document: RepositoryProfile | object,
digest: string,
) {
const sql = getSqlClient()
const [repository] = await sql<{ id: string }[]>`
insert into repositories (workspace_id, display_name, source_type)
values (${workspaceA}, ${`Composition repository ${randomUUID()}`}, 'manual')
returning id
`
const [revision] = await sql<{ id: string }[]>`
insert into repository_profile_revisions (
repository_id, revision_number, profile_json, content_digest, created_by
) values (${repository!.id}, 1, ${JSON.stringify(document)}::jsonb, ${digest}, ${userId})
returning id
`
return revision!.id
}
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`composition-source-${userId}@example.invalid`},
'Composition source integration', 'not-a-real-password-hash',
'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type)
values
(${workspaceA}, 'Composition source A', 'team'),
(${workspaceB}, 'Composition source B', 'team')
`
const builtIn = await insertPlaybook({
workspaceId: null,
slug: builtInSlug,
source: 'built_in',
published: true,
})
builtInPlaybookId = builtIn.playbookId
builtInVersionId = builtIn.versionId
privateVersionId = (
await insertPlaybook({
workspaceId: workspaceA,
slug: privateSlug,
source: 'private',
published: true,
})
).versionId
await insertPlaybook({
workspaceId: workspaceA,
slug: draftSlug,
source: 'private',
published: false,
})
const validProfile = profile('Composition source profile')
profileRevisionId = await insertProfile(
validProfile,
validProfile.metadata.contentDigest!,
)
corruptRevisionId = await insertProfile({}, 'b'.repeat(64))
reader = new DrizzleCompositionSourceReader()
})
afterAll(async () => {
const sql = getSqlClient()
if (builtInPlaybookId) {
await sql`delete from playbooks where id = ${builtInPlaybookId}`
}
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('shares built-ins but isolates exact private and unpublished versions', async () => {
await expect(
reader.findPublishedPlaybookVersion(workspaceA, builtInSlug, '1.0.0'),
).resolves.toMatchObject({ id: builtInVersionId, slug: builtInSlug })
await expect(
reader.findPublishedPlaybookVersion(workspaceB, builtInSlug, '1.0.0'),
).resolves.toMatchObject({ id: builtInVersionId })
await expect(
reader.findPublishedPlaybookVersion(workspaceA, privateSlug, '1.0.0'),
).resolves.toMatchObject({ id: privateVersionId })
await expect(
reader.findPublishedPlaybookVersion(workspaceB, privateSlug, '1.0.0'),
).resolves.toBeNull()
await expect(
reader.findPublishedPlaybookVersion(workspaceA, draftSlug, '1.0.0'),
).resolves.toBeNull()
})
it('resolves persisted version IDs without accepting cross-workspace IDs', async () => {
await expect(
reader.findPublishedPlaybookVersionById(workspaceA, privateVersionId),
).resolves.toMatchObject({ id: privateVersionId, slug: privateSlug })
await expect(
reader.findPublishedPlaybookVersionById(workspaceB, privateVersionId),
).resolves.toBeNull()
})
it('returns only workspace-owned profile revisions and validates their bytes', async () => {
await expect(
reader.findRepositoryProfileRevision(workspaceA, profileRevisionId),
).resolves.toMatchObject({ id: profileRevisionId, revisionNumber: 1 })
await expect(
reader.findRepositoryProfileRevision(workspaceB, profileRevisionId),
).resolves.toBeNull()
await expect(
reader.findRepositoryProfileRevision(workspaceA, corruptRevisionId),
).rejects.toThrow('failed integrity validation')
})
},
)
@@ -0,0 +1,215 @@
import {
applyRepositoryProfileServerMetadata,
type RepositoryProfile,
} from '@devrunbook/repository-intel'
import { describe, expect, it, vi } from 'vitest'
import type { SafePlaybookVersionProjection } from '../playbooks/playbook-catalog'
import type { repositoryProfileRevisions } from '../schema'
import {
DrizzleCompositionSourceReader,
type CompositionPlaybookCatalog,
type CompositionPlaybookVersionIdentityRowSource,
type CompositionProfileRevisionRowSource,
} from './composition-source-reader'
type RevisionRow = typeof repositoryProfileRevisions.$inferSelect
const workspaceId = '00000000-0000-4000-8000-000000000001'
const revisionId = '00000000-0000-4000-8000-000000000002'
function profile(): RepositoryProfile {
return applyRepositoryProfileServerMetadata(
{
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: { name: 'Source fixture', revision: 1, source: 'manual' },
spec: {
repositoryType: 'single-app',
stack: {
languages: ['TypeScript'],
frameworks: [],
packageManagers: ['pnpm'],
databases: ['PostgreSQL'],
deploymentTypes: ['Docker'],
testFrameworks: ['Vitest'],
},
commands: [],
paths: {
applicationRoots: ['apps/web'],
testRoots: ['tests'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['runtime'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'reversible-only',
documentationRequired: true,
networkAccess: 'forbidden',
productionDataAccess: 'forbidden',
},
},
},
1,
)
}
function version(): SafePlaybookVersionProjection {
return {
id: '00000000-0000-4000-8000-000000000003',
playbookId: '00000000-0000-4000-8000-000000000004',
version: '1.2.3',
digest: 'a'.repeat(64),
lifecycle: 'validated',
manifest: {
metadata: {
slug: 'bounded-change',
version: '1.2.3',
title: 'Bounded change',
},
spec: { intent: { outcome: 'Make a bounded change.' } },
},
template: '# Task\n',
quality: {},
publishedAt: new Date('2026-07-27T12:00:00.000Z'),
}
}
function revision(document = profile()): RevisionRow {
return {
id: revisionId,
repositoryId: '00000000-0000-4000-8000-000000000005',
revisionNumber: 1,
profileJson: document,
sourceSnapshotId: null,
contentDigest: document.metadata.contentDigest!,
createdBy: '00000000-0000-4000-8000-000000000006',
createdAt: new Date('2026-07-27T12:00:00.000Z'),
}
}
function dependencies(
selectedVersion: SafePlaybookVersionProjection | null = version(),
selectedRevision: RevisionRow | null = revision(),
) {
const catalog: CompositionPlaybookCatalog = {
findVersionBySlug: vi.fn(async () => selectedVersion),
}
const rows: CompositionProfileRevisionRowSource = {
findRevisionForWorkspace: vi.fn(async () => selectedRevision),
}
const identities: CompositionPlaybookVersionIdentityRowSource = {
findPublishedIdentityForWorkspace: vi.fn(async () => ({
slug: 'bounded-change',
version: '1.2.3',
})),
}
return {
catalog,
rows,
identities,
reader: new DrizzleCompositionSourceReader(catalog, rows, identities),
}
}
describe('DrizzleCompositionSourceReader', () => {
it('uses exact published catalog semantics with workspace scope', async () => {
const target = dependencies()
await expect(
target.reader.findPublishedPlaybookVersion(
workspaceId,
'bounded-change',
'1.2.3',
),
).resolves.toEqual({
id: version().id,
slug: 'bounded-change',
version: '1.2.3',
digest: 'a'.repeat(64),
lifecycle: 'validated',
manifest: version().manifest,
template: '# Task\n',
})
expect(target.catalog.findVersionBySlug).toHaveBeenCalledWith(
'bounded-change',
'1.2.3',
undefined,
{ workspaceId },
)
})
it('returns generic null for missing or inaccessible playbooks', async () => {
const target = dependencies(null)
await expect(
target.reader.findPublishedPlaybookVersion(
workspaceId,
'bounded-change',
'1.2.3',
),
).resolves.toBeNull()
})
it('resolves a persisted version ID through its governed visible identity', async () => {
const target = dependencies()
await expect(
target.reader.findPublishedPlaybookVersionById(workspaceId, version().id),
).resolves.toMatchObject({ id: version().id, slug: 'bounded-change' })
expect(
target.identities.findPublishedIdentityForWorkspace,
).toHaveBeenCalledWith(workspaceId, version().id)
expect(target.catalog.findVersionBySlug).toHaveBeenCalledWith(
'bounded-change',
'1.2.3',
undefined,
{ workspaceId },
)
})
it('returns generic null for an inaccessible persisted version ID', async () => {
const target = dependencies()
vi.mocked(
target.identities.findPublishedIdentityForWorkspace,
).mockResolvedValue(null)
await expect(
target.reader.findPublishedPlaybookVersionById(workspaceId, version().id),
).resolves.toBeNull()
expect(target.catalog.findVersionBySlug).not.toHaveBeenCalled()
})
it('loads an exact immutable profile revision through workspace scoping', async () => {
const target = dependencies()
await expect(
target.reader.findRepositoryProfileRevision(workspaceId, revisionId),
).resolves.toMatchObject({
id: revisionId,
revisionNumber: 1,
contentDigest: profile().metadata.contentDigest,
profile: { metadata: { revision: 1 } },
})
expect(target.rows.findRevisionForWorkspace).toHaveBeenCalledWith(
workspaceId,
revisionId,
)
})
it('returns generic null for a missing or cross-workspace revision', async () => {
const target = dependencies(version(), null)
await expect(
target.reader.findRepositoryProfileRevision(workspaceId, revisionId),
).resolves.toBeNull()
})
it('fails closed when stored profile bytes and digest disagree', async () => {
const target = dependencies(version(), {
...revision(),
contentDigest: 'f'.repeat(64),
})
await expect(
target.reader.findRepositoryProfileRevision(workspaceId, revisionId),
).rejects.toThrow('failed integrity validation')
})
})
@@ -0,0 +1,190 @@
import type {
CompositionPlaybookVersion,
CompositionRepositoryProfileRevision,
CompositionSourceReader,
} from '@devrunbook/application'
import {
validateRepositoryProfile,
type RepositoryProfile,
} from '@devrunbook/repository-intel'
import { and, eq, inArray, isNotNull, or } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
DrizzlePlaybookCatalog,
type PlaybookCatalogScope,
type PlaybookSource,
type SafePlaybookVersionProjection,
} from '../playbooks/playbook-catalog'
import {
playbooks,
playbookVersions,
repositories,
repositoryProfileRevisions,
} from '../schema'
type Database = ReturnType<typeof getDatabase>
type RevisionRow = typeof repositoryProfileRevisions.$inferSelect
export interface CompositionPlaybookCatalog {
findVersionBySlug(
slug: string,
version: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<SafePlaybookVersionProjection | null>
}
export interface CompositionProfileRevisionRowSource {
findRevisionForWorkspace(
workspaceId: string,
revisionId: string,
): Promise<RevisionRow | null>
}
export interface CompositionPlaybookVersionIdentityRowSource {
findPublishedIdentityForWorkspace(
workspaceId: string,
versionId: string,
): Promise<{ readonly slug: string; readonly version: string } | null>
}
export class DrizzleCompositionPlaybookVersionIdentityRowSource implements CompositionPlaybookVersionIdentityRowSource {
constructor(private readonly database: Database = getDatabase()) {}
async findPublishedIdentityForWorkspace(
workspaceId: string,
versionId: string,
): Promise<{ readonly slug: string; readonly version: string } | null> {
const [row] = await this.database
.select({
slug: playbooks.slug,
version: playbookVersions.semanticVersion,
})
.from(playbookVersions)
.innerJoin(playbooks, eq(playbookVersions.playbookId, playbooks.id))
.where(
and(
eq(playbookVersions.id, versionId),
isNotNull(playbookVersions.publishedAt),
inArray(playbooks.sourceType, ['built_in', 'private', 'imported']),
or(
eq(playbooks.sourceType, 'built_in'),
eq(playbooks.workspaceId, workspaceId),
),
),
)
.limit(1)
return row ?? null
}
}
export class DrizzleCompositionProfileRevisionRowSource implements CompositionProfileRevisionRowSource {
constructor(private readonly database: Database = getDatabase()) {}
async findRevisionForWorkspace(
workspaceId: string,
revisionId: string,
): Promise<RevisionRow | null> {
const [row] = await this.database
.select({ revision: repositoryProfileRevisions })
.from(repositoryProfileRevisions)
.innerJoin(
repositories,
eq(repositoryProfileRevisions.repositoryId, repositories.id),
)
.where(
and(
eq(repositoryProfileRevisions.id, revisionId),
eq(repositories.workspaceId, workspaceId),
),
)
.limit(1)
return row?.revision ?? null
}
}
function profileRevision(
row: RevisionRow,
): CompositionRepositoryProfileRevision {
const validation = validateRepositoryProfile(row.profileJson)
if (
!validation.valid ||
validation.contentDigest !== row.contentDigest ||
validation.profile.metadata.contentDigest !== row.contentDigest ||
validation.profile.metadata.revision !== row.revisionNumber
) {
throw new Error(
'Stored composition repository profile failed integrity validation',
)
}
return {
id: row.id,
repositoryId: row.repositoryId,
revisionNumber: row.revisionNumber,
contentDigest: row.contentDigest,
profile: validation.profile as RepositoryProfile,
}
}
export class DrizzleCompositionSourceReader implements CompositionSourceReader {
constructor(
private readonly playbookCatalog: CompositionPlaybookCatalog = new DrizzlePlaybookCatalog(),
private readonly profileRows: CompositionProfileRevisionRowSource = new DrizzleCompositionProfileRevisionRowSource(),
private readonly playbookIdentities: CompositionPlaybookVersionIdentityRowSource = new DrizzleCompositionPlaybookVersionIdentityRowSource(),
) {}
async findPublishedPlaybookVersion(
workspaceId: string,
slug: string,
version: string,
): Promise<CompositionPlaybookVersion | null> {
const found = await this.playbookCatalog.findVersionBySlug(
slug,
version,
undefined,
{ workspaceId },
)
return found
? {
id: found.id,
slug,
version: found.version,
digest: found.digest,
lifecycle: found.lifecycle,
manifest: found.manifest,
template: found.template,
}
: null
}
async findRepositoryProfileRevision(
workspaceId: string,
revisionId: string,
): Promise<CompositionRepositoryProfileRevision | null> {
const row = await this.profileRows.findRevisionForWorkspace(
workspaceId,
revisionId,
)
return row ? profileRevision(row) : null
}
/** Resolves a persisted draft reference without trusting client slug/version. */
async findPublishedPlaybookVersionById(
workspaceId: string,
versionId: string,
): Promise<CompositionPlaybookVersion | null> {
const identity =
await this.playbookIdentities.findPublishedIdentityForWorkspace(
workspaceId,
versionId,
)
if (!identity) return null
const version = await this.findPublishedPlaybookVersion(
workspaceId,
identity.slug,
identity.version,
)
return version?.id === versionId ? version : null
}
}
@@ -0,0 +1,292 @@
import { createHash, randomUUID } from 'node:crypto'
import {
applyRepositoryProfileServerMetadata,
type RepositoryProfile,
} from '@devrunbook/repository-intel'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzleGeneratedRunStore } from './generated-run-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function profile(name: string): RepositoryProfile {
return applyRepositoryProfileServerMetadata(
{
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: { name, revision: 1, source: 'manual' },
spec: {
repositoryType: 'single-app',
stack: {
languages: ['TypeScript'],
frameworks: [],
packageManagers: ['pnpm'],
databases: ['PostgreSQL'],
deploymentTypes: ['Docker'],
testFrameworks: ['Vitest'],
},
commands: [],
paths: {
applicationRoots: ['apps/web'],
testRoots: ['tests'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['runtime'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'reversible-only',
documentationRequired: true,
networkAccess: 'forbidden',
productionDataAccess: 'forbidden',
},
},
},
1,
)
}
describe.skipIf(!databaseIntegration)(
'generated-run history integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
const repositoryA = randomUUID()
const repositoryB = randomUUID()
const alphaSlug = `history-alpha-${randomUUID()}`
const betaSlug = `history-beta-${randomUUID()}`
let alphaVersionId: string
let betaVersionId: string
let profileRevisionA: string
let profileRevisionB: string
let store: DrizzleGeneratedRunStore
const runIds = [randomUUID(), randomUUID(), randomUUID()]
async function insertVersion(slug: string) {
const sql = getSqlClient()
const [playbook] = await sql<{ id: string }[]>`
insert into playbooks (
workspace_id, logical_id, slug, namespace, source_type
) values (
${workspaceA}, ${slug}, ${slug}, ${`private-${workspaceA}`}, 'private'
) returning id
`
const manifest = {
metadata: { slug, version: '1.0.0', title: 'History fixture' },
spec: { intent: { outcome: 'Verify history.' } },
}
const [version] = await sql<{ id: string }[]>`
insert into playbook_versions (
playbook_id, semantic_version, lifecycle, package_api_version,
title, summary, category, risk_tier, package_json, template_text,
content_digest, published_at, created_by
) values (
${playbook!.id}, '1.0.0', 'validated', 'devrunbook.io/v1alpha1',
'History fixture', 'History fixture', 'testing', 'low',
${JSON.stringify(manifest)}::jsonb, '# Task', ${'a'.repeat(64)},
'2026-07-27T12:00:00.000Z', ${userId}
) returning id
`
return version!.id
}
async function insertRun(input: {
id: string
workspaceId: string
playbookVersionId: string
slug: string
generatedAt: string
repository?: {
id: string
revisionId: string
document: RepositoryProfile
}
corruptRepository?: boolean
}) {
const sql = getSqlClient()
const prompt = `# ${input.id}\n`
const digest = createHash('sha256').update(prompt, 'utf8').digest('hex')
const repositorySnapshot = input.corruptRepository
? {
revisionId: input.repository!.revisionId,
repositoryId: input.repository!.id,
revisionNumber: 1,
contentDigest: input.repository!.document.metadata.contentDigest,
profile: {},
}
: input.repository
? {
revisionId: input.repository.revisionId,
repositoryId: input.repository.id,
revisionNumber: 1,
contentDigest: input.repository.document.metadata.contentDigest,
profile: input.repository.document,
}
: null
await sql`
insert into generated_runs (
id, workspace_id, playbook_version_id, playbook_snapshot_json,
repository_profile_snapshot_json, normalized_input_json,
policy_snapshot_json, provenance_json, lint_result_json,
rendered_prompt, render_digest, idempotency_key, generated_by,
generated_at
) values (
${input.id}, ${input.workspaceId}, ${input.playbookVersionId},
${JSON.stringify({ slug: input.slug })}::jsonb,
${repositorySnapshot === null ? null : JSON.stringify(repositorySnapshot)}::jsonb,
'{}'::jsonb, '{}'::jsonb, '[]'::jsonb,
${JSON.stringify({ exportReadiness: 'ready', findings: [] })}::jsonb,
${prompt}, ${digest}, ${randomUUID()}, ${userId}, ${input.generatedAt}
)
`
}
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`run-history-${userId}@example.invalid`}, 'Run history',
'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type)
values
(${workspaceA}, 'Run history A', 'team'),
(${workspaceB}, 'Run history B', 'team')
`
await sql`
insert into repositories (id, workspace_id, display_name, source_type)
values
(${repositoryA}, ${workspaceA}, 'Repository A', 'manual'),
(${repositoryB}, ${workspaceA}, 'Repository B', 'manual')
`
alphaVersionId = await insertVersion(alphaSlug)
betaVersionId = await insertVersion(betaSlug)
const profileA = profile('Repository A')
const profileB = profile('Repository B')
const [revisionA] = await sql<{ id: string }[]>`
insert into repository_profile_revisions (
repository_id, revision_number, profile_json, content_digest, created_by
) values (
${repositoryA}, 1, ${JSON.stringify(profileA)}::jsonb,
${profileA.metadata.contentDigest!}, ${userId}
) returning id
`
const [revisionB] = await sql<{ id: string }[]>`
insert into repository_profile_revisions (
repository_id, revision_number, profile_json, content_digest, created_by
) values (
${repositoryB}, 1, ${JSON.stringify(profileB)}::jsonb,
${profileB.metadata.contentDigest!}, ${userId}
) returning id
`
profileRevisionA = revisionA!.id
profileRevisionB = revisionB!.id
await insertRun({
id: runIds[0]!,
workspaceId: workspaceA,
playbookVersionId: alphaVersionId,
slug: alphaSlug,
generatedAt: '2026-07-27T12:00:00.000Z',
repository: {
id: repositoryA,
revisionId: profileRevisionA,
document: profileA,
},
})
await insertRun({
id: runIds[1]!,
workspaceId: workspaceA,
playbookVersionId: alphaVersionId,
slug: alphaSlug,
generatedAt: '2026-07-27T12:00:00.000Z',
})
await insertRun({
id: runIds[2]!,
workspaceId: workspaceA,
playbookVersionId: betaVersionId,
slug: betaSlug,
generatedAt: '2026-07-26T12:00:00.000Z',
repository: {
id: repositoryB,
revisionId: profileRevisionB,
document: profileB,
},
})
store = new DrizzleGeneratedRunStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('paginates generatedAt plus ID without duplicates or omissions', async () => {
const seen: string[] = []
let cursor: string | null = null
do {
const page = await store.listForWorkspace(workspaceA, {
limit: 1,
cursor,
})
seen.push(...page.items.map((item) => item.id))
cursor = page.nextCursor
} while (cursor)
const tied = runIds
.slice(0, 2)
.sort((left, right) => left.localeCompare(right, 'en'))
expect(seen).toEqual([...tied, runIds[2]])
expect(new Set(seen).size).toBe(3)
await expect(store.listForWorkspace(workspaceB, {})).resolves.toEqual({
items: [],
nextCursor: null,
})
})
it('filters playbooks relationally and repositories through validated snapshots', async () => {
const byPlaybook = await store.listForWorkspace(workspaceA, {
playbookSlug: alphaSlug,
})
expect(byPlaybook.items.map((item) => item.id).sort()).toEqual(
runIds.slice(0, 2).sort(),
)
const byRepository = await store.listForWorkspace(workspaceA, {
repositoryId: repositoryA,
})
expect(byRepository.items.map((item) => item.id)).toEqual([runIds[0]])
})
it('fails closed when a repository filter encounters a corrupt frozen snapshot', async () => {
const corruptId = randomUUID()
await insertRun({
id: corruptId,
workspaceId: workspaceA,
playbookVersionId: alphaVersionId,
slug: alphaSlug,
generatedAt: '2026-07-28T12:00:00.000Z',
repository: {
id: repositoryA,
revisionId: profileRevisionA,
document: profile('Repository A'),
},
corruptRepository: true,
})
await expect(
store.listForWorkspace(workspaceA, { repositoryId: repositoryA }),
).rejects.toMatchObject({ code: 'generated_run_persistence_corrupt' })
})
},
)
@@ -0,0 +1,183 @@
import type {
GeneratedRun,
StoreGeneratedRunResult,
} from '@devrunbook/application'
import { describe, expect, it } from 'vitest'
import {
assertGeneratedRunPersistenceIntegrity,
decodeGeneratedRunCursor,
DrizzleGeneratedRunStore,
encodeGeneratedRunCursor,
type GeneratedRunTransaction,
type GeneratedRunTransactionRunner,
} from './generated-run-store'
function run(overrides: Partial<GeneratedRun> = {}): GeneratedRun {
return {
id: '00000000-0000-4000-8000-000000000101',
workspaceId: '00000000-0000-4000-8000-000000000102',
generatedBy: '00000000-0000-4000-8000-000000000103',
sourceDraftId: null,
playbookVersionId: '00000000-0000-4000-8000-000000000104',
snapshots: {
playbook: { slug: 'root-cause-bugfix' },
repositoryProfile: null,
normalizedInput: { problem: 'broken result' },
policy: { autonomy: 'verify' },
provenance: [],
},
lint: { exportReadiness: 'ready', findings: [] },
renderedPrompt: '# Task\n',
renderDigest: 'a'.repeat(64),
idempotencyKey: 'request-1',
generatedAt: '2026-07-27T12:00:00.000Z',
...overrides,
}
}
class MemoryGeneratedRunRunner implements GeneratedRunTransactionRunner {
stored: GeneratedRun | null = null
locks: string[] = []
audits: GeneratedRun[] = []
async run<T>(
work: (transaction: GeneratedRunTransaction) => Promise<T>,
): Promise<T> {
const transaction: GeneratedRunTransaction = {
acquireIdempotencyLock: async (workspaceId, key) => {
this.locks.push(`${workspaceId}:${key}`)
},
findByIdempotencyKey: async (workspaceId, key) =>
this.stored?.workspaceId === workspaceId &&
this.stored.idempotencyKey === key
? this.stored
: null,
insert: async (candidate) => {
this.stored = candidate
return candidate
},
appendCreationAudit: async (candidate) => {
this.audits.push(candidate)
},
}
return work(transaction)
}
}
describe('DrizzleGeneratedRunStore', () => {
it('serializes creation and returns an exact retry without inserting again', async () => {
const runner = new MemoryGeneratedRunRunner()
const store = new DrizzleGeneratedRunStore(runner)
const candidate = run()
await expect(store.createIdempotently(candidate)).resolves.toEqual({
run: candidate,
created: true,
} satisfies StoreGeneratedRunResult)
await expect(store.createIdempotently(candidate)).resolves.toEqual({
run: candidate,
created: false,
} satisfies StoreGeneratedRunResult)
expect(runner.locks).toHaveLength(2)
expect(runner.audits).toEqual([candidate])
})
it('rejects reuse of the key for different immutable input', async () => {
const runner = new MemoryGeneratedRunRunner()
const store = new DrizzleGeneratedRunStore(runner)
await store.createIdempotently(run())
await expect(
store.createIdempotently(
run({
snapshots: { ...run().snapshots, policy: { autonomy: 'repair' } },
}),
),
).rejects.toMatchObject({ code: 'generated_run_idempotency_conflict' })
})
it.each([
['digest', { renderDigest: '0'.repeat(64) }],
['idempotency key', { idempotencyKey: '' }],
[
'snapshot JSON',
{
snapshots: {
...run().snapshots,
normalizedInput: { invalid: undefined },
},
},
],
[
'lint JSON',
{
lint: { exportReadiness: 'ready', findings: [{ severity: 'error' }] },
},
],
[
'repository snapshot identity',
{
snapshots: {
...run().snapshots,
repositoryProfile: {
repositoryId: '00000000-0000-4000-8000-000000000105',
},
},
},
],
])('rejects corrupt persisted %s', (_, changes) => {
expect(() =>
assertGeneratedRunPersistenceIntegrity({
...run(),
...(changes as Partial<GeneratedRun>),
}),
).toThrowError(
expect.objectContaining({ code: 'generated_run_persistence_corrupt' }),
)
})
it('round-trips the stable generatedAt and ID cursor', () => {
const cursor = {
generatedAt: '2026-07-27T12:00:00.000Z',
id: '00000000-0000-4000-8000-000000000101',
}
expect(decodeGeneratedRunCursor(encodeGeneratedRunCursor(cursor))).toEqual(
cursor,
)
})
it.each([
'',
'not-base64-json',
Buffer.from('{}').toString('base64url'),
Buffer.from(
JSON.stringify({ generatedAt: 'invalid', id: run().id }),
).toString('base64url'),
Buffer.from(
JSON.stringify({ generatedAt: run().generatedAt, id: 'not-a-uuid' }),
).toString('base64url'),
])('rejects malformed history cursor %s', (cursor) => {
expect(() => decodeGeneratedRunCursor(cursor)).toThrowError(
expect.objectContaining({ code: 'generated_run_cursor_invalid' }),
)
})
it('rejects invalid history limits and filters before database access', async () => {
const store = new DrizzleGeneratedRunStore(new MemoryGeneratedRunRunner())
await expect(
store.listForWorkspace(run().workspaceId, { limit: 0 }),
).rejects.toMatchObject({ code: 'generated_run_list_limit_invalid' })
await expect(
store.listForWorkspace(run().workspaceId, { playbookSlug: '../other' }),
).rejects.toMatchObject({ code: 'generated_run_filter_invalid' })
await expect(
store.listForWorkspace(run().workspaceId, {
repositoryId: 'not-a-uuid',
}),
).rejects.toMatchObject({ code: 'generated_run_filter_invalid' })
await expect(
store.listForWorkspace(run().workspaceId, { cursor: 'invalid' }),
).rejects.toMatchObject({ code: 'generated_run_cursor_invalid' })
})
})
@@ -0,0 +1,502 @@
import {
computeRenderDigest,
type GeneratedRun,
type GeneratedRunHistoryQuery,
type GeneratedRunHistoryReader,
type GeneratedRunPage,
type GeneratedRunStore,
type GeneratedRunReader,
type StoreGeneratedRunResult,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { validateRepositoryProfile } from '@devrunbook/repository-intel'
import { isDeepStrictEqual } from 'node:util'
import { and, asc, desc, eq, gt, lt, or, sql, type SQL } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
generatedRuns,
playbooks,
playbookVersions,
repositories,
repositoryProfileRevisions,
} from '../schema'
type GeneratedRunRow = typeof generatedRuns.$inferSelect
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
interface GeneratedRunCursor {
readonly generatedAt: string
readonly id: string
}
export interface GeneratedRunTransaction {
acquireIdempotencyLock(workspaceId: string, key: string): Promise<void>
findByIdempotencyKey(
workspaceId: string,
key: string,
): Promise<GeneratedRun | null>
insert(run: GeneratedRun): Promise<GeneratedRun>
appendCreationAudit(run: GeneratedRun): Promise<void>
}
export interface GeneratedRunTransactionRunner {
run<T>(work: (transaction: GeneratedRunTransaction) => Promise<T>): Promise<T>
}
function isJsonValue(value: unknown): boolean {
if (value === null || typeof value === 'string' || typeof value === 'boolean')
return true
if (typeof value === 'number') return Number.isFinite(value)
if (Array.isArray(value)) return value.every(isJsonValue)
if (typeof value !== 'object') return false
const prototype = Object.getPrototypeOf(value)
return (
(prototype === Object.prototype || prototype === null) &&
Object.values(value).every(isJsonValue)
)
}
function validLint(value: unknown): value is GeneratedRun['lint'] {
if (value === null || typeof value !== 'object' || Array.isArray(value))
return false
const lint = value as Record<string, unknown>
if (!['ready', 'warning', 'blocked'].includes(String(lint.exportReadiness)))
return false
if (!Array.isArray(lint.findings)) return false
return lint.findings.every((finding) => {
if (
finding === null ||
typeof finding !== 'object' ||
Array.isArray(finding)
)
return false
const item = finding as Record<string, unknown>
return (
typeof item.ruleId === 'string' &&
item.ruleId.length > 0 &&
['info', 'warning', 'error'].includes(String(item.severity)) &&
typeof item.message === 'string' &&
typeof item.source === 'string' &&
(item.controlPath === undefined ||
item.controlPath === null ||
typeof item.controlPath === 'string')
)
})
}
function validRepositorySnapshot(
value: GeneratedRun['snapshots']['repositoryProfile'],
): boolean {
if (value === null) return true
// Milestone 0 stored the canonical RepositoryProfile document directly.
// Keep those immutable historical records readable while requiring all new
// M4 snapshots to carry their revision and repository identity wrapper.
if ('apiVersion' in value && 'kind' in value) {
return validateRepositoryProfile(value).valid
}
const revisionId = value.revisionId
const repositoryId = value.repositoryId
const revisionNumber = value.revisionNumber
const contentDigest = value.contentDigest
const profile = value.profile
if (
typeof revisionId !== 'string' ||
typeof repositoryId !== 'string' ||
!Number.isSafeInteger(revisionNumber) ||
(revisionNumber as number) < 1 ||
typeof contentDigest !== 'string' ||
!/^[0-9a-f]{64}$/u.test(contentDigest) ||
profile === null ||
typeof profile !== 'object' ||
Array.isArray(profile)
) {
return false
}
const validation = validateRepositoryProfile(profile)
return (
validation.valid &&
validation.contentDigest === contentDigest &&
validation.profile.metadata.contentDigest === contentDigest &&
validation.profile.metadata.revision === revisionNumber
)
}
export function assertGeneratedRunPersistenceIntegrity(
run: GeneratedRun,
): void {
const validSnapshots =
isJsonValue(run.snapshots.playbook) &&
validRepositorySnapshot(run.snapshots.repositoryProfile) &&
isJsonValue(run.snapshots.normalizedInput) &&
isJsonValue(run.snapshots.policy) &&
Array.isArray(run.snapshots.provenance) &&
isJsonValue(run.snapshots.provenance)
const validDigest =
/^[0-9a-f]{64}$/u.test(run.renderDigest) &&
computeRenderDigest(run.renderedPrompt) === run.renderDigest
const validIdempotencyKey =
run.idempotencyKey.length > 0 &&
run.idempotencyKey.length <= 255 &&
run.idempotencyKey.trim() === run.idempotencyKey
if (
!validSnapshots ||
!validLint(run.lint) ||
!validDigest ||
!validIdempotencyKey
) {
throw new DomainError(
'generated_run_persistence_corrupt',
'Persisted generated-run data failed its integrity contract',
)
}
}
function invalidCursor(): never {
throw new DomainError(
'generated_run_cursor_invalid',
'Generated-run cursor is invalid',
)
}
export function encodeGeneratedRunCursor(cursor: GeneratedRunCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
}
export function decodeGeneratedRunCursor(value: string): GeneratedRunCursor {
if (value.length === 0 || value.length > 500) invalidCursor()
try {
const decoded = JSON.parse(
Buffer.from(value, 'base64url').toString('utf8'),
) as unknown
if (
decoded === null ||
typeof decoded !== 'object' ||
Array.isArray(decoded) ||
Object.keys(decoded).length !== 2 ||
!('generatedAt' in decoded) ||
!('id' in decoded) ||
typeof decoded.generatedAt !== 'string' ||
Number.isNaN(Date.parse(decoded.generatedAt)) ||
typeof decoded.id !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
decoded.id,
)
) {
invalidCursor()
}
return {
generatedAt: new Date(decoded.generatedAt).toISOString(),
id: decoded.id,
}
} catch (error) {
if (error instanceof DomainError) throw error
invalidCursor()
}
}
function boundedLimit(value: number | undefined): number {
const limit = value ?? 50
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new DomainError(
'generated_run_list_limit_invalid',
'Generated-run list limit must be between 1 and 100',
)
}
return limit
}
function assertHistoryFilters(query: GeneratedRunHistoryQuery): void {
if (
query.playbookSlug !== undefined &&
!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(query.playbookSlug)
) {
throw new DomainError(
'generated_run_filter_invalid',
'Generated-run history filter is invalid',
)
}
if (
query.repositoryId !== undefined &&
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
query.repositoryId,
)
) {
throw new DomainError(
'generated_run_filter_invalid',
'Generated-run history filter is invalid',
)
}
}
function historyCursorPredicate(cursor: GeneratedRunCursor): SQL {
const generatedAt = new Date(cursor.generatedAt)
return or(
lt(generatedRuns.generatedAt, generatedAt),
and(
eq(generatedRuns.generatedAt, generatedAt),
gt(generatedRuns.id, cursor.id),
),
)!
}
function mapRow(row: GeneratedRunRow): GeneratedRun {
const run: GeneratedRun = {
id: row.id,
workspaceId: row.workspaceId,
generatedBy: row.generatedBy,
sourceDraftId: row.sourceDraftId,
playbookVersionId: row.playbookVersionId,
snapshots: {
playbook:
row.playbookSnapshotJson as GeneratedRun['snapshots']['playbook'],
repositoryProfile:
row.repositoryProfileSnapshotJson as GeneratedRun['snapshots']['repositoryProfile'],
normalizedInput:
row.normalizedInputJson as GeneratedRun['snapshots']['normalizedInput'],
policy: row.policySnapshotJson as GeneratedRun['snapshots']['policy'],
provenance: row.provenanceJson as GeneratedRun['snapshots']['provenance'],
},
lint: row.lintResultJson as unknown as GeneratedRun['lint'],
renderedPrompt: row.renderedPrompt,
renderDigest: row.renderDigest,
idempotencyKey: row.idempotencyKey ?? '',
generatedAt: row.generatedAt.toISOString(),
}
assertGeneratedRunPersistenceIntegrity(run)
return run
}
function sameLogicalRun(left: GeneratedRun, right: GeneratedRun): boolean {
return isDeepStrictEqual(
{
workspaceId: left.workspaceId,
generatedBy: left.generatedBy,
sourceDraftId: left.sourceDraftId,
playbookVersionId: left.playbookVersionId,
snapshots: left.snapshots,
lint: left.lint,
renderedPrompt: left.renderedPrompt,
renderDigest: left.renderDigest,
idempotencyKey: left.idempotencyKey,
},
{
workspaceId: right.workspaceId,
generatedBy: right.generatedBy,
sourceDraftId: right.sourceDraftId,
playbookVersionId: right.playbookVersionId,
snapshots: right.snapshots,
lint: right.lint,
renderedPrompt: right.renderedPrompt,
renderDigest: right.renderDigest,
idempotencyKey: right.idempotencyKey,
},
)
}
class DrizzleGeneratedRunTransaction implements GeneratedRunTransaction {
constructor(private readonly transaction: Transaction) {}
async acquireIdempotencyLock(
workspaceId: string,
key: string,
): Promise<void> {
await this.transaction.execute(sql`
select pg_advisory_xact_lock(
hashtextextended(${`devrunbook:generated-run:${workspaceId}:${key}`}, 0)
)
`)
}
async findByIdempotencyKey(
workspaceId: string,
key: string,
): Promise<GeneratedRun | null> {
const [row] = await this.transaction
.select()
.from(generatedRuns)
.where(
and(
eq(generatedRuns.workspaceId, workspaceId),
eq(generatedRuns.idempotencyKey, key),
),
)
.limit(1)
return row ? mapRow(row) : null
}
async insert(run: GeneratedRun): Promise<GeneratedRun> {
const [row] = await this.transaction
.insert(generatedRuns)
.values({
id: run.id,
workspaceId: run.workspaceId,
sourceDraftId: run.sourceDraftId,
playbookVersionId: run.playbookVersionId,
playbookSnapshotJson: run.snapshots.playbook,
repositoryProfileSnapshotJson: run.snapshots.repositoryProfile,
normalizedInputJson: run.snapshots.normalizedInput,
policySnapshotJson: run.snapshots.policy,
provenanceJson: run.snapshots.provenance,
lintResultJson: run.lint,
renderedPrompt: run.renderedPrompt,
renderDigest: run.renderDigest,
idempotencyKey: run.idempotencyKey,
generatedBy: run.generatedBy,
generatedAt: new Date(run.generatedAt),
})
.returning()
if (!row) throw new Error('Generated-run insert did not return a row')
return mapRow(row)
}
async appendCreationAudit(run: GeneratedRun): Promise<void> {
await this.transaction.insert(auditEvents).values({
occurredAt: new Date(run.generatedAt),
actorUserId: run.generatedBy,
workspaceId: run.workspaceId,
action: 'generated_run.created',
resourceType: 'generated_run',
resourceId: run.id,
outcome: 'success',
metadataJson: {
playbookVersionId: run.playbookVersionId,
renderDigest: run.renderDigest,
},
})
}
}
export class DrizzleGeneratedRunTransactionRunner implements GeneratedRunTransactionRunner {
constructor(private readonly database: Database = getDatabase()) {}
run<T>(
work: (transaction: GeneratedRunTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction((transaction) =>
work(new DrizzleGeneratedRunTransaction(transaction)),
)
}
}
export class DrizzleGeneratedRunStore
implements GeneratedRunStore, GeneratedRunReader, GeneratedRunHistoryReader
{
constructor(
private readonly runner: GeneratedRunTransactionRunner = new DrizzleGeneratedRunTransactionRunner(),
private readonly database?: Database,
) {}
createIdempotently(run: GeneratedRun): Promise<StoreGeneratedRunResult> {
return this.runner.run(async (transaction) => {
await transaction.acquireIdempotencyLock(
run.workspaceId,
run.idempotencyKey,
)
const existing = await transaction.findByIdempotencyKey(
run.workspaceId,
run.idempotencyKey,
)
if (existing) {
if (!sameLogicalRun(existing, run)) {
throw new DomainError(
'generated_run_idempotency_conflict',
'Idempotency key is already associated with different generated-task input',
)
}
return { run: existing, created: false }
}
const created = await transaction.insert(run)
await transaction.appendCreationAudit(created)
return { run: created, created: true }
})
}
async findByIdForWorkspace(
workspaceId: string,
runId: string,
): Promise<GeneratedRun | null> {
const [row] = await (this.database ?? getDatabase())
.select()
.from(generatedRuns)
.where(
and(
eq(generatedRuns.workspaceId, workspaceId),
eq(generatedRuns.id, runId),
),
)
.limit(1)
return row ? mapRow(row) : null
}
async listForWorkspace(
workspaceId: string,
query: GeneratedRunHistoryQuery,
): Promise<GeneratedRunPage> {
const limit = boundedLimit(query.limit)
assertHistoryFilters(query)
const predicates: SQL[] = [eq(generatedRuns.workspaceId, workspaceId)]
if (query.playbookSlug !== undefined) {
predicates.push(eq(playbooks.slug, query.playbookSlug))
}
if (query.cursor) {
predicates.push(
historyCursorPredicate(decodeGeneratedRunCursor(query.cursor)),
)
}
const database = this.database ?? getDatabase()
const rows = query.repositoryId
? await database
.select({ run: generatedRuns })
.from(generatedRuns)
.innerJoin(
playbookVersions,
eq(generatedRuns.playbookVersionId, playbookVersions.id),
)
.innerJoin(playbooks, eq(playbookVersions.playbookId, playbooks.id))
.innerJoin(
repositoryProfileRevisions,
sql`${repositoryProfileRevisions.id}::text = ${generatedRuns.repositoryProfileSnapshotJson}->>'revisionId'`,
)
.innerJoin(
repositories,
eq(repositoryProfileRevisions.repositoryId, repositories.id),
)
.where(
and(
...predicates,
eq(repositories.workspaceId, workspaceId),
eq(repositories.id, query.repositoryId),
sql`${generatedRuns.repositoryProfileSnapshotJson}->>'repositoryId' = ${repositories.id}::text`,
sql`${generatedRuns.repositoryProfileSnapshotJson}->>'revisionNumber' = ${repositoryProfileRevisions.revisionNumber}::text`,
sql`${generatedRuns.repositoryProfileSnapshotJson}->>'contentDigest' = ${repositoryProfileRevisions.contentDigest}`,
),
)
.orderBy(desc(generatedRuns.generatedAt), asc(generatedRuns.id))
.limit(limit + 1)
: await database
.select({ run: generatedRuns })
.from(generatedRuns)
.innerJoin(
playbookVersions,
eq(generatedRuns.playbookVersionId, playbookVersions.id),
)
.innerJoin(playbooks, eq(playbookVersions.playbookId, playbooks.id))
.where(and(...predicates))
.orderBy(desc(generatedRuns.generatedAt), asc(generatedRuns.id))
.limit(limit + 1)
const items = rows.slice(0, limit).map((row) => mapRow(row.run))
const last = rows.length > limit ? rows[limit - 1]?.run : undefined
return {
items,
nextCursor: last
? encodeGeneratedRunCursor({
generatedAt: last.generatedAt.toISOString(),
id: last.id,
})
: null,
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import postgres, { type Sql } from 'postgres'
import * as schema from './schema'
let client: Sql | undefined
let database: PostgresJsDatabase<typeof schema> | undefined
export function getDatabase(url = process.env.DATABASE_URL) {
if (!url) throw new Error('DATABASE_URL is required')
client ??= postgres(url, { max: 10, prepare: false })
database ??= drizzle(client, { schema })
return database
}
export function getSqlClient(url = process.env.DATABASE_URL) {
getDatabase(url)
if (!client) throw new Error('Database client was not initialized')
return client
}
export async function closeDatabase() {
await client?.end()
client = undefined
database = undefined
}
export * from './schema'
export * from './setup-lock'
export * from './auth/auth-persistence'
export * from './auth/invitation-store'
export * from './auth/personal-data-store'
export * from './auth/session-management-store'
export * from './auth/password-reset/password-reset-store'
export * from './auth/workspace-authorization'
export * from './auth/operations-actor'
export * from './artifacts/generated-artifact-store'
export * from './composition/composition-draft-store'
export * from './composition/composition-source-reader'
export * from './generated-runs/generated-run-store'
export * from './playbooks/playbook-catalog'
export * from './playbooks/playbook-favorite-store'
export * from './playbooks/playbook-collection-store'
export * from './playbooks/built-in-importer'
export * from './playbooks/playbook-package-file-store'
export * from './playbooks/private-playbook-draft-store'
export * from './playbooks/private-playbook-publication-store'
export * from './jobs/postgres-job-store'
export * from './operations/postgres-operations-store'
export * from './operations/postgres-system-status-store'
export * from './operations/product-metric-store'
export * from './integrations/gitea-integration-store'
export * from './repositories/repository-store'
export * from './repositories/repository-snapshot-store'
export * from './repositories/repository-refresh-scheduler'
export * from './repositories/repository-preference-store'
export * from './retention/artifact-retention-store'
export * from './setup/first-run-store'
export * from './setup/instance-status'
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { assertStoredSecretEnvelope } from './gitea-integration-store'
describe('Gitea integration persistence envelope', () => {
it('accepts only the fixed AES-GCM persistence shape', () => {
expect(() =>
assertStoredSecretEnvelope({
envelopeVersion: 1,
keyVersion: 'v1',
nonce: Buffer.alloc(12, 1),
ciphertext: Buffer.alloc(32, 2),
authTag: Buffer.alloc(16, 3),
lastFour: '1234',
}),
).not.toThrow()
expect(() =>
assertStoredSecretEnvelope({
envelopeVersion: 1,
keyVersion: 'v1',
nonce: Buffer.alloc(11),
ciphertext: Buffer.alloc(1),
authTag: Buffer.alloc(16),
lastFour: '123',
}),
).toThrow('Encrypted integration secret envelope is invalid')
})
})
@@ -0,0 +1,646 @@
import type {
CreateStoredGiteaIntegrationRequest,
ExternalGiteaRepository,
ForgeCapabilityName,
ForgeCapabilityState,
GiteaIntegrationStore,
GiteaIntegrationWithSecret,
GiteaProbeResult,
GiteaSafeErrorCode,
SafeGiteaIntegration,
StoredSecretEnvelope,
} from '@devrunbook/application'
import { forgeCapabilityNames } from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { and, asc, desc, eq, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
integrationSecrets,
integrations,
repositories,
repositorySnapshots,
} from '../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
type IntegrationRow = typeof integrations.$inferSelect
type SecretRow = typeof integrationSecrets.$inferSelect
export interface ImportedGiteaRepository {
readonly id: string
readonly workspaceId: string
readonly integrationId: string
readonly externalId: string
readonly owner: string
readonly name: string
readonly displayName: string
readonly defaultBranch: string | null
readonly archived: boolean
readonly created: boolean
}
export type ImportedGiteaRepositoryIdentity = Omit<
ImportedGiteaRepository,
'created'
>
export interface ImportedGiteaRepositoryStatus extends ImportedGiteaRepositoryIdentity {
readonly latestSnapshotState:
'collecting' | 'complete' | 'failed' | 'cancelled' | null
readonly latestSnapshotAt: string | null
readonly hasCompletedSnapshot: boolean
}
const capabilityNames = new Set<string>(forgeCapabilityNames)
const capabilityStates = new Set<string>([
'supported',
'unsupported',
'forbidden',
'temporarily_unavailable',
])
function invalidPersistence(message: string): never {
throw new DomainError('gitea_persistence_invalid', message)
}
function assertCapabilities(
value: unknown,
): asserts value is Readonly<
Partial<Record<ForgeCapabilityName, ForgeCapabilityState>>
> {
if (
value === null ||
typeof value !== 'object' ||
Array.isArray(value) ||
Object.getPrototypeOf(value) !== Object.prototype ||
Object.entries(value).some(
([name, state]) =>
!capabilityNames.has(name) ||
typeof state !== 'string' ||
!capabilityStates.has(state),
)
) {
invalidPersistence('Gitea capability snapshot is invalid')
}
}
export function assertStoredSecretEnvelope(secret: StoredSecretEnvelope): void {
if (
secret.envelopeVersion !== 1 ||
secret.keyVersion.trim().length === 0 ||
secret.keyVersion.length > 64 ||
secret.nonce.byteLength !== 12 ||
secret.ciphertext.byteLength === 0 ||
secret.authTag.byteLength !== 16 ||
secret.lastFour.length !== 4
) {
throw new DomainError(
'integration_secret_envelope_invalid',
'Encrypted integration secret envelope is invalid',
)
}
}
function safeProjection(row: {
integration: IntegrationRow
secret: SecretRow | null
}): SafeGiteaIntegration {
assertCapabilities(row.integration.capabilitiesJson)
const remoteIdentity =
row.integration.remoteIdentityId && row.integration.remoteIdentityLogin
? {
id: row.integration.remoteIdentityId,
login: row.integration.remoteIdentityLogin,
}
: null
return {
id: row.integration.id,
workspaceId: row.integration.workspaceId,
displayName: row.integration.displayName,
baseUrl: row.integration.baseUrl,
status: row.integration.status as SafeGiteaIntegration['status'],
capabilities: structuredClone(row.integration.capabilitiesJson),
serverVersion: row.integration.serverVersion,
remoteIdentity,
healthCode: row.integration.healthCode as GiteaSafeErrorCode | null,
lastCheckedAt: row.integration.lastCheckedAt?.toISOString() ?? null,
secretLastFour: row.secret?.lastFour ?? null,
createdAt: row.integration.createdAt.toISOString(),
updatedAt: row.integration.updatedAt.toISOString(),
}
}
function storedEnvelope(row: SecretRow): StoredSecretEnvelope {
if (row.lastFour === null) {
throw new Error('Stored integration secret is missing its safe suffix')
}
const envelope: StoredSecretEnvelope = {
envelopeVersion: row.envelopeVersion,
keyVersion: row.keyVersion,
nonce: Buffer.from(row.nonce),
ciphertext: Buffer.from(row.ciphertext),
authTag: Buffer.from(row.authTag),
lastFour: row.lastFour,
}
assertStoredSecretEnvelope(envelope)
return envelope
}
function persistedProbeStatus(probe: GiteaProbeResult): 'healthy' | 'degraded' {
return probe.status === 'healthy' ? 'healthy' : 'degraded'
}
function probeColumns(probe: GiteaProbeResult, now: Date) {
assertCapabilities(probe.capabilities)
return {
baseUrl: probe.normalizedBaseUrl,
status: persistedProbeStatus(probe),
capabilitiesJson: probe.capabilities,
serverVersion: probe.serverVersion,
remoteIdentityId: probe.remoteIdentity?.id ?? null,
remoteIdentityLogin: probe.remoteIdentity?.login ?? null,
healthCode: probe.healthCode,
lastCheckedAt: now,
updatedAt: now,
} as const
}
async function findJoined(
database: Database | Transaction,
workspaceId: string,
integrationId: string,
) {
const [row] = await database
.select({ integration: integrations, secret: integrationSecrets })
.from(integrations)
.leftJoin(
integrationSecrets,
and(
eq(integrationSecrets.integrationId, integrations.id),
eq(integrationSecrets.secretKind, 'access_token'),
),
)
.where(
and(
eq(integrations.workspaceId, workspaceId),
eq(integrations.id, integrationId),
eq(integrations.type, 'gitea'),
),
)
.limit(1)
return row ?? null
}
export class DrizzleGiteaIntegrationStore implements GiteaIntegrationStore {
constructor(private readonly database: Database = getDatabase()) {}
async listSafeForWorkspace(
workspaceId: string,
): Promise<readonly SafeGiteaIntegration[]> {
const rows = await this.database
.select({ integration: integrations, secret: integrationSecrets })
.from(integrations)
.leftJoin(
integrationSecrets,
and(
eq(integrationSecrets.integrationId, integrations.id),
eq(integrationSecrets.secretKind, 'access_token'),
),
)
.where(
and(
eq(integrations.workspaceId, workspaceId),
eq(integrations.type, 'gitea'),
),
)
.orderBy(asc(integrations.displayName), asc(integrations.id))
return rows.map(safeProjection)
}
async findSafeForWorkspace(
workspaceId: string,
integrationId: string,
): Promise<SafeGiteaIntegration | null> {
const row = await findJoined(this.database, workspaceId, integrationId)
return row ? safeProjection(row) : null
}
async findWithSecretForWorkspace(
workspaceId: string,
integrationId: string,
): Promise<GiteaIntegrationWithSecret | null> {
const row = await findJoined(this.database, workspaceId, integrationId)
if (!row?.secret) return null
return {
integration: safeProjection(row),
secret: storedEnvelope(row.secret),
allowPrivateHttp: row.integration.allowPrivateHttp,
requestTimeoutMs: row.integration.requestTimeoutMs,
}
}
async createWithSecret(
request: CreateStoredGiteaIntegrationRequest,
): Promise<SafeGiteaIntegration> {
assertStoredSecretEnvelope(request.secret)
const now = new Date()
return this.database.transaction(async (transaction) => {
const [integration] = await transaction
.insert(integrations)
.values({
id: request.id,
workspaceId: request.workspaceId,
type: 'gitea',
displayName: request.displayName,
allowPrivateHttp: request.allowPrivateHttp,
requestTimeoutMs: request.requestTimeoutMs,
createdBy: request.createdBy,
createdAt: now,
...probeColumns(request.probe, now),
})
.returning()
if (!integration) throw new Error('Integration insert returned no row')
await transaction.insert(integrationSecrets).values({
integrationId: integration.id,
secretKind: 'access_token',
envelopeVersion: request.secret.envelopeVersion,
keyVersion: request.secret.keyVersion,
nonce: Buffer.from(request.secret.nonce),
ciphertext: Buffer.from(request.secret.ciphertext),
authTag: Buffer.from(request.secret.authTag),
lastFour: request.secret.lastFour,
createdAt: now,
})
await transaction.insert(auditEvents).values({
occurredAt: now,
actorUserId: request.createdBy,
workspaceId: request.workspaceId,
action: 'integration.created',
resourceType: 'integration',
resourceId: integration.id,
outcome: 'success',
metadataJson: {
type: 'gitea',
status: persistedProbeStatus(request.probe),
},
})
const row = await findJoined(
transaction,
request.workspaceId,
integration.id,
)
if (!row) throw new Error('Created integration could not be reloaded')
return safeProjection(row)
})
}
async updateHealth(request: {
readonly workspaceId: string
readonly integrationId: string
readonly actorId: string
readonly probe: GiteaProbeResult
}): Promise<SafeGiteaIntegration | null> {
const now = new Date()
return this.database.transaction(async (transaction) => {
const [updated] = await transaction
.update(integrations)
.set(probeColumns(request.probe, now))
.where(
and(
eq(integrations.workspaceId, request.workspaceId),
eq(integrations.id, request.integrationId),
eq(integrations.type, 'gitea'),
),
)
.returning({ id: integrations.id })
if (!updated) return null
await transaction.insert(auditEvents).values({
occurredAt: now,
actorUserId: request.actorId,
workspaceId: request.workspaceId,
action: 'integration.connection_tested',
resourceType: 'integration',
resourceId: updated.id,
outcome: request.probe.status === 'healthy' ? 'success' : 'failed',
metadataJson: {
status: persistedProbeStatus(request.probe),
...(request.probe.healthCode
? { code: request.probe.healthCode }
: {}),
},
})
const row = await findJoined(transaction, request.workspaceId, updated.id)
return row ? safeProjection(row) : null
})
}
async rotateSecret(request: {
readonly workspaceId: string
readonly integrationId: string
readonly actorId: string
readonly secret: StoredSecretEnvelope
readonly probe: GiteaProbeResult
}): Promise<SafeGiteaIntegration | null> {
assertStoredSecretEnvelope(request.secret)
const now = new Date()
return this.database.transaction(async (transaction) => {
const [integration] = await transaction
.select({ id: integrations.id })
.from(integrations)
.where(
and(
eq(integrations.workspaceId, request.workspaceId),
eq(integrations.id, request.integrationId),
eq(integrations.type, 'gitea'),
),
)
.limit(1)
.for('update')
if (!integration) return null
const [secret] = await transaction
.update(integrationSecrets)
.set({
envelopeVersion: request.secret.envelopeVersion,
keyVersion: request.secret.keyVersion,
nonce: Buffer.from(request.secret.nonce),
ciphertext: Buffer.from(request.secret.ciphertext),
authTag: Buffer.from(request.secret.authTag),
lastFour: request.secret.lastFour,
rotatedAt: now,
})
.where(
and(
eq(integrationSecrets.integrationId, integration.id),
eq(integrationSecrets.secretKind, 'access_token'),
),
)
.returning({ id: integrationSecrets.id })
if (!secret) throw new Error('Integration credential is missing')
await transaction
.update(integrations)
.set(probeColumns(request.probe, now))
.where(eq(integrations.id, integration.id))
await transaction.insert(auditEvents).values({
occurredAt: now,
actorUserId: request.actorId,
workspaceId: request.workspaceId,
action: 'integration.secret_rotated',
resourceType: 'integration',
resourceId: integration.id,
outcome: 'success',
metadataJson: {
secretKind: 'access-token',
keyVersion: request.secret.keyVersion,
lastFour: request.secret.lastFour,
status: persistedProbeStatus(request.probe),
},
})
const row = await findJoined(
transaction,
request.workspaceId,
integration.id,
)
return row ? safeProjection(row) : null
})
}
async deleteForWorkspace(request: {
readonly workspaceId: string
readonly integrationId: string
readonly actorId: string
}): Promise<boolean> {
return this.database.transaction(async (transaction) => {
const [deleted] = await transaction
.delete(integrations)
.where(
and(
eq(integrations.workspaceId, request.workspaceId),
eq(integrations.id, request.integrationId),
eq(integrations.type, 'gitea'),
),
)
.returning({ id: integrations.id })
if (!deleted) return false
await transaction.insert(auditEvents).values({
actorUserId: request.actorId,
workspaceId: request.workspaceId,
action: 'integration.deleted',
resourceType: 'integration',
resourceId: deleted.id,
outcome: 'success',
metadataJson: { type: 'gitea' },
})
return true
})
}
async importExternalRepository(request: {
readonly workspaceId: string
readonly integrationId: string
readonly repository: ExternalGiteaRepository
readonly now?: Date
}): Promise<ImportedGiteaRepository | null> {
const now = request.now ?? new Date()
return this.database.transaction(async (transaction) => {
await transaction.execute(sql`
select pg_advisory_xact_lock(
hashtextextended(${`devrunbook:gitea-repository:${request.workspaceId}:${request.integrationId}:${request.repository.externalId}`}, 0)
)
`)
const [integration] = await transaction
.select({ id: integrations.id })
.from(integrations)
.where(
and(
eq(integrations.workspaceId, request.workspaceId),
eq(integrations.id, request.integrationId),
eq(integrations.type, 'gitea'),
),
)
.limit(1)
if (!integration) return null
const [existing] = await transaction
.select()
.from(repositories)
.where(
and(
eq(repositories.workspaceId, request.workspaceId),
eq(repositories.integrationId, integration.id),
eq(repositories.externalId, request.repository.externalId),
eq(repositories.sourceType, 'gitea'),
),
)
.limit(1)
const displayName = `${request.repository.owner}/${request.repository.name}`
const [row] = existing
? await transaction
.update(repositories)
.set({
externalOwner: request.repository.owner,
externalName: request.repository.name,
displayName,
defaultBranch: request.repository.defaultBranch,
archived: request.repository.archived,
updatedAt: now,
})
.where(eq(repositories.id, existing.id))
.returning()
: await transaction
.insert(repositories)
.values({
workspaceId: request.workspaceId,
integrationId: integration.id,
sourceType: 'gitea',
externalId: request.repository.externalId,
externalOwner: request.repository.owner,
externalName: request.repository.name,
displayName,
defaultBranch: request.repository.defaultBranch,
archived: request.repository.archived,
createdAt: now,
updatedAt: now,
})
.returning()
if (!row) throw new Error('Repository import returned no row')
return {
id: row.id,
workspaceId: row.workspaceId,
integrationId: row.integrationId!,
externalId: row.externalId!,
owner: row.externalOwner!,
name: row.externalName!,
displayName: row.displayName,
defaultBranch: row.defaultBranch,
archived: row.archived,
created: !existing,
}
})
}
async findImportedRepositoryForWorkspace(
workspaceId: string,
repositoryId: string,
): Promise<ImportedGiteaRepositoryIdentity | null> {
const [row] = await this.database
.select({
id: repositories.id,
workspaceId: repositories.workspaceId,
integrationId: repositories.integrationId,
externalId: repositories.externalId,
owner: repositories.externalOwner,
name: repositories.externalName,
displayName: repositories.displayName,
defaultBranch: repositories.defaultBranch,
archived: repositories.archived,
})
.from(repositories)
.innerJoin(
integrations,
and(
eq(integrations.id, repositories.integrationId),
eq(integrations.workspaceId, repositories.workspaceId),
eq(integrations.type, 'gitea'),
),
)
.where(
and(
eq(repositories.id, repositoryId),
eq(repositories.workspaceId, workspaceId),
eq(repositories.sourceType, 'gitea'),
),
)
.limit(1)
if (!row) return null
if (!row.integrationId || !row.externalId || !row.owner || !row.name) {
invalidPersistence('Imported Gitea repository identity is incomplete')
}
return {
...row,
integrationId: row.integrationId,
externalId: row.externalId,
owner: row.owner,
name: row.name,
}
}
async listImportedRepositoriesForIntegration(
workspaceId: string,
integrationId: string,
): Promise<readonly ImportedGiteaRepositoryStatus[]> {
const rows = await this.database
.select({
id: repositories.id,
workspaceId: repositories.workspaceId,
integrationId: repositories.integrationId,
externalId: repositories.externalId,
owner: repositories.externalOwner,
name: repositories.externalName,
displayName: repositories.displayName,
defaultBranch: repositories.defaultBranch,
archived: repositories.archived,
})
.from(repositories)
.innerJoin(
integrations,
and(
eq(integrations.id, repositories.integrationId),
eq(integrations.workspaceId, repositories.workspaceId),
eq(integrations.type, 'gitea'),
),
)
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositories.integrationId, integrationId),
eq(repositories.sourceType, 'gitea'),
),
)
.orderBy(asc(repositories.displayName), asc(repositories.id))
.limit(101)
return Promise.all(
rows.map(async (row) => {
if (!row.integrationId || !row.externalId || !row.owner || !row.name) {
invalidPersistence('Imported Gitea repository identity is incomplete')
}
const [latest] = await this.database
.select({ snapshot: repositorySnapshots })
.from(repositorySnapshots)
.where(eq(repositorySnapshots.repositoryId, row.id))
.orderBy(
desc(repositorySnapshots.createdAt),
desc(repositorySnapshots.id),
)
.limit(1)
const [completed] = await this.database
.select({ id: repositorySnapshots.id })
.from(repositorySnapshots)
.where(
and(
eq(repositorySnapshots.repositoryId, row.id),
eq(repositorySnapshots.state, 'complete'),
),
)
.limit(1)
return {
...row,
integrationId: row.integrationId,
externalId: row.externalId,
owner: row.owner,
name: row.name,
latestSnapshotState:
(latest?.snapshot
.state as ImportedGiteaRepositoryStatus['latestSnapshotState']) ??
null,
latestSnapshotAt:
latest?.snapshot.capturedAt?.toISOString() ??
latest?.snapshot.createdAt.toISOString() ??
null,
hasCompletedSnapshot: Boolean(completed),
}
}),
)
}
}
@@ -0,0 +1,500 @@
import type { RepositoryProfile } from '@devrunbook/repository-intel'
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import {
RepositorySnapshotStore,
digestRepositoryEvidence,
} from '../repositories/repository-snapshot-store'
import { RepositoryRefreshScheduler } from '../repositories/repository-refresh-scheduler'
import { DrizzleGiteaIntegrationStore } from './gitea-integration-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function envelope(seed: number, lastFour: string) {
return {
envelopeVersion: 1 as const,
keyVersion: `v${seed}`,
nonce: Buffer.alloc(12, seed),
ciphertext: Buffer.alloc(48, seed + 1),
authTag: Buffer.alloc(16, seed + 2),
lastFour,
}
}
function probe(status: 'healthy' | 'degraded' | 'failed' = 'healthy') {
return {
normalizedBaseUrl: 'https://gitea.example.test/',
status,
serverVersion: '1.24.0',
remoteIdentity: { id: '7', login: 'devrunbook' },
capabilities: {
'repository-list': 'supported' as const,
contents: 'supported' as const,
},
healthCode: status === 'healthy' ? null : ('REMOTE_UNAVAILABLE' as const),
warnings: [],
}
}
function profile(name: string): RepositoryProfile {
return {
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: {
name,
revision: 1,
source: 'gitea',
sourceReference: 'gitea:example/acme/service',
},
spec: {
repositoryType: 'single-app',
defaultBranch: 'main',
stack: {
languages: ['TypeScript'],
frameworks: [],
packageManagers: ['pnpm'],
databases: [],
deploymentTypes: ['Docker'],
testFrameworks: ['Vitest'],
},
commands: [],
paths: {
applicationRoots: ['src'],
testRoots: ['tests'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['runtime'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'reversible-only',
documentationRequired: true,
networkAccess: 'read-only-approved-hosts',
productionDataAccess: 'forbidden',
},
sourceFacts: [
{
path: '/spec/defaultBranch',
value: 'main',
source: 'gitea',
evidence: ['repository.default_branch'],
confidence: 'high',
},
],
},
}
}
describe.skipIf(!databaseIntegration)('Gitea persistence integration', () => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
const jobId = randomUUID()
let integrations: DrizzleGiteaIntegrationStore
let snapshots: RepositorySnapshotStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`gitea-${userId}@example.invalid`}, 'Gitea persistence',
'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type) values
(${workspaceA}, 'Gitea persistence A', 'team'),
(${workspaceB}, 'Gitea persistence B', 'team')
`
await sql`
insert into workspace_memberships (workspace_id, user_id, role)
values (${workspaceA}, ${userId}, 'editor')
`
await sql`
insert into jobs (
id, workspace_id, type, state, idempotency_key, payload_json
) values (
${jobId}, ${workspaceA}, 'gitea.repository-snapshot', 'running',
${`snapshot-${jobId}`}, '{}'::jsonb
)
`
integrations = new DrizzleGiteaIntegrationStore()
snapshots = new RepositorySnapshotStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('stores an encrypted envelope but returns only a safe workspace projection', async () => {
const created = await integrations.createWithSecret({
id: randomUUID(),
workspaceId: workspaceA,
createdBy: userId,
displayName: 'Internal Gitea',
baseUrl: 'https://gitea.example.test/',
allowPrivateHttp: false,
requestTimeoutMs: 15_000,
secret: envelope(1, '1234'),
probe: probe(),
})
expect(created).toMatchObject({
workspaceId: workspaceA,
secretLastFour: '1234',
serverVersion: '1.24.0',
remoteIdentity: { id: '7', login: 'devrunbook' },
})
expect(JSON.stringify(created)).not.toMatch(/ciphertext|authTag|nonce/u)
await expect(
integrations.findSafeForWorkspace(workspaceB, created.id),
).resolves.toBeNull()
await expect(
integrations.findWithSecretForWorkspace(workspaceB, created.id),
).resolves.toBeNull()
const credential = await integrations.findWithSecretForWorkspace(
workspaceA,
created.id,
)
expect(credential?.secret.ciphertext).toEqual(Buffer.alloc(48, 2))
await expect(
integrations.rotateSecret({
workspaceId: workspaceB,
integrationId: created.id,
actorId: userId,
secret: envelope(2, '5678'),
probe: probe(),
}),
).resolves.toBeNull()
const rotated = await integrations.rotateSecret({
workspaceId: workspaceA,
integrationId: created.id,
actorId: userId,
secret: envelope(2, '5678'),
probe: probe(),
})
expect(rotated).toMatchObject({
status: 'healthy',
secretLastFour: '5678',
})
await expect(
integrations.updateHealth({
workspaceId: workspaceA,
integrationId: created.id,
actorId: userId,
probe: probe(),
}),
).resolves.toMatchObject({ status: 'healthy' })
await expect(
integrations.updateHealth({
workspaceId: workspaceA,
integrationId: created.id,
actorId: userId,
probe: {
...probe('degraded'),
capabilities: { authorization: 'Bearer leaked-value' },
} as never,
}),
).rejects.toThrow('Gitea capability snapshot is invalid')
const imported = await integrations.importExternalRepository({
workspaceId: workspaceA,
integrationId: created.id,
repository: {
externalId: '42',
owner: 'acme',
name: 'service',
defaultBranch: 'main',
archived: false,
private: true,
permissions: { pull: true, push: false, admin: false },
},
})
expect(imported).toMatchObject({ created: true, externalId: '42' })
await expect(
integrations.importExternalRepository({
workspaceId: workspaceA,
integrationId: created.id,
repository: {
externalId: '42',
owner: 'acme',
name: 'service',
defaultBranch: 'trunk',
archived: false,
private: true,
permissions: { pull: true, push: false, admin: false },
},
}),
).resolves.toMatchObject({
id: imported!.id,
created: false,
defaultBranch: 'trunk',
})
await expect(
integrations.importExternalRepository({
workspaceId: workspaceB,
integrationId: created.id,
repository: {
externalId: '42',
owner: 'acme',
name: 'service',
defaultBranch: 'main',
archived: false,
private: true,
permissions: { pull: true, push: false, admin: false },
},
}),
).resolves.toBeNull()
await expect(
integrations.findImportedRepositoryForWorkspace(workspaceA, imported!.id),
).resolves.toMatchObject({
id: imported!.id,
integrationId: created.id,
externalId: '42',
defaultBranch: 'trunk',
})
await expect(
integrations.findImportedRepositoryForWorkspace(workspaceB, imported!.id),
).resolves.toBeNull()
const collecting = await snapshots.beginCollection({
workspaceId: workspaceA,
repositoryId: imported!.id,
integrationId: created.id,
syncJobId: jobId,
})
expect(collecting).toMatchObject({ state: 'collecting', syncJobId: jobId })
await expect(
integrations.listImportedRepositoriesForIntegration(
workspaceA,
created.id,
),
).resolves.toEqual([
expect.objectContaining({
id: imported!.id,
latestSnapshotState: 'collecting',
hasCompletedSnapshot: false,
}),
])
await expect(
snapshots.resolveCollectionTarget({
workspaceId: workspaceA,
integrationId: created.id,
repositoryId: imported!.id,
syncJobId: jobId,
}),
).resolves.toEqual({
snapshotId: collecting!.id,
owner: 'acme',
name: 'service',
})
await expect(
snapshots.beginCollection({
workspaceId: workspaceB,
repositoryId: imported!.id,
integrationId: created.id,
syncJobId: jobId,
}),
).rejects.toThrow('different input')
const evidence = {
repository: { id: '42', defaultBranch: 'main' },
files: [{ path: 'package.json', digest: 'a'.repeat(64) }],
}
const completed = await snapshots.completeCollection({
workspaceId: workspaceA,
repositoryId: imported!.id,
snapshotId: collecting!.id,
createdBy: userId,
capabilities: { contents: 'supported', governance: 'forbidden' },
evidence,
profile: profile('Acme service'),
profileRevisionPolicy: 'create-initial-only',
findings: [
{
ruleId: 'governance-visibility-limited',
severity: 'info',
title: 'Governance visibility is limited',
rationale: 'The token cannot read branch protection settings.',
evidencePointer: '/capabilities/governance',
recommendedPlaybookSlug: 'gitea-best-practices',
},
],
})
expect(completed).toMatchObject({
snapshot: {
state: 'complete',
evidenceDigest: digestRepositoryEvidence(evidence),
},
profileRevision: { revisionNumber: 1 },
findingCount: 1,
})
await expect(
snapshots.getLastComplete(workspaceB, imported!.id),
).resolves.toBeNull()
await expect(
snapshots.getLastComplete(workspaceA, imported!.id),
).resolves.toMatchObject({ id: collecting!.id, state: 'complete' })
await expect(
integrations.listImportedRepositoriesForIntegration(
workspaceA,
created.id,
),
).resolves.toEqual([
expect.objectContaining({
id: imported!.id,
latestSnapshotState: 'complete',
hasCompletedSnapshot: true,
}),
])
const sql = getSqlClient()
await expect(
sql`update repository_snapshots set evidence_json = '{}'::jsonb where id = ${collecting!.id}`,
).rejects.toBeDefined()
const [secretAuditLeak] = await sql<{ body: string }[]>`
select string_agg(metadata_json::text, '') as body from audit_events
where resource_id = ${created.id}
`
expect(secretAuditLeak?.body ?? '').not.toMatch(
/ciphertext|authTag|nonce|Bearer|leaked-value/u,
)
const refreshJobId = randomUUID()
await sql`
insert into jobs (
id, workspace_id, type, state, idempotency_key, payload_json
) values (
${refreshJobId}, ${workspaceA}, 'gitea.repository-snapshot', 'running',
${`snapshot-${refreshJobId}`}, '{}'::jsonb
)
`
const refresh = await snapshots.beginCollection({
workspaceId: workspaceA,
repositoryId: imported!.id,
integrationId: created.id,
syncJobId: refreshJobId,
})
const proposedProfile = profile('Unreviewed remote name')
await expect(
snapshots.completeCollection({
workspaceId: workspaceA,
repositoryId: imported!.id,
snapshotId: refresh!.id,
createdBy: userId,
capabilities: { contents: 'supported' },
evidence: {
repository: { id: '42', defaultBranch: 'trunk' },
proposedProfile: JSON.parse(JSON.stringify(proposedProfile)) as never,
},
profile: proposedProfile,
profileRevisionPolicy: 'create-initial-only',
findings: [],
}),
).resolves.toMatchObject({
snapshot: { state: 'complete' },
profileRevision: null,
})
const [profileState] = await sql<
{ revision_count: number; display_name: string }[]
>`
select count(pr.id)::int as revision_count, max(r.display_name) as display_name
from repositories r
left join repository_profile_revisions pr on pr.repository_id = r.id
where r.id = ${imported!.id}
group by r.id
`
expect(profileState).toEqual({
revision_count: 1,
display_name: 'Acme service',
})
const failedJobId = randomUUID()
await sql`
insert into jobs (
id, workspace_id, type, state, idempotency_key, payload_json
) values (
${failedJobId}, ${workspaceA}, 'gitea.repository-snapshot', 'running',
${`snapshot-${failedJobId}`}, '{}'::jsonb
)
`
const failed = await snapshots.beginCollection({
workspaceId: workspaceA,
repositoryId: imported!.id,
integrationId: created.id,
syncJobId: failedJobId,
})
await expect(
snapshots.failCollection({
workspaceId: workspaceA,
snapshotId: failed!.id,
safeCode: 'REMOTE_UNAVAILABLE',
}),
).resolves.toBe(true)
await expect(
snapshots.getLastComplete(workspaceA, imported!.id),
).resolves.toMatchObject({ id: refresh!.id, state: 'complete' })
const scheduledAt = new Date(Date.now() + 2 * 3_600_000)
await expect(
new RepositoryRefreshScheduler(sql).plan({
staleAfterHours: 1,
now: scheduledAt,
}),
).resolves.toMatchObject({ queued: 1 })
await expect(
new RepositoryRefreshScheduler(sql).plan({
staleAfterHours: 1,
now: scheduledAt,
}),
).resolves.toMatchObject({ queued: 0 })
const [planned] = await sql<{ jobs: number; snapshots: number }[]>`
select
count(distinct j.id)::int as jobs,
count(distinct s.id)::int as snapshots
from jobs j
join repository_snapshots s on s.sync_job_id = j.id
where j.workspace_id = ${workspaceA}
and j.type = 'gitea.repository-snapshot'
and j.state = 'queued'
and j.payload_json->>'repositoryId' = ${imported!.id}
`
expect(planned).toEqual({ jobs: 1, snapshots: 1 })
await expect(
integrations.deleteForWorkspace({
workspaceId: workspaceB,
integrationId: created.id,
actorId: userId,
}),
).resolves.toBe(false)
await expect(
integrations.deleteForWorkspace({
workspaceId: workspaceA,
integrationId: created.id,
actorId: userId,
}),
).resolves.toBe(true)
await expect(
snapshots.getLastComplete(workspaceA, imported!.id),
).resolves.toMatchObject({
id: refresh!.id,
state: 'complete',
integrationId: null,
})
})
})
@@ -0,0 +1,138 @@
import { enqueueJob } from '@devrunbook/application'
import { randomUUID } from 'node:crypto'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { PostgresJobStore } from './postgres-job-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)('PostgresJobStore integration', () => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
let store: PostgresJobStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into workspaces (id, name, type)
values
(${workspaceA}, 'Job integration A', 'team'),
(${workspaceB}, 'Job integration B', 'team')
`
store = new PostgresJobStore(sql)
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await closeDatabase()
})
afterEach(async () => {
const sql = getSqlClient()
await sql`delete from jobs where workspace_id in (${workspaceA}, ${workspaceB})`
})
it('enqueues idempotently within a workspace without crossing workspace scope', async () => {
const request = {
workspaceId: workspaceA,
type: 'system.health-probe',
idempotencyKey: 'same-request',
payload: { requestedBy: 'integration' },
} as const
const first = await enqueueJob(store, request)
const repeated = await enqueueJob(store, request)
const otherWorkspace = await enqueueJob(store, {
...request,
workspaceId: workspaceB,
})
expect(first.created).toBe(true)
expect(repeated).toMatchObject({ created: false })
expect(repeated.job.id).toBe(first.job.id)
expect(otherWorkspace.job.id).not.toBe(first.job.id)
await expect(
store.findForWorkspace(workspaceB, first.job.id),
).resolves.toBeNull()
await expect(
store.findForWorkspace(workspaceA, first.job.id),
).resolves.toMatchObject({ id: first.job.id })
})
it('claims once and guards heartbeat and completion by lease owner', async () => {
await enqueueJob(store, {
workspaceId: workspaceA,
type: 'system.health-probe',
idempotencyKey: 'claim-once',
payload: {},
})
const claimed = await store.claim({
leaseOwner: 'integration-worker:lease-1',
leaseDurationMs: 30_000,
workspaceId: workspaceA,
})
expect(claimed).toMatchObject({ state: 'running', attemptCount: 1 })
if (!claimed) throw new Error('Expected an available job')
await expect(
store.heartbeat(claimed.id, 'wrong-owner', 30_000),
).resolves.toBe(false)
await expect(
store.succeed(claimed.id, 'wrong-owner', { phase: 'complete' }),
).resolves.toBe(false)
await expect(
store.succeed(claimed.id, 'integration-worker:lease-1', {
phase: 'complete',
}),
).resolves.toBe(true)
await expect(
store.findForWorkspace(workspaceA, claimed.id),
).resolves.toMatchObject({ state: 'succeeded', leaseOwner: null })
})
it('recovers an expired lease after restart and increments the attempt', async () => {
const queued = await enqueueJob(store, {
workspaceId: workspaceA,
type: 'system.health-probe',
idempotencyKey: 'restart-recovery',
payload: {},
maxAttempts: 3,
})
const firstClaim = await store.claim({
leaseOwner: 'stopped-worker:lease-1',
leaseDurationMs: 30_000,
workspaceId: workspaceA,
})
expect(firstClaim?.id).toBe(queued.job.id)
const sql = getSqlClient()
await sql`
update jobs set lease_expires_at = now() - interval '1 second'
where id = ${queued.job.id}
`
const recovered = await store.claim({
leaseOwner: 'replacement-worker:lease-2',
leaseDurationMs: 30_000,
workspaceId: workspaceA,
})
expect(recovered).toMatchObject({
id: queued.job.id,
state: 'running',
attemptCount: 2,
leaseOwner: 'replacement-worker:lease-2',
})
await expect(
store.succeed(queued.job.id, 'stopped-worker:lease-1', {}),
).resolves.toBe(false)
await expect(
store.succeed(queued.job.id, 'replacement-worker:lease-2', {
recovered: true,
}),
).resolves.toBe(true)
})
})
+309
View File
@@ -0,0 +1,309 @@
import type {
ClaimJobRequest,
EnqueueJobRequest,
JobFailure,
JobJsonValue,
JobRecord,
JobStore,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { isDeepStrictEqual } from 'node:util'
import type { Sql, TransactionSql } from 'postgres'
import { getSqlClient } from '../index'
interface JobRow {
id: string
workspace_id: string | null
type: string
state: JobRecord['state']
idempotency_key: string | null
payload_json: JobJsonValue
progress_json: JobJsonValue
attempt_count: number
max_attempts: number
lease_owner: string | null
lease_expires_at: Date | string | null
available_at: Date | string
started_at: Date | string | null
finished_at: Date | string | null
error_code: string | null
error_detail_redacted: string | null
created_at: Date | string
updated_at: Date | string
}
type QueryClient = Sql | TransactionSql
function date(value: Date | string): Date {
return value instanceof Date ? value : new Date(value)
}
function nullableDate(value: Date | string | null): Date | null {
return value === null ? null : date(value)
}
function mapRow(row: JobRow): JobRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
type: row.type,
state: row.state,
idempotencyKey: row.idempotency_key ?? '',
payload: row.payload_json,
progress: row.progress_json,
attemptCount: row.attempt_count,
maxAttempts: row.max_attempts,
leaseOwner: row.lease_owner,
leaseExpiresAt: nullableDate(row.lease_expires_at),
availableAt: date(row.available_at),
startedAt: nullableDate(row.started_at),
finishedAt: nullableDate(row.finished_at),
errorCode: row.error_code,
errorDetailRedacted: row.error_detail_redacted,
createdAt: date(row.created_at),
updatedAt: date(row.updated_at),
}
}
async function findIdempotent(
sql: QueryClient,
request: EnqueueJobRequest,
): Promise<JobRow | undefined> {
if (request.workspaceId === null) {
const [row] = await sql<JobRow[]>`
select * from jobs
where workspace_id is null
and type = ${request.type}
and idempotency_key = ${request.idempotencyKey}
limit 1
`
return row
}
const [row] = await sql<JobRow[]>`
select * from jobs
where workspace_id = ${request.workspaceId}
and type = ${request.type}
and idempotency_key = ${request.idempotencyKey}
limit 1
`
return row
}
function assertSameEnqueue(existing: JobRow, request: EnqueueJobRequest): void {
if (
!isDeepStrictEqual(existing.payload_json, request.payload) ||
existing.max_attempts !== (request.maxAttempts ?? 3)
) {
throw new DomainError(
'job_idempotency_conflict',
'Job idempotency key is already associated with different input',
)
}
}
/** PostgreSQL queue adapter. Payload JSON is stored as data and is never executed. */
export class PostgresJobStore implements JobStore {
constructor(private readonly sql: Sql = getSqlClient()) {}
enqueue(
request: EnqueueJobRequest,
): Promise<{ job: JobRecord; created: boolean }> {
return this.sql.begin(async (transaction) => {
const scope = request.workspaceId ?? 'global'
await transaction`
select pg_advisory_xact_lock(
hashtextextended(${`devrunbook:job:${scope}:${request.type}:${request.idempotencyKey}`}, 0)
)
`
const existing = await findIdempotent(transaction, request)
if (existing) {
assertSameEnqueue(existing, request)
return { job: mapRow(existing), created: false }
}
const explicitlyAvailableAt = request.availableAt?.toISOString() ?? null
const [inserted] = await transaction<JobRow[]>`
insert into jobs (
workspace_id, type, state, idempotency_key, payload_json,
progress_json, max_attempts, available_at
) values (
${request.workspaceId}, ${request.type}, 'queued',
${request.idempotencyKey}, ${JSON.stringify(request.payload)}::jsonb,
'{}'::jsonb, ${request.maxAttempts ?? 3},
coalesce(${explicitlyAvailableAt}::timestamptz, now())
)
returning *
`
if (!inserted) throw new Error('Job insert did not return a row')
return { job: mapRow(inserted), created: true }
}) as Promise<{ job: JobRecord; created: boolean }>
}
async claim(request: ClaimJobRequest): Promise<JobRecord | null> {
return this.sql.begin(async (transaction) => {
await transaction`
update jobs
set state = 'failed',
finished_at = now(),
lease_owner = null,
lease_expires_at = null,
error_code = 'job_retry_exhausted',
error_detail_redacted = 'Retry limit reached after a worker lease expired',
updated_at = now()
where state = 'running'
and lease_expires_at <= now()
and attempt_count >= max_attempts
`
await transaction`
update jobs
set state = 'queued',
available_at = now(),
lease_owner = null,
lease_expires_at = null,
error_code = 'job_lease_expired',
error_detail_redacted = 'Previous worker lease expired; job returned to the queue',
updated_at = now()
where state = 'running'
and lease_expires_at <= now()
and attempt_count < max_attempts
`
const leaseMilliseconds = Math.floor(request.leaseDurationMs)
const rows =
request.workspaceId === undefined
? await transaction<JobRow[]>`
with candidate as (
select id from jobs
where state = 'queued' and available_at <= now()
order by available_at, created_at
for update skip locked
limit 1
)
update jobs
set state = 'running',
attempt_count = attempt_count + 1,
lease_owner = ${request.leaseOwner},
lease_expires_at = now() + (${leaseMilliseconds} * interval '1 millisecond'),
started_at = coalesce(started_at, now()),
finished_at = null,
error_code = null,
error_detail_redacted = null,
updated_at = now()
from candidate
where jobs.id = candidate.id
returning jobs.*
`
: await transaction<JobRow[]>`
with candidate as (
select id from jobs
where state = 'queued'
and available_at <= now()
and workspace_id = ${request.workspaceId}
order by available_at, created_at
for update skip locked
limit 1
)
update jobs
set state = 'running',
attempt_count = attempt_count + 1,
lease_owner = ${request.leaseOwner},
lease_expires_at = now() + (${leaseMilliseconds} * interval '1 millisecond'),
started_at = coalesce(started_at, now()),
finished_at = null,
error_code = null,
error_detail_redacted = null,
updated_at = now()
from candidate
where jobs.id = candidate.id
returning jobs.*
`
return rows[0] ? mapRow(rows[0]) : null
}) as Promise<JobRecord | null>
}
async heartbeat(
jobId: string,
leaseOwner: string,
leaseDurationMs: number,
): Promise<boolean> {
const milliseconds = Math.floor(leaseDurationMs)
const rows = await this.sql<{ id: string }[]>`
update jobs
set lease_expires_at = now() + (${milliseconds} * interval '1 millisecond'),
updated_at = now()
where id = ${jobId}
and state = 'running'
and lease_owner = ${leaseOwner}
and lease_expires_at > now()
returning id
`
return rows.length === 1
}
async succeed(
jobId: string,
leaseOwner: string,
progress: JobJsonValue,
): Promise<boolean> {
const rows = await this.sql<{ id: string }[]>`
update jobs
set state = 'succeeded',
progress_json = ${JSON.stringify(progress)}::jsonb,
finished_at = now(), lease_owner = null, lease_expires_at = null,
error_code = null, error_detail_redacted = null, updated_at = now()
where id = ${jobId} and state = 'running' and lease_owner = ${leaseOwner}
returning id
`
return rows.length === 1
}
async retry(
jobId: string,
leaseOwner: string,
failure: JobFailure,
availableAt: Date,
): Promise<boolean> {
const rows = await this.sql<{ id: string }[]>`
update jobs
set state = 'queued',
available_at = ${availableAt.toISOString()}::timestamptz,
lease_owner = null, lease_expires_at = null,
error_code = ${failure.code},
error_detail_redacted = ${failure.detailRedacted}, updated_at = now()
where id = ${jobId} and state = 'running' and lease_owner = ${leaseOwner}
and attempt_count < max_attempts
returning id
`
return rows.length === 1
}
async fail(
jobId: string,
leaseOwner: string,
failure: JobFailure,
): Promise<boolean> {
const rows = await this.sql<{ id: string }[]>`
update jobs
set state = 'failed', finished_at = now(),
lease_owner = null, lease_expires_at = null,
error_code = ${failure.code},
error_detail_redacted = ${failure.detailRedacted}, updated_at = now()
where id = ${jobId} and state = 'running' and lease_owner = ${leaseOwner}
returning id
`
return rows.length === 1
}
async findForWorkspace(
workspaceId: string,
jobId: string,
): Promise<JobRecord | null> {
const [row] = await this.sql<JobRow[]>`
select * from jobs where id = ${jobId} and workspace_id = ${workspaceId}
limit 1
`
return row ? mapRow(row) : null
}
}
+8
View File
@@ -0,0 +1,8 @@
import { migrate } from 'drizzle-orm/postgres-js/migrator'
import { fileURLToPath } from 'node:url'
import { closeDatabase, getDatabase } from './index'
await migrate(getDatabase(), {
migrationsFolder: fileURLToPath(new URL('../migrations', import.meta.url)),
})
await closeDatabase()
@@ -0,0 +1,133 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { PostgresOperationsStore } from './postgres-operations-store'
import { PostgresSystemStatusStore } from './postgres-system-status-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)(
'PostgresOperationsStore integration',
() => {
const userId = randomUUID()
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const jobA = randomUUID()
const jobB = randomUUID()
let store: PostgresOperationsStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, email_verified,
instance_role, status
) values (
${userId}, ${`ops-${userId}@example.test`}, 'Operations tester',
'test-only-hash', true, 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type)
values (${workspaceA}, 'Ops A', 'team'), (${workspaceB}, 'Ops B', 'team')
`
await sql`
insert into jobs (
id, workspace_id, type, state, idempotency_key, progress_json,
attempt_count, max_attempts, error_code, error_detail_redacted
) values
(${jobA}, ${workspaceA}, 'gitea.repository-snapshot', 'failed',
'ops-retry-a', '{}'::jsonb, 3, 3, 'job_retry_exhausted',
'Retry limit reached after a transient source failure'),
(${jobB}, ${workspaceB}, 'gitea.repository-snapshot', 'failed',
'ops-retry-b', '{}'::jsonb, 1, 3, 'repository_snapshot_not_found',
'Repository snapshot is unavailable')
`
await sql`
insert into audit_events (
actor_user_id, workspace_id, action, resource_type, resource_id, outcome
) values
(${userId}, ${workspaceA}, 'ops.fixture.a', 'job', ${jobA}, 'success'),
(${userId}, ${workspaceB}, 'ops.fixture.b', 'job', ${jobB}, 'success')
`
store = new PostgresOperationsStore(sql)
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('paginates jobs and audit events without crossing workspace scope', async () => {
const jobs = await store.listJobs({
scope: { kind: 'workspace', workspaceId: workspaceA },
cursor: null,
limit: 1,
})
const audit = await store.listAuditEvents({
scope: { kind: 'workspace', workspaceId: workspaceA },
cursor: null,
limit: 10,
})
await expect(
store.findJob({ kind: 'workspace', workspaceId: workspaceA }, jobB),
).resolves.toBeNull()
expect(jobs.items.map((item) => item.id)).toEqual([jobA])
expect(audit.items.map((item) => item.action)).toContain('ops.fixture.a')
expect(audit.items.map((item) => item.action)).not.toContain(
'ops.fixture.b',
)
})
it('reports scoped failures and never invents backup evidence', async () => {
const status = await new PostgresSystemStatusStore(getSqlClient()).read(
workspaceA,
)
expect(status).toMatchObject({
failedJobs: 1,
lastObservedBackupAt: null,
})
expect(status.databaseBytes).toBeGreaterThan(0)
expect(status.schemaMigrationCount).toBeGreaterThan(0)
})
it('requeues only an allowlisted retryable terminal job and audits atomically', async () => {
await expect(
store.retryJob({
scope: { kind: 'workspace', workspaceId: workspaceA },
jobId: jobA,
actorUserId: userId,
workspaceId: workspaceA,
}),
).resolves.toBe('retried')
await expect(
store.retryJob({
scope: { kind: 'instance' },
jobId: jobB,
actorUserId: userId,
workspaceId: null,
}),
).resolves.toBe('not-retryable')
const sql = getSqlClient()
const [retried] = await sql<
{ state: string; max_attempts: number; error_code: string | null }[]
>`select state, max_attempts, error_code from jobs where id = ${jobA}`
const [audit] = await sql<{ count: number }[]>`
select count(*)::int as count from audit_events
where action = 'job.retry_requested' and resource_id = ${jobA}
`
expect(retried).toEqual({
state: 'queued',
max_attempts: 4,
error_code: null,
})
expect(audit?.count).toBe(1)
})
},
)
@@ -0,0 +1,278 @@
import type {
AuditEventRecord,
JobJsonValue,
JobState,
OperationsJob,
OperationsPage,
OperationsScope,
OperationsStore,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import type { Sql, TransactionSql } from 'postgres'
import { getSqlClient } from '../index'
interface CursorValue {
readonly timestamp: string
readonly id: string
}
interface JobRow {
id: string
workspace_id: string | null
type: string
state: JobState
progress_json: JobJsonValue
attempt_count: number
max_attempts: number
error_code: string | null
error_detail_redacted: string | null
created_at: Date | string
updated_at: Date | string
}
interface AuditRow {
id: string
occurred_at: Date | string
actor_user_id: string | null
workspace_id: string | null
action: string
resource_type: string
resource_id: string | null
outcome: AuditEventRecord['outcome']
metadata_json: Readonly<Record<string, unknown>>
}
type QueryClient = Sql | TransactionSql
function date(value: Date | string): Date {
return value instanceof Date ? value : new Date(value)
}
function encodeCursor(timestamp: Date, id: string): string {
return Buffer.from(
JSON.stringify({ timestamp: timestamp.toISOString(), id }),
'utf8',
).toString('base64url')
}
function decodeCursor(value: string | null): CursorValue | null {
if (value === null) return null
try {
const parsed = JSON.parse(
Buffer.from(value, 'base64url').toString('utf8'),
) as {
timestamp?: unknown
id?: unknown
}
if (
typeof parsed.timestamp !== 'string' ||
!Number.isFinite(Date.parse(parsed.timestamp)) ||
typeof parsed.id !== 'string' ||
!/^[0-9a-f-]{36}$/iu.test(parsed.id)
) {
throw new Error('invalid cursor')
}
return { timestamp: parsed.timestamp, id: parsed.id }
} catch {
throw new DomainError(
'operations_cursor_invalid',
'Operations cursor is invalid',
)
}
}
function retryable(
row: Pick<JobRow, 'state' | 'error_code' | 'max_attempts'>,
): boolean {
if (row.state !== 'failed' || row.max_attempts >= 20 || !row.error_code)
return false
return (
row.error_code === 'job_retry_exhausted' ||
row.error_code === 'job_lease_expired' ||
row.error_code === 'job_handler_failed' ||
/^repository_snapshot_(?:lease_lost|target_pending|cancelled|rate_limited|remote_unavailable|tls_error)$/u.test(
row.error_code,
)
)
}
function job(row: JobRow): OperationsJob {
return {
id: row.id,
workspaceId: row.workspace_id,
type: row.type,
state: row.state,
progress: row.progress_json,
attemptCount: row.attempt_count,
maxAttempts: row.max_attempts,
errorCode: row.error_code,
errorDetail: row.error_detail_redacted,
retryable: retryable(row),
createdAt: date(row.created_at),
updatedAt: date(row.updated_at),
}
}
function audit(row: AuditRow): AuditEventRecord {
return {
id: row.id,
occurredAt: date(row.occurred_at),
actorUserId: row.actor_user_id,
workspaceId: row.workspace_id,
action: row.action,
resourceType: row.resource_type,
resourceId: row.resource_id,
outcome: row.outcome,
metadata: row.metadata_json,
}
}
function page<T extends { readonly id: string }>(
rows: readonly T[],
limit: number,
timestamp: (row: T) => Date,
): OperationsPage<T> {
const items = rows.slice(0, limit)
const last = items.at(-1)
return {
items,
nextCursor:
rows.length > limit && last
? encodeCursor(timestamp(last), last.id)
: null,
}
}
async function findJobRow(
sql: QueryClient,
scope: OperationsScope,
jobId: string,
lock = false,
): Promise<JobRow | undefined> {
const rows =
scope.kind === 'instance'
? lock
? await sql<JobRow[]>`
select id, workspace_id, type, state, progress_json, attempt_count,
max_attempts, error_code, error_detail_redacted, created_at, updated_at
from jobs where id = ${jobId} for update
`
: await sql<JobRow[]>`
select id, workspace_id, type, state, progress_json, attempt_count,
max_attempts, error_code, error_detail_redacted, created_at, updated_at
from jobs where id = ${jobId}
`
: lock
? await sql<JobRow[]>`
select id, workspace_id, type, state, progress_json, attempt_count,
max_attempts, error_code, error_detail_redacted, created_at, updated_at
from jobs where id = ${jobId} and workspace_id = ${scope.workspaceId}
for update
`
: await sql<JobRow[]>`
select id, workspace_id, type, state, progress_json, attempt_count,
max_attempts, error_code, error_detail_redacted, created_at, updated_at
from jobs where id = ${jobId} and workspace_id = ${scope.workspaceId}
`
return rows[0]
}
export class PostgresOperationsStore implements OperationsStore {
constructor(private readonly sql: Sql = getSqlClient()) {}
async listJobs(request: {
scope: OperationsScope
cursor: string | null
limit: number
state?: JobState
}): Promise<OperationsPage<OperationsJob>> {
const cursor = decodeCursor(request.cursor)
const rows = await this.sql<JobRow[]>`
select id, workspace_id, type, state, progress_json, attempt_count,
max_attempts, error_code, error_detail_redacted, created_at, updated_at
from jobs
where (${request.scope.kind === 'instance'} or workspace_id = ${request.scope.kind === 'workspace' ? request.scope.workspaceId : null})
and (${request.state ?? null}::text is null or state = ${request.state ?? null})
and (${cursor?.timestamp ?? null}::timestamptz is null or (created_at, id) < (${cursor?.timestamp ?? null}::timestamptz, ${cursor?.id ?? null}::uuid))
order by created_at desc, id desc
limit ${request.limit + 1}
`
return page(rows.map(job), request.limit, (item) => item.createdAt)
}
async findJob(
scope: OperationsScope,
jobId: string,
): Promise<OperationsJob | null> {
const row = await findJobRow(this.sql, scope, jobId)
return row ? job(row) : null
}
retryJob(request: {
scope: OperationsScope
jobId: string
actorUserId: string
workspaceId: string | null
}): Promise<'retried' | 'not-found' | 'not-retryable'> {
return this.sql.begin(async (transaction) => {
const existing = await findJobRow(
transaction,
request.scope,
request.jobId,
true,
)
if (!existing) return 'not-found'
if (!retryable(existing)) return 'not-retryable'
await transaction`
update jobs
set state = 'queued', available_at = now(), finished_at = null,
lease_owner = null, lease_expires_at = null,
max_attempts = greatest(max_attempts, attempt_count + 1),
error_code = null, error_detail_redacted = null, updated_at = now()
where id = ${existing.id}
`
await transaction`
insert into audit_events (
actor_user_id, workspace_id, action, resource_type,
resource_id, outcome, metadata_json
) values (
${request.actorUserId}, ${existing.workspace_id},
'job.retry_requested', 'job', ${existing.id}, 'success',
${JSON.stringify({
jobType: existing.type,
priorErrorCode: existing.error_code,
priorAttemptCount: existing.attempt_count,
})}::jsonb
)
`
return 'retried'
}) as Promise<'retried' | 'not-found' | 'not-retryable'>
}
async listAuditEvents(request: {
scope: OperationsScope
cursor: string | null
limit: number
action?: string
workspaceId?: string
}): Promise<OperationsPage<AuditEventRecord>> {
const cursor = decodeCursor(request.cursor)
const workspaceFilter =
request.scope.kind === 'workspace'
? request.scope.workspaceId
: (request.workspaceId ?? null)
const rows = await this.sql<AuditRow[]>`
select id, occurred_at, actor_user_id, workspace_id, action,
resource_type, resource_id, outcome, metadata_json
from audit_events
where (${request.scope.kind === 'instance'} or workspace_id = ${request.scope.kind === 'workspace' ? request.scope.workspaceId : null})
and (${workspaceFilter}::uuid is null or workspace_id = ${workspaceFilter}::uuid)
and (${request.action ?? null}::text is null or action = ${request.action ?? null})
and (${cursor?.timestamp ?? null}::timestamptz is null or (occurred_at, id) < (${cursor?.timestamp ?? null}::timestamptz, ${cursor?.id ?? null}::uuid))
order by occurred_at desc, id desc
limit ${request.limit + 1}
`
return page(rows.map(audit), request.limit, (item) => item.occurredAt)
}
}
@@ -0,0 +1,61 @@
import type { Sql } from 'postgres'
import { getSqlClient } from '../index'
export interface SystemStatusRecord {
readonly databaseBytes: number
readonly artifactBytes: number
readonly failedJobs: number
readonly lastGiteaSyncAt: Date | null
readonly lastObservedBackupAt: Date | null
readonly schemaMigrationCount: number
}
interface StatusRow {
database_bytes: string | number
artifact_bytes: string | number
failed_jobs: string | number
last_gitea_sync_at: Date | string | null
last_observed_backup_at: Date | string | null
schema_migration_count: string | number
}
const date = (value: Date | string | null) =>
value === null ? null : value instanceof Date ? value : new Date(value)
/** Read-only operational projection; backup evidence is never inferred. */
export class PostgresSystemStatusStore {
constructor(private readonly sql: Sql = getSqlClient()) {}
async read(workspaceId: string | null): Promise<SystemStatusRecord> {
const [row] = await this.sql<StatusRow[]>`
select
pg_database_size(current_database()) as database_bytes,
coalesce((select sum(size_bytes) from generated_artifacts), 0) as artifact_bytes,
(select count(*) from jobs
where state = 'failed'
and (${workspaceId}::uuid is null or workspace_id = ${workspaceId}::uuid)
) as failed_jobs,
(select max(s.captured_at) from repository_snapshots s
join repositories r on r.id = s.repository_id
where s.state = 'complete'
and (${workspaceId}::uuid is null or r.workspace_id = ${workspaceId}::uuid)
) as last_gitea_sync_at,
(select max(occurred_at) from audit_events
where action = 'backup.observed'
and outcome = 'success'
and (${workspaceId}::uuid is null or workspace_id = ${workspaceId}::uuid)
) as last_observed_backup_at,
(select count(*) from drizzle.__drizzle_migrations) as schema_migration_count
`
if (!row) throw new Error('System status query returned no row')
return {
databaseBytes: Number(row.database_bytes),
artifactBytes: Number(row.artifact_bytes),
failedJobs: Number(row.failed_jobs),
lastGiteaSyncAt: date(row.last_gitea_sync_at),
lastObservedBackupAt: date(row.last_observed_backup_at),
schemaMigrationCount: Number(row.schema_migration_count),
}
}
}
@@ -0,0 +1,32 @@
import type {
ProductMetricStore,
SimpleFlowEvent,
} from '@devrunbook/application'
import { getSqlClient } from '../index'
export class PostgresProductMetricStore implements ProductMetricStore {
async recordSimpleFlowMetric(input: {
readonly actorUserId: string
readonly workspaceId: string
readonly event: SimpleFlowEvent
readonly taskSlug: string | null
readonly durationBucket: string | null
}): Promise<void> {
const sql = getSqlClient()
await sql`
insert into audit_events (
actor_user_id, workspace_id, action, resource_type,
resource_id, outcome, metadata_json
) values (
${input.actorUserId}, ${input.workspaceId},
${`product.simple_flow.${input.event}`}, 'product_metric', null,
'success',
${JSON.stringify({
taskSlug: input.taskSlug,
durationBucket: input.durationBucket,
})}::jsonb
)
`
}
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import {
persistBuiltInPlaybooks,
type BuiltInImportTransaction,
} from './built-in-importer'
describe('PostgreSQL built-in importer', () => {
it('rejects an incomplete catalog before issuing database statements', async () => {
await expect(
persistBuiltInPlaybooks({} as BuiltInImportTransaction, []),
).rejects.toMatchObject({ code: 'catalog_import_incomplete' })
})
})
@@ -0,0 +1,151 @@
import type {
BuiltInPlaybookImportRecord,
BuiltInPlaybookImportResult,
BuiltInPlaybookImportStore,
} from '@devrunbook/application'
import { requiredBuiltInPlaybookCount } from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { and, eq, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import { playbooks, playbookVersions } from '../schema'
type Database = ReturnType<typeof getDatabase>
export type BuiltInImportTransaction = Parameters<
Parameters<Database['transaction']>[0]
>[0]
export async function acquireBuiltInImportLock(
transaction: BuiltInImportTransaction,
): Promise<void> {
await transaction.execute(sql`
select pg_advisory_xact_lock(
hashtextextended('devrunbook:built-in-playbooks', 0)
)
`)
}
export async function persistBuiltInPlaybooks(
transaction: BuiltInImportTransaction,
records: readonly BuiltInPlaybookImportRecord[],
now: () => Date = () => new Date(),
): Promise<BuiltInPlaybookImportResult> {
if (records.length !== requiredBuiltInPlaybookCount) {
throw new DomainError(
'catalog_import_incomplete',
`Built-in import requires ${requiredBuiltInPlaybookCount} playbooks; received ${records.length}`,
)
}
let insertedPlaybooks = 0
let insertedVersions = 0
for (const record of records) {
const [insertedPlaybook] = await transaction
.insert(playbooks)
.values({
workspaceId: null,
logicalId: record.logicalId,
slug: record.slug,
namespace: record.namespace,
sourceType: record.sourceType,
})
.onConflictDoNothing({
target: [playbooks.namespace, playbooks.logicalId],
})
.returning({ id: playbooks.id, slug: playbooks.slug })
if (insertedPlaybook) insertedPlaybooks += 1
const existingPlaybook = insertedPlaybook
? undefined
: (
await transaction
.select({ id: playbooks.id, slug: playbooks.slug })
.from(playbooks)
.where(
and(
eq(playbooks.namespace, record.namespace),
eq(playbooks.logicalId, record.logicalId),
),
)
.limit(1)
)[0]
const playbook = insertedPlaybook ?? existingPlaybook
if (!playbook || playbook.slug !== record.slug) {
throw new DomainError(
'playbook_identity_conflict',
`Built-in playbook identity conflicts with ${record.logicalId}`,
)
}
const [insertedVersion] = await transaction
.insert(playbookVersions)
.values({
playbookId: playbook.id,
semanticVersion: record.semanticVersion,
lifecycle: record.lifecycle,
packageApiVersion: record.packageApiVersion,
title: record.title,
summary: record.summary,
category: record.category,
riskTier: record.riskTier,
packageJson: record.packageJson,
templateText: record.templateText,
contentDigest: record.contentDigest,
searchDocument: sql`to_tsvector('simple', ${record.searchProjection.searchText})`,
publishedAt: now(),
createdBy: null,
})
.onConflictDoNothing({
target: [playbookVersions.playbookId, playbookVersions.semanticVersion],
})
.returning({ contentDigest: playbookVersions.contentDigest })
if (insertedVersion) insertedVersions += 1
const existingVersion = insertedVersion
? undefined
: (
await transaction
.select({ contentDigest: playbookVersions.contentDigest })
.from(playbookVersions)
.where(
and(
eq(playbookVersions.playbookId, playbook.id),
eq(playbookVersions.semanticVersion, record.semanticVersion),
),
)
.limit(1)
)[0]
if (
(insertedVersion ?? existingVersion)?.contentDigest !==
record.contentDigest
) {
throw new DomainError(
'playbook_version_conflict',
`Published playbook ${record.slug}@${record.semanticVersion} has different content`,
)
}
}
return {
total: records.length,
insertedPlaybooks,
insertedVersions,
unchangedVersions: records.length - insertedVersions,
}
}
export class DrizzleBuiltInPlaybookImporter implements BuiltInPlaybookImportStore {
constructor(
private readonly database: Database = getDatabase(),
private readonly now: () => Date = () => new Date(),
) {}
importBuiltIns(
records: readonly BuiltInPlaybookImportRecord[],
): Promise<BuiltInPlaybookImportResult> {
return this.database.transaction(async (transaction) => {
await acquireBuiltInImportLock(transaction)
return persistBuiltInPlaybooks(transaction, records, this.now)
})
}
}
@@ -0,0 +1,356 @@
import { describe, expect, it, vi } from 'vitest'
import {
compareSemanticVersions,
DrizzlePlaybookCatalog,
effectiveCatalogSources,
type PersistedPlaybookVersionRow,
type PlaybookCatalogQuery,
type PlaybookCatalogRowSource,
type PlaybookSource,
} from './playbook-catalog'
function row(
version: string,
lifecycle = 'reviewed',
overrides: Partial<PersistedPlaybookVersionRow> = {},
): PersistedPlaybookVersionRow {
return {
playbookId: 'playbook-1',
slug: 'root-cause-bugfix',
source: 'built_in',
versionId: `version-${version}`,
version,
lifecycle,
title: 'Root-Cause Bug Fix',
summary: 'Find the defect and prove the repair.',
category: 'implementation',
riskTier: 'moderate',
digest: version.padEnd(64, '0').slice(0, 64),
manifest: {
metadata: { tags: ['bugfix', 'testing'] },
spec: {
type: 'guided',
modes: ['execute'],
defaultMode: 'execute',
autonomy: { min: 'diagnose', default: 'verify', max: 'repair' },
compatibility: { languages: ['typescript'] },
},
quality: { reviewStatus: 'technical-reviewed' },
},
template: '# Mission\n',
publishedAt: new Date('2026-01-01T00:00:00.000Z'),
searchMatches: true,
...overrides,
}
}
class FakeRows implements PlaybookCatalogRowSource {
readonly listPublished = vi.fn(async (_query: PlaybookCatalogQuery) => {
void _query
return this.values
})
readonly listPublishedBySlug = vi.fn(
async (slug: string, source?: PlaybookSource) =>
this.values.filter(
(value) => value.slug === slug && (!source || value.source === source),
),
)
readonly findPublishedVersion = vi.fn(
async (slug: string, version: string, source?: PlaybookSource) =>
this.values.find(
(value) =>
value.slug === slug &&
value.version === version &&
(!source || value.source === source),
) ?? null,
)
constructor(readonly values: readonly PersistedPlaybookVersionRow[]) {}
}
describe('Semantic Version precedence', () => {
it('handles numeric components, prereleases, and build metadata', () => {
expect(compareSemanticVersions('1.10.0', '1.9.0')).toBeGreaterThan(0)
expect(compareSemanticVersions('2.0.0', '2.0.0-rc.1')).toBeGreaterThan(0)
expect(
compareSemanticVersions('2.0.0-rc.10', '2.0.0-rc.2'),
).toBeGreaterThan(0)
expect(compareSemanticVersions('1.0.0+two', '1.0.0+one')).toBe(0)
})
})
describe('catalog source normalization', () => {
it('uses the catalog defaults for omitted and empty source filters', () => {
expect(effectiveCatalogSources(undefined)).toEqual([
'built_in',
'private',
'imported',
])
expect(effectiveCatalogSources([])).toEqual([
'built_in',
'private',
'imported',
])
expect(effectiveCatalogSources(['private'])).toEqual(['private'])
})
})
describe('DrizzlePlaybookCatalog', () => {
it('returns one deterministic recommended version per playbook', async () => {
const rows = new FakeRows([
row('3.0.0', 'deprecated'),
row('2.0.0-rc.1', 'validated'),
row('1.10.0', 'battle-tested'),
row('2.0.0', 'draft'),
row('1.9.0'),
row('1.0.0', 'reviewed', {
playbookId: 'playbook-2',
slug: 'accessibility-audit',
versionId: 'accessibility-1',
title: 'Accessibility Audit',
}),
])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.list()).resolves.toMatchObject([
{ slug: 'accessibility-audit', currentVersion: '1.0.0' },
{
slug: 'root-cause-bugfix',
currentVersion: '2.0.0-rc.1',
lifecycle: 'validated',
source: 'built_in',
type: 'guided',
defaultMode: 'execute',
defaultAutonomy: 'verify',
tags: ['bugfix', 'testing'],
},
])
})
it('forwards typed filters to the searchable row source', async () => {
const rows = new FakeRows([row('1.0.0')])
const catalog = new DrizzlePlaybookCatalog(rows)
const query: PlaybookCatalogQuery = {
q: 'root cause',
category: ['implementation'],
riskTier: ['moderate'],
lifecycle: ['reviewed'],
source: ['built_in'],
}
await catalog.list(query)
expect(rows.listPublished).toHaveBeenCalledWith(query, undefined)
})
it('treats an empty source selection as the default catalog scope', async () => {
const rows = new FakeRows([row('1.0.0')])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.list({ source: [] })).resolves.toMatchObject([
{ slug: 'root-cause-bugfix', source: 'built_in' },
])
})
it('filters type, mode, autonomy range, stack, quality and favorites', async () => {
const rows = new FakeRows([
row('1.0.0', 'reviewed', { favorite: true }),
row('1.0.0', 'reviewed', {
playbookId: 'playbook-2',
slug: 'other-playbook',
versionId: 'other-version',
manifest: {
metadata: { tags: ['other'] },
spec: {
type: 'quick',
modes: ['inspect'],
defaultMode: 'inspect',
autonomy: {
min: 'observe',
default: 'diagnose',
max: 'plan',
},
compatibility: { languages: ['go'] },
},
quality: { reviewStatus: 'editorial-reviewed' },
},
}),
])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(
catalog.list({
type: ['guided'],
mode: ['execute'],
autonomy: ['repair'],
stack: ['TypeScript'],
quality: ['technical-reviewed'],
favorite: true,
}),
).resolves.toMatchObject([
{
slug: 'root-cause-bugfix',
supportedModes: ['execute'],
autonomyMin: 'diagnose',
autonomyMax: 'repair',
stacks: ['typescript'],
qualityStatus: 'technical-reviewed',
favorite: true,
},
])
})
it('sorts deterministically by update, title, quality and relevance', async () => {
const rows = new FakeRows([
row('1.0.0', 'reviewed', {
publishedAt: new Date('2026-01-01T00:00:00Z'),
}),
row('1.0.0', 'reviewed', {
playbookId: 'playbook-2',
slug: 'accessibility-audit',
versionId: 'accessibility-version',
title: 'Accessibility Audit',
publishedAt: new Date('2026-02-01T00:00:00Z'),
manifest: {
metadata: { tags: ['accessibility'] },
spec: {
type: 'guided',
modes: ['inspect'],
defaultMode: 'inspect',
autonomy: {
min: 'observe',
default: 'diagnose',
max: 'plan',
},
compatibility: {},
},
quality: { reviewStatus: 'editorial-reviewed' },
},
}),
])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.list({ sort: 'updated' })).resolves.toMatchObject([
{ slug: 'accessibility-audit' },
{ slug: 'root-cause-bugfix' },
])
await expect(catalog.list({ sort: 'title' })).resolves.toMatchObject([
{ title: 'Accessibility Audit' },
{ title: 'Root-Cause Bug Fix' },
])
await expect(catalog.list({ sort: 'quality' })).resolves.toMatchObject([
{ slug: 'root-cause-bugfix' },
{ slug: 'accessibility-audit' },
])
await expect(
catalog.list({ q: 'bugfix', sort: 'relevance' }),
).resolves.toMatchObject([
{ slug: 'root-cause-bugfix', matchReasons: ['tag'] },
{ slug: 'accessibility-audit' },
])
})
it('applies filters to the recommended projection rather than an older version', async () => {
const rows = new FakeRows([
row('2.0.0', 'validated', { category: 'implementation' }),
row('1.0.0', 'reviewed', { category: 'audit' }),
])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.list({ category: ['audit'] })).resolves.toHaveLength(0)
await expect(
catalog.list({
q: 'root cause',
category: ['implementation'],
riskTier: ['moderate'],
lifecycle: ['validated'],
source: ['built_in'],
}),
).resolves.toMatchObject([
{ slug: 'root-cause-bugfix', currentVersion: '2.0.0' },
])
})
it('allows explicit draft and deprecated lifecycle browsing without changing the default', async () => {
const rows = new FakeRows([
row('3.0.0', 'deprecated'),
row('2.0.0', 'draft'),
row('1.0.0', 'validated'),
])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.list()).resolves.toMatchObject([
{ currentVersion: '1.0.0', lifecycle: 'validated' },
])
await expect(
catalog.list({ lifecycle: ['deprecated'] }),
).resolves.toMatchObject([
{ currentVersion: '3.0.0', lifecycle: 'deprecated' },
])
await expect(catalog.list({ lifecycle: ['draft'] })).resolves.toMatchObject(
[{ currentVersion: '2.0.0', lifecycle: 'draft' }],
)
})
it('returns current safe content and complete published version history', async () => {
const rows = new FakeRows([
row('2.0.0', 'deprecated'),
row('1.10.0', 'validated'),
row('1.9.0', 'reviewed'),
])
const catalog = new DrizzlePlaybookCatalog(rows)
const detail = await catalog.findBySlug('root-cause-bugfix', 'built_in')
expect(detail?.current).toMatchObject({
version: '1.10.0',
lifecycle: 'validated',
template: '# Mission\n',
quality: { reviewStatus: 'technical-reviewed' },
})
expect(detail?.versions.map(({ version }) => version)).toEqual([
'2.0.0',
'1.10.0',
'1.9.0',
])
await expect(
catalog.findVersionBySlug('root-cause-bugfix', '2.0.0', 'built_in'),
).resolves.toMatchObject({
version: '2.0.0',
lifecycle: 'deprecated',
manifest: { metadata: { tags: ['bugfix', 'testing'] } },
})
})
it('keeps deprecated-only identities readable without recommending them in the default list', async () => {
const rows = new FakeRows([row('2.0.0', 'deprecated')])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.list()).resolves.toEqual([])
await expect(
catalog.findBySlug('root-cause-bugfix', 'built_in'),
).resolves.toMatchObject({
current: { version: '2.0.0', lifecycle: 'deprecated' },
})
})
it('keeps Milestone 0 built-in summary callers compatible', async () => {
const rows = new FakeRows([
row('2.0.0', 'deprecated'),
row('1.10.0', 'validated'),
])
const catalog = new DrizzlePlaybookCatalog(rows)
await expect(catalog.listBuiltIns()).resolves.toEqual([
expect.objectContaining({
slug: 'root-cause-bugfix',
version: '1.10.0',
lifecycle: 'validated',
}),
])
await expect(
catalog.findBuiltInBySlug('root-cause-bugfix'),
).resolves.toMatchObject({ version: '1.10.0' })
})
})
@@ -0,0 +1,755 @@
import { and, asc, eq, inArray, isNotNull, or, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import { playbooks, playbookVersions } from '../schema'
export type PlaybookLifecycle =
'draft' | 'reviewed' | 'validated' | 'battle-tested' | 'deprecated'
export type PlaybookRiskTier = 'low' | 'moderate' | 'high' | 'critical'
export type PlaybookSource = 'built_in' | 'private' | 'imported'
export type PlaybookType = 'quick' | 'guided' | 'run-pack'
export type PlaybookMode =
'inspect' | 'plan' | 'guided' | 'execute' | 'recovery'
export type PlaybookAutonomy =
'observe' | 'diagnose' | 'plan' | 'implement' | 'verify' | 'repair'
export type PlaybookQualityStatus =
| 'unreviewed'
| 'editorial-reviewed'
| 'technical-reviewed'
| 'evaluation-backed'
export type PlaybookSort = 'relevance' | 'updated' | 'title' | 'quality'
export interface PlaybookCatalogScope {
readonly workspaceId: string
readonly userId?: string
}
export interface PlaybookCatalogQuery {
readonly q?: string
readonly category?: readonly string[]
readonly type?: readonly PlaybookType[]
readonly mode?: readonly PlaybookMode[]
readonly autonomy?: readonly PlaybookAutonomy[]
readonly riskTier?: readonly PlaybookRiskTier[]
readonly stack?: readonly string[]
readonly lifecycle?: readonly PlaybookLifecycle[]
readonly quality?: readonly PlaybookQualityStatus[]
readonly source?: readonly PlaybookSource[]
readonly favorite?: boolean
readonly sort?: PlaybookSort
}
export interface PlaybookCatalogSummary {
readonly id: string
readonly slug: string
readonly title: string
readonly summary: string
readonly category: string
readonly source: PlaybookSource
readonly currentVersion: string
readonly lifecycle: PlaybookLifecycle
readonly riskTier: PlaybookRiskTier
readonly type: string
readonly defaultMode: string
readonly defaultAutonomy: string
readonly supportedModes: readonly string[]
readonly autonomyMin: string
readonly autonomyMax: string
readonly stacks: readonly string[]
readonly qualityStatus: string
readonly publishedAt: Date
readonly favorite: boolean
readonly tags: readonly string[]
readonly matchReasons: readonly string[]
readonly digest: string
}
export interface PlaybookVersionSummary {
readonly version: string
readonly lifecycle: PlaybookLifecycle
readonly publishedAt: Date
readonly digest: string
}
export interface SafePlaybookVersionProjection {
readonly id: string
readonly playbookId: string
readonly version: string
readonly digest: string
readonly lifecycle: PlaybookLifecycle
readonly manifest: Readonly<Record<string, unknown>>
readonly template: string
readonly quality: Readonly<Record<string, unknown>>
readonly publishedAt: Date
}
export interface PlaybookCatalogDetail {
readonly id: string
readonly slug: string
readonly source: PlaybookSource
readonly favorite: boolean
readonly current: SafePlaybookVersionProjection | null
readonly versions: readonly PlaybookVersionSummary[]
}
/** Compatibility shape used by the Milestone 0 web slice. */
export interface PersistedPlaybookSummary {
readonly slug: string
readonly title: string
readonly version: string
readonly lifecycle: string
readonly category: string
readonly riskTier: string
readonly summary: string
readonly digest: string
}
export interface PersistedPlaybookVersionRow {
readonly playbookId: string
readonly slug: string
readonly source: string
readonly versionId: string
readonly version: string
readonly lifecycle: string
readonly title: string
readonly summary: string
readonly category: string
readonly riskTier: string
readonly digest: string
readonly manifest: unknown
readonly template: string
readonly publishedAt: Date
readonly searchMatches?: boolean
readonly favorite?: boolean
}
export interface PlaybookCatalogRowSource {
listPublished(
query: PlaybookCatalogQuery,
scope?: PlaybookCatalogScope,
): Promise<readonly PersistedPlaybookVersionRow[]>
listPublishedBySlug(
slug: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<readonly PersistedPlaybookVersionRow[]>
findPublishedVersion(
slug: string,
version: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<PersistedPlaybookVersionRow | null>
}
const catalogSources: readonly PlaybookSource[] = [
'built_in',
'private',
'imported',
]
export function effectiveCatalogSources(
source: readonly PlaybookSource[] | undefined,
): readonly PlaybookSource[] {
return source?.length ? source : catalogSources
}
const recommendedLifecycles: readonly PlaybookLifecycle[] = [
'reviewed',
'validated',
'battle-tested',
]
const autonomyLevels: readonly PlaybookAutonomy[] = [
'observe',
'diagnose',
'plan',
'implement',
'verify',
'repair',
]
function isSource(value: string): value is PlaybookSource {
return catalogSources.includes(value as PlaybookSource)
}
function isLifecycle(value: string): value is PlaybookLifecycle {
return [
'draft',
'reviewed',
'validated',
'battle-tested',
'deprecated',
].includes(value)
}
function isRiskTier(value: string): value is PlaybookRiskTier {
return ['low', 'moderate', 'high', 'critical'].includes(value)
}
function object(value: unknown): Readonly<Record<string, unknown>> {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Readonly<Record<string, unknown>>)
: {}
}
function string(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function strings(value: unknown): readonly string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: []
}
function stackValues(spec: Readonly<Record<string, unknown>>): string[] {
const compatibility = object(spec.compatibility)
return [
'languages',
'frameworks',
'packageManagers',
'databases',
'deploymentTypes',
].flatMap((field) => [...strings(compatibility[field])])
}
function matchReasons(
row: PersistedPlaybookVersionRow,
queryText: string | undefined,
): string[] {
const q = queryText?.trim().toLocaleLowerCase('en')
if (!q) return []
const manifest = object(row.manifest)
const metadata = object(manifest.metadata)
const spec = object(manifest.spec)
const intent = object(spec.intent)
const fields: Array<[string, readonly string[]]> = [
['title', [row.title]],
['summary', [row.summary]],
['category', [row.category]],
['tag', strings(metadata.tags)],
['problem', [string(intent.problem)]],
['outcome', [string(intent.outcome)]],
['stack', stackValues(spec)],
]
const tokens = q.split(/\s+/u).filter(Boolean)
const reasons = fields
.filter(([, values]) =>
values.some((value) =>
tokens.some((token) => value.toLocaleLowerCase('en').includes(token)),
),
)
.map(([field]) => field)
return reasons.length > 0 ? reasons : ['indexed-content']
}
function supportsAutonomy(
spec: Readonly<Record<string, unknown>>,
requested: readonly PlaybookAutonomy[],
): boolean {
if (requested.length === 0) return true
const autonomy = object(spec.autonomy)
const minimum = autonomyLevels.indexOf(
string(autonomy.min) as PlaybookAutonomy,
)
const maximum = autonomyLevels.indexOf(
string(autonomy.max) as PlaybookAutonomy,
)
return requested.some((level) => {
const index = autonomyLevels.indexOf(level)
return (
minimum >= 0 && maximum >= minimum && index >= minimum && index <= maximum
)
})
}
interface ParsedSemver {
readonly core: readonly [number, number, number]
readonly prerelease: readonly string[]
}
function parseSemver(value: string): ParsedSemver | null {
const match =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(
value,
)
if (!match) return null
return {
core: [Number(match[1]), Number(match[2]), Number(match[3])],
prerelease: match[4]?.split('.') ?? [],
}
}
function compareIdentifiers(left: string, right: string): number {
const leftNumeric = /^\d+$/u.test(left)
const rightNumeric = /^\d+$/u.test(right)
if (leftNumeric && rightNumeric) return Number(left) - Number(right)
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
return left < right ? -1 : left > right ? 1 : 0
}
/** Returns a positive number when left has higher Semantic Version precedence. */
export function compareSemanticVersions(left: string, right: string): number {
const parsedLeft = parseSemver(left)
const parsedRight = parseSemver(right)
if (!parsedLeft || !parsedRight) {
throw new Error(`Invalid persisted Semantic Version: ${left}, ${right}`)
}
for (let index = 0; index < 3; index += 1) {
const difference = parsedLeft.core[index]! - parsedRight.core[index]!
if (difference !== 0) return difference
}
if (parsedLeft.prerelease.length === 0)
return parsedRight.prerelease.length === 0 ? 0 : 1
if (parsedRight.prerelease.length === 0) return -1
const length = Math.max(
parsedLeft.prerelease.length,
parsedRight.prerelease.length,
)
for (let index = 0; index < length; index += 1) {
const leftIdentifier = parsedLeft.prerelease[index]
const rightIdentifier = parsedRight.prerelease[index]
if (leftIdentifier === undefined) return -1
if (rightIdentifier === undefined) return 1
const difference = compareIdentifiers(leftIdentifier, rightIdentifier)
if (difference !== 0) return difference
}
return 0
}
function compareRowsNewestFirst(
left: PersistedPlaybookVersionRow,
right: PersistedPlaybookVersionRow,
): number {
const precedence = compareSemanticVersions(left.version, right.version)
if (precedence !== 0) return -precedence
const versionTie = left.version.localeCompare(right.version, 'en')
return versionTie !== 0
? versionTie
: left.versionId.localeCompare(right.versionId, 'en')
}
function safeVersion(
row: PersistedPlaybookVersionRow,
): SafePlaybookVersionProjection {
if (!isLifecycle(row.lifecycle))
throw new Error(`Invalid persisted lifecycle: ${row.lifecycle}`)
const manifest = object(row.manifest)
return {
id: row.versionId,
playbookId: row.playbookId,
version: row.version,
digest: row.digest,
lifecycle: row.lifecycle,
manifest,
template: row.template,
quality: object(manifest.quality),
publishedAt: row.publishedAt,
}
}
function summary(
row: PersistedPlaybookVersionRow,
queryText?: string,
): PlaybookCatalogSummary {
if (!isSource(row.source))
throw new Error(`Invalid persisted catalog source: ${row.source}`)
if (!isLifecycle(row.lifecycle))
throw new Error(`Invalid persisted lifecycle: ${row.lifecycle}`)
if (!isRiskTier(row.riskTier))
throw new Error(`Invalid persisted risk tier: ${row.riskTier}`)
const manifest = object(row.manifest)
const metadata = object(manifest.metadata)
const spec = object(manifest.spec)
const autonomy = object(spec.autonomy)
const quality = object(manifest.quality)
return {
id: row.playbookId,
slug: row.slug,
title: row.title,
summary: row.summary,
category: row.category,
source: row.source,
currentVersion: row.version,
lifecycle: row.lifecycle,
riskTier: row.riskTier,
type: string(spec.type),
defaultMode: string(spec.defaultMode),
defaultAutonomy: string(autonomy.default),
supportedModes: strings(spec.modes),
autonomyMin: string(autonomy.min),
autonomyMax: string(autonomy.max),
stacks: stackValues(spec),
qualityStatus: string(quality.reviewStatus),
publishedAt: row.publishedAt,
favorite: row.favorite === true,
tags: strings(metadata.tags),
matchReasons: matchReasons(row, queryText),
digest: row.digest,
}
}
function selectRecommended(
rows: readonly PersistedPlaybookVersionRow[],
): PersistedPlaybookVersionRow[] {
const selected = new Map<string, PersistedPlaybookVersionRow>()
for (const row of rows) {
if (
!isLifecycle(row.lifecycle) ||
!recommendedLifecycles.includes(row.lifecycle)
) {
continue
}
const current = selected.get(row.playbookId)
if (!current || compareRowsNewestFirst(row, current) < 0) {
selected.set(row.playbookId, row)
}
}
return [...selected.values()].sort(
(left, right) =>
left.slug.localeCompare(right.slug, 'en') ||
left.playbookId.localeCompare(right.playbookId, 'en'),
)
}
function selectForCatalog(
rows: readonly PersistedPlaybookVersionRow[],
query: PlaybookCatalogQuery,
): PersistedPlaybookVersionRow[] {
if (!query.lifecycle?.length) return selectRecommended(rows)
const candidates = rows.filter(
(row) =>
isLifecycle(row.lifecycle) && query.lifecycle!.includes(row.lifecycle),
)
const selected = new Map<string, PersistedPlaybookVersionRow>()
for (const row of candidates) {
const current = selected.get(row.playbookId)
const rowRecommended = recommendedLifecycles.includes(
row.lifecycle as PlaybookLifecycle,
)
const currentRecommended =
current !== undefined &&
recommendedLifecycles.includes(current.lifecycle as PlaybookLifecycle)
if (
!current ||
(rowRecommended && !currentRecommended) ||
(rowRecommended === currentRecommended &&
compareRowsNewestFirst(row, current) < 0)
) {
selected.set(row.playbookId, row)
}
}
return [...selected.values()].sort(
(left, right) =>
left.slug.localeCompare(right.slug, 'en') ||
left.playbookId.localeCompare(right.playbookId, 'en'),
)
}
function matchesQuery(
row: PersistedPlaybookVersionRow,
query: PlaybookCatalogQuery,
): boolean {
const q = query.q?.trim()
const manifest = object(row.manifest)
const spec = object(manifest.spec)
const quality = object(manifest.quality)
const requestedStacks = query.stack?.map((value) =>
value.toLocaleLowerCase('en'),
)
const availableStacks = stackValues(spec).map((value) =>
value.toLocaleLowerCase('en'),
)
return (
(!q || row.searchMatches === true) &&
(!query.category?.length || query.category.includes(row.category)) &&
(!query.type?.length ||
query.type.includes(string(spec.type) as PlaybookType)) &&
(!query.mode?.length ||
strings(spec.modes).some((mode) =>
query.mode!.includes(mode as PlaybookMode),
)) &&
(!query.autonomy?.length || supportsAutonomy(spec, query.autonomy)) &&
(!query.riskTier?.length ||
(isRiskTier(row.riskTier) && query.riskTier.includes(row.riskTier))) &&
(!requestedStacks?.length ||
requestedStacks.some((stack) => availableStacks.includes(stack))) &&
(!query.lifecycle?.length ||
(isLifecycle(row.lifecycle) &&
query.lifecycle.includes(row.lifecycle))) &&
(!query.quality?.length ||
query.quality.includes(
string(quality.reviewStatus) as PlaybookQualityStatus,
)) &&
(!query.source?.length ||
(isSource(row.source) && query.source.includes(row.source))) &&
(query.favorite !== true || row.favorite === true)
)
}
const qualityRank: Record<string, number> = {
unreviewed: 0,
'editorial-reviewed': 1,
'technical-reviewed': 2,
'evaluation-backed': 3,
}
const matchReasonWeight: Record<string, number> = {
title: 8,
tag: 6,
problem: 5,
outcome: 5,
summary: 4,
stack: 3,
category: 2,
'indexed-content': 1,
}
function sortSummaries(
items: PlaybookCatalogSummary[],
sort: PlaybookSort = 'relevance',
): PlaybookCatalogSummary[] {
const stable = (
left: PlaybookCatalogSummary,
right: PlaybookCatalogSummary,
) =>
left.slug.localeCompare(right.slug, 'en') ||
left.id.localeCompare(right.id, 'en')
return items.sort((left, right) => {
if (sort === 'updated') {
const difference =
right.publishedAt.getTime() - left.publishedAt.getTime()
return difference || stable(left, right)
}
if (sort === 'title') {
return left.title.localeCompare(right.title, 'en') || stable(left, right)
}
if (sort === 'quality') {
const difference =
(qualityRank[right.qualityStatus] ?? -1) -
(qualityRank[left.qualityStatus] ?? -1)
return difference || stable(left, right)
}
const score = (item: PlaybookCatalogSummary) =>
item.matchReasons.reduce(
(total, reason) => total + (matchReasonWeight[reason] ?? 0),
0,
)
return score(right) - score(left) || stable(left, right)
})
}
class DrizzlePlaybookCatalogRowSource implements PlaybookCatalogRowSource {
async listPublished(
query: PlaybookCatalogQuery,
scope?: PlaybookCatalogScope,
): Promise<readonly PersistedPlaybookVersionRow[]> {
const q = query.q?.trim()
const predicates = [
isNotNull(playbookVersions.publishedAt),
inArray(playbooks.sourceType, [...effectiveCatalogSources(query.source)]),
scope
? or(
eq(playbooks.sourceType, 'built_in'),
eq(playbooks.workspaceId, scope.workspaceId),
)
: eq(playbooks.sourceType, 'built_in'),
]
return this.selectRows(and(...predicates), q, scope)
}
async listPublishedBySlug(
slug: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<readonly PersistedPlaybookVersionRow[]> {
return this.selectRows(
and(
isNotNull(playbookVersions.publishedAt),
eq(playbooks.slug, slug),
source ? eq(playbooks.sourceType, source) : undefined,
inArray(playbooks.sourceType, [...catalogSources]),
scope
? or(
eq(playbooks.sourceType, 'built_in'),
eq(playbooks.workspaceId, scope.workspaceId),
)
: eq(playbooks.sourceType, 'built_in'),
),
undefined,
scope,
)
}
async findPublishedVersion(
slug: string,
version: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<PersistedPlaybookVersionRow | null> {
const rows = await this.selectRows(
and(
isNotNull(playbookVersions.publishedAt),
eq(playbooks.slug, slug),
eq(playbookVersions.semanticVersion, version),
source ? eq(playbooks.sourceType, source) : undefined,
inArray(playbooks.sourceType, [...catalogSources]),
scope
? or(
eq(playbooks.sourceType, 'built_in'),
eq(playbooks.workspaceId, scope.workspaceId),
)
: eq(playbooks.sourceType, 'built_in'),
),
undefined,
scope,
)
return rows[0] ?? null
}
private async selectRows(
predicate: ReturnType<typeof and>,
q?: string,
scope?: PlaybookCatalogScope,
): Promise<readonly PersistedPlaybookVersionRow[]> {
const rows = await getDatabase()
.select({
playbookId: playbooks.id,
slug: playbooks.slug,
source: playbooks.sourceType,
versionId: playbookVersions.id,
version: playbookVersions.semanticVersion,
lifecycle: playbookVersions.lifecycle,
title: playbookVersions.title,
summary: playbookVersions.summary,
category: playbookVersions.category,
riskTier: playbookVersions.riskTier,
digest: playbookVersions.contentDigest,
manifest: playbookVersions.packageJson,
template: playbookVersions.templateText,
publishedAt: playbookVersions.publishedAt,
searchMatches: q
? sql<boolean>`${playbookVersions.searchDocument} @@ websearch_to_tsquery('simple', ${q})`
: sql<boolean>`true`,
favorite: scope?.userId
? sql<boolean>`exists (
select 1 from favorites f
where f.workspace_id = ${scope.workspaceId}
and f.user_id = ${scope.userId}
and f.playbook_id = ${playbooks.id}
)`
: sql<boolean>`false`,
})
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(predicate)
.orderBy(
asc(playbooks.slug),
asc(playbookVersions.semanticVersion),
asc(playbookVersions.id),
)
return rows.map((row) => ({
...row,
publishedAt: row.publishedAt!,
}))
}
}
export class DrizzlePlaybookCatalog {
constructor(
private readonly rows: PlaybookCatalogRowSource = new DrizzlePlaybookCatalogRowSource(),
) {}
async list(
query: PlaybookCatalogQuery = {},
scope?: PlaybookCatalogScope,
): Promise<PlaybookCatalogSummary[]> {
return sortSummaries(
selectForCatalog(await this.rows.listPublished(query, scope), query)
.filter((row) => matchesQuery(row, query))
.map((row) => summary(row, query.q)),
query.sort,
)
}
async findBySlug(
slug: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<PlaybookCatalogDetail | null> {
const rows = [
...(await this.rows.listPublishedBySlug(slug, source, scope)),
].sort(compareRowsNewestFirst)
if (rows.length === 0) return null
const current = selectRecommended(rows)[0] ?? rows[0]!
const identity = rows[0]!
if (!isSource(identity.source))
throw new Error(`Invalid persisted catalog source: ${identity.source}`)
return {
id: identity.playbookId,
slug: identity.slug,
source: identity.source,
favorite: identity.favorite === true,
current: safeVersion(current),
versions: rows.map((row) => {
if (!isLifecycle(row.lifecycle))
throw new Error(`Invalid persisted lifecycle: ${row.lifecycle}`)
return {
version: row.version,
lifecycle: row.lifecycle,
publishedAt: row.publishedAt,
digest: row.digest,
}
}),
}
}
async findVersionBySlug(
slug: string,
version: string,
source?: PlaybookSource,
scope?: PlaybookCatalogScope,
): Promise<SafePlaybookVersionProjection | null> {
const row = await this.rows.findPublishedVersion(
slug,
version,
source,
scope,
)
return row ? safeVersion(row) : null
}
async listBuiltIns(): Promise<PersistedPlaybookSummary[]> {
return (await this.list({ source: ['built_in'] })).map((item) => ({
slug: item.slug,
title: item.title,
version: item.currentVersion,
lifecycle: item.lifecycle,
category: item.category,
riskTier: item.riskTier,
summary: item.summary,
digest: item.digest,
}))
}
async findBuiltInBySlug(
slug: string,
): Promise<PersistedPlaybookSummary | null> {
const currentRow = selectRecommended(
await this.rows.listPublishedBySlug(slug, 'built_in'),
)[0]
if (!currentRow) return null
const current = summary(currentRow)
return {
slug: current.slug,
title: current.title,
version: current.currentVersion,
lifecycle: current.lifecycle,
category: current.category,
riskTier: current.riskTier,
summary: current.summary,
digest: current.digest,
}
}
}
@@ -0,0 +1,170 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzlePlaybookCollectionStore } from './playbook-collection-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)(
'DrizzlePlaybookCollectionStore integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const creatorA = randomUUID()
const creatorB = randomUUID()
const builtInPlaybookId = randomUUID()
const workspaceAPlaybookId = randomUUID()
const workspaceBPlaybookId = randomUUID()
let store: DrizzlePlaybookCollectionStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values
(${creatorA}, ${`collections-a-${creatorA}@example.invalid`}, 'A', 'fixture', 'user', 'active'),
(${creatorB}, ${`collections-b-${creatorB}@example.invalid`}, 'B', 'fixture', 'user', 'active')
`
await sql`
insert into workspaces (id, name, type) values
(${workspaceA}, 'Collections A', 'team'),
(${workspaceB}, 'Collections B', 'team')
`
await sql`
insert into playbooks (
id, workspace_id, logical_id, slug, namespace, source_type
) values
(${builtInPlaybookId}, null, ${randomUUID()}, ${`collections-built-in-${builtInPlaybookId}`}, 'builtin', 'built_in'),
(${workspaceAPlaybookId}, ${workspaceA}, ${randomUUID()}, ${`collections-a-${workspaceAPlaybookId}`}, ${`private-${workspaceA}`}, 'private'),
(${workspaceBPlaybookId}, ${workspaceB}, ${randomUUID()}, ${`collections-b-${workspaceBPlaybookId}`}, ${`private-${workspaceB}`}, 'private')
`
store = new DrizzlePlaybookCollectionStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from playbooks where id in (${builtInPlaybookId}, ${workspaceAPlaybookId}, ${workspaceBPlaybookId})`
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id in (${creatorA}, ${creatorB})`
await closeDatabase()
})
it('keeps names and lists personal within a shared workspace', async () => {
const first = await store.create({
workspaceId: workspaceA,
createdBy: creatorA,
name: 'Release checks',
description: 'A owned',
})
const second = await store.create({
workspaceId: workspaceA,
createdBy: creatorB,
name: 'Release checks',
description: 'B owned',
})
expect(first).not.toBe('duplicate')
expect(second).not.toBe('duplicate')
await expect(
store.create({
workspaceId: workspaceA,
createdBy: creatorA,
name: 'Release checks',
description: 'duplicate',
}),
).resolves.toBe('duplicate')
const listed = await store.list({
workspaceId: workspaceA,
createdBy: creatorA,
})
expect(listed).toHaveLength(1)
expect(listed[0]).toMatchObject({
name: 'Release checks',
description: 'A owned',
itemCount: 0,
playbookIds: [],
})
})
it('adds accessible targets idempotently and blocks substitutions', async () => {
const [collection] = await store.list({
workspaceId: workspaceA,
createdBy: creatorA,
})
expect(collection).toBeDefined()
for (const playbookId of [builtInPlaybookId, workspaceAPlaybookId]) {
await expect(
store.mutateItem({
workspaceId: workspaceA,
createdBy: creatorA,
collectionId: collection!.id,
playbookId,
mutation: 'add',
}),
).resolves.toBe(true)
}
await store.mutateItem({
workspaceId: workspaceA,
createdBy: creatorA,
collectionId: collection!.id,
playbookId: builtInPlaybookId,
mutation: 'add',
})
await expect(
store.mutateItem({
workspaceId: workspaceA,
createdBy: creatorA,
collectionId: collection!.id,
playbookId: workspaceBPlaybookId,
mutation: 'add',
}),
).resolves.toBe(false)
await expect(
store.mutateItem({
workspaceId: workspaceA,
createdBy: creatorB,
collectionId: collection!.id,
playbookId: workspaceAPlaybookId,
mutation: 'add',
}),
).resolves.toBe(false)
await expect(
store.mutateItem({
workspaceId: workspaceB,
createdBy: creatorA,
collectionId: collection!.id,
playbookId: builtInPlaybookId,
mutation: 'add',
}),
).resolves.toBe(false)
const [updated] = await store.list({
workspaceId: workspaceA,
createdBy: creatorA,
})
expect(updated).toMatchObject({ itemCount: 2 })
expect(updated?.playbookIds).toEqual(
expect.arrayContaining([builtInPlaybookId, workspaceAPlaybookId]),
)
const sql = getSqlClient()
const audits = await sql<{ action: string; count: number }[]>`
select action, count(*)::int as count
from audit_events
where workspace_id = ${workspaceA} and resource_id = ${collection!.id}
group by action
`
expect(audits).toEqual(
expect.arrayContaining([
{ action: 'collection.created', count: 1 },
{ action: 'collection.item.added', count: 2 },
]),
)
})
},
)
@@ -0,0 +1,68 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { describe, expect, it } from 'vitest'
import * as schema from '../schema'
import {
buildAccessibleCollectionTargetQuery,
buildOwnedCollectionsQuery,
isOwnerNameConflict,
} from './playbook-collection-store'
const workspaceId = '00000000-0000-4000-8000-000000000001'
const createdBy = '00000000-0000-4000-8000-000000000002'
const collectionId = '00000000-0000-4000-8000-000000000003'
const playbookId = '00000000-0000-4000-8000-000000000004'
describe('collection authorization queries', () => {
it('recognizes only the governed owner-name conflict through wrappers', () => {
expect(
isOwnerNameConflict({
cause: {
code: '23505',
constraint_name: 'collections_owner_name_uq',
},
}),
).toBe(true)
expect(
isOwnerNameConflict({
cause: { code: '23505', constraint_name: 'another_constraint' },
}),
).toBe(false)
})
it('lists by both workspace and creator identity', () => {
const database = drizzle.mock({ schema })
const query = buildOwnedCollectionsQuery(database, {
workspaceId,
createdBy,
}).toSQL()
expect(query.sql).toContain('"collections"."workspace_id" = $')
expect(query.sql).toContain('"collections"."created_by" = $')
expect(query.params).toEqual(
expect.arrayContaining([workspaceId, createdBy]),
)
})
it('requires an owned collection and built-in or same-workspace playbook', () => {
const database = drizzle.mock({ schema })
const query = buildAccessibleCollectionTargetQuery(database, {
workspaceId,
createdBy,
collectionId,
playbookId,
}).toSQL()
expect(query.sql).toContain('inner join "playbooks"')
expect(query.sql).toContain('"collections"."workspace_id" = $')
expect(query.sql).toContain('"collections"."created_by" = $')
expect(query.sql).toContain('"playbooks"."source_type" = $')
expect(query.params).toEqual(
expect.arrayContaining([
workspaceId,
createdBy,
collectionId,
playbookId,
'built_in',
]),
)
})
})
@@ -0,0 +1,189 @@
import type {
PlaybookCollection,
PlaybookCollectionStore,
} from '@devrunbook/application'
import { and, asc, eq, or, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import { auditEvents, collectionItems, collections, playbooks } from '../schema'
type Database = ReturnType<typeof getDatabase>
export function isOwnerNameConflict(error: unknown): boolean {
let candidate = error
for (let depth = 0; depth < 4; depth += 1) {
if (candidate === null || typeof candidate !== 'object') return false
if (
'code' in candidate &&
candidate.code === '23505' &&
'constraint_name' in candidate &&
candidate.constraint_name === 'collections_owner_name_uq'
) {
return true
}
candidate = 'cause' in candidate ? candidate.cause : undefined
}
return false
}
function collectionProjection() {
return {
id: collections.id,
name: collections.name,
description: collections.description,
itemCount: sql<number>`count(${collectionItems.playbookId})::int`,
playbookIds: sql<
string[]
>`coalesce(array_agg(${collectionItems.playbookId} order by ${collectionItems.position}, ${collectionItems.addedAt}, ${collectionItems.playbookId}) filter (where ${collectionItems.playbookId} is not null), '{}'::uuid[])`,
createdAt: collections.createdAt,
updatedAt: collections.updatedAt,
}
}
export function buildOwnedCollectionsQuery(
database: Pick<Database, 'select'>,
input: { readonly workspaceId: string; readonly createdBy: string },
) {
return database
.select(collectionProjection())
.from(collections)
.leftJoin(collectionItems, eq(collectionItems.collectionId, collections.id))
.where(
and(
eq(collections.workspaceId, input.workspaceId),
eq(collections.createdBy, input.createdBy),
),
)
.groupBy(collections.id)
.orderBy(asc(collections.name), asc(collections.id))
}
export function buildAccessibleCollectionTargetQuery(
database: Pick<Database, 'select'>,
input: {
readonly workspaceId: string
readonly createdBy: string
readonly collectionId: string
readonly playbookId: string
},
) {
return database
.select({ collectionId: collections.id })
.from(collections)
.innerJoin(
playbooks,
and(
eq(playbooks.id, input.playbookId),
or(
eq(playbooks.sourceType, 'built_in'),
eq(playbooks.workspaceId, input.workspaceId),
),
),
)
.where(
and(
eq(collections.id, input.collectionId),
eq(collections.workspaceId, input.workspaceId),
eq(collections.createdBy, input.createdBy),
),
)
.limit(1)
}
export class DrizzlePlaybookCollectionStore implements PlaybookCollectionStore {
constructor(private readonly database: Database = getDatabase()) {}
async list(input: {
readonly workspaceId: string
readonly createdBy: string
}): Promise<readonly PlaybookCollection[]> {
return buildOwnedCollectionsQuery(this.database, input)
}
async create(input: {
readonly workspaceId: string
readonly createdBy: string
readonly name: string
readonly description: string
}): Promise<PlaybookCollection | 'duplicate'> {
try {
return await this.database.transaction(async (transaction) => {
const [created] = await transaction
.insert(collections)
.values(input)
.returning()
if (!created) throw new Error('Collection insert returned no row')
await transaction.insert(auditEvents).values({
actorUserId: input.createdBy,
workspaceId: input.workspaceId,
action: 'collection.created',
resourceType: 'collection',
resourceId: created.id,
outcome: 'success',
metadataJson: {},
})
return {
id: created.id,
name: created.name,
description: created.description,
itemCount: 0,
playbookIds: [],
createdAt: created.createdAt,
updatedAt: created.updatedAt,
}
})
} catch (error) {
if (isOwnerNameConflict(error)) return 'duplicate'
throw error
}
}
async mutateItem(input: {
readonly workspaceId: string
readonly createdBy: string
readonly collectionId: string
readonly playbookId: string
readonly mutation: 'add' | 'remove'
}): Promise<boolean> {
return this.database.transaction(async (transaction) => {
const [target] = await buildAccessibleCollectionTargetQuery(
transaction,
input,
)
if (!target) return false
const changed =
input.mutation === 'add'
? await transaction
.insert(collectionItems)
.values({
collectionId: input.collectionId,
playbookId: input.playbookId,
})
.onConflictDoNothing()
.returning({ playbookId: collectionItems.playbookId })
: await transaction
.delete(collectionItems)
.where(
and(
eq(collectionItems.collectionId, input.collectionId),
eq(collectionItems.playbookId, input.playbookId),
),
)
.returning({ playbookId: collectionItems.playbookId })
if (changed.length > 0) {
await transaction.insert(auditEvents).values({
actorUserId: input.createdBy,
workspaceId: input.workspaceId,
action: `collection.item.${input.mutation === 'add' ? 'added' : 'removed'}`,
resourceType: 'collection',
resourceId: input.collectionId,
outcome: 'success',
metadataJson: { playbookId: input.playbookId },
})
}
return true
})
}
}
@@ -0,0 +1,90 @@
import { drizzle } from 'drizzle-orm/postgres-js'
import { describe, expect, it } from 'vitest'
import * as schema from '../schema'
import {
buildAccessibleFavoriteTargetQuery,
DrizzlePlaybookFavoriteStore,
type PlaybookFavoriteTransaction,
type PlaybookFavoriteTransactionRunner,
} from './playbook-favorite-store'
const workspaceId = '00000000-0000-4000-8000-000000000001'
const userId = '00000000-0000-4000-8000-000000000002'
const playbookId = '00000000-0000-4000-8000-000000000003'
class MemoryRunner implements PlaybookFavoriteTransactionRunner {
accessible = true
favorite = false
additions = 0
removals = 0
async run<T>(
work: (transaction: PlaybookFavoriteTransaction) => Promise<T>,
): Promise<T> {
return work({
targetIsAccessible: async () => this.accessible,
add: async () => {
if (!this.favorite) this.additions += 1
this.favorite = true
},
remove: async () => {
if (this.favorite) this.removals += 1
this.favorite = false
},
})
}
}
describe('favorite target query', () => {
it('accepts only the requested built-in or same-workspace playbook', () => {
const database = drizzle.mock({ schema })
const query = buildAccessibleFavoriteTargetQuery(database, {
workspaceId,
playbookId,
}).toSQL()
expect(query.sql).toContain('from "playbooks"')
expect(query.sql).toContain('"playbooks"."source_type" = $')
expect(query.sql).toContain('"playbooks"."workspace_id" = $')
expect(query.params).toEqual(
expect.arrayContaining([playbookId, 'built_in', workspaceId]),
)
})
})
describe('DrizzlePlaybookFavoriteStore', () => {
it('adds and removes idempotently', async () => {
const runner = new MemoryRunner()
const store = new DrizzlePlaybookFavoriteStore(runner)
const input = { workspaceId, userId, playbookId }
await expect(
store.mutateFavorite({ ...input, mutation: 'add' }),
).resolves.toBe(true)
await store.mutateFavorite({ ...input, mutation: 'add' })
await expect(
store.mutateFavorite({ ...input, mutation: 'remove' }),
).resolves.toBe(true)
await store.mutateFavorite({ ...input, mutation: 'remove' })
expect(runner.additions).toBe(1)
expect(runner.removals).toBe(1)
})
it('does not mutate a missing or inaccessible target', async () => {
const runner = new MemoryRunner()
runner.accessible = false
const store = new DrizzlePlaybookFavoriteStore(runner)
await expect(
store.mutateFavorite({
workspaceId,
userId,
playbookId,
mutation: 'add',
}),
).resolves.toBe(false)
expect(runner.favorite).toBe(false)
})
})
@@ -0,0 +1,116 @@
import type {
PlaybookFavoriteMutation,
PlaybookFavoriteStore,
} from '@devrunbook/application'
import { and, eq, or } from 'drizzle-orm'
import { getDatabase } from '../index'
import { favorites, playbooks } from '../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
type QueryExecutor = Pick<Database, 'select'>
export interface FavoriteTarget {
readonly workspaceId: string
readonly playbookId: string
}
export function buildAccessibleFavoriteTargetQuery(
database: QueryExecutor,
target: FavoriteTarget,
) {
return database
.select({ id: playbooks.id })
.from(playbooks)
.where(
and(
eq(playbooks.id, target.playbookId),
or(
eq(playbooks.sourceType, 'built_in'),
eq(playbooks.workspaceId, target.workspaceId),
),
),
)
.limit(1)
}
export interface PlaybookFavoriteTransaction {
targetIsAccessible(target: FavoriteTarget): Promise<boolean>
add(input: FavoriteTarget & { readonly userId: string }): Promise<void>
remove(input: FavoriteTarget & { readonly userId: string }): Promise<void>
}
export interface PlaybookFavoriteTransactionRunner {
run<T>(
work: (transaction: PlaybookFavoriteTransaction) => Promise<T>,
): Promise<T>
}
class DrizzlePlaybookFavoriteTransaction implements PlaybookFavoriteTransaction {
constructor(private readonly transaction: Transaction) {}
async targetIsAccessible(target: FavoriteTarget): Promise<boolean> {
const [row] = await buildAccessibleFavoriteTargetQuery(
this.transaction,
target,
)
return Boolean(row)
}
async add(
input: FavoriteTarget & { readonly userId: string },
): Promise<void> {
await this.transaction.insert(favorites).values(input).onConflictDoNothing()
}
async remove(
input: FavoriteTarget & { readonly userId: string },
): Promise<void> {
await this.transaction
.delete(favorites)
.where(
and(
eq(favorites.workspaceId, input.workspaceId),
eq(favorites.userId, input.userId),
eq(favorites.playbookId, input.playbookId),
),
)
}
}
export class DrizzlePlaybookFavoriteTransactionRunner implements PlaybookFavoriteTransactionRunner {
constructor(private readonly database: Database = getDatabase()) {}
run<T>(
work: (transaction: PlaybookFavoriteTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction((transaction) =>
work(new DrizzlePlaybookFavoriteTransaction(transaction)),
)
}
}
export class DrizzlePlaybookFavoriteStore implements PlaybookFavoriteStore {
constructor(
private readonly runner: PlaybookFavoriteTransactionRunner = new DrizzlePlaybookFavoriteTransactionRunner(),
) {}
mutateFavorite(input: {
readonly workspaceId: string
readonly userId: string
readonly playbookId: string
readonly mutation: PlaybookFavoriteMutation
}): Promise<boolean> {
return this.runner.run(async (transaction) => {
const target = {
workspaceId: input.workspaceId,
playbookId: input.playbookId,
}
if (!(await transaction.targetIsAccessible(target))) return false
if (input.mutation === 'add') await transaction.add(input)
else await transaction.remove(input)
return true
})
}
}
@@ -0,0 +1,155 @@
import { createHash, randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import {
DrizzlePlaybookPackageFileStore,
type PlaybookPackageFileInput,
} from './playbook-package-file-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function packageFile(path: string, text: string): PlaybookPackageFileInput {
const content = Buffer.from(text)
return {
path,
role: path === 'prompt.md' ? 'template' : 'documentation',
content,
sizeBytes: content.byteLength,
sha256: createHash('sha256').update(content).digest('hex'),
}
}
describe.skipIf(!databaseIntegration)(
'DrizzlePlaybookPackageFileStore integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
const playbookId = randomUUID()
const versionId = randomUUID()
const initialDigest = 'a'.repeat(64)
let store: DrizzlePlaybookPackageFileStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`package-files-${userId}@example.invalid`},
'Package file integration', 'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type) values
(${workspaceA}, 'Package file integration A', 'team'),
(${workspaceB}, 'Package file integration B', 'team')
`
await sql`
insert into playbooks (
id, workspace_id, logical_id, slug, namespace, source_type
) values (
${playbookId}, ${workspaceA}, ${randomUUID()}, ${`draft-${playbookId}`},
${`private-${workspaceA}`}, 'private'
)
`
await sql`
insert into playbook_versions (
id, playbook_id, semantic_version, lifecycle, package_api_version,
title, summary, category, risk_tier, package_json, template_text,
content_digest, created_by
) values (
${versionId}, ${playbookId}, '1.0.0', 'draft',
'devrunbook.io/v1alpha1', 'Draft', 'Draft', 'testing', 'low',
'{}'::jsonb, '', ${initialDigest}, ${userId}
)
`
store = new DrizzlePlaybookPackageFileStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('atomically replaces a workspace-scoped draft inventory with CAS', async () => {
const files = [
packageFile('README.md', 'docs\n'),
packageFile('prompt.md', 'prompt\n'),
]
const draftDigest = 'b'.repeat(64)
const replaced = await store.replaceDraftFiles({
workspaceId: workspaceA,
playbookVersionId: versionId,
files,
expected: { draftRevision: 1, draftDigest: initialDigest },
draftDigest,
draftValidation: {
valid: false,
issues: [{ path: 'playbook.yaml', line: 4, message: 'Required' }],
},
})
expect(replaced).toMatchObject({ draftRevision: 2, draftDigest })
expect(replaced?.files.map((item) => item.path)).toEqual([
'README.md',
'prompt.md',
])
await expect(
store.listForWorkspaceVersion(workspaceB, versionId),
).resolves.toBeNull()
await expect(
store.listForWorkspaceVersion(workspaceA, versionId),
).resolves.toHaveLength(2)
await expect(
store.replaceDraftFiles({
workspaceId: workspaceA,
playbookVersionId: versionId,
files: [packageFile('prompt.md', 'stale\n')],
expected: { draftRevision: 1, draftDigest: initialDigest },
draftDigest: 'c'.repeat(64),
draftValidation: { valid: true, issues: [] },
}),
).rejects.toMatchObject({ code: 'playbook_draft_conflict' })
await expect(
store.listForWorkspaceVersion(workspaceA, versionId),
).resolves.toHaveLength(2)
})
it('enforces file integrity and published immutability inside PostgreSQL', async () => {
const sql = getSqlClient()
await expect(
sql`
insert into playbook_package_files (
playbook_version_id, path, role, content, size_bytes, sha256
) values (
${versionId}, '../escape', 'resource', ${Buffer.from('x')}, 1,
${createHash('sha256').update('x').digest('hex')}
)
`,
).rejects.toBeDefined()
await sql`
update playbook_versions set published_at = now() where id = ${versionId}
`
await expect(
sql`delete from playbook_package_files where playbook_version_id = ${versionId}`,
).rejects.toBeDefined()
await expect(
store.replaceDraftFiles({
workspaceId: workspaceA,
playbookVersionId: versionId,
files: [],
expected: { draftRevision: 2, draftDigest: 'b'.repeat(64) },
draftDigest: 'd'.repeat(64),
draftValidation: { valid: true, issues: [] },
}),
).rejects.toMatchObject({ code: 'published_playbook_immutable' })
})
},
)
@@ -0,0 +1,75 @@
import { createHash } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import {
MAX_PLAYBOOK_PACKAGE_FILE_BYTES,
validatePlaybookPackageFiles,
type PlaybookPackageFileInput,
} from './playbook-package-file-store'
function file(
path = 'prompt.md',
content = Buffer.from('prompt\n'),
): PlaybookPackageFileInput {
return {
path,
role: 'template',
content,
sizeBytes: content.byteLength,
sha256: createHash('sha256').update(content).digest('hex'),
}
}
describe('playbook package file validation', () => {
it('accepts normalized content with an exact byte count and digest', () => {
expect(() =>
validatePlaybookPackageFiles([
file(),
{ ...file('playbook.yaml'), role: 'manifest' },
]),
).not.toThrow()
})
it.each([
'../secret',
'/absolute',
'nested//file',
'nested/./file',
'nested\\file',
' prompt.md',
'cafe\u0301.md',
])('rejects unsafe or non-normalized path %s', (path) => {
expect(() => validatePlaybookPackageFiles([file(path)])).toThrowError(
expect.objectContaining({ code: 'playbook_package_file_invalid' }),
)
})
it('rejects duplicate paths regardless of role', () => {
expect(() =>
validatePlaybookPackageFiles([
file(),
{ ...file(), role: 'documentation' },
]),
).toThrowError(
expect.objectContaining({ code: 'playbook_package_file_invalid' }),
)
})
it('rejects mismatched sizes, digests, unsupported roles and oversized files', () => {
const valid = file()
for (const invalid of [
{ ...valid, sizeBytes: valid.sizeBytes + 1 },
{ ...valid, sha256: 'A'.repeat(64) },
{ ...valid, role: 'executable' as never },
{
...valid,
content: Buffer.alloc(MAX_PLAYBOOK_PACKAGE_FILE_BYTES + 1),
sizeBytes: MAX_PLAYBOOK_PACKAGE_FILE_BYTES + 1,
},
]) {
expect(() => validatePlaybookPackageFiles([invalid])).toThrowError(
expect.objectContaining({ code: 'playbook_package_file_invalid' }),
)
}
})
})
@@ -0,0 +1,269 @@
import { DomainError } from '@devrunbook/domain'
import { and, asc, eq } from 'drizzle-orm'
import { createHash } from 'node:crypto'
import { getDatabase } from '../index'
import { playbookPackageFiles, playbooks, playbookVersions } from '../schema'
type Database = ReturnType<typeof getDatabase>
export const MAX_PLAYBOOK_PACKAGE_FILES = 500
export const MAX_PLAYBOOK_PACKAGE_FILE_BYTES = 5 * 1024 * 1024
export const PLAYBOOK_PACKAGE_FILE_ROLES = [
'manifest',
'template',
'partial',
'documentation',
'changelog',
'example',
'evaluation',
'resource',
'run-pack-resource',
] as const
export type PlaybookPackageFileRole =
(typeof PLAYBOOK_PACKAGE_FILE_ROLES)[number]
export interface PlaybookPackageFileInput {
readonly path: string
readonly role: PlaybookPackageFileRole
readonly content: Buffer
readonly sizeBytes: number
readonly sha256: string
}
export interface StoredPlaybookPackageFile extends PlaybookPackageFileInput {
readonly id: string
readonly playbookVersionId: string
readonly createdAt: string
}
export interface ReplaceDraftPackageFilesRequest {
readonly workspaceId: string
readonly playbookVersionId: string
readonly files: readonly PlaybookPackageFileInput[]
readonly expected: {
readonly draftRevision: number
readonly draftDigest: string
}
readonly draftDigest: string
readonly draftValidation: Readonly<Record<string, unknown>>
}
export interface ReplaceDraftPackageFilesResult {
readonly draftRevision: number
readonly draftDigest: string
readonly files: readonly StoredPlaybookPackageFile[]
}
const digestPattern = /^[0-9a-f]{64}$/u
const roles = new Set<string>(PLAYBOOK_PACKAGE_FILE_ROLES)
function invalidFile(
message: string,
details?: Record<string, unknown>,
): never {
throw new DomainError('playbook_package_file_invalid', message, details)
}
export function validatePlaybookPackageFiles(
files: readonly PlaybookPackageFileInput[],
): void {
if (files.length > MAX_PLAYBOOK_PACKAGE_FILES) {
invalidFile('A playbook package cannot contain more than 500 files')
}
const paths = new Set<string>()
for (const file of files) {
const segments = file.path.split('/')
if (
file.path.length < 1 ||
file.path.length > 512 ||
file.path !== file.path.trim() ||
file.path !== file.path.normalize('NFC') ||
file.path.startsWith('/') ||
file.path.includes('\\') ||
segments.some(
(segment) =>
segment.length === 0 || segment === '.' || segment === '..',
)
) {
invalidFile('Package file path must be a normalized safe relative path', {
path: file.path,
})
}
if (paths.has(file.path)) {
invalidFile('Package file paths must be unique', { path: file.path })
}
paths.add(file.path)
if (!roles.has(file.role)) {
invalidFile('Package file role is unsupported', {
path: file.path,
role: file.role,
})
}
if (
!Number.isSafeInteger(file.sizeBytes) ||
file.sizeBytes < 0 ||
file.sizeBytes > MAX_PLAYBOOK_PACKAGE_FILE_BYTES ||
file.content.byteLength !== file.sizeBytes
) {
invalidFile('Package file size is invalid', { path: file.path })
}
const actualDigest = createHash('sha256').update(file.content).digest('hex')
if (!digestPattern.test(file.sha256) || file.sha256 !== actualDigest) {
invalidFile('Package file SHA-256 does not match its content', {
path: file.path,
})
}
}
}
function mapFile(
row: typeof playbookPackageFiles.$inferSelect,
): StoredPlaybookPackageFile {
return {
id: row.id,
playbookVersionId: row.playbookVersionId,
path: row.path,
role: row.role as PlaybookPackageFileRole,
content: Buffer.from(row.content),
sizeBytes: row.sizeBytes,
sha256: row.sha256,
createdAt: row.createdAt.toISOString(),
}
}
export class DrizzlePlaybookPackageFileStore {
constructor(private readonly database: Database = getDatabase()) {}
async listForWorkspaceVersion(
workspaceId: string,
playbookVersionId: string,
): Promise<readonly StoredPlaybookPackageFile[] | null> {
const [visible] = await this.database
.select({ id: playbookVersions.id })
.from(playbookVersions)
.innerJoin(playbooks, eq(playbooks.id, playbookVersions.playbookId))
.where(
and(
eq(playbooks.workspaceId, workspaceId),
eq(playbookVersions.id, playbookVersionId),
),
)
.limit(1)
if (!visible) return null
const rows = await this.database
.select()
.from(playbookPackageFiles)
.where(eq(playbookPackageFiles.playbookVersionId, playbookVersionId))
.orderBy(asc(playbookPackageFiles.path))
return rows.map(mapFile)
}
replaceDraftFiles(
request: ReplaceDraftPackageFilesRequest,
): Promise<ReplaceDraftPackageFilesResult | null> {
validatePlaybookPackageFiles(request.files)
const validatedFiles = request.files.map((file) => ({
...file,
content: Buffer.from(file.content),
}))
if (!digestPattern.test(request.draftDigest)) {
throw new DomainError(
'playbook_draft_digest_invalid',
'Draft digest must be a lowercase SHA-256 value',
)
}
if (
!request.draftValidation ||
Array.isArray(request.draftValidation) ||
typeof request.draftValidation !== 'object'
) {
throw new DomainError(
'playbook_draft_validation_invalid',
'Draft validation result must be an object',
)
}
return this.database.transaction(async (transaction) => {
const [version] = await transaction
.select({
id: playbookVersions.id,
publishedAt: playbookVersions.publishedAt,
draftRevision: playbookVersions.draftRevision,
draftDigest: playbookVersions.draftDigest,
})
.from(playbookVersions)
.innerJoin(playbooks, eq(playbooks.id, playbookVersions.playbookId))
.where(
and(
eq(playbooks.workspaceId, request.workspaceId),
eq(playbookVersions.id, request.playbookVersionId),
),
)
.for('update')
.limit(1)
if (!version) return null
if (version.publishedAt) {
throw new DomainError(
'published_playbook_immutable',
'Published playbook package files cannot be changed',
)
}
if (
version.draftRevision !== request.expected.draftRevision ||
version.draftDigest !== request.expected.draftDigest
) {
throw new DomainError(
'playbook_draft_conflict',
'Playbook draft changed since it was read',
{
currentDraftRevision: version.draftRevision,
currentDraftDigest: version.draftDigest,
},
)
}
await transaction
.delete(playbookPackageFiles)
.where(
eq(playbookPackageFiles.playbookVersionId, request.playbookVersionId),
)
const inserted =
validatedFiles.length === 0
? []
: await transaction
.insert(playbookPackageFiles)
.values(
validatedFiles.map((file) => ({
playbookVersionId: request.playbookVersionId,
path: file.path,
role: file.role,
content: file.content,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})),
)
.returning()
const draftRevision = version.draftRevision + 1
await transaction
.update(playbookVersions)
.set({
draftRevision,
draftDigest: request.draftDigest,
draftValidationJson: request.draftValidation,
})
.where(eq(playbookVersions.id, version.id))
return {
draftRevision,
draftDigest: request.draftDigest,
files: inserted
.map(mapFile)
.sort((left, right) =>
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
),
}
})
}
}
@@ -0,0 +1,207 @@
import { createHash, randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type {
ValidatedPrivatePlaybookFile,
ValidatedPrivatePlaybookPackage,
} from '@devrunbook/application'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzlePrivatePlaybookDraftStore } from './private-playbook-draft-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function file(
path: string,
text: string,
role: ValidatedPrivatePlaybookFile['role'],
overrides: Partial<ValidatedPrivatePlaybookFile> = {},
): ValidatedPrivatePlaybookFile {
const content = Buffer.from(text)
return {
path,
role,
mediaType: 'text/plain',
content,
sizeBytes: content.byteLength,
sha256: createHash('sha256').update(content).digest('hex'),
digest: true,
exportByDefault: true,
...overrides,
}
}
function packageValue(
suffix: string,
lifecycle: ValidatedPrivatePlaybookPackage['lifecycle'] = 'draft',
): ValidatedPrivatePlaybookPackage {
return {
logicalId: `private-${suffix}`,
slug: `private-${suffix}`,
semanticVersion: '0.1.0',
lifecycle,
packageApiVersion: 'devrunbook.io/v1alpha1',
title: `Private ${suffix}`,
summary: `Private package ${suffix}`,
category: 'Authoring',
riskTier: 'moderate',
packageJson: { kind: 'PlaybookPackage', suffix },
templateText: `# ${suffix}\n`,
contentDigest: createHash('sha256').update(suffix).digest('hex'),
searchText: `Private ${suffix}\nAuthoring`,
files: [
file(
'playbook.yaml',
`kind: PlaybookPackage\n# ${suffix}\n`,
'manifest',
{
mediaType: 'application/yaml',
},
),
file('prompt.md', `# ${suffix}\n`, 'template', {
mediaType: 'text/x-private-prompt',
digest: false,
exportByDefault: false,
}),
],
}
}
describe.skipIf(!databaseIntegration)(
'DrizzlePrivatePlaybookDraftStore integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
const namespace = `private.${workspaceA}`
const now = new Date('2026-07-27T12:00:00.000Z')
let store: DrizzlePrivatePlaybookDraftStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`private-draft-${userId}@example.invalid`},
'Private draft integration', 'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type) values
(${workspaceA}, 'Private draft integration A', 'team'),
(${workspaceB}, 'Private draft integration B', 'team')
`
store = new DrizzlePrivatePlaybookDraftStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('creates, scopes and atomically CAS-replaces the complete projection', async () => {
const initialPackage = packageValue('initial')
const created = await store.createDraft({
workspaceId: workspaceA,
createdBy: userId,
namespace,
package: initialPackage,
now,
})
expect(created).toMatchObject({
logicalId: initialPackage.logicalId,
draftRevision: 1,
draftDigest: initialPackage.contentDigest,
})
expect(created.files[1]).toMatchObject({
path: 'prompt.md',
mediaType: 'text/x-private-prompt',
digest: false,
exportByDefault: false,
})
await expect(
store.findVersionForWorkspace(workspaceB, created.versionId),
).resolves.toBeNull()
await expect(store.listDraftsForWorkspace(workspaceB)).resolves.toEqual(
[],
)
const replacement = {
...packageValue('replacement', 'reviewed'),
semanticVersion: '0.2.0',
category: 'Operations',
riskTier: 'high' as const,
}
const replaced = await store.replaceDraft({
workspaceId: workspaceA,
versionId: created.versionId,
updatedBy: userId,
expectedRevision: created.draftRevision,
expectedDigest: created.draftDigest,
package: replacement,
now: new Date(now.getTime() + 60_000),
})
expect(replaced).toMatchObject({
playbookId: created.playbookId,
logicalId: replacement.logicalId,
slug: replacement.slug,
semanticVersion: '0.2.0',
lifecycle: 'reviewed',
category: 'Operations',
riskTier: 'high',
draftRevision: 2,
draftDigest: replacement.contentDigest,
})
expect(Buffer.from(replaced!.files[1]!.content)).toEqual(
Buffer.from('# replacement\n'),
)
await expect(
store.replaceDraft({
workspaceId: workspaceA,
versionId: created.versionId,
updatedBy: userId,
expectedRevision: 1,
expectedDigest: initialPackage.contentDigest,
package: initialPackage,
now,
}),
).rejects.toMatchObject({ code: 'private_playbook_draft_conflict' })
await expect(
store.replaceDraft({
workspaceId: workspaceB,
versionId: created.versionId,
updatedBy: userId,
expectedRevision: 2,
expectedDigest: replacement.contentDigest,
package: replacement,
now,
}),
).resolves.toBeNull()
})
it('turns identity uniqueness violations into safe domain conflicts', async () => {
const duplicate = packageValue('duplicate')
await store.createDraft({
workspaceId: workspaceA,
createdBy: userId,
namespace,
package: duplicate,
now,
})
await expect(
store.createDraft({
workspaceId: workspaceA,
createdBy: userId,
namespace,
package: duplicate,
now,
}),
).rejects.toMatchObject({ code: 'private_playbook_draft_conflict' })
})
},
)
@@ -0,0 +1,209 @@
import { createHash } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'
import { DrizzlePrivatePlaybookDraftStore } from './private-playbook-draft-store'
const createdAt = new Date('2026-07-27T10:00:00.000Z')
const changedAt = new Date('2026-07-27T11:00:00.000Z')
const playbook = {
id: '00000000-0000-4000-8000-000000000001',
workspaceId: '00000000-0000-4000-8000-000000000002',
logicalId: 'private-example',
slug: 'private-example',
namespace: 'private.workspace',
sourceType: 'private',
createdAt,
updatedAt: createdAt,
}
const version = {
id: '00000000-0000-4000-8000-000000000003',
playbookId: playbook.id,
semanticVersion: '0.1.0',
lifecycle: 'draft',
packageApiVersion: 'devrunbook.io/v1alpha1',
title: 'Private example',
summary: 'Summary',
category: 'Authoring',
riskTier: 'low',
packageJson: { kind: 'PlaybookPackage' },
templateText: '# Prompt\n',
contentDigest: 'a'.repeat(64),
draftRevision: 2,
draftDigest: 'b'.repeat(64),
draftValidationJson: {
valid: true,
issues: [],
persistence: {
privatePackageFileMetadata: {
'prompt.md': {
mediaType: 'text/x-devrunbook',
digest: false,
exportByDefault: false,
},
},
},
},
searchDocument: null,
publishedAt: null,
supersedesVersionId: null,
createdBy: '00000000-0000-4000-8000-000000000004',
createdAt,
}
const file = {
id: '00000000-0000-4000-8000-000000000005',
playbookVersionId: version.id,
path: 'prompt.md',
role: 'template',
content: Buffer.from('# Prompt\n'),
sizeBytes: 9,
sha256: 'c'.repeat(64),
createdAt: changedAt,
}
describe('DrizzlePrivatePlaybookDraftStore', () => {
it('maps workspace summaries without exposing package bytes', async () => {
const query = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn(async () => [{ playbook, version }]),
}
const store = new DrizzlePrivatePlaybookDraftStore({
select: vi.fn(() => query),
} as never)
await expect(
store.listDraftsForWorkspace(playbook.workspaceId),
).resolves.toEqual([
{
playbookId: playbook.id,
versionId: version.id,
slug: playbook.slug,
semanticVersion: version.semanticVersion,
title: version.title,
lifecycle: 'draft',
draftRevision: 2,
draftDigest: version.draftDigest,
publishedAt: null,
updatedAt: createdAt.toISOString(),
},
])
})
it('restores exact bytes and namespaced file metadata', async () => {
const versionQuery = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(async () => [{ playbook, version }]),
}
const fileQuery = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn(async () => [file]),
}
const select = vi
.fn()
.mockReturnValueOnce(versionQuery)
.mockReturnValueOnce(fileQuery)
const store = new DrizzlePrivatePlaybookDraftStore({ select } as never)
const found = await store.findVersionForWorkspace(
playbook.workspaceId,
version.id,
)
expect(found).toMatchObject({
logicalId: playbook.logicalId,
draftRevision: 2,
updatedAt: changedAt.toISOString(),
})
expect(found?.files[0]).toMatchObject({
path: 'prompt.md',
mediaType: 'text/x-devrunbook',
digest: false,
exportByDefault: false,
})
expect(Buffer.from(found!.files[0]!.content)).toEqual(file.content)
})
it('returns null before querying files when the workspace projection is absent', async () => {
const query = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(async () => []),
}
const select = vi.fn(() => query)
const store = new DrizzlePrivatePlaybookDraftStore({ select } as never)
await expect(
store.findVersionForWorkspace(playbook.workspaceId, version.id),
).resolves.toBeNull()
expect(select).toHaveBeenCalledTimes(1)
})
it('rejects published mutation before issuing an update', async () => {
const query = {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
for: vi.fn().mockReturnThis(),
limit: vi.fn(async () => [
{
playbook,
version: { ...version, publishedAt: changedAt },
},
]),
}
const transaction = {
select: vi.fn(() => query),
update: vi.fn(),
}
const database = {
transaction: vi.fn(
async (operation: (value: typeof transaction) => unknown) =>
operation(transaction),
),
}
const content = Buffer.from('# Prompt\n')
const store = new DrizzlePrivatePlaybookDraftStore(database as never)
await expect(
store.replaceDraft({
workspaceId: playbook.workspaceId,
versionId: version.id,
updatedBy: version.createdBy,
expectedRevision: version.draftRevision,
expectedDigest: version.draftDigest,
now: changedAt,
package: {
logicalId: playbook.logicalId,
slug: playbook.slug,
semanticVersion: version.semanticVersion,
lifecycle: 'reviewed',
packageApiVersion: version.packageApiVersion,
title: version.title,
summary: version.summary,
category: version.category,
riskTier: 'low',
packageJson: version.packageJson,
templateText: version.templateText,
contentDigest: version.contentDigest,
searchText: version.title,
files: [
{
path: 'prompt.md',
role: 'template',
mediaType: 'text/markdown',
content,
sizeBytes: content.byteLength,
sha256: createHash('sha256').update(content).digest('hex'),
digest: true,
exportByDefault: true,
},
],
},
}),
).rejects.toMatchObject({ code: 'private_playbook_published_immutable' })
expect(transaction.update).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,475 @@
import type {
PrivatePlaybookDraft,
PrivatePlaybookDraftStore,
PrivatePlaybookDraftSummary,
ValidatedPrivatePlaybookFile,
ValidatedPrivatePlaybookPackage,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { and, asc, desc, eq, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
playbookPackageFiles,
playbooks,
playbookVersions,
} from '../schema'
import { validatePlaybookPackageFiles } from './playbook-package-file-store'
type Database = ReturnType<typeof getDatabase>
type VersionRow = typeof playbookVersions.$inferSelect
type PlaybookRow = typeof playbooks.$inferSelect
type FileRow = typeof playbookPackageFiles.$inferSelect
interface FileMetadata {
readonly mediaType: string
readonly digest: boolean
readonly exportByDefault: boolean
}
interface DraftValidationProjection extends Record<string, unknown> {
readonly valid: true
readonly issues: readonly never[]
readonly persistence: {
readonly privatePackageFileMetadata: Readonly<Record<string, FileMetadata>>
}
}
interface JoinedVersionRow {
readonly playbook: PlaybookRow
readonly version: VersionRow
}
const lifecycles = new Set([
'draft',
'reviewed',
'validated',
'battle-tested',
'deprecated',
])
const riskTiers = new Set(['low', 'moderate', 'high', 'critical'])
function persistenceProjection(
files: readonly ValidatedPrivatePlaybookFile[],
): DraftValidationProjection {
return {
valid: true,
issues: [],
persistence: {
privatePackageFileMetadata: Object.fromEntries(
files.map((file) => [
file.path,
{
mediaType: file.mediaType,
digest: file.digest,
exportByDefault: file.exportByDefault,
},
]),
),
},
}
}
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function persistedFileMetadata(
validation: unknown,
path: string,
): FileMetadata | null {
const root = object(validation)
const persistence = object(root?.persistence)
const metadata = object(persistence?.privatePackageFileMetadata)
const value = object(metadata?.[path])
return value &&
typeof value.mediaType === 'string' &&
typeof value.digest === 'boolean' &&
typeof value.exportByDefault === 'boolean'
? {
mediaType: value.mediaType,
digest: value.digest,
exportByDefault: value.exportByDefault,
}
: null
}
function fallbackMediaType(path: string): string {
if (/\.ya?ml$/iu.test(path)) return 'application/yaml'
if (/\.md$/iu.test(path)) return 'text/markdown'
if (/\.json$/iu.test(path)) return 'application/json'
if (/\.(?:csv|toml|tsv|txt|xml)$/iu.test(path)) return 'text/plain'
return 'application/octet-stream'
}
function mapFile(
row: FileRow,
validation: unknown,
): ValidatedPrivatePlaybookFile {
const metadata = persistedFileMetadata(validation, row.path)
return {
path: row.path,
role: row.role as ValidatedPrivatePlaybookFile['role'],
mediaType: metadata?.mediaType ?? fallbackMediaType(row.path),
content: new Uint8Array(row.content),
sizeBytes: row.sizeBytes,
sha256: row.sha256,
digest: metadata?.digest ?? true,
exportByDefault: metadata?.exportByDefault ?? true,
}
}
function lifecycle(value: string): PrivatePlaybookDraft['lifecycle'] {
if (!lifecycles.has(value)) {
throw new Error(`Invalid persisted private playbook lifecycle: ${value}`)
}
return value as PrivatePlaybookDraft['lifecycle']
}
function riskTier(value: string): PrivatePlaybookDraft['riskTier'] {
if (!riskTiers.has(value)) {
throw new Error(`Invalid persisted private playbook risk tier: ${value}`)
}
return value as PrivatePlaybookDraft['riskTier']
}
function updatedAt(version: VersionRow, files: readonly FileRow[]): string {
const latest = files.reduce(
(current, file) =>
file.createdAt.getTime() > current.getTime() ? file.createdAt : current,
version.createdAt,
)
return latest.toISOString()
}
function mapSummary(row: JoinedVersionRow): PrivatePlaybookDraftSummary {
return {
playbookId: row.playbook.id,
versionId: row.version.id,
slug: row.playbook.slug,
semanticVersion: row.version.semanticVersion,
title: row.version.title,
lifecycle: lifecycle(row.version.lifecycle),
draftRevision: row.version.draftRevision,
draftDigest: row.version.draftDigest,
publishedAt: row.version.publishedAt?.toISOString() ?? null,
updatedAt: row.playbook.updatedAt.toISOString(),
}
}
function mapDraft(
row: JoinedVersionRow,
files: readonly FileRow[],
): PrivatePlaybookDraft {
return {
...mapSummary(row),
updatedAt: updatedAt(row.version, files),
logicalId: row.playbook.logicalId,
packageApiVersion: row.version.packageApiVersion,
summary: row.version.summary,
category: row.version.category,
riskTier: riskTier(row.version.riskTier),
packageJson: row.version.packageJson,
templateText: row.version.templateText,
files: [...files]
.sort((left, right) =>
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
)
.map((file) => mapFile(file, row.version.draftValidationJson)),
}
}
function fileValues(
versionId: string,
files: readonly ValidatedPrivatePlaybookFile[],
now: Date,
) {
return files.map((file) => ({
playbookVersionId: versionId,
path: file.path,
role: file.role,
content: Buffer.from(file.content),
sizeBytes: file.sizeBytes,
sha256: file.sha256,
createdAt: now,
}))
}
function validateFiles(files: readonly ValidatedPrivatePlaybookFile[]): void {
if (files.length === 0) {
throw new DomainError(
'playbook_package_file_invalid',
'A private playbook package must contain files',
)
}
validatePlaybookPackageFiles(
files.map((file) => ({
path: file.path,
role: file.role,
content: Buffer.from(file.content),
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})),
)
}
function conflict(error?: unknown): never {
throw new DomainError(
'private_playbook_draft_conflict',
'A private playbook with this identity, slug or version already exists',
error instanceof Error ? { cause: error.name } : undefined,
)
}
function isUniqueViolation(error: unknown): boolean {
let current = error
const visited = new Set<unknown>()
while (
typeof current === 'object' &&
current !== null &&
!visited.has(current)
) {
if ('code' in current && current.code === '23505') return true
visited.add(current)
current = 'cause' in current ? current.cause : null
}
return false
}
export class DrizzlePrivatePlaybookDraftStore implements PrivatePlaybookDraftStore {
constructor(private readonly database: Database = getDatabase()) {}
async listDraftsForWorkspace(
workspaceId: string,
): Promise<readonly PrivatePlaybookDraftSummary[]> {
const rows = await this.database
.select({ playbook: playbooks, version: playbookVersions })
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(
and(
eq(playbooks.workspaceId, workspaceId),
eq(playbooks.sourceType, 'private'),
),
)
.orderBy(desc(playbookVersions.createdAt), asc(playbookVersions.id))
return rows.map(mapSummary)
}
async findVersionForWorkspace(
workspaceId: string,
versionId: string,
): Promise<PrivatePlaybookDraft | null> {
const [row] = await this.database
.select({ playbook: playbooks, version: playbookVersions })
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(
and(
eq(playbooks.workspaceId, workspaceId),
eq(playbooks.sourceType, 'private'),
eq(playbookVersions.id, versionId),
),
)
.limit(1)
if (!row) return null
const files = await this.database
.select()
.from(playbookPackageFiles)
.where(eq(playbookPackageFiles.playbookVersionId, versionId))
.orderBy(asc(playbookPackageFiles.path))
return mapDraft(row, files)
}
async createDraft(request: {
readonly workspaceId: string
readonly createdBy: string
readonly namespace: string
readonly package: ValidatedPrivatePlaybookPackage
readonly now: Date
}): Promise<PrivatePlaybookDraft> {
validateFiles(request.package.files)
try {
return await this.database.transaction(async (transaction) => {
const [playbook] = await transaction
.insert(playbooks)
.values({
workspaceId: request.workspaceId,
logicalId: request.package.logicalId,
slug: request.package.slug,
namespace: request.namespace,
sourceType: 'private',
createdAt: request.now,
updatedAt: request.now,
})
.returning()
const [version] = await transaction
.insert(playbookVersions)
.values({
playbookId: playbook!.id,
semanticVersion: request.package.semanticVersion,
lifecycle: request.package.lifecycle,
packageApiVersion: request.package.packageApiVersion,
title: request.package.title,
summary: request.package.summary,
category: request.package.category,
riskTier: request.package.riskTier,
packageJson: request.package.packageJson,
templateText: request.package.templateText,
contentDigest: request.package.contentDigest,
draftRevision: 1,
draftDigest: request.package.contentDigest,
draftValidationJson: persistenceProjection(request.package.files),
searchDocument: sql`to_tsvector('simple', ${request.package.searchText})`,
createdBy: request.createdBy,
createdAt: request.now,
})
.returning()
const files = await transaction
.insert(playbookPackageFiles)
.values(fileValues(version!.id, request.package.files, request.now))
.returning()
await transaction.insert(auditEvents).values({
occurredAt: request.now,
actorUserId: request.createdBy,
workspaceId: request.workspaceId,
action: 'private_playbook.draft_created',
resourceType: 'playbook_version',
resourceId: version!.id,
outcome: 'success',
metadataJson: {
playbookId: playbook!.id,
draftRevision: 1,
draftDigest: request.package.contentDigest,
},
})
return mapDraft(
{ playbook: playbook!, version: version! },
files.sort((left, right) =>
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
),
)
})
} catch (error) {
if (isUniqueViolation(error)) conflict(error)
throw error
}
}
async replaceDraft(request: {
readonly workspaceId: string
readonly versionId: string
readonly updatedBy: string
readonly expectedRevision: number
readonly expectedDigest: string
readonly package: ValidatedPrivatePlaybookPackage
readonly now: Date
}): Promise<PrivatePlaybookDraft | null> {
validateFiles(request.package.files)
try {
return await this.database.transaction(async (transaction) => {
const [row] = await transaction
.select({ playbook: playbooks, version: playbookVersions })
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(
and(
eq(playbooks.workspaceId, request.workspaceId),
eq(playbooks.sourceType, 'private'),
eq(playbookVersions.id, request.versionId),
),
)
.for('update')
.limit(1)
if (!row) return null
if (row.version.publishedAt) {
throw new DomainError(
'private_playbook_published_immutable',
'Published playbook versions cannot be edited',
)
}
if (
row.version.draftRevision !== request.expectedRevision ||
row.version.draftDigest !== request.expectedDigest
) {
conflict()
}
const [playbook] = await transaction
.update(playbooks)
.set({
logicalId: request.package.logicalId,
slug: request.package.slug,
updatedAt: request.now,
})
.where(eq(playbooks.id, row.playbook.id))
.returning()
const [version] = await transaction
.update(playbookVersions)
.set({
semanticVersion: request.package.semanticVersion,
lifecycle: request.package.lifecycle,
packageApiVersion: request.package.packageApiVersion,
title: request.package.title,
summary: request.package.summary,
category: request.package.category,
riskTier: request.package.riskTier,
packageJson: request.package.packageJson,
templateText: request.package.templateText,
contentDigest: request.package.contentDigest,
draftRevision: row.version.draftRevision + 1,
draftDigest: request.package.contentDigest,
draftValidationJson: persistenceProjection(request.package.files),
searchDocument: sql`to_tsvector('simple', ${request.package.searchText})`,
})
.where(eq(playbookVersions.id, row.version.id))
.returning()
await transaction
.delete(playbookPackageFiles)
.where(eq(playbookPackageFiles.playbookVersionId, row.version.id))
const files = await transaction
.insert(playbookPackageFiles)
.values(
fileValues(row.version.id, request.package.files, request.now),
)
.returning()
await transaction.insert(auditEvents).values({
occurredAt: request.now,
actorUserId: request.updatedBy,
workspaceId: request.workspaceId,
action: 'private_playbook.draft_replaced',
resourceType: 'playbook_version',
resourceId: row.version.id,
outcome: 'success',
metadataJson: {
playbookId: row.playbook.id,
previousDraftRevision: row.version.draftRevision,
draftRevision: version!.draftRevision,
draftDigest: request.package.contentDigest,
},
})
return mapDraft(
{ playbook: playbook!, version: version! },
files.sort((left, right) =>
Buffer.compare(Buffer.from(left.path), Buffer.from(right.path)),
),
)
})
} catch (error) {
if (isUniqueViolation(error)) conflict(error)
throw error
}
}
}
@@ -0,0 +1,410 @@
import { createHash, randomUUID } from 'node:crypto'
import {
createQualityMatrix,
type StaticEvaluationCase,
type StaticEvaluationResult,
type ValidatedPrivatePlaybookFile,
type ValidatedPrivatePlaybookPackage,
} from '@devrunbook/application'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzlePlaybookCatalog } from './playbook-catalog'
import { DrizzlePrivatePlaybookDraftStore } from './private-playbook-draft-store'
import { DrizzlePrivatePlaybookPublicationStore } from './private-playbook-publication-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function file(
path: string,
text: string,
role: ValidatedPrivatePlaybookFile['role'],
): ValidatedPrivatePlaybookFile {
const content = Buffer.from(text)
return {
path,
role,
mediaType: path.endsWith('.md') ? 'text/markdown' : 'application/yaml',
content,
sizeBytes: content.byteLength,
sha256: createHash('sha256').update(content).digest('hex'),
digest: true,
exportByDefault: true,
}
}
function packageValue(suffix: string): ValidatedPrivatePlaybookPackage {
const contentDigest = createHash('sha256').update(suffix).digest('hex')
return {
logicalId: `private-${suffix}`,
slug: `private-${suffix}`,
semanticVersion: '1.0.0',
lifecycle: 'draft',
packageApiVersion: 'devrunbook.io/v1alpha1',
title: `Private ${suffix}`,
summary: 'Publication integration package',
category: 'Authoring',
riskTier: 'moderate',
packageJson: { kind: 'PlaybookPackage', suffix },
templateText: '# Safe prompt\n',
contentDigest,
searchText: `Private ${suffix}`,
files: [
file('playbook.yaml', `kind: PlaybookPackage\n# ${suffix}\n`, 'manifest'),
file('prompt.md', '# Safe prompt\n', 'template'),
file('CHANGELOG.md', '# Changelog\n\n- Initial release.\n', 'changelog'),
],
}
}
describe.skipIf(!databaseIntegration)(
'private playbook publication and evaluation persistence',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
let drafts: DrizzlePrivatePlaybookDraftStore
let publication: DrizzlePrivatePlaybookPublicationStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`publication-${userId}@example.invalid`},
'Publication integration', 'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type) values
(${workspaceA}, 'Publication integration A', 'team'),
(${workspaceB}, 'Publication integration B', 'team')
`
drafts = new DrizzlePrivatePlaybookDraftStore()
publication = new DrizzlePrivatePlaybookPublicationStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('binds review and latest static evidence to the exact draft digest', async () => {
const now = new Date('2026-07-27T14:00:00.000Z')
const draft = await drafts.createDraft({
workspaceId: workspaceA,
createdBy: userId,
namespace: `private.${workspaceA}`,
package: packageValue('evidence'),
now,
})
const fixtureDigest = 'b'.repeat(64)
const environmentDigest = 'c'.repeat(64)
const evaluationCase: StaticEvaluationCase = {
id: 'safe-render',
version: '1.0.0',
target: {
id: draft.logicalId,
version: draft.semanticVersion,
digest: draft.draftDigest,
},
fixture: {
id: 'fixture.safe-repository',
version: '1.0.0',
digest: fixtureDigest,
environmentDigest,
},
expectedHeadings: ['# Safe prompt'],
requiredText: [],
prohibitedText: [],
deterministic: true,
}
await expect(
publication.upsertStaticCase({
workspaceId: workspaceB,
versionId: draft.versionId,
evaluationCase,
caseDigest: 'd'.repeat(64),
now,
}),
).resolves.toBeNull()
await expect(
publication.upsertStaticCase({
workspaceId: workspaceA,
versionId: draft.versionId,
evaluationCase,
caseDigest: 'd'.repeat(64),
now,
}),
).resolves.toEqual(expect.any(String))
const result: StaticEvaluationResult = {
caseId: evaluationCase.id,
caseVersion: evaluationCase.version,
target: evaluationCase.target,
fixture: evaluationCase.fixture,
status: 'passed',
checks: [],
evaluatedAt: now.toISOString(),
renderedPromptDigest: 'e'.repeat(64),
dimensions: createQualityMatrix([]),
}
await expect(
publication.appendStaticResult({
workspaceId: workspaceA,
versionId: draft.versionId,
logicalCaseId: evaluationCase.id,
fixtureVersion: evaluationCase.fixture.version,
result,
environment: { runtime: 'static-only' },
executedBy: userId,
now,
}),
).resolves.toEqual(expect.any(String))
await expect(
publication.attestReview({
workspaceId: workspaceA,
versionId: draft.versionId,
reviewedBy: userId,
attestedDigest: draft.draftDigest,
schemaAndSemanticValidationPassed: true,
blockingLintFindingCount: 0,
limitationsDocumented: true,
unresolvedSafetyRegression: false,
review: { checklistVersion: '1.0.0' },
reviewedAt: now,
}),
).resolves.toBe(true)
const candidate = await publication.findPublicationCandidate(
workspaceA,
draft.versionId,
)
expect(candidate).toMatchObject({
evidence: {
schemaAndSemanticValidationPassed: true,
blockingLintFindingCount: 0,
humanEditorialReviewCompleted: true,
limitationsDocumented: true,
unresolvedSafetyRegression: false,
evaluationResults: [{ caseId: 'safe-render', status: 'passed' }],
},
policy: { requiredEvaluationCaseIds: ['safe-render'] },
})
await publication.appendStaticResult({
workspaceId: workspaceA,
versionId: draft.versionId,
logicalCaseId: evaluationCase.id,
fixtureVersion: evaluationCase.fixture.version,
result: {
...result,
status: 'failed',
evaluatedAt: new Date(now.getTime() + 500).toISOString(),
},
environment: { runtime: 'static-only' },
executedBy: userId,
now: new Date(now.getTime() + 500),
})
await expect(
publication.publishDraft({
workspaceId: workspaceA,
versionId: draft.versionId,
publishedBy: userId,
expectedRevision: draft.draftRevision,
expectedDigest: draft.draftDigest,
lifecycle: 'validated',
now: new Date(now.getTime() + 600),
}),
).rejects.toMatchObject({ code: 'private_playbook_publish_conflict' })
await expect(
publication.findPublicationCandidate(workspaceA, draft.versionId),
).resolves.toMatchObject({
evidence: { evaluationResults: [{ status: 'failed' }] },
})
const changed = {
...packageValue('evidence'),
contentDigest: 'f'.repeat(64),
}
const replaced = await drafts.replaceDraft({
workspaceId: workspaceA,
versionId: draft.versionId,
updatedBy: userId,
expectedRevision: draft.draftRevision,
expectedDigest: draft.draftDigest,
package: changed,
now: new Date(now.getTime() + 1000),
})
const staleCandidate = await publication.findPublicationCandidate(
workspaceA,
draft.versionId,
)
expect(replaced?.draftDigest).toBe(changed.contentDigest)
expect(staleCandidate?.evidence).toMatchObject({
schemaAndSemanticValidationPassed: false,
humanEditorialReviewCompleted: false,
evaluationResults: [],
})
})
it('publishes with CAS, keeps the source immutable and clones exact package bytes', async () => {
const now = new Date('2026-07-27T15:00:00.000Z')
const draft = await drafts.createDraft({
workspaceId: workspaceA,
createdBy: userId,
namespace: `private.${workspaceA}`,
package: packageValue('publish'),
now,
})
await publication.attestReview({
workspaceId: workspaceA,
versionId: draft.versionId,
reviewedBy: userId,
attestedDigest: draft.draftDigest,
schemaAndSemanticValidationPassed: true,
blockingLintFindingCount: 0,
limitationsDocumented: true,
unresolvedSafetyRegression: false,
review: { checklistVersion: '1.0.0' },
reviewedAt: now,
})
const published = await publication.publishDraft({
workspaceId: workspaceA,
versionId: draft.versionId,
publishedBy: userId,
expectedRevision: draft.draftRevision,
expectedDigest: draft.draftDigest,
lifecycle: 'reviewed',
now,
})
expect(published).toMatchObject({
lifecycle: 'reviewed',
publishedAt: now.toISOString(),
})
const catalog = new DrizzlePlaybookCatalog()
await expect(
catalog.list(
{ source: ['private'] },
{ workspaceId: workspaceA, userId },
),
).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
slug: draft.slug,
currentVersion: draft.semanticVersion,
source: 'private',
}),
]),
)
await expect(
catalog.list(
{ source: ['private'] },
{ workspaceId: workspaceB, userId },
),
).resolves.not.toEqual(
expect.arrayContaining([expect.objectContaining({ slug: draft.slug })]),
)
await expect(
publication.publishDraft({
workspaceId: workspaceA,
versionId: draft.versionId,
publishedBy: userId,
expectedRevision: draft.draftRevision,
expectedDigest: draft.draftDigest,
lifecycle: 'reviewed',
now,
}),
).rejects.toMatchObject({ code: 'private_playbook_publish_conflict' })
const next = await publication.createNextDraft({
workspaceId: workspaceA,
sourceVersionId: draft.versionId,
semanticVersion: '1.1.0',
createdBy: userId,
now: new Date(now.getTime() + 1000),
})
expect(next).toMatchObject({
lifecycle: 'draft',
semanticVersion: '1.1.0',
publishedAt: null,
draftRevision: 1,
draftDigest: draft.draftDigest,
})
expect(next?.packageJson).toEqual(draft.packageJson)
expect(
next?.files.map((item) => [item.path, Buffer.from(item.content)]),
).toEqual(
draft.files.map((item) => [item.path, Buffer.from(item.content)]),
)
await expect(
publication.createNextDraft({
workspaceId: workspaceA,
sourceVersionId: draft.versionId,
semanticVersion: '1.1.0',
createdBy: userId,
now,
}),
).rejects.toMatchObject({ code: 'private_playbook_publish_conflict' })
await publication.attestReview({
workspaceId: workspaceA,
versionId: next!.versionId,
reviewedBy: userId,
attestedDigest: next!.draftDigest,
schemaAndSemanticValidationPassed: true,
blockingLintFindingCount: 0,
limitationsDocumented: true,
unresolvedSafetyRegression: false,
review: { checklistVersion: '1.0.0', purpose: 'deprecation' },
reviewedAt: new Date(now.getTime() + 2000),
})
await expect(
publication.publishDraft({
workspaceId: workspaceA,
versionId: next!.versionId,
publishedBy: userId,
expectedRevision: next!.draftRevision,
expectedDigest: next!.draftDigest,
lifecycle: 'deprecated',
now: new Date(now.getTime() + 2000),
}),
).resolves.toMatchObject({ lifecycle: 'deprecated' })
await expect(
catalog.findBySlug(draft.slug, 'private', {
workspaceId: workspaceA,
userId,
}),
).resolves.toMatchObject({
current: { version: '1.0.0', lifecycle: 'reviewed' },
versions: expect.arrayContaining([
expect.objectContaining({
version: '1.1.0',
lifecycle: 'deprecated',
}),
]),
})
const sql = getSqlClient()
const [audit] = await sql<
[{ actions: string[] }]
>`select array_agg(action order by occurred_at) as actions
from audit_events
where workspace_id = ${workspaceA}
and resource_id in (${draft.versionId}, ${next!.versionId})`
expect(audit.actions).toEqual(
expect.arrayContaining([
'private_playbook.draft_created',
'private_playbook.version_published',
'private_playbook.next_draft_created',
]),
)
})
},
)
@@ -0,0 +1,52 @@
import type { StaticEvaluationCase } from '@devrunbook/application'
import { describe, expect, it } from 'vitest'
import { DrizzlePrivatePlaybookPublicationStore } from './private-playbook-publication-store'
describe('DrizzlePrivatePlaybookPublicationStore validation boundaries', () => {
const store = new DrizzlePrivatePlaybookPublicationStore({} as never)
it('rejects malformed explicit review evidence before persistence', async () => {
await expect(
store.attestReview({
workspaceId: 'workspace',
versionId: 'version',
reviewedBy: 'reviewer',
attestedDigest: 'not-a-digest',
schemaAndSemanticValidationPassed: true,
blockingLintFindingCount: -1,
limitationsDocumented: true,
unresolvedSafetyRegression: false,
review: {},
reviewedAt: new Date(),
}),
).rejects.toMatchObject({ code: 'private_playbook_review_invalid' })
})
it('rejects malformed target, fixture and environment digests without reading storage', async () => {
const evaluationCase: StaticEvaluationCase = {
id: 'case',
version: '1.0.0',
target: { id: 'private.case', version: '1.0.0', digest: 'x' },
fixture: {
id: 'fixture.case',
version: '1.0.0',
digest: 'y',
environmentDigest: 'z',
},
expectedHeadings: [],
requiredText: [],
prohibitedText: [],
deterministic: true,
}
await expect(
store.upsertStaticCase({
workspaceId: 'workspace',
versionId: 'version',
evaluationCase,
caseDigest: 'bad',
now: new Date(),
}),
).rejects.toMatchObject({ code: 'private_playbook_evaluation_invalid' })
})
})
@@ -0,0 +1,768 @@
import type {
CurrentEvaluationContext,
PrivatePlaybookPublicationCandidate,
PrivatePlaybookPublicationStore,
StaticEvaluationCase,
StaticEvaluationResult,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { and, asc, desc, eq, isNotNull, isNull } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
evaluationCases,
evaluationResults,
playbookPackageFiles,
playbookReviewAttestations,
playbooks,
playbookVersions,
} from '../schema'
import { DrizzlePrivatePlaybookDraftStore } from './private-playbook-draft-store'
type Database = ReturnType<typeof getDatabase>
type EvaluationCaseRow = typeof evaluationCases.$inferSelect
type EvaluationResultRow = typeof evaluationResults.$inferSelect
const digestPattern = /^[0-9a-f]{64}$/u
function object(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
}
function sameIdentity(
left: {
readonly id: string
readonly version: string
readonly digest: string
},
right: {
readonly id: string
readonly version: string
readonly digest: string
},
): boolean {
return (
left.id === right.id &&
left.version === right.version &&
left.digest === right.digest
)
}
function completeCase(row: EvaluationCaseRow): row is EvaluationCaseRow & {
caseVersion: string
targetDigest: string
fixtureId: string
fixtureDigest: string
environmentDigest: string
} {
return (
typeof row.caseVersion === 'string' &&
typeof row.targetDigest === 'string' &&
typeof row.fixtureId === 'string' &&
typeof row.fixtureDigest === 'string' &&
typeof row.environmentDigest === 'string'
)
}
function persistedStaticResult(
row: EvaluationResultRow,
evaluationCase: EvaluationCaseRow,
currentTarget: StaticEvaluationResult['target'],
): StaticEvaluationResult | null {
if (!completeCase(evaluationCase)) return null
const value = object(row.resultJson)
const target = object(value?.target)
const fixture = object(value?.fixture)
if (
(value?.status !== 'passed' && value?.status !== 'failed') ||
value.status !== row.status ||
value.caseId !== evaluationCase.logicalCaseId ||
value.caseVersion !== evaluationCase.caseVersion ||
!Array.isArray(value.checks) ||
typeof value.evaluatedAt !== 'string' ||
typeof value.renderedPromptDigest !== 'string' ||
!object(value.dimensions) ||
typeof target?.id !== 'string' ||
typeof target.version !== 'string' ||
typeof target.digest !== 'string' ||
typeof fixture?.id !== 'string' ||
typeof fixture.version !== 'string' ||
typeof fixture.digest !== 'string' ||
typeof fixture.environmentDigest !== 'string'
) {
return null
}
const result = value as unknown as StaticEvaluationResult
if (
!sameIdentity(result.target, currentTarget) ||
result.target.digest !== row.targetDigest ||
result.target.digest !== evaluationCase.targetDigest ||
result.fixture.id !== evaluationCase.fixtureId ||
result.fixture.version !== evaluationCase.fixtureVersion ||
result.fixture.digest !== row.fixtureDigest ||
result.fixture.digest !== evaluationCase.fixtureDigest ||
result.fixture.environmentDigest !== row.environmentDigest ||
result.fixture.environmentDigest !== evaluationCase.environmentDigest
) {
return null
}
return result
}
function isUniqueViolation(error: unknown): boolean {
let current = error
const seen = new Set<unknown>()
while (
typeof current === 'object' &&
current !== null &&
!seen.has(current)
) {
if ('code' in current && current.code === '23505') return true
seen.add(current)
current = 'cause' in current ? current.cause : null
}
return false
}
function conflict(message: string, error?: unknown): never {
throw new DomainError(
'private_playbook_publish_conflict',
message,
error instanceof Error ? { cause: error.name } : undefined,
)
}
export interface PrivatePlaybookReviewAttestation {
readonly workspaceId: string
readonly versionId: string
readonly reviewedBy: string
readonly attestedDigest: string
readonly schemaAndSemanticValidationPassed: boolean
readonly blockingLintFindingCount: number
readonly limitationsDocumented: boolean
readonly unresolvedSafetyRegression: boolean
readonly review: Readonly<Record<string, unknown>>
readonly reviewedAt: Date
}
export interface PrivatePlaybookEvaluationStore {
attestReview(attestation: PrivatePlaybookReviewAttestation): Promise<boolean>
upsertStaticCase(request: {
readonly workspaceId: string
readonly versionId: string
readonly evaluationCase: StaticEvaluationCase
readonly caseDigest: string
readonly now: Date
}): Promise<string | null>
appendStaticResult(request: {
readonly workspaceId: string
readonly versionId: string
readonly logicalCaseId: string
readonly fixtureVersion: string
readonly result: StaticEvaluationResult
readonly environment: Readonly<Record<string, unknown>>
readonly executedBy: string
readonly now: Date
}): Promise<string | null>
}
export class DrizzlePrivatePlaybookPublicationStore
implements PrivatePlaybookPublicationStore, PrivatePlaybookEvaluationStore
{
private readonly drafts: DrizzlePrivatePlaybookDraftStore
constructor(private readonly database: Database = getDatabase()) {
this.drafts = new DrizzlePrivatePlaybookDraftStore(database)
}
async findPublicationCandidate(
workspaceId: string,
versionId: string,
): Promise<PrivatePlaybookPublicationCandidate | null> {
const draft = await this.drafts.findVersionForWorkspace(
workspaceId,
versionId,
)
if (!draft) return null
const [review] = await this.database
.select()
.from(playbookReviewAttestations)
.where(
and(
eq(playbookReviewAttestations.playbookVersionId, versionId),
eq(playbookReviewAttestations.attestedDigest, draft.draftDigest),
),
)
.orderBy(desc(playbookReviewAttestations.reviewedAt))
.limit(1)
const cases = await this.database
.select()
.from(evaluationCases)
.where(
and(
eq(evaluationCases.playbookVersionId, versionId),
eq(evaluationCases.targetDigest, draft.draftDigest),
isNotNull(evaluationCases.caseVersion),
isNotNull(evaluationCases.fixtureId),
isNotNull(evaluationCases.fixtureDigest),
isNotNull(evaluationCases.environmentDigest),
),
)
.orderBy(asc(evaluationCases.logicalCaseId))
const completeCases = cases.filter(completeCase)
const resultRows = await this.database
.select({ evaluationCase: evaluationCases, result: evaluationResults })
.from(evaluationResults)
.innerJoin(
evaluationCases,
eq(evaluationResults.evaluationCaseId, evaluationCases.id),
)
.where(
and(
eq(evaluationCases.playbookVersionId, versionId),
eq(evaluationResults.targetDigest, draft.draftDigest),
),
)
.orderBy(desc(evaluationResults.executedAt), desc(evaluationResults.id))
const target = {
id: draft.logicalId,
version: draft.semanticVersion,
digest: draft.draftDigest,
}
const latestByCase = new Map<string, StaticEvaluationResult>()
const seenCases = new Set<string>()
for (const row of resultRows) {
if (seenCases.has(row.evaluationCase.logicalCaseId)) continue
seenCases.add(row.evaluationCase.logicalCaseId)
const result = persistedStaticResult(
row.result,
row.evaluationCase,
target,
)
if (result) latestByCase.set(row.evaluationCase.logicalCaseId, result)
}
const primaryCase = completeCases[0]
const currentEvaluationContext: CurrentEvaluationContext = {
target,
fixture: primaryCase
? {
id: primaryCase.fixtureId,
version: primaryCase.fixtureVersion,
digest: primaryCase.fixtureDigest,
environmentDigest: primaryCase.environmentDigest,
}
: { id: '', version: '', digest: '', environmentDigest: '' },
}
return {
draft,
evidence: {
schemaAndSemanticValidationPassed:
review?.schemaAndSemanticValidationPassed ?? false,
blockingLintFindingCount:
review?.blockingLintFindingCount ?? Number.MAX_SAFE_INTEGER,
humanEditorialReviewCompleted: review !== undefined,
limitationsDocumented: review?.limitationsDocumented ?? false,
evaluationResults: [...latestByCase.values()],
currentEvaluationContext,
unresolvedSafetyRegression: review?.unresolvedSafetyRegression ?? true,
realWorldRunCount: 0,
realWorldFailureCount: 0,
unaddressedSevereIncidentCount: 0,
},
policy: {
requiredEvaluationCaseIds:
completeCases.length === 0
? ['__required_static_evaluation__']
: completeCases.map((item) => item.logicalCaseId),
minimumRealWorldRuns: Number.MAX_SAFE_INTEGER,
maximumFailureRate: 0,
maximumEvidenceAgeDays: 0,
},
}
}
async publishDraft(
request: Parameters<PrivatePlaybookPublicationStore['publishDraft']>[0],
) {
let changed: boolean
try {
changed = await this.database.transaction(
async (transaction) => {
const [row] = await transaction
.select({ version: playbookVersions, playbook: playbooks })
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(
and(
eq(playbooks.workspaceId, request.workspaceId),
eq(playbooks.sourceType, 'private'),
eq(playbookVersions.id, request.versionId),
),
)
.for('update')
.limit(1)
if (!row) return false
if (row.version.publishedAt) {
conflict('Published playbook versions are immutable.')
}
if (
row.version.draftRevision !== request.expectedRevision ||
row.version.draftDigest !== request.expectedDigest
) {
conflict('The private playbook draft changed before publication.')
}
const [review] = await transaction
.select()
.from(playbookReviewAttestations)
.where(
and(
eq(
playbookReviewAttestations.playbookVersionId,
request.versionId,
),
eq(
playbookReviewAttestations.attestedDigest,
request.expectedDigest,
),
),
)
.orderBy(desc(playbookReviewAttestations.reviewedAt))
.limit(1)
if (
!review?.schemaAndSemanticValidationPassed ||
review.blockingLintFindingCount !== 0 ||
!review.limitationsDocumented
) {
conflict('Current review evidence is required for publication.')
}
if (request.lifecycle === 'validated') {
if (review.unresolvedSafetyRegression) {
conflict('An unresolved safety regression blocks validation.')
}
const cases = (
await transaction
.select()
.from(evaluationCases)
.where(
and(
eq(evaluationCases.playbookVersionId, request.versionId),
eq(evaluationCases.targetDigest, request.expectedDigest),
isNotNull(evaluationCases.caseVersion),
isNotNull(evaluationCases.fixtureId),
isNotNull(evaluationCases.fixtureDigest),
isNotNull(evaluationCases.environmentDigest),
),
)
).filter(completeCase)
if (cases.length === 0) {
conflict(
'Current passing static evaluation evidence is required.',
)
}
const results = await transaction
.select({
evaluationCase: evaluationCases,
result: evaluationResults,
})
.from(evaluationResults)
.innerJoin(
evaluationCases,
eq(evaluationResults.evaluationCaseId, evaluationCases.id),
)
.where(eq(evaluationCases.playbookVersionId, request.versionId))
.orderBy(
desc(evaluationResults.executedAt),
desc(evaluationResults.id),
)
const target = {
id: row.playbook.logicalId,
version: row.version.semanticVersion,
digest: row.version.draftDigest,
}
const latest = new Map<string, StaticEvaluationResult | null>()
for (const resultRow of results) {
const caseId = resultRow.evaluationCase.logicalCaseId
if (latest.has(caseId)) continue
latest.set(
caseId,
persistedStaticResult(
resultRow.result,
resultRow.evaluationCase,
target,
),
)
}
if (
cases.some(
(evaluationCase) =>
latest.get(evaluationCase.logicalCaseId)?.status !== 'passed',
)
) {
conflict('Every required static evaluation must currently pass.')
}
}
const [published] = await transaction
.update(playbookVersions)
.set({ lifecycle: request.lifecycle, publishedAt: request.now })
.where(
and(
eq(playbookVersions.id, request.versionId),
eq(playbookVersions.draftRevision, request.expectedRevision),
eq(playbookVersions.draftDigest, request.expectedDigest),
isNull(playbookVersions.publishedAt),
),
)
.returning({ id: playbookVersions.id })
if (!published) {
conflict('The private playbook draft changed before publication.')
}
await transaction.insert(auditEvents).values({
occurredAt: request.now,
actorUserId: request.publishedBy,
workspaceId: request.workspaceId,
action: 'private_playbook.version_published',
resourceType: 'playbook_version',
resourceId: request.versionId,
outcome: 'success',
metadataJson: {
playbookId: row.playbook.id,
semanticVersion: row.version.semanticVersion,
lifecycle: request.lifecycle,
contentDigest: request.expectedDigest,
draftRevision: request.expectedRevision,
},
})
return true
},
{ isolationLevel: 'serializable' },
)
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === '40001'
) {
conflict('Publication evidence changed concurrently.', error)
}
throw error
}
return changed
? this.drafts.findVersionForWorkspace(
request.workspaceId,
request.versionId,
)
: null
}
async createNextDraft(
request: Parameters<PrivatePlaybookPublicationStore['createNextDraft']>[0],
) {
let newVersionId: string | null = null
try {
await this.database.transaction(async (transaction) => {
const [source] = await transaction
.select({ version: playbookVersions, playbook: playbooks })
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(
and(
eq(playbooks.workspaceId, request.workspaceId),
eq(playbooks.sourceType, 'private'),
eq(playbookVersions.id, request.sourceVersionId),
),
)
.for('update')
.limit(1)
if (!source) return
if (!source.version.publishedAt) {
conflict('Only a published private version can be cloned.')
}
const [version] = await transaction
.insert(playbookVersions)
.values({
playbookId: source.playbook.id,
semanticVersion: request.semanticVersion,
lifecycle: 'draft',
packageApiVersion: source.version.packageApiVersion,
title: source.version.title,
summary: source.version.summary,
category: source.version.category,
riskTier: source.version.riskTier,
packageJson: source.version.packageJson,
templateText: source.version.templateText,
contentDigest: source.version.contentDigest,
draftRevision: 1,
draftDigest: source.version.draftDigest,
draftValidationJson: source.version.draftValidationJson,
searchDocument: source.version.searchDocument,
supersedesVersionId: source.version.id,
createdBy: request.createdBy,
createdAt: request.now,
})
.returning({ id: playbookVersions.id })
newVersionId = version!.id
const files = await transaction
.select()
.from(playbookPackageFiles)
.where(eq(playbookPackageFiles.playbookVersionId, source.version.id))
.orderBy(asc(playbookPackageFiles.path))
if (files.length > 0) {
await transaction.insert(playbookPackageFiles).values(
files.map((file) => ({
playbookVersionId: version!.id,
path: file.path,
role: file.role,
content: file.content,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
createdAt: request.now,
})),
)
}
await transaction.insert(auditEvents).values({
occurredAt: request.now,
actorUserId: request.createdBy,
workspaceId: request.workspaceId,
action: 'private_playbook.next_draft_created',
resourceType: 'playbook_version',
resourceId: version!.id,
outcome: 'success',
metadataJson: {
playbookId: source.playbook.id,
sourceVersionId: source.version.id,
sourceDigest: source.version.draftDigest,
semanticVersion: request.semanticVersion,
},
})
})
} catch (error) {
if (isUniqueViolation(error)) {
conflict(
'A private playbook version with this Semantic Version exists.',
error,
)
}
throw error
}
return newVersionId
? this.drafts.findVersionForWorkspace(request.workspaceId, newVersionId)
: null
}
async attestReview(
attestation: PrivatePlaybookReviewAttestation,
): Promise<boolean> {
if (
!digestPattern.test(attestation.attestedDigest) ||
!Number.isSafeInteger(attestation.blockingLintFindingCount) ||
attestation.blockingLintFindingCount < 0
) {
throw new DomainError(
'private_playbook_review_invalid',
'Review evidence is invalid',
)
}
const [version] = await this.database
.select({ id: playbookVersions.id })
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.where(
and(
eq(playbooks.workspaceId, attestation.workspaceId),
eq(playbooks.sourceType, 'private'),
eq(playbookVersions.id, attestation.versionId),
eq(playbookVersions.draftDigest, attestation.attestedDigest),
isNull(playbookVersions.publishedAt),
),
)
.limit(1)
if (!version) return false
await this.database.insert(playbookReviewAttestations).values({
playbookVersionId: attestation.versionId,
reviewedBy: attestation.reviewedBy,
attestedDigest: attestation.attestedDigest,
schemaAndSemanticValidationPassed:
attestation.schemaAndSemanticValidationPassed,
blockingLintFindingCount: attestation.blockingLintFindingCount,
limitationsDocumented: attestation.limitationsDocumented,
unresolvedSafetyRegression: attestation.unresolvedSafetyRegression,
reviewJson: attestation.review,
reviewedAt: attestation.reviewedAt,
})
return true
}
async upsertStaticCase(
request: Parameters<PrivatePlaybookEvaluationStore['upsertStaticCase']>[0],
) {
const value = request.evaluationCase
if (
!digestPattern.test(request.caseDigest) ||
!digestPattern.test(value.target.digest) ||
!digestPattern.test(value.fixture.digest) ||
!digestPattern.test(value.fixture.environmentDigest)
) {
throw new DomainError(
'private_playbook_evaluation_invalid',
'Evaluation digests are invalid',
)
}
const draft = await this.drafts.findVersionForWorkspace(
request.workspaceId,
request.versionId,
)
if (!draft || draft.publishedAt) return null
if (
value.target.id !== draft.logicalId ||
value.target.version !== draft.semanticVersion ||
value.target.digest !== draft.draftDigest
) {
throw new DomainError(
'private_playbook_evaluation_stale',
'Evaluation case target is stale',
)
}
const existing = await this.database
.select()
.from(evaluationCases)
.where(eq(evaluationCases.playbookVersionId, request.versionId))
const incompatibleFixture = existing
.filter(completeCase)
.some(
(item) =>
item.fixtureId !== value.fixture.id ||
item.fixtureVersion !== value.fixture.version ||
item.fixtureDigest !== value.fixture.digest ||
item.environmentDigest !== value.fixture.environmentDigest,
)
if (incompatibleFixture) {
throw new DomainError(
'private_playbook_evaluation_fixture_conflict',
'All required static cases for a version must share one exact fixture and environment binding',
)
}
const [row] = await this.database
.insert(evaluationCases)
.values({
playbookVersionId: request.versionId,
logicalCaseId: value.id,
caseVersion: value.version,
fixtureVersion: value.fixture.version,
targetDigest: value.target.digest,
fixtureId: value.fixture.id,
fixtureDigest: value.fixture.digest,
environmentDigest: value.fixture.environmentDigest,
caseJson: value,
caseDigest: request.caseDigest,
createdAt: request.now,
})
.onConflictDoUpdate({
target: [
evaluationCases.playbookVersionId,
evaluationCases.logicalCaseId,
evaluationCases.fixtureVersion,
],
set: {
caseVersion: value.version,
targetDigest: value.target.digest,
fixtureId: value.fixture.id,
fixtureDigest: value.fixture.digest,
environmentDigest: value.fixture.environmentDigest,
caseJson: value,
caseDigest: request.caseDigest,
createdAt: request.now,
},
})
.returning({ id: evaluationCases.id })
return row!.id
}
async appendStaticResult(
request: Parameters<
PrivatePlaybookEvaluationStore['appendStaticResult']
>[0],
) {
const [row] = await this.database
.select({
evaluationCase: evaluationCases,
version: playbookVersions,
playbook: playbooks,
})
.from(playbooks)
.innerJoin(
playbookVersions,
eq(playbookVersions.playbookId, playbooks.id),
)
.innerJoin(
evaluationCases,
eq(evaluationCases.playbookVersionId, playbookVersions.id),
)
.where(
and(
eq(playbooks.workspaceId, request.workspaceId),
eq(playbooks.sourceType, 'private'),
eq(playbookVersions.id, request.versionId),
isNull(playbookVersions.publishedAt),
eq(evaluationCases.logicalCaseId, request.logicalCaseId),
eq(evaluationCases.fixtureVersion, request.fixtureVersion),
),
)
.limit(1)
if (!row || !completeCase(row.evaluationCase)) return null
const currentTarget = {
id: row.playbook.logicalId,
version: row.version.semanticVersion,
digest: row.version.draftDigest,
}
if (
!sameIdentity(request.result.target, currentTarget) ||
request.result.caseId !== row.evaluationCase.logicalCaseId ||
request.result.caseVersion !== row.evaluationCase.caseVersion ||
request.result.fixture.id !== row.evaluationCase.fixtureId ||
request.result.fixture.version !== row.evaluationCase.fixtureVersion ||
request.result.fixture.digest !== row.evaluationCase.fixtureDigest ||
request.result.fixture.environmentDigest !==
row.evaluationCase.environmentDigest
) {
throw new DomainError(
'private_playbook_evaluation_stale',
'Evaluation result binding is stale',
)
}
const [inserted] = await this.database
.insert(evaluationResults)
.values({
evaluationCaseId: row.evaluationCase.id,
environmentJson: request.environment,
targetDigest: request.result.target.digest,
fixtureDigest: request.result.fixture.digest,
environmentDigest: request.result.fixture.environmentDigest,
resultJson: request.result,
status: request.result.status,
dimensionScoresJson: request.result.dimensions,
executedBy: request.executedBy,
executedAt: request.now,
})
.returning({ id: evaluationResults.id })
return inserted!.id
}
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import {
evaluateMigrationPreflight,
type MigrationPreflightSnapshot,
} from './migration-preflight'
const safe: MigrationPreflightSnapshot = {
appliedMigrationCount: 9,
migrationTableExists: true,
migrationHashesMatch: true,
databaseMajorVersion: 17,
legacyNullRunIdempotencyKeys: 0,
invalidIntegrationSecretEnvelopes: 0,
publishedDraftDigestMismatches: 0,
invalidEvaluationDigestBindings: 0,
publishedImmutabilityTriggerPresent: true,
}
describe('migration preflight evaluation', () => {
it('accepts the exact supported schema', () => {
expect(evaluateMigrationPreflight(safe)).toEqual([])
})
it('reports a fresh database without treating it as corrupt', () => {
expect(
evaluateMigrationPreflight({
...safe,
migrationTableExists: false,
appliedMigrationCount: 0,
publishedImmutabilityTriggerPresent: false,
}),
).toEqual([
expect.objectContaining({
severity: 'information',
code: 'fresh-database',
}),
])
})
it('blocks divergent history and every documented legacy hazard', () => {
const findings = evaluateMigrationPreflight({
...safe,
migrationHashesMatch: false,
legacyNullRunIdempotencyKeys: 2,
invalidIntegrationSecretEnvelopes: 3,
publishedDraftDigestMismatches: 4,
invalidEvaluationDigestBindings: 5,
publishedImmutabilityTriggerPresent: false,
})
expect(
findings.filter(({ severity }) => severity === 'blocker'),
).toHaveLength(6)
})
})
@@ -0,0 +1,98 @@
export const EXPECTED_MIGRATION_COUNT = 9
export interface MigrationPreflightSnapshot {
readonly appliedMigrationCount: number
readonly migrationTableExists: boolean
readonly migrationHashesMatch: boolean
readonly databaseMajorVersion: number
readonly legacyNullRunIdempotencyKeys: number
readonly invalidIntegrationSecretEnvelopes: number
readonly publishedDraftDigestMismatches: number
readonly invalidEvaluationDigestBindings: number
readonly publishedImmutabilityTriggerPresent: boolean
}
export interface MigrationPreflightFinding {
readonly severity: 'blocker' | 'warning' | 'information'
readonly code: string
readonly detail: string
}
export function evaluateMigrationPreflight(
snapshot: MigrationPreflightSnapshot,
): readonly MigrationPreflightFinding[] {
const findings: MigrationPreflightFinding[] = []
if (snapshot.appliedMigrationCount > EXPECTED_MIGRATION_COUNT) {
findings.push({
severity: 'blocker',
code: 'schema-newer-than-application',
detail: `Database has ${snapshot.appliedMigrationCount} migrations; this release knows ${EXPECTED_MIGRATION_COUNT}.`,
})
} else if (!snapshot.migrationHashesMatch) {
findings.push({
severity: 'blocker',
code: 'migration-history-diverged',
detail:
'Applied migration hashes are not an exact prefix of this release.',
})
}
if (snapshot.databaseMajorVersion !== 17) {
findings.push({
severity: 'warning',
code: 'postgres-version-not-release-baseline',
detail: `PostgreSQL ${snapshot.databaseMajorVersion} differs from the supported release baseline 17.`,
})
}
if (snapshot.legacyNullRunIdempotencyKeys > 0) {
findings.push({
severity: 'blocker',
code: 'migration-0003-null-idempotency-keys',
detail: `${snapshot.legacyNullRunIdempotencyKeys} generated runs have a null idempotency key.`,
})
}
if (snapshot.invalidIntegrationSecretEnvelopes > 0) {
findings.push({
severity: 'blocker',
code: 'migration-0004-invalid-secret-envelope',
detail: `${snapshot.invalidIntegrationSecretEnvelopes} integration secret envelopes violate migration 0004 constraints.`,
})
}
if (snapshot.publishedDraftDigestMismatches > 0) {
findings.push({
severity: 'blocker',
code: 'migration-0005-draft-digest-mismatch',
detail: `${snapshot.publishedDraftDigestMismatches} published versions have a draft/content digest mismatch.`,
})
}
if (snapshot.invalidEvaluationDigestBindings > 0) {
findings.push({
severity: 'blocker',
code: 'migration-0006-invalid-evaluation-digest',
detail: `${snapshot.invalidEvaluationDigestBindings} evaluation bindings contain a malformed digest.`,
})
}
if (
snapshot.appliedMigrationCount > 0 &&
!snapshot.publishedImmutabilityTriggerPresent
) {
findings.push({
severity: 'blocker',
code: 'published-immutability-trigger-missing',
detail: 'The published playbook immutability trigger is missing.',
})
}
if (!snapshot.migrationTableExists) {
findings.push({
severity: 'information',
code: 'fresh-database',
detail: 'No migration table exists; all nine migrations are pending.',
})
} else if (snapshot.appliedMigrationCount < EXPECTED_MIGRATION_COUNT) {
findings.push({
severity: 'information',
code: 'migrations-pending',
detail: `${EXPECTED_MIGRATION_COUNT - snapshot.appliedMigrationCount} migrations are pending.`,
})
}
return findings
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { assertBenchmarkDatabase, percentile } from './performance-benchmark'
describe('release performance benchmark safety', () => {
it('requires both an isolated suffix and explicit acknowledgement', () => {
expect(() =>
assertBenchmarkDatabase(
'devrunbook_benchmark',
'isolated-benchmark-database',
),
).not.toThrow()
expect(() =>
assertBenchmarkDatabase('devrunbook', 'isolated-benchmark-database'),
).toThrow('_benchmark')
expect(() =>
assertBenchmarkDatabase('devrunbook_benchmark', undefined),
).toThrow('DEVRUNBOOK_PERFORMANCE_ACK')
})
it('uses nearest-rank percentiles deterministically', () => {
expect(percentile([5, 1, 4, 2, 3], 0.5)).toBe(3)
expect(percentile([5, 1, 4, 2, 3], 0.95)).toBe(5)
})
})
@@ -0,0 +1,25 @@
export function assertBenchmarkDatabase(
databaseName: string,
acknowledgement: string | undefined,
): void {
if (!/^[a-zA-Z0-9_]+_benchmark$/u.test(databaseName)) {
throw new Error('Benchmark database name must end in _benchmark.')
}
if (acknowledgement !== 'isolated-benchmark-database') {
throw new Error(
'Set DEVRUNBOOK_PERFORMANCE_ACK=isolated-benchmark-database.',
)
}
}
export function percentile(
samples: readonly number[],
quantile: number,
): number {
if (samples.length === 0) throw new Error('At least one sample is required.')
if (quantile < 0 || quantile > 1)
throw new Error('Quantile must be between zero and one.')
const ordered = [...samples].sort((left, right) => left - right)
const index = Math.ceil(quantile * ordered.length) - 1
return ordered[Math.max(0, index)]!
}
@@ -0,0 +1,103 @@
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { PostgresRepositoryPreferenceStore } from './repository-preference-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
describe.skipIf(!databaseIntegration)(
'PostgresRepositoryPreferenceStore integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userA = randomUUID()
const userB = randomUUID()
const repositoryA = randomUUID()
const repositoryB = randomUUID()
const store = new PostgresRepositoryPreferenceStore()
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (id, email, display_name, password_hash, instance_role, status)
values
(${userA}, ${`preference-a-${userA}@example.invalid`}, 'Preference A', 'test-only', 'user', 'active'),
(${userB}, ${`preference-b-${userB}@example.invalid`}, 'Preference B', 'test-only', 'user', 'active')
`
await sql`
insert into workspaces (id, name, type)
values (${workspaceA}, 'Preference A', 'team'), (${workspaceB}, 'Preference B', 'team')
`
await sql`
insert into repositories (
id, workspace_id, source_type, display_name, default_branch
) values
(${repositoryA}, ${workspaceA}, 'manual', 'Repository A', 'main'),
(${repositoryB}, ${workspaceB}, 'manual', 'Repository B', 'main')
`
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id in (${userA}, ${userB})`
await closeDatabase()
})
it('persists favorites and recency for exactly one workspace and user', async () => {
await expect(
store.set({
workspaceId: workspaceA,
userId: userA,
repositoryId: repositoryA,
favorite: true,
markUsed: true,
}),
).resolves.toBe(true)
await expect(
store.list({ workspaceId: workspaceA, userId: userA }),
).resolves.toEqual([
expect.objectContaining({
repositoryId: repositoryA,
favorite: true,
lastUsedAt: expect.any(Date),
}),
])
await expect(
store.list({ workspaceId: workspaceA, userId: userB }),
).resolves.toEqual([])
})
it('denies cross-workspace, missing and archived repository mutations', async () => {
await expect(
store.set({
workspaceId: workspaceA,
userId: userA,
repositoryId: repositoryB,
favorite: true,
}),
).resolves.toBe(false)
await expect(
store.set({
workspaceId: workspaceA,
userId: userA,
repositoryId: randomUUID(),
favorite: true,
}),
).resolves.toBe(false)
const sql = getSqlClient()
await sql`update repositories set archived = true where id = ${repositoryA}`
await expect(
store.set({
workspaceId: workspaceA,
userId: userA,
repositoryId: repositoryA,
favorite: false,
}),
).resolves.toBe(false)
})
},
)
@@ -0,0 +1,78 @@
import type {
RepositoryPreference,
RepositoryPreferenceStore,
} from '@devrunbook/application'
import { getSqlClient } from '../index'
export class PostgresRepositoryPreferenceStore implements RepositoryPreferenceStore {
async list(input: {
readonly workspaceId: string
readonly userId: string
}): Promise<readonly RepositoryPreference[]> {
const sql = getSqlClient()
const rows = await sql<
{
repository_id: string
favorite: boolean
last_used_at: Date | string | null
}[]
>`
select preference.repository_id, preference.favorite, preference.last_used_at
from repository_preferences preference
join repositories repository on repository.id = preference.repository_id
where preference.workspace_id = ${input.workspaceId}
and preference.user_id = ${input.userId}
and repository.workspace_id = ${input.workspaceId}
and repository.archived = false
order by preference.favorite desc, preference.last_used_at desc nulls last,
preference.repository_id
`
return rows.map((row) => ({
repositoryId: row.repository_id,
favorite: row.favorite,
lastUsedAt:
row.last_used_at === null
? null
: row.last_used_at instanceof Date
? row.last_used_at
: new Date(row.last_used_at),
}))
}
async set(input: {
readonly workspaceId: string
readonly userId: string
readonly repositoryId: string
readonly favorite?: boolean
readonly markUsed?: boolean
}): Promise<boolean> {
const sql = getSqlClient()
const favorite = input.favorite ?? null
const markUsed = input.markUsed === true
const rows = await sql<{ repository_id: string }[]>`
insert into repository_preferences (
workspace_id, user_id, repository_id, favorite, last_used_at
)
select ${input.workspaceId}, ${input.userId}, repository.id,
${favorite ?? false},
case when ${markUsed} then now() else null end
from repositories repository
where repository.id = ${input.repositoryId}
and repository.workspace_id = ${input.workspaceId}
and repository.archived = false
on conflict (workspace_id, user_id, repository_id) do update
set favorite = case
when ${favorite}::boolean is null then repository_preferences.favorite
else ${favorite}::boolean
end,
last_used_at = case
when ${markUsed} then now()
else repository_preferences.last_used_at
end,
updated_at = now()
returning repository_id
`
return rows.length === 1
}
}
@@ -0,0 +1,100 @@
import { createHash, randomUUID } from 'node:crypto'
import type { Sql } from 'postgres'
import { getSqlClient } from '../index'
interface DueRepositoryRow {
repository_id: string
workspace_id: string
integration_id: string
requested_by: string
}
export interface RepositoryRefreshScheduleResult {
readonly examined: number
readonly queued: number
}
/** Database-backed periodic planner. The queue's unique key is the restart lock. */
export class RepositoryRefreshScheduler {
constructor(private readonly sql: Sql = getSqlClient()) {}
async plan(request: {
readonly staleAfterHours: number
readonly batchSize?: number
readonly now?: Date
}): Promise<RepositoryRefreshScheduleResult> {
const now = request.now ?? new Date()
const staleAfterHours = Math.max(1, Math.floor(request.staleAfterHours))
const batchSize = Math.min(500, Math.max(1, request.batchSize ?? 100))
const bucket = Math.floor(now.getTime() / (staleAfterHours * 3_600_000))
return this.sql.begin(async (transaction) => {
const due = await transaction<DueRepositoryRow[]>`
select r.id as repository_id, r.workspace_id, r.integration_id,
i.created_by as requested_by
from repositories r
join integrations i on i.id = r.integration_id
where r.source_type = 'gitea'
and r.archived = false
and i.type = 'gitea'
and i.status <> 'disabled'
and not exists (
select 1 from jobs j
where j.workspace_id = r.workspace_id
and j.type = 'gitea.repository-snapshot'
and j.state in ('queued', 'running')
and j.payload_json->>'repositoryId' = r.id::text
)
and coalesce((
select max(s.captured_at) from repository_snapshots s
where s.repository_id = r.id and s.state = 'complete'
), '-infinity'::timestamptz) <= ${now.toISOString()}::timestamptz - (${staleAfterHours} * interval '1 hour')
order by r.updated_at asc, r.id asc
limit ${batchSize}
for update of r skip locked
`
let queued = 0
for (const candidate of due) {
const jobId = randomUUID()
const snapshotId = randomUUID()
const idempotencyKey = createHash('sha256')
.update(
`scheduled-repository-refresh-v1\0${candidate.repository_id}\0${bucket}`,
)
.digest('hex')
const payload = {
schemaVersion: 1,
workspaceId: candidate.workspace_id,
repositoryId: candidate.repository_id,
integrationId: candidate.integration_id,
requestedBy: candidate.requested_by,
collectionMode: 'bounded-read-only',
profileRevisionPolicy: 'create-initial-only',
}
const inserted = await transaction<{ id: string }[]>`
insert into jobs (
id, workspace_id, type, state, idempotency_key, payload_json,
progress_json, max_attempts, available_at, created_at, updated_at
) values (
${jobId}, ${candidate.workspace_id}, 'gitea.repository-snapshot',
'queued', ${idempotencyKey}, ${JSON.stringify(payload)}::jsonb,
'{}'::jsonb, 3, ${now.toISOString()}, ${now.toISOString()}, ${now.toISOString()}
) on conflict do nothing returning id
`
if (!inserted[0]) continue
await transaction`
insert into repository_snapshots (
id, repository_id, integration_id, state,
capability_snapshot_json, evidence_json, sync_job_id, created_at
) values (
${snapshotId}, ${candidate.repository_id}, ${candidate.integration_id},
'collecting', '{}'::jsonb, '{}'::jsonb, ${jobId}, ${now.toISOString()}
)
`
queued += 1
}
return { examined: due.length, queued }
}) as Promise<RepositoryRefreshScheduleResult>
}
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { digestRepositoryEvidence } from './repository-snapshot-store'
describe('repository snapshot persistence', () => {
it('digests canonical evidence independently of object key order', () => {
expect(digestRepositoryEvidence({ b: [2, 1], a: 'value' })).toBe(
digestRepositoryEvidence({ a: 'value', b: [2, 1] }),
)
expect(digestRepositoryEvidence({ a: 'other', b: [2, 1] })).not.toBe(
digestRepositoryEvidence({ a: 'value', b: [2, 1] }),
)
})
it('rejects non-JSON evidence before persistence', () => {
expect(() => digestRepositoryEvidence({ value: Number.NaN })).toThrow(
'evidence must be finite JSON data',
)
})
})
@@ -0,0 +1,720 @@
import { createHash } from 'node:crypto'
import { isDeepStrictEqual } from 'node:util'
import { DomainError } from '@devrunbook/domain'
import {
applyRepositoryProfileServerMetadata,
type RepositoryProfile,
validateRepositoryProfile,
} from '@devrunbook/repository-intel'
import { and, desc, eq, sql } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
auditEvents,
integrations,
jobs,
repositories,
repositoryFindings,
repositoryProfileRevisions,
repositorySnapshots,
users,
workspaceMemberships,
} from '../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
type JsonValue =
| null
| boolean
| number
| string
| readonly JsonValue[]
| { readonly [key: string]: JsonValue }
export type RepositorySnapshotState =
'collecting' | 'complete' | 'failed' | 'cancelled'
export interface RepositorySnapshotFindingInput {
readonly ruleId: string
readonly severity: 'info' | 'low' | 'medium' | 'high' | 'critical'
readonly title: string
readonly rationale: string
readonly evidencePointer: string
readonly recommendedPlaybookSlug?: string | null
}
export interface RepositorySnapshotRecord {
readonly id: string
readonly repositoryId: string
readonly integrationId: string | null
readonly state: RepositorySnapshotState
readonly capturedAt: string | null
readonly capabilities: JsonValue
readonly evidence: JsonValue
readonly evidenceDigest: string | null
readonly syncJobId: string | null
readonly createdAt: string
}
export interface CompletedRepositorySnapshot {
readonly snapshotId: string
readonly snapshot: RepositorySnapshotRecord
readonly profileRevision: {
readonly id: string
readonly revisionNumber: number
readonly contentDigest: string
} | null
readonly findingCount: number
}
function isJsonValue(value: unknown): value is JsonValue {
if (value === null || typeof value === 'boolean' || typeof value === 'string')
return true
if (typeof value === 'number') return Number.isFinite(value)
if (Array.isArray(value)) return value.every(isJsonValue)
if (typeof value !== 'object') return false
const prototype = Object.getPrototypeOf(value)
return (
(prototype === Object.prototype || prototype === null) &&
Object.values(value).every(isJsonValue)
)
}
function assertJson(value: unknown, field: string): asserts value is JsonValue {
if (!isJsonValue(value)) {
throw new DomainError(
'repository_snapshot_input_invalid',
`${field} must be finite JSON data`,
)
}
const visit = (item: JsonValue): boolean => {
if (typeof item === 'string') {
return /\bBearer\s+\S+|\b(?:password|secret|token)\s*[:=]\s*\S+/iu.test(
item,
)
}
if (Array.isArray(item)) return item.some(visit)
if (item && typeof item === 'object') {
return Object.entries(item).some(
([key, nested]) =>
/^(?:authorization|password|secret|token|accessToken|apiKey)$/iu.test(
key,
) || visit(nested),
)
}
return false
}
if (visit(value)) {
throw new DomainError(
'repository_snapshot_sensitive_data',
`${field} contains secret-like data`,
)
}
}
function canonicalJson(value: JsonValue): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
const record = value as { readonly [key: string]: JsonValue }
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key]!)}`)
.join(',')}}`
}
export function digestRepositoryEvidence(evidence: unknown): string {
assertJson(evidence, 'evidence')
return createHash('sha256').update(canonicalJson(evidence)).digest('hex')
}
function mapSnapshot(
row: typeof repositorySnapshots.$inferSelect,
): RepositorySnapshotRecord {
assertJson(row.capabilitySnapshotJson, 'stored capabilities')
assertJson(row.evidenceJson, 'stored evidence')
return {
id: row.id,
repositoryId: row.repositoryId,
integrationId: row.integrationId,
state: row.state as RepositorySnapshotState,
capturedAt: row.capturedAt?.toISOString() ?? null,
capabilities: structuredClone(row.capabilitySnapshotJson),
evidence: structuredClone(row.evidenceJson),
evidenceDigest: row.evidenceDigest,
syncJobId: row.syncJobId,
createdAt: row.createdAt.toISOString(),
}
}
function requiredText(value: string, field: string, maximum: number): string {
const normalized = value.trim()
if (
normalized.length === 0 ||
normalized.length > maximum ||
[...normalized].some((character) => character.charCodeAt(0) < 0x20)
) {
throw new DomainError(
'repository_snapshot_input_invalid',
`${field} is invalid`,
)
}
return normalized
}
function validatedFindings(
findings: readonly RepositorySnapshotFindingInput[],
): readonly RepositorySnapshotFindingInput[] {
if (findings.length > 1_000) {
throw new DomainError(
'repository_snapshot_input_invalid',
'Snapshot contains too many findings',
)
}
const identities = new Set<string>()
return findings.map((finding) => {
const normalized = {
...finding,
ruleId: requiredText(finding.ruleId, 'finding.ruleId', 128),
title: requiredText(finding.title, 'finding.title', 255),
rationale: requiredText(finding.rationale, 'finding.rationale', 4_096),
evidencePointer: requiredText(
finding.evidencePointer,
'finding.evidencePointer',
1_024,
),
recommendedPlaybookSlug: finding.recommendedPlaybookSlug
? requiredText(
finding.recommendedPlaybookSlug,
'finding.recommendedPlaybookSlug',
128,
)
: null,
}
const identity = `${normalized.ruleId}\0${normalized.evidencePointer}`
if (identities.has(identity)) {
throw new DomainError(
'repository_snapshot_input_invalid',
'Snapshot contains duplicate findings',
)
}
identities.add(identity)
return normalized
})
}
function snapshotNotCollecting(): never {
throw new DomainError(
'repository_snapshot_state_conflict',
'Repository snapshot is not collecting',
)
}
async function findSnapshotForWorkspace(
database: Database | Transaction,
workspaceId: string,
snapshotId: string,
): Promise<typeof repositorySnapshots.$inferSelect | null> {
const [row] = await database
.select({ snapshot: repositorySnapshots })
.from(repositorySnapshots)
.innerJoin(
repositories,
eq(repositorySnapshots.repositoryId, repositories.id),
)
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositorySnapshots.id, snapshotId),
),
)
.limit(1)
return row?.snapshot ?? null
}
export class RepositorySnapshotStore {
constructor(private readonly database: Database = getDatabase()) {}
async beginCollection(request: {
readonly workspaceId: string
readonly repositoryId: string
readonly integrationId: string
readonly syncJobId: string | null
readonly now?: Date
}): Promise<RepositorySnapshotRecord | null> {
const now = request.now ?? new Date()
return this.database.transaction(async (transaction) => {
if (request.syncJobId) {
const [existing] = await transaction
.select({
snapshot: repositorySnapshots,
workspaceId: repositories.workspaceId,
})
.from(repositorySnapshots)
.innerJoin(
repositories,
eq(repositorySnapshots.repositoryId, repositories.id),
)
.where(eq(repositorySnapshots.syncJobId, request.syncJobId))
.limit(1)
if (existing) {
if (
existing.workspaceId !== request.workspaceId ||
existing.snapshot.repositoryId !== request.repositoryId ||
existing.snapshot.integrationId !== request.integrationId
) {
throw new DomainError(
'repository_snapshot_idempotency_conflict',
'Snapshot job is already associated with different input',
)
}
return mapSnapshot(existing.snapshot)
}
const [job] = await transaction
.select({ id: jobs.id })
.from(jobs)
.where(
and(
eq(jobs.id, request.syncJobId),
eq(jobs.workspaceId, request.workspaceId),
),
)
.limit(1)
if (!job) return null
}
const [identity] = await transaction
.select({
repositoryId: repositories.id,
integrationId: integrations.id,
})
.from(repositories)
.innerJoin(
integrations,
eq(repositories.integrationId, integrations.id),
)
.where(
and(
eq(repositories.workspaceId, request.workspaceId),
eq(repositories.id, request.repositoryId),
eq(repositories.sourceType, 'gitea'),
eq(integrations.workspaceId, request.workspaceId),
eq(integrations.id, request.integrationId),
eq(integrations.type, 'gitea'),
),
)
.limit(1)
.for('update')
if (!identity) return null
const [snapshot] = await transaction
.insert(repositorySnapshots)
.values({
repositoryId: identity.repositoryId,
integrationId: identity.integrationId,
state: 'collecting',
capabilitySnapshotJson: {},
evidenceJson: {},
syncJobId: request.syncJobId,
createdAt: now,
})
.returning()
if (!snapshot) throw new Error('Snapshot insert returned no row')
return mapSnapshot(snapshot)
})
}
async resolveCollectionTarget(request: {
readonly workspaceId: string
readonly integrationId: string
readonly repositoryId: string
readonly syncJobId: string
}): Promise<{
readonly snapshotId: string
readonly owner: string
readonly name: string
} | null> {
const [target] = await this.database
.select({
snapshotId: repositorySnapshots.id,
owner: repositories.externalOwner,
name: repositories.externalName,
})
.from(repositorySnapshots)
.innerJoin(
repositories,
eq(repositories.id, repositorySnapshots.repositoryId),
)
.innerJoin(
integrations,
eq(integrations.id, repositorySnapshots.integrationId),
)
.where(
and(
eq(repositories.workspaceId, request.workspaceId),
eq(repositories.id, request.repositoryId),
eq(repositories.sourceType, 'gitea'),
eq(integrations.workspaceId, request.workspaceId),
eq(integrations.id, request.integrationId),
eq(integrations.type, 'gitea'),
eq(repositorySnapshots.syncJobId, request.syncJobId),
eq(repositorySnapshots.state, 'collecting'),
),
)
.limit(1)
if (!target) return null
if (!target.owner || !target.name) {
throw new DomainError(
'repository_snapshot_identity_invalid',
'Repository snapshot identity is incomplete',
)
}
return {
snapshotId: target.snapshotId,
owner: target.owner,
name: target.name,
}
}
async completeCollection(request: {
readonly workspaceId: string
readonly repositoryId: string
readonly snapshotId: string
readonly createdBy: string
readonly capabilities: JsonValue
readonly evidence: JsonValue
readonly profile: unknown
readonly profileRevisionPolicy: 'create-initial-only'
readonly findings: readonly RepositorySnapshotFindingInput[]
readonly capturedAt?: Date
}): Promise<CompletedRepositorySnapshot | null> {
if (request.profileRevisionPolicy !== 'create-initial-only') {
throw new DomainError(
'repository_snapshot_input_invalid',
'Snapshot profile revision policy is invalid',
)
}
assertJson(request.capabilities, 'capabilities')
assertJson(request.evidence, 'evidence')
const findings = validatedFindings(request.findings)
const capturedAt = request.capturedAt ?? new Date()
const validation = validateRepositoryProfile(request.profile, {
verifyDigest: false,
})
if (
!validation.valid ||
!['gitea', 'mixed'].includes(validation.profile.metadata.source)
) {
throw new DomainError(
'repository_snapshot_profile_invalid',
'Snapshot repository profile is invalid',
)
}
const evidenceDigest = digestRepositoryEvidence(request.evidence)
return this.database.transaction(async (transaction) => {
const [identity] = await transaction
.select({ repository: repositories, snapshot: repositorySnapshots })
.from(repositories)
.innerJoin(
repositorySnapshots,
eq(repositorySnapshots.repositoryId, repositories.id),
)
.where(
and(
eq(repositories.workspaceId, request.workspaceId),
eq(repositories.id, request.repositoryId),
eq(repositorySnapshots.id, request.snapshotId),
),
)
.limit(1)
.for('update')
if (!identity) return null
if (identity.snapshot.state === 'complete') {
if (
identity.snapshot.evidenceDigest !== evidenceDigest ||
!isDeepStrictEqual(
identity.snapshot.capabilitySnapshotJson,
request.capabilities,
)
) {
throw new DomainError(
'repository_snapshot_idempotency_conflict',
'Completed snapshot is associated with different input',
)
}
const [revision] = await transaction
.select()
.from(repositoryProfileRevisions)
.where(
eq(repositoryProfileRevisions.sourceSnapshotId, request.snapshotId),
)
.limit(1)
const [findingCountRow] = await transaction
.select({ count: sql<number>`count(*)::int` })
.from(repositoryFindings)
.where(eq(repositoryFindings.snapshotId, request.snapshotId))
return {
snapshotId: identity.snapshot.id,
snapshot: mapSnapshot(identity.snapshot),
profileRevision: revision
? {
id: revision.id,
revisionNumber: revision.revisionNumber,
contentDigest: revision.contentDigest,
}
: null,
findingCount: findingCountRow?.count ?? 0,
}
}
if (identity.snapshot.state !== 'collecting') snapshotNotCollecting()
const [membership] = await transaction
.select({ userId: users.id })
.from(users)
.innerJoin(
workspaceMemberships,
eq(workspaceMemberships.userId, users.id),
)
.where(
and(
eq(users.id, request.createdBy),
eq(users.status, 'active'),
eq(workspaceMemberships.workspaceId, request.workspaceId),
),
)
.limit(1)
if (!membership) return null
const [current] = await transaction
.select({
id: repositoryProfileRevisions.id,
revisionNumber: repositoryProfileRevisions.revisionNumber,
})
.from(repositoryProfileRevisions)
.where(
eq(repositoryProfileRevisions.repositoryId, identity.repository.id),
)
.orderBy(desc(repositoryProfileRevisions.revisionNumber))
.limit(1)
const revisionNumber = (current?.revisionNumber ?? 0) + 1
const profileCandidate: RepositoryProfile = {
...validation.profile,
metadata: {
name: validation.profile.metadata.name,
source: validation.profile.metadata.source,
capturedAt: capturedAt.toISOString(),
revision: revisionNumber,
...(validation.profile.metadata.sourceReference
? {
sourceReference: validation.profile.metadata.sourceReference,
}
: {}),
},
}
const profile = applyRepositoryProfileServerMetadata(
profileCandidate,
revisionNumber,
)
const revision = current
? null
: (
await transaction
.insert(repositoryProfileRevisions)
.values({
repositoryId: identity.repository.id,
revisionNumber,
profileJson: profile,
sourceSnapshotId: identity.snapshot.id,
contentDigest: profile.metadata.contentDigest!,
createdBy: request.createdBy,
createdAt: capturedAt,
})
.returning()
)[0]
if (!current && !revision)
throw new Error('Profile revision insert returned no row')
if (findings.length > 0) {
await transaction.insert(repositoryFindings).values(
findings.map((finding) => ({
snapshotId: identity.snapshot.id,
ruleId: finding.ruleId,
severity: finding.severity,
title: finding.title,
rationale: finding.rationale,
evidencePointer: finding.evidencePointer,
recommendedPlaybookSlug: finding.recommendedPlaybookSlug ?? null,
status: 'open',
updatedAt: capturedAt,
})),
)
}
const [snapshot] = await transaction
.update(repositorySnapshots)
.set({
state: 'complete',
capturedAt,
capabilitySnapshotJson: request.capabilities,
evidenceJson: request.evidence,
evidenceDigest,
})
.where(
and(
eq(repositorySnapshots.id, identity.snapshot.id),
eq(repositorySnapshots.state, 'collecting'),
),
)
.returning()
if (!snapshot) snapshotNotCollecting()
if (revision) {
await transaction
.update(repositories)
.set({
displayName: profile.metadata.name,
defaultBranch: profile.spec.defaultBranch ?? null,
updatedAt: capturedAt,
})
.where(
and(
eq(repositories.workspaceId, request.workspaceId),
eq(repositories.id, identity.repository.id),
),
)
}
await transaction.insert(auditEvents).values({
occurredAt: capturedAt,
actorUserId: request.createdBy,
workspaceId: request.workspaceId,
action: 'repository.snapshot_completed',
resourceType: 'repository_snapshot',
resourceId: snapshot.id,
outcome: 'success',
metadataJson: {
repositoryId: identity.repository.id,
evidenceDigest,
profileRevision: revision?.revisionNumber ?? null,
profileRevisionCreated: revision !== null,
findingCount: findings.length,
},
})
return {
snapshotId: snapshot.id,
snapshot: mapSnapshot(snapshot),
profileRevision: revision
? {
id: revision.id,
revisionNumber,
contentDigest: revision.contentDigest,
}
: null,
findingCount: findings.length,
}
})
}
async failCollection(request: {
readonly workspaceId: string
readonly snapshotId: string
readonly safeCode: string
}): Promise<boolean> {
const safeCode = requiredText(request.safeCode, 'safeCode', 128)
if (!/^[A-Z][A-Z0-9_]*$/u.test(safeCode)) {
throw new DomainError(
'repository_snapshot_input_invalid',
'safeCode is invalid',
)
}
const snapshot = await findSnapshotForWorkspace(
this.database,
request.workspaceId,
request.snapshotId,
)
if (!snapshot) return false
const [updated] = await this.database
.update(repositorySnapshots)
.set({ state: 'failed', evidenceJson: { failure: { code: safeCode } } })
.where(
and(
eq(repositorySnapshots.id, snapshot.id),
eq(repositorySnapshots.state, 'collecting'),
),
)
.returning({ id: repositorySnapshots.id })
return Boolean(updated)
}
async getLastPreflightDigest(request: {
readonly workspaceId: string
readonly repositoryId: string
}): Promise<string | null> {
const snapshot = await this.getLastComplete(
request.workspaceId,
request.repositoryId,
)
if (!snapshot?.evidence || typeof snapshot.evidence !== 'object')
return null
const digest = (snapshot.evidence as { preflightDigest?: unknown })
.preflightDigest
return typeof digest === 'string' && /^[a-f0-9]{64}$/u.test(digest)
? digest
: null
}
async cancelUnchangedCollection(request: {
readonly workspaceId: string
readonly snapshotId: string
readonly preflightDigest: string
}): Promise<boolean> {
if (!/^[a-f0-9]{64}$/u.test(request.preflightDigest)) {
throw new DomainError(
'repository_snapshot_input_invalid',
'preflightDigest is invalid',
)
}
const snapshot = await findSnapshotForWorkspace(
this.database,
request.workspaceId,
request.snapshotId,
)
if (!snapshot) return false
const [updated] = await this.database
.update(repositorySnapshots)
.set({
state: 'cancelled',
evidenceJson: {
unchanged: true,
preflightDigest: request.preflightDigest,
},
})
.where(
and(
eq(repositorySnapshots.id, snapshot.id),
eq(repositorySnapshots.state, 'collecting'),
),
)
.returning({ id: repositorySnapshots.id })
return Boolean(updated)
}
async getLastComplete(
workspaceId: string,
repositoryId: string,
): Promise<RepositorySnapshotRecord | null> {
const [row] = await this.database
.select({ snapshot: repositorySnapshots })
.from(repositorySnapshots)
.innerJoin(
repositories,
eq(repositorySnapshots.repositoryId, repositories.id),
)
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositories.id, repositoryId),
eq(repositorySnapshots.state, 'complete'),
),
)
.orderBy(
desc(repositorySnapshots.capturedAt),
desc(repositorySnapshots.createdAt),
desc(repositorySnapshots.id),
)
.limit(1)
return row ? mapSnapshot(row.snapshot) : null
}
}
@@ -0,0 +1,325 @@
import type { RepositoryProfileDraft } from '@devrunbook/application'
import {
applyRepositoryProfileServerMetadata,
type RepositoryProfile,
} from '@devrunbook/repository-intel'
import { randomUUID } from 'node:crypto'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { closeDatabase, getSqlClient } from '../index'
import { DrizzleRepositoryStore } from './repository-store'
const databaseIntegration =
process.env.DEVRUNBOOK_DATABASE_INTEGRATION === 'true' &&
Boolean(process.env.DATABASE_URL)
function profile(name: string, revision = 1): RepositoryProfile {
return applyRepositoryProfileServerMetadata(
{
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: { name, revision, source: 'manual' },
spec: {
repositoryType: 'single-app',
defaultBranch: 'main',
stack: {
languages: ['TypeScript'],
frameworks: [],
packageManagers: ['pnpm'],
databases: ['PostgreSQL'],
deploymentTypes: ['Docker'],
testFrameworks: ['Vitest'],
},
commands: [],
paths: {
applicationRoots: ['apps/web'],
testRoots: ['tests'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['runtime'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'reversible-only',
documentationRequired: true,
networkAccess: 'forbidden',
productionDataAccess: 'forbidden',
},
},
},
revision,
)
}
function draft(document: RepositoryProfile): RepositoryProfileDraft {
const metadata = { ...document.metadata } as Record<string, unknown>
delete metadata.revision
delete metadata.contentDigest
return {
...document,
metadata: metadata as RepositoryProfileDraft['metadata'],
}
}
describe.skipIf(!databaseIntegration)(
'DrizzleRepositoryStore integration',
() => {
const workspaceA = randomUUID()
const workspaceB = randomUUID()
const userId = randomUUID()
const missingUserId = randomUUID()
let store: DrizzleRepositoryStore
beforeAll(async () => {
const sql = getSqlClient()
await sql`
insert into users (
id, email, display_name, password_hash, instance_role, status
) values (
${userId}, ${`repository-${userId}@example.invalid`},
'Repository integration', 'not-a-real-password-hash', 'user', 'active'
)
`
await sql`
insert into workspaces (id, name, type)
values
(${workspaceA}, 'Repository integration A', 'team'),
(${workspaceB}, 'Repository integration B', 'team')
`
store = new DrizzleRepositoryStore()
})
afterAll(async () => {
const sql = getSqlClient()
await sql`delete from workspaces where id in (${workspaceA}, ${workspaceB})`
await sql`delete from users where id = ${userId}`
await closeDatabase()
})
it('creates identity plus revision atomically and scopes every read to workspace', async () => {
const initial = profile('Scoped repository')
const created = await store.createManualWithInitialProfile({
workspaceId: workspaceA,
createdBy: userId,
displayName: 'Scoped repository',
defaultBranch: 'main',
initialProfile: initial,
})
await expect(
store.findByIdForWorkspace(workspaceA, created.repository.id),
).resolves.toMatchObject({ currentProfileRevision: 1 })
await expect(
store.findByIdForWorkspace(workspaceB, created.repository.id),
).resolves.toBeNull()
await expect(
store.findCurrentProfileForWorkspace(workspaceB, created.repository.id),
).resolves.toBeNull()
const sql = getSqlClient()
const [beforeCrossWorkspaceAppend] = await sql<{ count: number }[]>`
select count(*)::int as count from repository_profile_revisions
where repository_id = ${created.repository.id}
`
await expect(
store.appendImmutableRevision({
workspaceId: workspaceB,
repositoryId: created.repository.id,
createdBy: userId,
expected: {
revision: 1,
contentDigest: created.revision.contentDigest,
},
validatedDraft: draft(profile('Cross-workspace attempt')),
}),
).resolves.toBeNull()
const [afterCrossWorkspaceAppend] = await sql<{ count: number }[]>`
select count(*)::int as count from repository_profile_revisions
where repository_id = ${created.repository.id}
`
expect(afterCrossWorkspaceAppend?.count).toBe(
beforeCrossWorkspaceAppend?.count,
)
const failedName = `rollback-${randomUUID()}`
await expect(
store.createManualWithInitialProfile({
workspaceId: workspaceA,
createdBy: missingUserId,
displayName: failedName,
defaultBranch: 'main',
initialProfile: profile(failedName),
}),
).rejects.toBeDefined()
const [rolledBack] = await sql<{ count: number }[]>`
select count(*)::int as count from repositories
where workspace_id = ${workspaceA} and display_name = ${failedName}
`
expect(rolledBack?.count).toBe(0)
})
it('uses stable pagination and explicit source and archived filters', async () => {
const first = await store.createManualWithInitialProfile({
workspaceId: workspaceA,
createdBy: userId,
displayName: 'Page A',
defaultBranch: 'main',
initialProfile: profile('Page A'),
})
await store.createManualWithInitialProfile({
workspaceId: workspaceA,
createdBy: userId,
displayName: 'Page B',
defaultBranch: 'main',
initialProfile: profile('Page B'),
})
const page = await store.listForWorkspace(workspaceA, {
source: 'manual',
archived: false,
limit: 1,
})
expect(page.items).toHaveLength(1)
expect(page.nextCursor).not.toBeNull()
const next = await store.listForWorkspace(workspaceA, {
source: 'manual',
archived: false,
cursor: page.nextCursor,
limit: 100,
})
expect(next.items.map((item) => item.id)).not.toContain(page.items[0]?.id)
const sql = getSqlClient()
await sql`update repositories set archived = true where id = ${first.repository.id}`
const archived = await store.listForWorkspace(workspaceA, {
source: 'manual',
archived: true,
})
expect(archived.items.map((item) => item.id)).toContain(
first.repository.id,
)
})
it('serializes equal ETags so one append wins and one conflicts', async () => {
const initial = profile('Concurrent repository')
const created = await store.createManualWithInitialProfile({
workspaceId: workspaceA,
createdBy: userId,
displayName: 'Concurrent repository',
defaultBranch: 'main',
initialProfile: initial,
})
const request = {
workspaceId: workspaceA,
repositoryId: created.repository.id,
createdBy: userId,
expected: {
revision: 1,
contentDigest: created.revision.contentDigest,
},
validatedDraft: draft(profile('Concurrent repository renamed')),
} as const
const results = await Promise.allSettled([
store.appendImmutableRevision(request),
store.appendImmutableRevision(request),
])
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1)
expect(
results.filter((result) => result.status === 'rejected'),
).toHaveLength(1)
const current = await store.findCurrentProfileForWorkspace(
workspaceA,
created.repository.id,
)
expect(current?.revisionNumber).toBe(2)
const noOp = await store.appendImmutableRevision({
...request,
expected: { revision: 2, contentDigest: current!.contentDigest },
})
expect(noOp).toMatchObject({
created: false,
revision: { revisionNumber: 2 },
})
})
it('never changes a generated run frozen with an earlier profile revision', async () => {
const sql = getSqlClient()
const initial = profile('Frozen run repository')
const created = await store.createManualWithInitialProfile({
workspaceId: workspaceA,
createdBy: userId,
displayName: 'Frozen run repository',
defaultBranch: 'main',
initialProfile: initial,
})
const identity = randomUUID()
const slug = `profile-snapshot-${identity}`
const [playbook] = await sql<{ id: string }[]>`
insert into playbooks (
workspace_id, logical_id, slug, namespace, source_type
) values (
${workspaceA}, ${identity}, ${slug}, ${`private-${workspaceA}`},
'private'
)
returning id
`
const [version] = await sql<{ id: string }[]>`
insert into playbook_versions (
playbook_id, semantic_version, lifecycle, package_api_version,
title, summary, category, risk_tier, package_json, template_text,
content_digest, created_by
) values (
${playbook!.id}, '1.0.0', 'validated', 'devrunbook.io/v1alpha1',
'Snapshot fixture', 'Snapshot fixture', 'testing', 'low',
'{}'::jsonb, 'fixture', ${'c'.repeat(64)}, ${userId}
)
returning id
`
const [run] = await sql<{ id: string }[]>`
insert into generated_runs (
workspace_id, playbook_version_id, playbook_snapshot_json,
repository_profile_snapshot_json, normalized_input_json,
policy_snapshot_json, provenance_json, lint_result_json,
rendered_prompt, render_digest, idempotency_key, generated_by
) values (
${workspaceA}, ${version!.id}, '{}'::jsonb,
${JSON.stringify(initial)}::jsonb,
'{}'::jsonb, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb,
'frozen prompt', ${'d'.repeat(64)}, ${randomUUID()}, ${userId}
)
returning id
`
const [before] = await sql<{ snapshot: RepositoryProfile }[]>`
select repository_profile_snapshot_json as snapshot
from generated_runs where id = ${run!.id}
`
await store.appendImmutableRevision({
workspaceId: workspaceA,
repositoryId: created.repository.id,
createdBy: userId,
expected: {
revision: 1,
contentDigest: created.revision.contentDigest,
},
validatedDraft: draft(profile('Renamed after frozen run')),
})
const [after] = await sql<{ snapshot: RepositoryProfile }[]>`
select repository_profile_snapshot_json as snapshot
from generated_runs where id = ${run!.id}
`
expect(after?.snapshot).toEqual(before?.snapshot)
expect(after?.snapshot.metadata.revision).toBe(1)
expect(after?.snapshot.metadata.contentDigest).toBe(
created.revision.contentDigest,
)
})
},
)
@@ -0,0 +1,290 @@
import type {
RepositoryProfileDraft,
RepositoryProfileRevision,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import {
applyRepositoryProfileServerMetadata,
type RepositoryProfile,
} from '@devrunbook/repository-intel'
import { describe, expect, it, vi } from 'vitest'
import {
buildRepositorySummaryQuery,
decodeRepositoryCursor,
DrizzleRepositoryStore,
encodeRepositoryCursor,
mapRepositorySummaryRow,
type RepositoryProfileTransaction,
type RepositoryProfileTransactionRunner,
} from './repository-store'
import { closeDatabase, getDatabase } from '../index'
const workspaceId = '00000000-0000-4000-8000-000000000001'
const repositoryId = '00000000-0000-4000-8000-000000000002'
const userId = '00000000-0000-4000-8000-000000000003'
function profile(name = 'Example', revision = 1): RepositoryProfile {
return applyRepositoryProfileServerMetadata(
{
apiVersion: 'devrunbook.io/v1alpha1',
kind: 'RepositoryProfile',
metadata: { name, revision, source: 'manual' },
spec: {
repositoryType: 'single-app',
defaultBranch: 'main',
stack: {
languages: ['TypeScript'],
frameworks: [],
packageManagers: ['pnpm'],
databases: ['PostgreSQL'],
deploymentTypes: ['Docker'],
testFrameworks: ['Vitest'],
},
commands: [],
paths: {
applicationRoots: ['apps/web'],
testRoots: ['tests'],
documentationRoots: ['docs'],
generated: ['dist'],
protected: ['runtime'],
excluded: ['node_modules'],
},
policies: {
preserveBackwardCompatibility: true,
newDependencies: 'justify',
gitWrite: 'none',
migrations: 'reversible-only',
documentationRequired: true,
networkAccess: 'forbidden',
productionDataAccess: 'forbidden',
},
},
},
revision,
)
}
function draft(document: RepositoryProfile): RepositoryProfileDraft {
const metadata = { ...document.metadata } as Record<string, unknown>
delete metadata.revision
delete metadata.contentDigest
return {
...document,
metadata: metadata as RepositoryProfileDraft['metadata'],
}
}
function revision(document: RepositoryProfile): RepositoryProfileRevision {
return {
id: '00000000-0000-4000-8000-000000000004',
repositoryId,
revisionNumber: document.metadata.revision,
profile: document,
contentDigest: document.metadata.contentDigest!,
createdBy: userId,
createdAt: '2026-07-27T12:00:00.000Z',
}
}
class Runner implements RepositoryProfileTransactionRunner {
constructor(readonly transaction: RepositoryProfileTransaction) {}
run<T>(
work: (transaction: RepositoryProfileTransaction) => Promise<T>,
): Promise<T> {
return work(this.transaction)
}
}
function transaction(current: RepositoryProfileRevision) {
return {
insertManualIdentity: vi.fn(),
insertRevision: vi.fn(async (input) => revision(input.profile)),
lockIdentityForWorkspace: vi.fn(async () => ({
id: repositoryId,
archived: false,
})),
findCurrentRevision: vi.fn(async () => current),
updateIdentityFromProfile: vi.fn(async () => undefined),
} satisfies RepositoryProfileTransaction
}
function storeWith(tx: RepositoryProfileTransaction) {
return new DrizzleRepositoryStore(
{} as never,
new Runner(tx),
() => new Date('2026-07-27T13:00:00.000Z'),
)
}
describe('repository cursor', () => {
it('normalizes PostgreSQL aggregate timestamp strings in repository summaries', () => {
expect(
mapRepositorySummaryRow({
id: repositoryId,
displayName: 'Imported service',
sourceType: 'gitea',
defaultBranch: 'main',
archived: false,
currentProfileRevision: 1,
lastSnapshotAt: '2026-07-27T11:07:00.000Z',
createdAt: new Date('2026-07-27T11:00:00.000Z'),
updatedAt: new Date('2026-07-27T11:07:00.000Z'),
}),
).toMatchObject({ lastSnapshotAt: '2026-07-27T11:07:00.000Z' })
})
it('round-trips the stable updated_at/id tuple', () => {
const value = {
updatedAt: '2026-07-27T12:00:00.000Z',
id: repositoryId,
}
expect(decodeRepositoryCursor(encodeRepositoryCursor(value))).toEqual(value)
})
it.each(['', 'not-json', Buffer.from('{}').toString('base64url')])(
'rejects malformed cursor %s',
(value) => {
expect(() => decodeRepositoryCursor(value)).toThrow(DomainError)
},
)
})
describe('repository summary query', () => {
it('qualifies the outer repository in correlated revision lookups', async () => {
const query = buildRepositorySummaryQuery(
getDatabase('postgresql://unused:unused@127.0.0.1:1/unused'),
workspaceId,
repositoryId,
).toSQL().sql
expect(query).toContain(
'"repository_profile_revisions"."repository_id" = "summary_repositories"."id"',
)
expect(query).toContain(
'"repository_snapshots"."repository_id" = "summary_repositories"."id"',
)
await closeDatabase()
})
})
describe('DrizzleRepositoryStore immutable revision orchestration', () => {
it('returns the current revision for a semantic no-op without writing', async () => {
const current = revision(profile())
const tx = transaction(current)
const result = await storeWith(tx).appendImmutableRevision({
workspaceId,
repositoryId,
createdBy: userId,
expected: { revision: 1, contentDigest: current.contentDigest },
validatedDraft: draft(current.profile),
})
expect(result).toEqual({ revision: current, created: false })
expect(tx.insertRevision).not.toHaveBeenCalled()
expect(tx.updateIdentityFromProfile).not.toHaveBeenCalled()
})
it('allocates one contiguous server-owned revision and updates identity', async () => {
const current = revision(profile())
const tx = transaction(current)
const result = await storeWith(tx).appendImmutableRevision({
workspaceId,
repositoryId,
createdBy: userId,
expected: { revision: 1, contentDigest: current.contentDigest },
validatedDraft: draft(profile('Renamed')),
})
expect(result?.created).toBe(true)
expect(result?.revision.revisionNumber).toBe(2)
expect(result?.revision.profile.metadata.revision).toBe(2)
expect(result?.revision.contentDigest).toMatch(/^[a-f0-9]{64}$/u)
expect(tx.insertRevision).toHaveBeenCalledWith(
expect.objectContaining({
repositoryId,
revisionNumber: 2,
createdBy: userId,
}),
)
expect(tx.updateIdentityFromProfile).toHaveBeenCalledOnce()
})
it('checks the exact strong ETag before no-op detection', async () => {
const current = revision(profile())
const tx = transaction(current)
await expect(
storeWith(tx).appendImmutableRevision({
workspaceId,
repositoryId,
createdBy: userId,
expected: { revision: 1, contentDigest: 'f'.repeat(64) },
validatedDraft: draft(current.profile),
}),
).rejects.toMatchObject({
code: 'repository_profile_conflict',
details: {
currentRevision: 1,
currentEtag: `"profile:1:${current.contentDigest}"`,
recovery: 'reload-and-review',
},
})
expect(tx.insertRevision).not.toHaveBeenCalled()
})
it('keeps current lookup and identity update workspace-scoped', async () => {
const current = revision(profile())
const tx = transaction(current)
await storeWith(tx).appendImmutableRevision({
workspaceId,
repositoryId,
createdBy: userId,
expected: { revision: 1, contentDigest: current.contentDigest },
validatedDraft: draft(profile('Renamed')),
})
expect(tx.findCurrentRevision).toHaveBeenCalledWith(
workspaceId,
repositoryId,
)
expect(tx.updateIdentityFromProfile).toHaveBeenCalledWith(
workspaceId,
repositoryId,
expect.anything(),
new Date('2026-07-27T13:00:00.000Z'),
)
})
it('rejects initial profiles whose declared digest does not match content', () => {
const initial = profile()
expect(() =>
storeWith(transaction(revision(initial))).createManualWithInitialProfile({
workspaceId,
createdBy: userId,
displayName: 'Tampered',
defaultBranch: 'main',
initialProfile: {
...initial,
metadata: { ...initial.metadata, name: 'Tampered' },
},
}),
).toThrowError(DomainError)
})
it('returns null for missing, cross-workspace, or archived identities', async () => {
const current = revision(profile())
for (const identity of [null, { id: repositoryId, archived: true }]) {
const tx = transaction(current)
tx.lockIdentityForWorkspace.mockResolvedValueOnce(identity!)
await expect(
storeWith(tx).appendImmutableRevision({
workspaceId,
repositoryId,
createdBy: userId,
expected: { revision: 1, contentDigest: current.contentDigest },
validatedDraft: draft(current.profile),
}),
).resolves.toBeNull()
expect(tx.findCurrentRevision).not.toHaveBeenCalled()
}
})
})
@@ -0,0 +1,563 @@
import {
formatStrongProfileEtag,
type AppendRepositoryProfileRevisionStoreRequest,
type AppendRepositoryProfileRevisionStoreResult,
type CreateManualRepositoryStoreRequest,
type RepositoryListQuery,
type RepositoryPage,
type RepositoryProfileDraft,
type RepositoryProfileRevision,
type RepositoryStore,
type RepositorySummary,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import {
applyRepositoryProfileServerMetadata,
digestRepositoryProfile,
type RepositoryProfile,
validateRepositoryProfile,
} from '@devrunbook/repository-intel'
import {
and,
asc,
desc,
eq,
gt,
ilike,
lt,
or,
sql,
type SQL,
} from 'drizzle-orm'
import { alias } from 'drizzle-orm/pg-core'
import { getDatabase } from '../index'
import { repositories, repositoryProfileRevisions } from '../schema'
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
type RepositoryRow = typeof repositories.$inferSelect
type RevisionRow = typeof repositoryProfileRevisions.$inferSelect
interface RepositoryCursor {
readonly updatedAt: string
readonly id: string
}
interface LockedRepository {
readonly id: string
readonly archived: boolean
}
export interface RepositoryProfileTransaction {
insertManualIdentity(
request: CreateManualRepositoryStoreRequest,
): Promise<RepositoryRow>
insertRevision(input: {
readonly repositoryId: string
readonly revisionNumber: number
readonly profile: RepositoryProfile
readonly createdBy: string
}): Promise<RepositoryProfileRevision>
lockIdentityForWorkspace(
workspaceId: string,
repositoryId: string,
): Promise<LockedRepository | null>
findCurrentRevision(
workspaceId: string,
repositoryId: string,
): Promise<RepositoryProfileRevision | null>
updateIdentityFromProfile(
workspaceId: string,
repositoryId: string,
profile: RepositoryProfile,
updatedAt: Date,
): Promise<void>
}
export interface RepositoryProfileTransactionRunner {
run<T>(
work: (transaction: RepositoryProfileTransaction) => Promise<T>,
): Promise<T>
}
function invalidCursor(): never {
throw new DomainError(
'repository_cursor_invalid',
'Repository cursor is invalid',
)
}
export function encodeRepositoryCursor(cursor: RepositoryCursor): string {
return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url')
}
export function decodeRepositoryCursor(value: string): RepositoryCursor {
if (value.length === 0 || value.length > 512) invalidCursor()
try {
const decoded = JSON.parse(
Buffer.from(value, 'base64url').toString('utf8'),
) as unknown
if (
typeof decoded !== 'object' ||
decoded === null ||
Object.keys(decoded).length !== 2 ||
!('updatedAt' in decoded) ||
!('id' in decoded) ||
typeof decoded.updatedAt !== 'string' ||
Number.isNaN(Date.parse(decoded.updatedAt)) ||
typeof decoded.id !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
decoded.id,
)
) {
invalidCursor()
}
return {
updatedAt: new Date(decoded.updatedAt).toISOString(),
id: decoded.id,
}
} catch (error) {
if (error instanceof DomainError) throw error
invalidCursor()
}
}
function boundedLimit(value: number | undefined): number {
const limit = value ?? 50
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new DomainError(
'repository_list_limit_invalid',
'Repository list limit must be between 1 and 100',
)
}
return limit
}
const summaryRepositories = alias(repositories, 'summary_repositories')
const currentRevisionExpression = sql<number | null>`(
select max("repository_profile_revisions"."revision_number")
from "repository_profile_revisions"
where "repository_profile_revisions"."repository_id" = "summary_repositories"."id"
)`
const latestSnapshotExpression = sql<Date | null>`(
select max("repository_snapshots"."captured_at")
from "repository_snapshots"
where "repository_snapshots"."repository_id" = "summary_repositories"."id"
)`
function summarySelection() {
return {
id: summaryRepositories.id,
displayName: summaryRepositories.displayName,
sourceType: summaryRepositories.sourceType,
defaultBranch: summaryRepositories.defaultBranch,
archived: summaryRepositories.archived,
currentProfileRevision: currentRevisionExpression,
lastSnapshotAt: latestSnapshotExpression,
createdAt: summaryRepositories.createdAt,
updatedAt: summaryRepositories.updatedAt,
}
}
function cursorPredicate(cursor: RepositoryCursor): SQL {
const updatedAt = new Date(cursor.updatedAt)
return or(
lt(summaryRepositories.updatedAt, updatedAt),
and(
eq(summaryRepositories.updatedAt, updatedAt),
gt(summaryRepositories.id, cursor.id),
),
)!
}
export function buildRepositoryListQuery(
database: Database,
workspaceId: string,
query: RepositoryListQuery,
) {
const limit = boundedLimit(query.limit)
const predicates: SQL[] = [
eq(summaryRepositories.workspaceId, workspaceId),
eq(summaryRepositories.archived, query.archived ?? false),
]
if (query.source)
predicates.push(eq(summaryRepositories.sourceType, query.source))
if (query.q)
predicates.push(ilike(summaryRepositories.displayName, `%${query.q}%`))
if (query.cursor)
predicates.push(cursorPredicate(decodeRepositoryCursor(query.cursor)))
return database
.select(summarySelection())
.from(summaryRepositories)
.where(and(...predicates))
.orderBy(desc(summaryRepositories.updatedAt), asc(summaryRepositories.id))
.limit(limit + 1)
}
export function buildRepositorySummaryQuery(
database: Database,
workspaceId: string,
repositoryId: string,
) {
return database
.select(summarySelection())
.from(summaryRepositories)
.where(
and(
eq(summaryRepositories.workspaceId, workspaceId),
eq(summaryRepositories.id, repositoryId),
),
)
.limit(1)
}
export function mapRepositorySummaryRow(row: {
id: string
displayName: string
sourceType: string
defaultBranch: string | null
archived: boolean
currentProfileRevision: number | null
lastSnapshotAt: Date | string | null
createdAt: Date
updatedAt: Date
}): RepositorySummary {
return {
id: row.id,
displayName: row.displayName,
sourceType: row.sourceType as RepositorySummary['sourceType'],
defaultBranch: row.defaultBranch,
archived: row.archived,
currentProfileRevision: row.currentProfileRevision,
lastSnapshotAt:
row.lastSnapshotAt === null
? null
: new Date(row.lastSnapshotAt).toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}
}
function mapRevision(row: RevisionRow): RepositoryProfileRevision {
const validation = validateRepositoryProfile(row.profileJson)
if (
!validation.valid ||
validation.contentDigest !== row.contentDigest ||
validation.profile.metadata.contentDigest !== row.contentDigest ||
validation.profile.metadata.revision !== row.revisionNumber
) {
throw new Error(
'Stored repository profile revision failed integrity validation',
)
}
return {
id: row.id,
repositoryId: row.repositoryId,
revisionNumber: row.revisionNumber,
profile: validation.profile,
contentDigest: row.contentDigest,
createdBy: row.createdBy,
createdAt: row.createdAt.toISOString(),
}
}
function draftAtRevision(
draft: RepositoryProfileDraft,
revision: number,
): RepositoryProfile {
return {
...draft,
metadata: { ...draft.metadata, revision },
}
}
class DrizzleRepositoryProfileTransaction implements RepositoryProfileTransaction {
constructor(private readonly transaction: Transaction) {}
async insertManualIdentity(
request: CreateManualRepositoryStoreRequest,
): Promise<RepositoryRow> {
const [row] = await this.transaction
.insert(repositories)
.values({
workspaceId: request.workspaceId,
displayName: request.displayName,
sourceType: 'manual',
defaultBranch: request.defaultBranch,
})
.returning()
if (!row) throw new Error('Repository insert did not return a row')
return row
}
async insertRevision(input: {
repositoryId: string
revisionNumber: number
profile: RepositoryProfile
createdBy: string
}): Promise<RepositoryProfileRevision> {
const validation = validateRepositoryProfile(input.profile)
const digest = input.profile.metadata.contentDigest
if (
!validation.valid ||
!digest ||
validation.contentDigest !== digest ||
input.profile.metadata.revision !== input.revisionNumber
) {
throw new Error(
'Repository profile failed integrity validation before insert',
)
}
const [row] = await this.transaction
.insert(repositoryProfileRevisions)
.values({
repositoryId: input.repositoryId,
revisionNumber: input.revisionNumber,
profileJson: input.profile,
contentDigest: digest,
createdBy: input.createdBy,
})
.returning()
if (!row)
throw new Error('Repository profile revision insert did not return a row')
return mapRevision(row)
}
async lockIdentityForWorkspace(
workspaceId: string,
repositoryId: string,
): Promise<LockedRepository | null> {
const [row] = await this.transaction
.select({ id: repositories.id, archived: repositories.archived })
.from(repositories)
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositories.id, repositoryId),
),
)
.limit(1)
.for('update')
return row ?? null
}
async findCurrentRevision(
workspaceId: string,
repositoryId: string,
): Promise<RepositoryProfileRevision | null> {
const [row] = await this.transaction
.select({ revision: repositoryProfileRevisions })
.from(repositories)
.innerJoin(
repositoryProfileRevisions,
eq(repositoryProfileRevisions.repositoryId, repositories.id),
)
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositories.id, repositoryId),
),
)
.orderBy(desc(repositoryProfileRevisions.revisionNumber))
.limit(1)
return row ? mapRevision(row.revision) : null
}
async updateIdentityFromProfile(
workspaceId: string,
repositoryId: string,
profile: RepositoryProfile,
updatedAt: Date,
): Promise<void> {
await this.transaction
.update(repositories)
.set({
displayName: profile.metadata.name,
defaultBranch: profile.spec.defaultBranch ?? null,
updatedAt,
})
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositories.id, repositoryId),
),
)
}
}
export class DrizzleRepositoryProfileTransactionRunner implements RepositoryProfileTransactionRunner {
constructor(private readonly database: Database = getDatabase()) {}
run<T>(
work: (transaction: RepositoryProfileTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction((transaction) =>
work(new DrizzleRepositoryProfileTransaction(transaction)),
)
}
}
export class DrizzleRepositoryStore implements RepositoryStore {
constructor(
private readonly database: Database = getDatabase(),
private readonly runner: RepositoryProfileTransactionRunner = new DrizzleRepositoryProfileTransactionRunner(
database,
),
private readonly now: () => Date = () => new Date(),
) {}
async listForWorkspace(
workspaceId: string,
query: RepositoryListQuery,
): Promise<RepositoryPage> {
const limit = boundedLimit(query.limit)
const rows = await buildRepositoryListQuery(
this.database,
workspaceId,
query,
)
const items = rows.slice(0, limit).map(mapRepositorySummaryRow)
const last = rows.length > limit ? rows[limit - 1] : undefined
return {
items,
nextCursor: last
? encodeRepositoryCursor({
updatedAt: last.updatedAt.toISOString(),
id: last.id,
})
: null,
}
}
async findByIdForWorkspace(
workspaceId: string,
repositoryId: string,
): Promise<RepositorySummary | null> {
const [row] = await buildRepositorySummaryQuery(
this.database,
workspaceId,
repositoryId,
)
return row ? mapRepositorySummaryRow(row) : null
}
async findCurrentProfileForWorkspace(
workspaceId: string,
repositoryId: string,
): Promise<RepositoryProfileRevision | null> {
const [row] = await this.database
.select({ revision: repositoryProfileRevisions })
.from(repositories)
.innerJoin(
repositoryProfileRevisions,
eq(repositoryProfileRevisions.repositoryId, repositories.id),
)
.where(
and(
eq(repositories.workspaceId, workspaceId),
eq(repositories.id, repositoryId),
),
)
.orderBy(desc(repositoryProfileRevisions.revisionNumber))
.limit(1)
return row ? mapRevision(row.revision) : null
}
createManualWithInitialProfile(request: CreateManualRepositoryStoreRequest) {
if (
request.initialProfile.metadata.revision !== 1 ||
!request.initialProfile.metadata.contentDigest ||
request.initialProfile.metadata.contentDigest !==
digestRepositoryProfile(request.initialProfile)
) {
throw new DomainError(
'repository_profile_invalid',
'Initial repository profile requires server metadata for revision 1',
)
}
return this.runner.run(async (transaction) => {
const row = await transaction.insertManualIdentity(request)
const revision = await transaction.insertRevision({
repositoryId: row.id,
revisionNumber: 1,
profile: request.initialProfile,
createdBy: request.createdBy,
})
return {
repository: mapRepositorySummaryRow({
...row,
currentProfileRevision: 1,
lastSnapshotAt: null,
}),
revision,
}
})
}
appendImmutableRevision(
request: AppendRepositoryProfileRevisionStoreRequest,
): Promise<AppendRepositoryProfileRevisionStoreResult | null> {
return this.runner.run(async (transaction) => {
const identity = await transaction.lockIdentityForWorkspace(
request.workspaceId,
request.repositoryId,
)
if (!identity || identity.archived) return null
const current = await transaction.findCurrentRevision(
request.workspaceId,
identity.id,
)
if (!current)
throw new Error('Repository identity has no profile revision')
if (
current.revisionNumber !== request.expected.revision ||
current.contentDigest !== request.expected.contentDigest
) {
throw new DomainError(
'repository_profile_conflict',
'Repository profile changed since it was read',
{
currentRevision: current.revisionNumber,
currentEtag: formatStrongProfileEtag(
current.revisionNumber,
current.contentDigest,
),
recovery: 'reload-and-review',
},
)
}
const sameRevisionCandidate = applyRepositoryProfileServerMetadata(
draftAtRevision(request.validatedDraft, current.revisionNumber),
current.revisionNumber,
)
if (
sameRevisionCandidate.metadata.contentDigest === current.contentDigest
) {
return { revision: current, created: false }
}
const revisionNumber = current.revisionNumber + 1
const profile = applyRepositoryProfileServerMetadata(
draftAtRevision(request.validatedDraft, revisionNumber),
revisionNumber,
)
const revision = await transaction.insertRevision({
repositoryId: identity.id,
revisionNumber,
profile,
createdBy: request.createdBy,
})
await transaction.updateIdentityFromProfile(
request.workspaceId,
identity.id,
profile,
this.now(),
)
return { revision, created: true }
})
}
}
@@ -0,0 +1,57 @@
import type {
ArtifactRetentionStore,
ExpiredArtifactCandidate,
} from '@devrunbook/application'
import { and, asc, eq, lte } from 'drizzle-orm'
import { getDatabase } from '../index'
import { auditEvents, generatedArtifacts, generatedRuns } from '../schema'
type Database = ReturnType<typeof getDatabase>
export class DrizzleArtifactRetentionStore implements ArtifactRetentionStore {
constructor(private readonly database: Database = getDatabase()) {}
listExpired(now: Date, limit: number): Promise<ExpiredArtifactCandidate[]> {
return this.database
.select({
id: generatedArtifacts.id,
workspaceId: generatedRuns.workspaceId,
storageKey: generatedArtifacts.storageKey,
})
.from(generatedArtifacts)
.innerJoin(generatedRuns, eq(generatedRuns.id, generatedArtifacts.runId))
.where(lte(generatedArtifacts.expiresAt, now))
.orderBy(asc(generatedArtifacts.expiresAt), asc(generatedArtifacts.id))
.limit(limit)
}
finalizeDeletion(input: {
readonly artifact: ExpiredArtifactCandidate
readonly now: Date
}): Promise<boolean> {
return this.database.transaction(async (transaction) => {
const rows = await transaction
.delete(generatedArtifacts)
.where(
and(
eq(generatedArtifacts.id, input.artifact.id),
eq(generatedArtifacts.storageKey, input.artifact.storageKey),
lte(generatedArtifacts.expiresAt, input.now),
),
)
.returning({ id: generatedArtifacts.id })
if (rows.length !== 1) return false
await transaction.insert(auditEvents).values({
actorUserId: null,
workspaceId: input.artifact.workspaceId,
action: 'artifact.retention_deleted',
resourceType: 'generated_artifact',
resourceId: input.artifact.id,
outcome: 'success',
metadataJson: { renderedPromptRetained: true },
})
return true
})
}
}
+285
View File
@@ -0,0 +1,285 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { getTableName } from 'drizzle-orm'
import { getTableConfig } from 'drizzle-orm/pg-core'
import { describe, expect, it } from 'vitest'
import * as schema from './schema'
import { trySetupAdvisoryLockQuery } from './setup-lock'
const expectedTables = [
'audit_events',
'auth_sessions',
'collection_items',
'collections',
'composition_drafts',
'evaluation_cases',
'evaluation_results',
'favorites',
'generated_artifacts',
'generated_runs',
'instance_settings',
'integration_secrets',
'integrations',
'invitations',
'jobs',
'password_reset_tokens',
'playbook_package_files',
'playbook_review_attestations',
'playbook_versions',
'playbooks',
'repositories',
'repository_findings',
'repository_preferences',
'repository_profile_revisions',
'repository_snapshots',
'run_feedback',
'users',
'workspace_memberships',
'workspaces',
]
const migrationPath = fileURLToPath(
new URL('../migrations/0000_jittery_wind_dancer.sql', import.meta.url),
)
const migration = readFileSync(migrationPath, 'utf8')
const repositoryInvariantMigration = readFileSync(
fileURLToPath(new URL('../migrations/0002_wild_wraith.sql', import.meta.url)),
'utf8',
)
const compositionInvariantMigration = readFileSync(
fileURLToPath(
new URL('../migrations/0003_polite_kronos.sql', import.meta.url),
),
'utf8',
)
const giteaPersistenceMigration = readFileSync(
fileURLToPath(
new URL(
'../migrations/0004_gitea_persistence_hardening.sql',
import.meta.url,
),
),
'utf8',
)
const playbookPackageFileMigration = readFileSync(
fileURLToPath(
new URL('../migrations/0005_luxuriant_changeling.sql', import.meta.url),
),
'utf8',
)
const playbookPublicationMigration = readFileSync(
fileURLToPath(
new URL('../migrations/0006_worried_prodigy.sql', import.meta.url),
),
'utf8',
)
const repositoryPreferenceMigration = readFileSync(
fileURLToPath(
new URL('../migrations/0008_third_menace.sql', import.meta.url),
),
'utf8',
)
describe('database contract', () => {
it('exports exactly the 29 governed tables', () => {
const actualTables = Object.values(schema)
.map((value) => {
try {
return getTableName(value)
} catch {
return undefined
}
})
.filter((name) => name !== undefined)
.sort()
expect(actualTables).toEqual(expectedTables)
})
it('keeps the application-owned auth storage contract', () => {
expect(Object.keys(schema.authSessions)).toEqual(
expect.arrayContaining([
'tokenHash',
'idleExpiresAt',
'absoluteExpiresAt',
'revokedAt',
]),
)
expect(Object.keys(schema.users)).toContain('passwordHash')
expect(Object.keys(schema.users)).toEqual(
expect.arrayContaining(['emailVerified', 'image']),
)
const authCompatibilityMigration = readFileSync(
fileURLToPath(
new URL('../migrations/0001_daily_mystique.sql', import.meta.url),
),
'utf8',
)
expect(authCompatibilityMigration).toContain(
'ADD COLUMN "email_verified" boolean DEFAULT false NOT NULL',
)
expect(authCompatibilityMigration).toContain('ADD COLUMN "image" text')
})
it('creates every table and the corrected job idempotency indexes', () => {
for (const table of expectedTables) {
expect(
`${migration}\n${playbookPackageFileMigration}\n${playbookPublicationMigration}\n${repositoryPreferenceMigration}`,
).toContain(`CREATE TABLE "${table}"`)
}
expect(migration).toContain('"jobs_workspace_type_idempotency_uq"')
expect(migration).toContain('"workspace_id" is not null')
expect(migration).toContain('"jobs_global_type_idempotency_uq"')
expect(migration).toContain('"workspace_id" is null')
})
it('enforces package inventory integrity and published immutability', () => {
expect(playbookPackageFileMigration).toContain(
'playbook_package_files_version_path_uq',
)
expect(playbookPackageFileMigration).toContain(
'playbook_package_files_size_check',
)
expect(playbookPackageFileMigration).toContain(
'playbook_package_files_immutable_when_published',
)
expect(playbookPackageFileMigration).toContain(
'UPDATE "playbook_versions" SET "draft_digest" = "content_digest"',
)
const triggerDrop = playbookPackageFileMigration.indexOf(
'DROP TRIGGER "playbook_versions_published_immutable_trg"',
)
const backfill = playbookPackageFileMigration.indexOf(
'UPDATE "playbook_versions" SET "draft_digest" = "content_digest"',
)
const triggerRestore = playbookPackageFileMigration.lastIndexOf(
'CREATE TRIGGER playbook_versions_published_immutable_trg',
)
expect(triggerDrop).toBeGreaterThanOrEqual(0)
expect(backfill).toBeGreaterThan(triggerDrop)
expect(triggerRestore).toBeGreaterThan(backfill)
})
it('seeds setup state and installs transaction-scoped locking', () => {
expect(migration).toContain('INSERT INTO "instance_settings"')
expect(migration).toContain('devrunbook_try_setup_advisory_lock()')
expect(migration).toContain('pg_try_advisory_xact_lock')
expect(trySetupAdvisoryLockQuery.queryChunks.length).toBeGreaterThan(0)
})
it('protects every required immutable record class', () => {
for (const trigger of [
'playbook_versions_published_immutable_trg',
'repository_profile_revisions_immutable_trg',
'repository_snapshots_complete_immutable_trg',
'generated_runs_immutable_trg',
'evaluation_results_immutable_trg',
'audit_events_append_only_trg',
]) {
expect(migration).toContain(`CREATE TRIGGER ${trigger}`)
}
expect(playbookPublicationMigration).toContain(
'CREATE TRIGGER playbook_review_attestations_immutable_trg',
)
})
it('indexes workspace repository lists and constrains profile revision identity', () => {
const repositoryConfig = getTableConfig(schema.repositories)
const revisionConfig = getTableConfig(schema.repositoryProfileRevisions)
expect(repositoryConfig.indexes.map((item) => item.config.name)).toContain(
'repositories_workspace_updated_idx',
)
expect(revisionConfig.checks.map((item) => item.name)).toEqual(
expect.arrayContaining([
'repository_profile_revisions_revision_positive_check',
'repository_profile_revisions_content_digest_check',
]),
)
expect(repositoryInvariantMigration).toContain(
'CREATE INDEX "repositories_workspace_updated_idx" ON "repositories" USING btree ("workspace_id","archived","updated_at" DESC NULLS LAST,"id")',
)
expect(repositoryInvariantMigration).toContain(
'CHECK ("repository_profile_revisions"."revision_number" > 0)',
)
expect(repositoryInvariantMigration).toContain(
`CHECK ("repository_profile_revisions"."content_digest" ~ '^[0-9a-f]{64}$')`,
)
expect(repositoryInvariantMigration).not.toMatch(
/\b(?:DROP|UPDATE|DELETE)\b/u,
)
})
it('adds monotonic draft concurrency and conservative digest constraints', () => {
const draftConfig = getTableConfig(schema.compositionDrafts)
const runConfig = getTableConfig(schema.generatedRuns)
expect(Object.keys(schema.compositionDrafts)).toEqual(
expect.arrayContaining([
'revision',
'policyOverrideJson',
'outputFormat',
]),
)
expect(draftConfig.checks.map((item) => item.name)).toEqual(
expect.arrayContaining([
'composition_drafts_revision_positive_check',
'composition_drafts_last_render_digest_check',
'composition_drafts_output_format_check',
]),
)
expect(runConfig.checks.map((item) => item.name)).toEqual(
expect.arrayContaining([
'generated_runs_render_digest_check',
'generated_runs_idempotency_key_check',
]),
)
expect(compositionInvariantMigration).toContain(
'ADD COLUMN "revision" integer DEFAULT 1 NOT NULL',
)
expect(compositionInvariantMigration).toContain(
'"generated_runs_render_digest_check"',
)
expect(schema.generatedRuns.idempotencyKey.notNull).toBe(true)
expect(compositionInvariantMigration).toContain(
'ALTER COLUMN "idempotency_key" SET NOT NULL',
)
expect(compositionInvariantMigration).not.toMatch(
/\b(?:DROP|UPDATE|DELETE)\b/u,
)
})
it('hardens encrypted Gitea envelopes and complete snapshots additively', () => {
const integrationConfig = getTableConfig(schema.integrations)
const secretConfig = getTableConfig(schema.integrationSecrets)
const snapshotConfig = getTableConfig(schema.repositorySnapshots)
expect(integrationConfig.indexes.map((item) => item.config.name)).toContain(
'integrations_workspace_status_idx',
)
expect(secretConfig.checks.map((item) => item.name)).toEqual(
expect.arrayContaining([
'integration_secrets_envelope_version_check',
'integration_secrets_nonce_length_check',
'integration_secrets_auth_tag_length_check',
]),
)
expect(snapshotConfig.checks.map((item) => item.name)).toContain(
'repository_snapshots_complete_integrity_check',
)
expect(snapshotConfig.indexes.map((item) => item.config.name)).toEqual(
expect.arrayContaining([
'repository_snapshots_sync_job_uq',
'repository_snapshots_integration_state_idx',
]),
)
expect(giteaPersistenceMigration).toContain(
'integration_secrets_nonce_length_check',
)
expect(giteaPersistenceMigration).toContain(
'repository_snapshots_complete_integrity_check',
)
expect(giteaPersistenceMigration).not.toMatch(/\b(?:DROP|UPDATE|DELETE)\b/u)
})
})
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { sql } from 'drizzle-orm'
/**
* This query must run inside the same transaction as first-run setup. PostgreSQL
* releases the advisory lock automatically on commit or rollback.
*/
export const trySetupAdvisoryLockQuery = sql<{ acquired: boolean }>`
select devrunbook_try_setup_advisory_lock() as acquired
`
@@ -0,0 +1,81 @@
import type {
FirstRunStore,
FirstRunTransaction,
} from '@devrunbook/application'
import { completeFirstRun } from '@devrunbook/application'
import { describe, expect, it, vi } from 'vitest'
import {
DrizzleFirstRunStore,
type FirstRunTransactionRunner,
type PrevalidatedImportedPlaybookRecord,
} from './first-run-store'
function records(): PrevalidatedImportedPlaybookRecord[] {
return Array.from({ length: 28 }, (_, index) => ({
logicalId: `builtin.${index}`,
slug: `builtin-${index}`,
namespace: 'builtin',
sourceType: 'built_in',
semanticVersion: '1.0.0',
lifecycle: 'reviewed',
packageApiVersion: 'devrunbook.io/v1alpha1',
title: `Built-in ${index}`,
summary: 'Validated package',
category: 'foundation',
riskTier: 'low',
packageJson: {},
templateText: '# Task\n',
contentDigest: index.toString(16).padStart(64, '0'),
searchProjection: { searchText: `Built-in ${index}` },
}))
}
class CapturingRunner implements FirstRunTransactionRunner {
received: readonly PrevalidatedImportedPlaybookRecord[] = []
readonly transaction: FirstRunTransaction = {
isSetupComplete: vi.fn(async () => false),
createOwner: vi.fn(async () => ({ id: 'owner-1' })),
createPersonalWorkspace: vi.fn(async () => ({ id: 'workspace-1' })),
addOwnerMembership: vi.fn(async () => undefined),
importBuiltInPlaybooks: vi.fn(async () => ({ imported: 28 })),
completeSetup: vi.fn(async () => undefined),
appendAuditEvent: vi.fn(async () => undefined),
}
async run<T>(
imported: readonly PrevalidatedImportedPlaybookRecord[],
work: (transaction: FirstRunTransaction) => Promise<T>,
): Promise<T> {
this.received = imported
return work(this.transaction)
}
}
describe('DrizzleFirstRunStore', () => {
it('passes exactly 28 prevalidated records into one transaction runner', async () => {
const runner = new CapturingRunner()
const store: FirstRunStore = new DrizzleFirstRunStore(records(), runner)
const result = await completeFirstRun(store, {
instanceName: 'DevRunbook',
publicBaseUrl: 'https://runbook.example.test',
owner: {
email: 'owner@example.test',
displayName: 'Owner',
passwordHash: 'better-auth-password-hash',
},
configuration: { instanceName: 'DevRunbook' },
configurationDigest: 'b'.repeat(64),
})
expect(runner.received).toHaveLength(28)
expect(result).toEqual({
ownerId: 'owner-1',
workspaceId: 'workspace-1',
importedPlaybooks: 28,
})
expect(runner.transaction.completeSetup).toHaveBeenCalledTimes(1)
expect(runner.transaction.appendAuditEvent).toHaveBeenCalledTimes(1)
})
})
+184
View File
@@ -0,0 +1,184 @@
import type {
BuiltInPlaybookImportRecord,
FirstRunOwner,
FirstRunStore,
FirstRunTransaction,
} from '@devrunbook/application'
import { DomainError } from '@devrunbook/domain'
import { eq } from 'drizzle-orm'
import { getDatabase } from '../index'
import {
acquireBuiltInImportLock,
persistBuiltInPlaybooks,
} from '../playbooks/built-in-importer'
import {
auditEvents,
instanceSettings,
users,
workspaceMemberships,
workspaces,
} from '../schema'
import { trySetupAdvisoryLockQuery } from '../setup-lock'
export type PrevalidatedImportedPlaybookRecord = BuiltInPlaybookImportRecord
export interface FirstRunTransactionRunner {
run<T>(
records: readonly PrevalidatedImportedPlaybookRecord[],
work: (transaction: FirstRunTransaction) => Promise<T>,
): Promise<T>
}
type Database = ReturnType<typeof getDatabase>
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0]
class DrizzleFirstRunTransaction implements FirstRunTransaction {
constructor(
private readonly transaction: Transaction,
private readonly records: readonly PrevalidatedImportedPlaybookRecord[],
private readonly now: () => Date,
) {}
async acquireSetupLock(): Promise<boolean> {
const result = await this.transaction.execute(trySetupAdvisoryLockQuery)
return result[0]?.acquired === true
}
async isSetupComplete(): Promise<boolean> {
const [row] = await this.transaction
.select({ setupCompletedAt: instanceSettings.setupCompletedAt })
.from(instanceSettings)
.where(eq(instanceSettings.singleton, true))
.limit(1)
return row?.setupCompletedAt !== null && row?.setupCompletedAt !== undefined
}
async createOwner(owner: FirstRunOwner): Promise<{ id: string }> {
const [row] = await this.transaction
.insert(users)
.values({
email: owner.email,
displayName: owner.displayName,
passwordHash: owner.passwordHash,
instanceRole: 'instance_owner',
status: 'active',
})
.returning({ id: users.id })
if (!row) throw new Error('Owner insert did not return a row')
return row
}
async createPersonalWorkspace(input: {
ownerId: string
name: string
}): Promise<{ id: string }> {
const [row] = await this.transaction
.insert(workspaces)
.values({ name: input.name, type: 'personal' })
.returning({ id: workspaces.id })
if (!row) throw new Error('Workspace insert did not return a row')
return row
}
async addOwnerMembership(input: {
ownerId: string
workspaceId: string
}): Promise<void> {
await this.transaction.insert(workspaceMemberships).values({
userId: input.ownerId,
workspaceId: input.workspaceId,
role: 'owner',
})
}
async importBuiltInPlaybooks(): Promise<{ imported: number }> {
await acquireBuiltInImportLock(this.transaction)
const result = await persistBuiltInPlaybooks(
this.transaction,
this.records,
this.now,
)
return { imported: result.total }
}
async completeSetup(input: {
ownerId: string
configuration: Readonly<Record<string, unknown>>
configurationDigest: string
}): Promise<void> {
const rows = await this.transaction
.update(instanceSettings)
.set({
setupCompletedAt: this.now(),
ownerUserId: input.ownerId,
configJson: input.configuration,
configDigest: input.configurationDigest,
updatedAt: this.now(),
})
.where(eq(instanceSettings.singleton, true))
.returning({ singleton: instanceSettings.singleton })
if (rows.length !== 1) {
throw new DomainError(
'setup_state_missing',
'First-run settings singleton is missing',
)
}
}
async appendAuditEvent(input: {
actorUserId: string
workspaceId: string
action: 'instance.setup.completed'
}): Promise<void> {
await this.transaction.insert(auditEvents).values({
actorUserId: input.actorUserId,
workspaceId: input.workspaceId,
action: input.action,
resourceType: 'instance',
resourceId: input.actorUserId,
outcome: 'success',
metadataJson: {},
})
}
}
export class DrizzleFirstRunTransactionRunner implements FirstRunTransactionRunner {
constructor(
private readonly database: Database = getDatabase(),
private readonly now: () => Date = () => new Date(),
) {}
run<T>(
records: readonly PrevalidatedImportedPlaybookRecord[],
work: (transaction: FirstRunTransaction) => Promise<T>,
): Promise<T> {
return this.database.transaction(async (databaseTransaction) => {
const transaction = new DrizzleFirstRunTransaction(
databaseTransaction,
records,
this.now,
)
if (!(await transaction.acquireSetupLock())) {
throw new DomainError(
'setup_in_progress',
'Another first-run setup transaction is in progress',
)
}
return work(transaction)
})
}
}
export class DrizzleFirstRunStore implements FirstRunStore {
constructor(
private readonly records: readonly PrevalidatedImportedPlaybookRecord[],
private readonly runner: FirstRunTransactionRunner = new DrizzleFirstRunTransactionRunner(),
) {}
withSetupLock<T>(
work: (transaction: FirstRunTransaction) => Promise<T>,
): Promise<T> {
return this.runner.run(this.records, work)
}
}
+44
View File
@@ -0,0 +1,44 @@
import { eq } from 'drizzle-orm'
import { getDatabase } from '../index'
import { instanceSettings } from '../schema'
export type InstanceState =
'uninitialized' | 'ready' | 'maintenance' | 'recovery_required'
export interface PersistedInstanceStatus {
state: InstanceState
setupRequired: boolean
schemaVersion: string
}
export async function getPersistedInstanceStatus(
maintenanceMode = false,
database: ReturnType<typeof getDatabase> = getDatabase(),
): Promise<PersistedInstanceStatus> {
const [settings] = await database
.select({ setupCompletedAt: instanceSettings.setupCompletedAt })
.from(instanceSettings)
.where(eq(instanceSettings.singleton, true))
.limit(1)
if (!settings) {
return {
state: 'recovery_required',
setupRequired: true,
schemaVersion: '0001',
}
}
if (!settings.setupCompletedAt) {
return {
state: 'uninitialized',
setupRequired: true,
schemaVersion: '0001',
}
}
return {
state: maintenanceMode ? 'maintenance' : 'ready',
setupRequired: false,
schemaVersion: '0001',
}
}
+8
View File
@@ -0,0 +1,8 @@
import { getSqlClient, closeDatabase } from './index'
const sql = getSqlClient()
const rows = await sql<{ count: number }[]>`
select count(*)::int as count from drizzle.__drizzle_migrations
`
console.log(JSON.stringify({ migrationCount: rows[0]?.count ?? 0 }))
await closeDatabase()
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}