This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user