kiroku-store-migrations 0.1.1.0 → 0.2.0.0
raw patch · 22 files changed
+1173/−574 lines, 22 filesdep +asyncdep +directorydep +filepathdep −attoparsecdep −codddep −file-embeddep ~basePVP ok
version bump matches the API change (PVP)
Dependencies added: async, directory, filepath, optparse-applicative, pg-migrate, pg-migrate-cli, pg-migrate-embed, pg-migrate-import-codd, pg-migrate-test-support, template-haskell, temporary
Dependencies removed: attoparsec, codd, file-embed, hasql-pool, kiroku-store, streaming, time, vector
Dependency ranges changed: base
API changes (from Hackage documentation)
- Kiroku.Store.Migrations: runKirokuMigrations :: CoddSettings -> DiffTime -> VerifySchemas -> IO ApplyResult
- Kiroku.Store.Migrations: runKirokuMigrationsNoCheck :: CoddSettings -> DiffTime -> IO ApplyResult
+ Kiroku.Store.Migrations: data DefinitionError
+ Kiroku.Store.Migrations: data MigrationComponent
+ Kiroku.Store.Migrations: data MigrationPlan
+ Kiroku.Store.Migrations: data PlanError
+ Kiroku.Store.Migrations: kirokuMigrationPlan :: Either PlanError MigrationPlan
+ Kiroku.Store.Migrations.History.Codd: kirokuCoddHistoryMappings :: NonEmpty HistoryMapping
+ Kiroku.Store.Migrations.History.Codd: kirokuCoddManifestText :: Text
+ Kiroku.Store.Migrations.History.Codd: kirokuCoddSourceConfig :: ConnectionProvider -> Bool -> Text -> Confirmation -> Either CoddDefinitionError CoddSourceConfig
+ Kiroku.Store.Migrations.History.Codd: kirokuCoddSourcePayloads :: Map FilePath ByteString
+ Kiroku.Store.Migrations.History.Codd: kirokuLegacyMigrationNames :: NonEmpty FilePath
+ Kiroku.Store.Migrations.New: data AuthoringError
+ Kiroku.Store.Migrations.New: defaultMigrationsDir :: FilePath
+ Kiroku.Store.Migrations.New: migrationTemplate :: String -> ByteString
+ Kiroku.Store.Migrations.New: newMigrationFile :: FilePath -> String -> IO (Either AuthoringError FilePath)
- Kiroku.Store.Migrations: kirokuMigrations :: (MonadFail m, EnvVars m) => m [AddedSqlMigration m]
+ Kiroku.Store.Migrations: kirokuMigrations :: Either DefinitionError MigrationComponent
Files
- CHANGELOG.md +46/−1
- README.md +105/−27
- app/Main.hs +41/−7
- kiroku-store-migrations.cabal +45/−28
- migrations.lock +7/−0
- migrations/0001-kiroku-bootstrap.sql +229/−0
- migrations/0002-add-subscription-dead-letters.sql +31/−0
- migrations/0003-notify-trigger-append-guard.sql +26/−0
- migrations/0004-dead-letters-event-id-index.sql +10/−0
- migrations/0005-index-hygiene-and-streams-fillfactor.sql +33/−0
- migrations/0006-stream-name-length-check.sql +9/−0
- migrations/0007-stream-truncate-before.sql +8/−0
- migrations/0008-schema-management-comment.sql +2/−0
- migrations/manifest +8/−0
- sql-migrations/2026-05-16-00-00-00-kiroku-bootstrap.sql +0/−229
- sql-migrations/2026-05-26-00-00-00-add-subscription-dead-letters.sql +0/−31
- src/Kiroku/Store/Migrations.hs +23/−49
- src/Kiroku/Store/Migrations/History/Codd.hs +99/−0
- src/Kiroku/Store/Migrations/Internal/Definition.hs +23/−0
- src/Kiroku/Store/Migrations/Internal/EmbedFile.hs +19/−0
- src/Kiroku/Store/Migrations/New.hs +40/−0
- test/Main.hs +369/−202
CHANGELOG.md view
@@ -2,11 +2,56 @@ ## Unreleased +## 0.2.0.0 — 2026-07-11++### Breaking Changes++* Migrated the package's runtime from Codd to `pg-migrate`. The public API now+ exports the native `kirokuMigrations` component and `kirokuMigrationPlan`+ instead of Codd settings, runner, ledger-status, and schema-check wrappers.+* Replaced timestamped runtime identities with manifest-ordered `0001` through+ `0007` identities while preserving every SQL payload byte.+* Replaced the Codd CLI and `CODD_*` configuration surface with the standard+ `pg-migrate-cli` command tree and `DATABASE_URL`. `verify` now compares the+ declared plan with the `pgmigrate` ledger; it does not compare live schema+ objects with an expected-schema snapshot.++### New Features++* Added a manifest-backed, compile-time-embedded migration component that+ applications can compose with other libraries in explicit dependency order.+* Added checked-in Codd history mappings and `SamePayload` evidence for safe,+ non-replaying import from current `codd` and legacy `codd_schema` ledgers.+ Shared-ledger consumers can combine Kiroku's exported payloads and mappings+ with their own components before importing.+* Added the standard `pg-migrate-cli` planning, inspection, execution,+ verification, status, and numeric migration-authoring commands.+* Added fresh-apply, rerun, concurrent-apply, strict ledger verification, Codd+ import, partial-row rejection, audit, and source-preservation coverage. The+ full Kiroku store suite now consumes the same native plan through+ `kiroku-test-support`.+* Appended `0008-schema-management-comment`, a non-destructive observable+ native-runner canary. Fresh and imported-prefix tests prove it applies once,+ verifies strictly, and reruns as `AlreadyApplied` without changing historical+ payloads or Codd mappings.++### Changed++* Preserved the seven historical SQL payloads byte-for-byte while moving their+ authoritative ordering to `migrations/manifest`; `migrations.lock` remains+ the source evidence used during Codd history import.+* Removed Codd, `codd-extras`, `file-embed`, and `postgresql-simple` from the+ normal library and executable dependency closure.+* Removed the orphaned Codd expected-schema snapshot, its writer executable, the+ Cabal flag that gated it, and the accompanying Nix closure workaround. Codd+ ledger history import remains supported independently through+ `pg-migrate-import-codd`.+ ## 0.1.1.0 — 2026-05-31 ### New Features -* Forward migration `2026-05-26-00-00-00-add-subscription-dead-letters.sql`:+* Forward migration `2026-05-29-15-26-04-add-subscription-dead-letters.sql`: creates the `kiroku.dead_letters` table (per consumer-group member, with a foreign key to `kiroku.events`) and its recency index, supporting per-event dead-letter recording for subscriptions (MasterPlan 6 / plan 40).
README.md view
@@ -1,40 +1,118 @@ # kiroku-store-migrations -`kiroku-store-migrations` owns schema evolution for `kiroku-store`.-It embeds Kiroku's timestamped SQL migrations and runs them through-`codd`, so a service can migrate its database before opening-`Kiroku.Store.withStore`.+`kiroku-store-migrations` owns Kiroku's PostgreSQL schema as one native+`pg-migrate` component named `kiroku`. The component embeds an ordered manifest+and seven immutable SQL payloads, so applications can compose it with other+libraries without copying Kiroku SQL. -Run the executable with codd's standard environment variables:+## Public API +```haskell+import Kiroku.Store.Migrations++kirokuMigrations :: Either DefinitionError MigrationComponent+kirokuMigrationPlan :: Either PlanError MigrationPlan+```++Applications with more than one component should consume `kirokuMigrations`+and build their own explicit dependency-ordered plan. The single-component+`kirokuMigrationPlan` is convenient for Kiroku-only deployments.++Existing databases can import their Codd ledger through+`Kiroku.Store.Migrations.History.Codd`:++```haskell+kirokuCoddHistoryMappings :: NonEmpty HistoryMapping+kirokuCoddSourcePayloads :: Map FilePath ByteString+kirokuCoddManifestText :: Text++kirokuCoddSourceConfig+ :: ConnectionProvider+ -> Bool+ -> Text+ -> Confirmation+ -> Either CoddDefinitionError CoddSourceConfig+```++The mapping selects the seven historical timestamped Codd names, verifies the+checked-in `migrations.lock` SHA-256 evidence against the exact embedded native+bytes, and maps them to `kiroku/0001-kiroku-bootstrap` through+`kiroku/0007-stream-truncate-before`. Import writes only the `pgmigrate` ledger;+it never executes already-applied SQL. Consumers with a shared Codd ledger can+combine the exported names, payload map, manifest text, and history mappings+with their own component evidence before constructing one atomic import.++## CLI++`kiroku-store-migrate` mounts the standard `pg-migrate-cli` command groups:+ ```bash-CODD_CONNECTION='host=/tmp port=5432 dbname=kiroku user=kiroku_admin' \-CODD_MIGRATION_DIRS=unused-for-embedded-migrations \-CODD_EXPECTED_SCHEMA_DIR=unused-for-unverified-embedded-migrations \-CODD_SCHEMAS=kiroku \-kiroku-store-migrate+kiroku-store-migrate --help+kiroku-store-migrate plan+kiroku-store-migrate list+kiroku-store-migrate check kiroku-store-migrations/migrations/manifest+kiroku-store-migrate up --database-url "$DATABASE_URL"+kiroku-store-migrate verify --database-url "$DATABASE_URL"+kiroku-store-migrate status --database-url "$DATABASE_URL" ``` -The bootstrap migration creates a dedicated `kiroku` schema and installs-every Kiroku table, index, function, and trigger inside it, leaving-`public` free for application objects. `CODD_SCHEMAS=kiroku` tells codd to-track that schema. The runtime role therefore needs privileges on the-`kiroku` schema (for example `USAGE` plus the table privileges it uses),-not on `public`.+Database commands accept `--database-url`. When it is omitted the executable+uses `DATABASE_URL`. Local `plan`, `list`, `check`, and `new` commands need no+database environment variable. `verify` compares the declared plan strictly+with the `pgmigrate` ledger; it is not a live schema snapshot comparison. -After migrations run, start the application normally:+For Haskell callers: ```haskell-withStore (defaultConnectionSettings connString) app+import Database.PostgreSQL.Migrate+import Hasql.Connection.Settings qualified as Settings+import Kiroku.Store.Migrations++main :: IO ()+main = do+ plan <- either (fail . show) pure kirokuMigrationPlan+ result <- runMigrationPlan defaultRunOptions (Settings.connectionString databaseUrl) plan+ either (fail . show) (const (pure ())) result ``` -`codd` is forward-only. Once a migration has run in production,-reverting the Haskell package does not undo the database change. Repair-state by restoring from backup or by shipping another forward migration.+## Authoring -This first implementation runs without codd expected-schema verification-because Kiroku does not yet ship a checked-in codd expected-schema-snapshot. `CODD_EXPECTED_SCHEMA_DIR` is still required by codd's settings-parser, but this executable does not read from it. Operators should treat-the migration table as the source of applied-version truth until strict-snapshots are added.+The authoritative source is `migrations/manifest`; each line names one SQL file+in execution order. Create the next numeric file with the standard CLI:++```bash+kiroku-store-migrate new \+ --manifest kiroku-store-migrations/migrations/manifest \+ --description "add widget index"+```++The helper exclusively creates the inferred file and atomically appends its+name to the manifest. Never edit a released payload. Correct mistakes with a+new forward migration. The seven initial payloads intentionally retain their+exact historical bytes, including old comments, because Codd import uses+`SamePayload` evidence.++Run the package suite after every migration change:++```bash+cabal test kiroku-store-migrations:kiroku-store-migrations-test+```++It proves manifest order, legacy SHA-256 parity, fresh apply, strict verify,+idempotent rerun, concurrent locking, current Codd V5 import, legacy+`codd_schema` import, partial-row rejection, import audit records, and+source-ledger preservation. `cabal test kiroku-store:kiroku-store-test` consumes+the same native plan through `kiroku-test-support` and proves the complete store+behavior, including append and read scenarios.++## Recovery++Migrations are forward-only. Before a persistent upgrade, take a backup. If an+applied migration is bad, either restore that backup or append a corrective+migration. Do not delete or rewrite an applied `pgmigrate.migrations` row except+through the reviewed `pg-migrate` repair workflow.++The historical script under `ledger-fixups/` remains checked in only as source+evidence for databases that previously needed Codd timestamp repair. New native+migrations use component-local numeric identities and do not use timestamped+filenames.
app/Main.hs view
@@ -1,11 +1,45 @@-module Main where+module Main (main) where -import Codd.Environment (getCoddSettings)-import Data.Time (secondsToDiffTime)-import Kiroku.Store.Migrations (runKirokuMigrationsNoCheck)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy.Char8 qualified as LazyByteString+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Database.PostgreSQL.Migrate (defaultRunOptions)+import Database.PostgreSQL.Migrate.CLI+import Hasql.Connection.Settings qualified as Settings+import Kiroku.Store.Migrations (kirokuMigrationPlan)+import Options.Applicative+import System.Environment (lookupEnv)+import System.Exit qualified as Exit main :: IO () main = do- settings <- getCoddSettings- _ <- runKirokuMigrationsNoCheck settings (secondsToDiffTime 5)- pure ()+ plan <- either (fail . show) pure kirokuMigrationPlan+ command <-+ execParser+ ( info+ (migrationCommandParser plan <**> helper)+ (fullDesc <> progDesc "Manage the Kiroku migration component")+ )+ defaultDatabaseUrl <- lookupEnv "DATABASE_URL"+ let defaultSettings =+ Settings.connectionString (Text.pack (maybe "" id defaultDatabaseUrl))+ environment = cliEnvironment defaultSettings plan defaultRunOptions+ outcome <- runMigrationCommand environment command+ case commandOutputFormat command of+ TextOutput -> Text.IO.putStrLn (renderMigrationCommandText outcome)+ JsonOutput -> LazyByteString.putStrLn (Aeson.encode (renderMigrationCommandJson outcome))+ Exit.exitWith+ (case exitClass outcome of ExitSuccess -> Exit.ExitSuccess; _ -> Exit.ExitFailure 1)++commandOutputFormat :: MigrationCommand -> OutputFormat+commandOutputFormat command =+ case command of+ Plan PlanOptions{output = OutputOptions format} -> format+ List ListOptions{output = OutputOptions format} -> format+ Check CheckOptions{output = OutputOptions format} -> format+ Status StatusOptions{output = OutputOptions format} -> format+ Verify VerifyOptions{output = OutputOptions format} -> format+ Up UpOptions{output = OutputOptions format} -> format+ Repair RepairOptions{output = OutputOptions format} -> format+ New NewOptions{output = OutputOptions format} -> format
kiroku-store-migrations.cabal view
@@ -1,10 +1,10 @@ cabal-version: 3.0 name: kiroku-store-migrations-version: 0.1.1.0+version: 0.2.0.0 synopsis: Schema migrations for kiroku-store description:- Embedded codd migrations and a migration executable for installing and- upgrading the PostgreSQL schema used by @kiroku-store@.+ Native pg-migrate component, Codd history mapping, and migration executable+ for installing and upgrading the PostgreSQL schema used by @kiroku-store@. homepage: https://github.com/shinzui/kiroku bug-reports: https://github.com/shinzui/kiroku/issues@@ -15,8 +15,10 @@ category: Database, Eventing extra-doc-files: CHANGELOG.md extra-source-files:+ migrations/*.sql+ migrations/manifest+ migrations.lock README.md- sql-migrations/*.sql source-repository head type: git@@ -32,16 +34,26 @@ library import: common- exposed-modules: Kiroku.Store.Migrations+ exposed-modules:+ Kiroku.Store.Migrations+ Kiroku.Store.Migrations.History.Codd+ Kiroku.Store.Migrations.New++ other-modules:+ Kiroku.Store.Migrations.Internal.Definition+ Kiroku.Store.Migrations.Internal.EmbedFile+ hs-source-dirs: src build-depends:- , base >=4.18 && <5- , bytestring >=0.11 && <0.13- , codd >=0.1.8 && <0.2- , file-embed >=0.0.15 && <0.0.17- , streaming >=0.2 && <0.3- , text >=2.0 && <2.2- , time >=1.12 && <1.15+ , base >=4.18 && <5+ , bytestring >=0.11 && <0.13+ , containers >=0.6 && <0.8+ , filepath >=1.4 && <1.6+ , pg-migrate ^>=1.0.0.0+ , pg-migrate-embed ^>=1.0.0.0+ , pg-migrate-import-codd ^>=1.0.0.0+ , template-haskell >=2.20 && <2.24+ , text >=2.0 && <2.2 executable kiroku-store-migrate import: common@@ -49,10 +61,15 @@ hs-source-dirs: app ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends:- , base- , codd+ , aeson >=2.1 && <2.3+ , base >=4.18 && <5+ , bytestring >=0.11 && <0.13+ , hasql >=1.10 && <1.11 , kiroku-store-migrations- , time >=1.12 && <1.15+ , optparse-applicative >=0.17 && <0.20+ , pg-migrate ^>=1.0.0.0+ , pg-migrate-cli ^>=1.0.0.0+ , text >=2.0 && <2.2 test-suite kiroku-store-migrations-test import: common@@ -61,17 +78,17 @@ hs-source-dirs: test ghc-options: -threaded -rtsopts -with-rtsopts=-N build-depends:- , aeson >=2.1 && <2.3- , attoparsec- , base >=4.18 && <5- , codd- , containers >=0.6 && <0.8- , ephemeral-pg >=0.2 && <0.3- , hasql >=1.10 && <1.11- , hasql-pool >=1.2 && <1.5- , hspec >=2.10 && <2.12- , kiroku-store ^>=0.2+ , async+ , base >=4.18 && <5+ , bytestring >=0.11 && <0.13+ , directory >=1.3+ , ephemeral-pg >=0.2 && <0.3+ , filepath >=1.4 && <1.6+ , hasql >=1.10 && <1.11+ , hspec >=2.10 && <2.12 , kiroku-store-migrations- , text >=2.0 && <2.2- , time >=1.12 && <1.15- , vector >=0.13 && <0.14+ , pg-migrate ^>=1.0.0.0+ , pg-migrate-import-codd ^>=1.0.0.0+ , pg-migrate-test-support ^>=1.0.0.0+ , temporary >=1.3 && <1.4+ , text >=2.0 && <2.2
+ migrations.lock view
@@ -0,0 +1,7 @@+af7c777f8702d9b5bce6b3ea16540ab7542bc78725cb45e945c6d5b6025a4d66 2026-05-16-12-17-14-kiroku-bootstrap.sql+2771cdd2605a8808ea469c0bcba175c8ba4c5d2465f43eb05c56edb188fa8141 2026-05-29-15-26-04-add-subscription-dead-letters.sql+432796c59389d45478ba773b62e9f38d0a562807061eb49280d295ec83248351 2026-06-14-13-17-09-notify-trigger-append-guard.sql+f698db092ebf34beeddecc949bbd506afc0b9c63d3e9983ad5d14f203f2ee1cd 2026-06-14-13-25-40-dead-letters-event-id-index.sql+80c28c7743f1d9967eadf921c0509fc8477db16500466bf7a49c5f59aadb0fa2 2026-06-14-13-54-48-index-hygiene-and-streams-fillfactor.sql+a0e4338a2feadf8f01499a3809d3de78966966689b095ea46195c4d07743e4aa 2026-06-14-14-01-17-stream-name-length-check.sql+a4fe42ad8702b43c3d6f72edf263d6b301e783493d49f40a35dd14d2a7928fa7 2026-06-24-09-42-22-stream-truncate-before.sql
+ migrations/0001-kiroku-bootstrap.sql view
@@ -0,0 +1,229 @@+-- Kiroku Store bootstrap migration (codd)+-- Supports PostgreSQL 17+.+--+-- kiroku-store-migrations is the schema owner. codd applies this file+-- verbatim, records that the timestamped migration ran, and skips it on later+-- runs. Future schema changes add new timestamped SQL files in this directory+-- instead of changing kiroku-store.+--+-- All Kiroku-owned objects live in the dedicated `kiroku` schema, leaving+-- `public` free for application objects. Creating the schema and setting+-- search_path first means every unqualified object name in the rest of this+-- file resolves into the Kiroku schema.+CREATE SCHEMA IF NOT EXISTS kiroku;+SET search_path TO kiroku, pg_catalog;++-- PostgreSQL 18 provides pg_catalog.uuidv7(); PostgreSQL 17 needs this+-- Kiroku-schema fallback before events.event_id DEFAULT uuidv7() is parsed.+-- With search_path set above, the unqualified CREATE FUNCTION lands in the+-- Kiroku schema, and to_regprocedure('uuidv7()') resolves through search_path+-- (pg_catalog first for the built-in, then the Kiroku schema for the fallback).+DO $$+BEGIN+ IF to_regprocedure('pg_catalog.uuidv7()') IS NULL+ AND to_regprocedure('uuidv7()') IS NULL THEN+ EXECUTE $fn$+ CREATE FUNCTION uuidv7()+ RETURNS uuid+ AS $body$+ DECLARE+ unix_ts_ms bytea;+ uuid_bytes bytea;+ BEGIN+ unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);+ uuid_bytes = uuid_send(gen_random_uuid());+ uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);+ uuid_bytes = set_byte(uuid_bytes, 6, (b'0111' || get_byte(uuid_bytes, 6)::bit(4))::bit(8)::int);+ RETURN encode(uuid_bytes, 'hex')::uuid;+ END+ $body$+ LANGUAGE plpgsql+ VOLATILE+ $fn$;+ END IF;+END+$$;++-- Streams (including $all as stream_id = 0)+CREATE TABLE IF NOT EXISTS streams (+ stream_id BIGSERIAL PRIMARY KEY,+ stream_name TEXT NOT NULL,+ category TEXT GENERATED ALWAYS AS (split_part(stream_name, '-', 1)) STORED,+ stream_version BIGINT NOT NULL DEFAULT 0,+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),+ deleted_at TIMESTAMPTZ,+ CONSTRAINT ix_streams_stream_name UNIQUE (stream_name)+);++-- Seed the $all stream+INSERT INTO streams (stream_id, stream_name, stream_version)+VALUES (0, '$all', 0)+ON CONFLICT DO NOTHING;++-- Reset sequence past the reserved stream_id=0+SELECT setval('streams_stream_id_seq', GREATEST((SELECT MAX(stream_id) FROM streams), 1));++-- Events (flat table — stream membership tracked in stream_events)+CREATE TABLE IF NOT EXISTS events (+ event_id UUID PRIMARY KEY DEFAULT uuidv7(),+ event_type TEXT NOT NULL,+ causation_id UUID,+ correlation_id UUID,+ data JSONB NOT NULL,+ metadata JSONB,+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()+);++-- Stream-event junction (each event gets 2+ rows: source stream + $all + any links)+CREATE TABLE IF NOT EXISTS stream_events (+ event_id UUID NOT NULL REFERENCES events(event_id),+ stream_id BIGINT NOT NULL REFERENCES streams(stream_id),+ stream_version BIGINT NOT NULL,+ original_stream_id BIGINT NOT NULL,+ original_stream_version BIGINT NOT NULL,+ PRIMARY KEY (event_id, stream_id)+);++-- Indexes++-- Primary read path: fetch events from a stream in order+CREATE INDEX IF NOT EXISTS ix_stream_events_stream_version+ ON stream_events (stream_id, stream_version);++-- Event type filtering (for server-side subscription filtering)+CREATE INDEX IF NOT EXISTS ix_events_event_type+ ON events (event_type);++-- Correlation tracing+CREATE INDEX IF NOT EXISTS ix_events_correlation_id+ ON events (correlation_id) WHERE correlation_id IS NOT NULL;++-- Causation tracing+CREATE INDEX IF NOT EXISTS ix_events_causation_id+ ON events (causation_id) WHERE causation_id IS NOT NULL;++-- Category filtering (for readCategory — uses generated column, not LIKE)+CREATE INDEX IF NOT EXISTS ix_streams_category+ ON streams (category);++-- Category read path: find $all entries by originating stream, ordered by global position+-- Enables efficient category reads by allowing the planner to: look up category stream_ids →+-- index scan $all for each → merge ordered by stream_version+CREATE INDEX IF NOT EXISTS ix_stream_events_all_by_origin+ ON stream_events (original_stream_id, stream_version)+ WHERE stream_id = 0;++-- Subscriptions (checkpoint persistence for subscription positions).+-- consumer_group_member / consumer_group_size carry static consumer-group+-- topology (ExecPlan 28 / EP-1). Non-group subscriptions are member 0, size 1.+-- The unique key is composite (subscription_name, consumer_group_member) so each+-- group member persists its own checkpoint under one shared subscription name.+CREATE TABLE IF NOT EXISTS subscriptions (+ subscription_id BIGSERIAL PRIMARY KEY,+ subscription_name TEXT NOT NULL,+ stream_name TEXT NOT NULL DEFAULT '$all',+ last_seen BIGINT NOT NULL DEFAULT 0,+ consumer_group_member INT NOT NULL DEFAULT 0,+ consumer_group_size INT NOT NULL DEFAULT 1,+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()+);++-- Idempotent convergence for databases created before EP-1: add the columns if+-- missing, drop the old auto-named single-column unique constraint if present,+-- and install the composite unique index. All guarded so the bootstrap body is+-- still safe in disposable local databases; codd records the timestamped+-- migration and does not reapply it after a successful run.+ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS consumer_group_member INT NOT NULL DEFAULT 0;+ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS consumer_group_size INT NOT NULL DEFAULT 1;+ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_subscription_name_key;+CREATE UNIQUE INDEX IF NOT EXISTS ix_subscriptions_name_member+ ON subscriptions (subscription_name, consumer_group_member);++-- Triggers++-- NOTIFY on stream changes (fires once per append, not per event)+CREATE OR REPLACE FUNCTION notify_events() RETURNS TRIGGER AS $$+BEGIN+ PERFORM pg_notify(+ TG_TABLE_SCHEMA || '.events',+ NEW.stream_name || ',' || NEW.stream_id || ',' || NEW.stream_version+ );+ RETURN NEW;+END;+$$ LANGUAGE plpgsql;++DROP TRIGGER IF EXISTS stream_events_notify ON streams;+CREATE TRIGGER stream_events_notify+ AFTER INSERT OR UPDATE ON streams+ FOR EACH ROW EXECUTE FUNCTION notify_events();++-- Immutability: prevent event mutation+CREATE OR REPLACE FUNCTION prevent_mutation() RETURNS TRIGGER AS $$+BEGIN+ RAISE EXCEPTION 'Immutable table: % cannot be updated', TG_TABLE_NAME;+END;+$$ LANGUAGE plpgsql;++DROP TRIGGER IF EXISTS no_update_events ON events;+CREATE TRIGGER no_update_events+ BEFORE UPDATE ON events+ FOR EACH ROW EXECUTE FUNCTION prevent_mutation();++DROP TRIGGER IF EXISTS no_update_stream_events ON stream_events;+CREATE TRIGGER no_update_stream_events+ BEFORE UPDATE ON stream_events+ FOR EACH ROW EXECUTE FUNCTION prevent_mutation();++-- Gated hard deletes (for maintenance/GDPR only)+CREATE OR REPLACE FUNCTION protect_deletion() RETURNS TRIGGER AS $$+BEGIN+ IF current_setting('kiroku.enable_hard_deletes', true) = 'on' THEN+ RETURN OLD;+ END IF;+ RAISE EXCEPTION 'Hard deletes require: SET LOCAL kiroku.enable_hard_deletes = ''on''';+END;+$$ LANGUAGE plpgsql;++DROP TRIGGER IF EXISTS no_delete_events ON events;+CREATE TRIGGER no_delete_events+ BEFORE DELETE ON events+ FOR EACH ROW EXECUTE FUNCTION protect_deletion();++DROP TRIGGER IF EXISTS no_delete_stream_events ON stream_events;+CREATE TRIGGER no_delete_stream_events+ BEFORE DELETE ON stream_events+ FOR EACH ROW EXECUTE FUNCTION protect_deletion();++DROP TRIGGER IF EXISTS no_delete_streams ON streams;+CREATE TRIGGER no_delete_streams+ BEFORE DELETE ON streams+ FOR EACH ROW EXECUTE FUNCTION protect_deletion();++-- TRUNCATE bypasses row-level triggers, so the BEFORE DELETE triggers above+-- do not protect against an operator running TRUNCATE on these tables. Add+-- statement-level BEFORE TRUNCATE triggers gated by the same GUC so the+-- protection is symmetric. See EP-1 F6.+CREATE OR REPLACE FUNCTION protect_truncation() RETURNS TRIGGER AS $$+BEGIN+ IF current_setting('kiroku.enable_hard_deletes', true) = 'on' THEN+ RETURN NULL;+ END IF;+ RAISE EXCEPTION 'TRUNCATE requires: SET LOCAL kiroku.enable_hard_deletes = ''on''';+END;+$$ LANGUAGE plpgsql;++DROP TRIGGER IF EXISTS no_truncate_events ON events;+CREATE TRIGGER no_truncate_events+ BEFORE TRUNCATE ON events+ FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();++DROP TRIGGER IF EXISTS no_truncate_stream_events ON stream_events;+CREATE TRIGGER no_truncate_stream_events+ BEFORE TRUNCATE ON stream_events+ FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();++DROP TRIGGER IF EXISTS no_truncate_streams ON streams;+CREATE TRIGGER no_truncate_streams+ BEFORE TRUNCATE ON streams+ FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();
+ migrations/0002-add-subscription-dead-letters.sql view
@@ -0,0 +1,31 @@+-- Add the kiroku.dead_letters table (MasterPlan 6 / EP-2, docs/plans/40-...).+--+-- Forward, additive migration: codd applies this file once, records it, and+-- skips it on later runs. It does not edit the bootstrap migration and does not+-- mutate existing event data.+--+-- A dead-letter row records one event that a subscription handler asked to+-- "dead-letter" (return DeadLetter, or exhaust its bounded retry budget). The+-- event itself stays immutable in kiroku.events; this table references it by+-- event_id and global_position rather than copying the payload. The+-- consumer_group_member column (default 0, matching kiroku.subscriptions)+-- attributes the row to the member that produced it, so a consumer group's dead+-- letters are per-member. The worker writes a row and advances the member's+-- checkpoint in one atomic statement (see SQL.insertDeadLetterAndCheckpointStmt).++CREATE TABLE IF NOT EXISTS kiroku.dead_letters (+ dead_letter_id BIGSERIAL PRIMARY KEY,+ subscription_name TEXT NOT NULL,+ consumer_group_member INT NOT NULL DEFAULT 0,+ global_position BIGINT NOT NULL,+ event_id UUID NOT NULL REFERENCES kiroku.events(event_id),+ reason JSONB NOT NULL,+ reason_summary TEXT NOT NULL,+ attempt_count INT NOT NULL,+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),+ UNIQUE (subscription_name, consumer_group_member, global_position, event_id)+);++-- Operator read path: list a subscription member's dead letters by recency.+CREATE INDEX IF NOT EXISTS ix_dead_letters_subscription_created_at+ ON kiroku.dead_letters (subscription_name, consumer_group_member, created_at);
+ migrations/0003-notify-trigger-append-guard.sql view
@@ -0,0 +1,26 @@+-- Guard append notifications at the trigger level.+--+-- The trigger function and payload format stay unchanged:+-- stream_name,stream_id,stream_version+--+-- INSERT covers newly-created streams. UPDATE covers later appends to an+-- existing stream. Both exclude the internal $all row (stream_id = 0), and the+-- UPDATE trigger only fires when an append advances stream_version, not for+-- lifecycle updates such as soft-delete or undelete.++DROP TRIGGER IF EXISTS stream_events_notify ON kiroku.streams;++DROP TRIGGER IF EXISTS stream_events_notify_insert ON kiroku.streams;+CREATE TRIGGER stream_events_notify_insert+ AFTER INSERT ON kiroku.streams+ FOR EACH ROW+ WHEN (NEW.stream_id <> 0)+ EXECUTE FUNCTION kiroku.notify_events();++DROP TRIGGER IF EXISTS stream_events_notify_update ON kiroku.streams;+CREATE TRIGGER stream_events_notify_update+ AFTER UPDATE ON kiroku.streams+ FOR EACH ROW+ WHEN (NEW.stream_id <> 0+ AND NEW.stream_version IS DISTINCT FROM OLD.stream_version)+ EXECUTE FUNCTION kiroku.notify_events();
+ migrations/0004-dead-letters-event-id-index.sql view
@@ -0,0 +1,10 @@+-- Index dead_letters by event_id (MasterPlan 9 / EP-5, docs/plans/60-...).+--+-- dead_letters.event_id has a FK to kiroku.events. The UNIQUE key leads with+-- subscription_name, so every referential-integrity check triggered by a+-- DELETE on kiroku.events (the hard-delete path) was a sequential scan of+-- dead_letters, and the hard-delete transaction's own dead-letter pre-delete+-- (Kiroku.Store.SQL.deleteDeadLettersForOrphanedEventsStmt) needs the same+-- access path.+CREATE INDEX IF NOT EXISTS ix_dead_letters_event_id+ ON kiroku.dead_letters (event_id);
+ migrations/0005-index-hygiene-and-streams-fillfactor.sql view
@@ -0,0 +1,33 @@+-- Index hygiene and $all hot-row tuning (MasterPlan 9 / EP-5, docs/plans/60-...).++-- 1. ix_events_event_type is referenced by no statement in kiroku-store; it is+-- pure write amplification on every append. Server-side event-type pushdown+-- (see the EventTypeFilter haddock in kiroku-store) should re-add a+-- fit-for-purpose index when it ships.+DROP INDEX IF EXISTS kiroku.ix_events_event_type;++-- 2. Stream versions are unique per stream by construction (assigned under the+-- stream row lock; $all versions are the global position). Enforce it so a+-- version-assignment bug surfaces as a loud 23505 instead of silent+-- duplicates. Built as a new unique index, then the old non-unique index is+-- dropped (an index cannot be altered to unique in place).+CREATE UNIQUE INDEX IF NOT EXISTS ux_stream_events_stream_version+ ON kiroku.stream_events (stream_id, stream_version);+DROP INDEX IF EXISTS kiroku.ix_stream_events_stream_version;++-- 3. readDeadLetters orders by (global_position DESC, dead_letter_id DESC) --+-- the store's canonical, deterministic "newest first". Re-key the read+-- index to match so the read is index-ordered instead of sorting each time.+CREATE INDEX IF NOT EXISTS ix_dead_letters_subscription_position+ ON kiroku.dead_letters+ (subscription_name, consumer_group_member,+ global_position DESC, dead_letter_id DESC);+DROP INDEX IF EXISTS kiroku.ix_dead_letters_subscription_created_at;++-- 4. The $all row (stream_id 0) is updated by every append in the database.+-- Its updated column (stream_version) is not indexed, so updates are+-- HOT-eligible when the page has free space; fillfactor 50 reserves that+-- space on newly written pages. Existing pages converge through normal+-- update/prune activity (VACUUM cannot run inside this migration's+-- transaction). Autovacuum tuning for this table is left to operators.+ALTER TABLE kiroku.streams SET (fillfactor = 50);
+ migrations/0006-stream-name-length-check.sql view
@@ -0,0 +1,9 @@+-- Defense-in-depth bound on stream-name length (MasterPlan 9 / EP-5,+-- docs/plans/60-...). The Haskell store validates this before any SQL+-- (StoreError StreamNameTooLong, maxStreamNameBytes = 512); the constraint+-- catches writers that bypass the library (raw SQL via runTransaction,+-- psql sessions). 512 bytes is far below pg_notify's 8,000-byte payload+-- limit, so the append-notification trigger can never abort on payload size.+ALTER TABLE kiroku.streams+ ADD CONSTRAINT chk_streams_stream_name_length+ CHECK (octet_length(stream_name) <= 512);
+ migrations/0007-stream-truncate-before.sql view
@@ -0,0 +1,8 @@+-- Logical truncate-before marker for close-the-book compaction+-- (ExecPlan docs/plans/65). Per-stream cursor: ordered stream reads return+-- only events whose stream_version >= truncate_before. Default 0 keeps all+-- events (per-stream versions are 1-based). Reversible; the global $all log+-- is never affected. UPDATE on streams is already permitted (soft-delete+-- uses it), so no trigger/GUC changes are needed.+ALTER TABLE kiroku.streams+ ADD COLUMN IF NOT EXISTS truncate_before BIGINT NOT NULL DEFAULT 0;
+ migrations/0008-schema-management-comment.sql view
@@ -0,0 +1,2 @@+COMMENT ON SCHEMA kiroku IS+ 'Managed by pg-migrate component kiroku through 0008-schema-management-comment';
+ migrations/manifest view
@@ -0,0 +1,8 @@+0001-kiroku-bootstrap.sql+0002-add-subscription-dead-letters.sql+0003-notify-trigger-append-guard.sql+0004-dead-letters-event-id-index.sql+0005-index-hygiene-and-streams-fillfactor.sql+0006-stream-name-length-check.sql+0007-stream-truncate-before.sql+0008-schema-management-comment.sql
− sql-migrations/2026-05-16-00-00-00-kiroku-bootstrap.sql
@@ -1,229 +0,0 @@--- Kiroku Store bootstrap migration (codd)--- Supports PostgreSQL 17+.------ kiroku-store-migrations is the schema owner. codd applies this file--- verbatim, records that the timestamped migration ran, and skips it on later--- runs. Future schema changes add new timestamped SQL files in this directory--- instead of changing kiroku-store.------ All Kiroku-owned objects live in the dedicated `kiroku` schema, leaving--- `public` free for application objects. Creating the schema and setting--- search_path first means every unqualified object name in the rest of this--- file resolves into the Kiroku schema.-CREATE SCHEMA IF NOT EXISTS kiroku;-SET search_path TO kiroku, pg_catalog;---- PostgreSQL 18 provides pg_catalog.uuidv7(); PostgreSQL 17 needs this--- Kiroku-schema fallback before events.event_id DEFAULT uuidv7() is parsed.--- With search_path set above, the unqualified CREATE FUNCTION lands in the--- Kiroku schema, and to_regprocedure('uuidv7()') resolves through search_path--- (pg_catalog first for the built-in, then the Kiroku schema for the fallback).-DO $$-BEGIN- IF to_regprocedure('pg_catalog.uuidv7()') IS NULL- AND to_regprocedure('uuidv7()') IS NULL THEN- EXECUTE $fn$- CREATE FUNCTION uuidv7()- RETURNS uuid- AS $body$- DECLARE- unix_ts_ms bytea;- uuid_bytes bytea;- BEGIN- unix_ts_ms = substring(int8send(floor(extract(epoch from clock_timestamp()) * 1000)::bigint) from 3);- uuid_bytes = uuid_send(gen_random_uuid());- uuid_bytes = overlay(uuid_bytes placing unix_ts_ms from 1 for 6);- uuid_bytes = set_byte(uuid_bytes, 6, (b'0111' || get_byte(uuid_bytes, 6)::bit(4))::bit(8)::int);- RETURN encode(uuid_bytes, 'hex')::uuid;- END- $body$- LANGUAGE plpgsql- VOLATILE- $fn$;- END IF;-END-$$;---- Streams (including $all as stream_id = 0)-CREATE TABLE IF NOT EXISTS streams (- stream_id BIGSERIAL PRIMARY KEY,- stream_name TEXT NOT NULL,- category TEXT GENERATED ALWAYS AS (split_part(stream_name, '-', 1)) STORED,- stream_version BIGINT NOT NULL DEFAULT 0,- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),- deleted_at TIMESTAMPTZ,- CONSTRAINT ix_streams_stream_name UNIQUE (stream_name)-);---- Seed the $all stream-INSERT INTO streams (stream_id, stream_name, stream_version)-VALUES (0, '$all', 0)-ON CONFLICT DO NOTHING;---- Reset sequence past the reserved stream_id=0-SELECT setval('streams_stream_id_seq', GREATEST((SELECT MAX(stream_id) FROM streams), 1));---- Events (flat table — stream membership tracked in stream_events)-CREATE TABLE IF NOT EXISTS events (- event_id UUID PRIMARY KEY DEFAULT uuidv7(),- event_type TEXT NOT NULL,- causation_id UUID,- correlation_id UUID,- data JSONB NOT NULL,- metadata JSONB,- created_at TIMESTAMPTZ NOT NULL DEFAULT now()-);---- Stream-event junction (each event gets 2+ rows: source stream + $all + any links)-CREATE TABLE IF NOT EXISTS stream_events (- event_id UUID NOT NULL REFERENCES events(event_id),- stream_id BIGINT NOT NULL REFERENCES streams(stream_id),- stream_version BIGINT NOT NULL,- original_stream_id BIGINT NOT NULL,- original_stream_version BIGINT NOT NULL,- PRIMARY KEY (event_id, stream_id)-);---- Indexes---- Primary read path: fetch events from a stream in order-CREATE INDEX IF NOT EXISTS ix_stream_events_stream_version- ON stream_events (stream_id, stream_version);---- Event type filtering (for server-side subscription filtering)-CREATE INDEX IF NOT EXISTS ix_events_event_type- ON events (event_type);---- Correlation tracing-CREATE INDEX IF NOT EXISTS ix_events_correlation_id- ON events (correlation_id) WHERE correlation_id IS NOT NULL;---- Causation tracing-CREATE INDEX IF NOT EXISTS ix_events_causation_id- ON events (causation_id) WHERE causation_id IS NOT NULL;---- Category filtering (for readCategory — uses generated column, not LIKE)-CREATE INDEX IF NOT EXISTS ix_streams_category- ON streams (category);---- Category read path: find $all entries by originating stream, ordered by global position--- Enables efficient category reads by allowing the planner to: look up category stream_ids →--- index scan $all for each → merge ordered by stream_version-CREATE INDEX IF NOT EXISTS ix_stream_events_all_by_origin- ON stream_events (original_stream_id, stream_version)- WHERE stream_id = 0;---- Subscriptions (checkpoint persistence for subscription positions).--- consumer_group_member / consumer_group_size carry static consumer-group--- topology (ExecPlan 28 / EP-1). Non-group subscriptions are member 0, size 1.--- The unique key is composite (subscription_name, consumer_group_member) so each--- group member persists its own checkpoint under one shared subscription name.-CREATE TABLE IF NOT EXISTS subscriptions (- subscription_id BIGSERIAL PRIMARY KEY,- subscription_name TEXT NOT NULL,- stream_name TEXT NOT NULL DEFAULT '$all',- last_seen BIGINT NOT NULL DEFAULT 0,- consumer_group_member INT NOT NULL DEFAULT 0,- consumer_group_size INT NOT NULL DEFAULT 1,- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()-);---- Idempotent convergence for databases created before EP-1: add the columns if--- missing, drop the old auto-named single-column unique constraint if present,--- and install the composite unique index. All guarded so the bootstrap body is--- still safe in disposable local databases; codd records the timestamped--- migration and does not reapply it after a successful run.-ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS consumer_group_member INT NOT NULL DEFAULT 0;-ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS consumer_group_size INT NOT NULL DEFAULT 1;-ALTER TABLE subscriptions DROP CONSTRAINT IF EXISTS subscriptions_subscription_name_key;-CREATE UNIQUE INDEX IF NOT EXISTS ix_subscriptions_name_member- ON subscriptions (subscription_name, consumer_group_member);---- Triggers---- NOTIFY on stream changes (fires once per append, not per event)-CREATE OR REPLACE FUNCTION notify_events() RETURNS TRIGGER AS $$-BEGIN- PERFORM pg_notify(- TG_TABLE_SCHEMA || '.events',- NEW.stream_name || ',' || NEW.stream_id || ',' || NEW.stream_version- );- RETURN NEW;-END;-$$ LANGUAGE plpgsql;--DROP TRIGGER IF EXISTS stream_events_notify ON streams;-CREATE TRIGGER stream_events_notify- AFTER INSERT OR UPDATE ON streams- FOR EACH ROW EXECUTE FUNCTION notify_events();---- Immutability: prevent event mutation-CREATE OR REPLACE FUNCTION prevent_mutation() RETURNS TRIGGER AS $$-BEGIN- RAISE EXCEPTION 'Immutable table: % cannot be updated', TG_TABLE_NAME;-END;-$$ LANGUAGE plpgsql;--DROP TRIGGER IF EXISTS no_update_events ON events;-CREATE TRIGGER no_update_events- BEFORE UPDATE ON events- FOR EACH ROW EXECUTE FUNCTION prevent_mutation();--DROP TRIGGER IF EXISTS no_update_stream_events ON stream_events;-CREATE TRIGGER no_update_stream_events- BEFORE UPDATE ON stream_events- FOR EACH ROW EXECUTE FUNCTION prevent_mutation();---- Gated hard deletes (for maintenance/GDPR only)-CREATE OR REPLACE FUNCTION protect_deletion() RETURNS TRIGGER AS $$-BEGIN- IF current_setting('kiroku.enable_hard_deletes', true) = 'on' THEN- RETURN OLD;- END IF;- RAISE EXCEPTION 'Hard deletes require: SET LOCAL kiroku.enable_hard_deletes = ''on''';-END;-$$ LANGUAGE plpgsql;--DROP TRIGGER IF EXISTS no_delete_events ON events;-CREATE TRIGGER no_delete_events- BEFORE DELETE ON events- FOR EACH ROW EXECUTE FUNCTION protect_deletion();--DROP TRIGGER IF EXISTS no_delete_stream_events ON stream_events;-CREATE TRIGGER no_delete_stream_events- BEFORE DELETE ON stream_events- FOR EACH ROW EXECUTE FUNCTION protect_deletion();--DROP TRIGGER IF EXISTS no_delete_streams ON streams;-CREATE TRIGGER no_delete_streams- BEFORE DELETE ON streams- FOR EACH ROW EXECUTE FUNCTION protect_deletion();---- TRUNCATE bypasses row-level triggers, so the BEFORE DELETE triggers above--- do not protect against an operator running TRUNCATE on these tables. Add--- statement-level BEFORE TRUNCATE triggers gated by the same GUC so the--- protection is symmetric. See EP-1 F6.-CREATE OR REPLACE FUNCTION protect_truncation() RETURNS TRIGGER AS $$-BEGIN- IF current_setting('kiroku.enable_hard_deletes', true) = 'on' THEN- RETURN NULL;- END IF;- RAISE EXCEPTION 'TRUNCATE requires: SET LOCAL kiroku.enable_hard_deletes = ''on''';-END;-$$ LANGUAGE plpgsql;--DROP TRIGGER IF EXISTS no_truncate_events ON events;-CREATE TRIGGER no_truncate_events- BEFORE TRUNCATE ON events- FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();--DROP TRIGGER IF EXISTS no_truncate_stream_events ON stream_events;-CREATE TRIGGER no_truncate_stream_events- BEFORE TRUNCATE ON stream_events- FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();--DROP TRIGGER IF EXISTS no_truncate_streams ON streams;-CREATE TRIGGER no_truncate_streams- BEFORE TRUNCATE ON streams- FOR EACH STATEMENT EXECUTE FUNCTION protect_truncation();
− sql-migrations/2026-05-26-00-00-00-add-subscription-dead-letters.sql
@@ -1,31 +0,0 @@--- Add the kiroku.dead_letters table (MasterPlan 6 / EP-2, docs/plans/40-...).------ Forward, additive migration: codd applies this file once, records it, and--- skips it on later runs. It does not edit the bootstrap migration and does not--- mutate existing event data.------ A dead-letter row records one event that a subscription handler asked to--- "dead-letter" (return DeadLetter, or exhaust its bounded retry budget). The--- event itself stays immutable in kiroku.events; this table references it by--- event_id and global_position rather than copying the payload. The--- consumer_group_member column (default 0, matching kiroku.subscriptions)--- attributes the row to the member that produced it, so a consumer group's dead--- letters are per-member. The worker writes a row and advances the member's--- checkpoint in one atomic statement (see SQL.insertDeadLetterAndCheckpointStmt).--CREATE TABLE IF NOT EXISTS kiroku.dead_letters (- dead_letter_id BIGSERIAL PRIMARY KEY,- subscription_name TEXT NOT NULL,- consumer_group_member INT NOT NULL DEFAULT 0,- global_position BIGINT NOT NULL,- event_id UUID NOT NULL REFERENCES kiroku.events(event_id),- reason JSONB NOT NULL,- reason_summary TEXT NOT NULL,- attempt_count INT NOT NULL,- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),- UNIQUE (subscription_name, consumer_group_member, global_position, event_id)-);---- Operator read path: list a subscription member's dead letters by recency.-CREATE INDEX IF NOT EXISTS ix_dead_letters_subscription_created_at- ON kiroku.dead_letters (subscription_name, consumer_group_member, created_at);
src/Kiroku/Store/Migrations.hs view
@@ -1,56 +1,30 @@-{-# LANGUAGE ScopedTypeVariables #-}-{-# LANGUAGE TemplateHaskell #-}- module Kiroku.Store.Migrations (+ DefinitionError,+ MigrationComponent,+ MigrationPlan,+ PlanError,+ kirokuMigrationPlan, kirokuMigrations,- runKirokuMigrations,- runKirokuMigrationsNoCheck, ) where -import Codd (ApplyResult (SchemasNotVerified), CoddSettings, VerifySchemas, applyMigrations, applyMigrationsNoCheck)-import Codd.Logging (runCoddLogger)-import Codd.Parsing (AddedSqlMigration, EnvVars, PureStream (..), parseAddedSqlMigration)-import Data.ByteString (ByteString)-import Data.FileEmbed (embedDir)-import Data.Text.Encoding qualified as TE-import Data.Time (DiffTime)-import Streaming.Prelude qualified as Streaming---- | Kiroku's embedded SQL migrations, ordered by timestamped filename.-kirokuMigrations :: (MonadFail m, EnvVars m) => m [AddedSqlMigration m]-kirokuMigrations =- traverse parseEmbeddedMigration embeddedMigrationFiles- where- parseEmbeddedMigration :: forall m. (MonadFail m, EnvVars m) => (FilePath, ByteString) -> m (AddedSqlMigration m)- parseEmbeddedMigration (name, bytes) = do- let stream :: PureStream m- stream = PureStream $ Streaming.yield (TE.decodeUtf8 bytes)- result <-- parseAddedSqlMigration- name- stream- case result of- Left err -> fail ("Invalid Kiroku migration " <> name <> ": " <> err)- Right migration -> pure migration---- | Run Kiroku's embedded migrations through codd.-runKirokuMigrations :: CoddSettings -> DiffTime -> VerifySchemas -> IO ApplyResult-runKirokuMigrations settings connectTimeout verifySchemas =- runCoddLogger $ do- migrations <- kirokuMigrations- applyMigrations settings (Just migrations) connectTimeout verifySchemas+import Data.List.NonEmpty (NonEmpty (..))+import Database.PostgreSQL.Migrate (+ DefinitionError,+ MigrationComponent,+ MigrationPlan,+ PlanError,+ migrationPlan,+ )+import Kiroku.Store.Migrations.Internal.Definition (kirokuMigrations) -{- | Run Kiroku's embedded migrations through codd without expected-schema-verification.+{- | The complete single-component Kiroku migration plan. -This is the right entry point until the caller owns a codd expected-schema-snapshot. Use 'runKirokuMigrations' when schema verification is configured.+The embedded manifest is validated at compile time, so a component-definition+failure indicates a broken package invariant rather than an operator error. -}-runKirokuMigrationsNoCheck :: CoddSettings -> DiffTime -> IO ApplyResult-runKirokuMigrationsNoCheck settings connectTimeout =- runCoddLogger $ do- migrations <- kirokuMigrations- applyMigrationsNoCheck settings (Just migrations) connectTimeout (const (pure SchemasNotVerified))--embeddedMigrationFiles :: [(FilePath, ByteString)]-embeddedMigrationFiles = $(embedDir "sql-migrations")+kirokuMigrationPlan :: Either PlanError MigrationPlan+kirokuMigrationPlan =+ case kirokuMigrations of+ Left definitionError ->+ error ("invalid embedded Kiroku migration component: " <> show definitionError)+ Right component -> migrationPlan (component :| [])
+ src/Kiroku/Store/Migrations/History/Codd.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE TemplateHaskell #-}++module Kiroku.Store.Migrations.History.Codd (+ kirokuCoddHistoryMappings,+ kirokuCoddManifestText,+ kirokuCoddSourceConfig,+ kirokuCoddSourcePayloads,+ kirokuLegacyMigrationNames,+) where++import Data.Bifunctor (first)+import Data.ByteString (ByteString)+import Data.Foldable (toList)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Map.Strict qualified as Map+import Data.Text (Text)+import Database.PostgreSQL.Migrate (+ Confirmation,+ ConnectionProvider,+ EvidenceRequirement (Evidence),+ HistoryMapping,+ PayloadRelation (SamePayload),+ historyMapping,+ migrationId,+ )+import Database.PostgreSQL.Migrate.History.Codd (+ CoddDefinitionError,+ CoddSourceConfig,+ coddEvidenceKey,+ coddSourceConfig,+ parseCoddManifest,+ )+import Kiroku.Store.Migrations.Internal.Definition (embeddedMigrationEntries)+import Kiroku.Store.Migrations.Internal.EmbedFile (embedTextFile)++kirokuLegacyMigrationNames :: NonEmpty FilePath+kirokuLegacyMigrationNames =+ "2026-05-16-12-17-14-kiroku-bootstrap.sql"+ :| [ "2026-05-29-15-26-04-add-subscription-dead-letters.sql"+ , "2026-06-14-13-17-09-notify-trigger-append-guard.sql"+ , "2026-06-14-13-25-40-dead-letters-event-id-index.sql"+ , "2026-06-14-13-54-48-index-hygiene-and-streams-fillfactor.sql"+ , "2026-06-14-14-01-17-stream-name-length-check.sql"+ , "2026-06-24-09-42-22-stream-truncate-before.sql"+ ]++kirokuCoddHistoryMappings :: NonEmpty HistoryMapping+kirokuCoddHistoryMappings =+ zipWithNonEmpty mapping kirokuLegacyMigrationNames nativeMigrationNames+ where+ mapping sourceFilename targetName =+ historyMapping+ (definitionInvariant (migrationId "kiroku" targetName))+ (Evidence sourceKey)+ (SamePayload sourceKey)+ where+ sourceKey = definitionInvariant (first show (coddEvidenceKey sourceFilename))++kirokuCoddSourceConfig ::+ ConnectionProvider ->+ Bool ->+ Text ->+ Confirmation ->+ Either CoddDefinitionError CoddSourceConfig+kirokuCoddSourceConfig sourceProvider strictSource reason confirmation =+ coddSourceConfig+ sourceProvider+ kirokuLegacyMigrationNames+ strictSource+ kirokuCoddSourcePayloads+ (Just (definitionInvariant (parseCoddManifest kirokuCoddManifestText)))+ reason+ confirmation++nativeMigrationNames :: NonEmpty Text+nativeMigrationNames =+ "0001-kiroku-bootstrap"+ :| [ "0002-add-subscription-dead-letters"+ , "0003-notify-trigger-append-guard"+ , "0004-dead-letters-event-id-index"+ , "0005-index-hygiene-and-streams-fillfactor"+ , "0006-stream-name-length-check"+ , "0007-stream-truncate-before"+ ]++kirokuCoddSourcePayloads :: Map.Map FilePath ByteString+kirokuCoddSourcePayloads =+ Map.fromList+ (zip (toList kirokuLegacyMigrationNames) (snd <$> toList embeddedMigrationEntries))++kirokuCoddManifestText :: Text+kirokuCoddManifestText = $(embedTextFile "migrations.lock")++zipWithNonEmpty :: (a -> b -> c) -> NonEmpty a -> NonEmpty b -> NonEmpty c+zipWithNonEmpty combine (firstA :| restA) (firstB :| restB) =+ combine firstA firstB :| zipWith combine restA restB++definitionInvariant :: (Show error) => Either error value -> value+definitionInvariant = either (error . ("invalid checked-in Kiroku migration definition: " <>) . show) id
+ src/Kiroku/Store/Migrations/Internal/Definition.hs view
@@ -0,0 +1,23 @@+{-# LANGUAGE TemplateHaskell #-}++module Kiroku.Store.Migrations.Internal.Definition (+ embeddedMigrationEntries,+ kirokuMigrations,+) where++import Data.ByteString (ByteString)+import Data.List.NonEmpty (NonEmpty)+import Database.PostgreSQL.Migrate (+ DefinitionError,+ MigrationComponent,+ migrationComponentFromEmbeddedSql,+ )+import Database.PostgreSQL.Migrate.Embed (embedMigrationManifest)++embeddedMigrationEntries :: NonEmpty (FilePath, ByteString)+embeddedMigrationEntries =+ $(embedMigrationManifest "migrations/manifest")++kirokuMigrations :: Either DefinitionError MigrationComponent+kirokuMigrations =+ migrationComponentFromEmbeddedSql "kiroku" mempty embeddedMigrationEntries
+ src/Kiroku/Store/Migrations/Internal/EmbedFile.hs view
@@ -0,0 +1,19 @@+{-# LANGUAGE TemplateHaskell #-}++module Kiroku.Store.Migrations.Internal.EmbedFile (embedTextFile) where++import Data.ByteString qualified as ByteString+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Language.Haskell.TH (Exp (..), Lit (..), Q)+import Language.Haskell.TH.Syntax qualified as TH++embedTextFile :: FilePath -> Q Exp+embedTextFile inputPath = do+ path <- TH.makeRelativeToProject inputPath+ TH.addDependentFile path+ bytes <- TH.runIO (ByteString.readFile path)+ case Text.Encoding.decodeUtf8' bytes of+ Left decodeError -> fail ("invalid UTF-8 in " <> path <> ": " <> show decodeError)+ Right contents ->+ pure (AppE (VarE 'Text.pack) (LitE (StringL (Text.unpack contents))))
+ src/Kiroku/Store/Migrations/New.hs view
@@ -0,0 +1,40 @@+module Kiroku.Store.Migrations.New (+ AuthoringError,+ defaultMigrationsDir,+ newMigrationFile,+ migrationTemplate,+) where++import Data.ByteString (ByteString)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import Database.PostgreSQL.Migrate.Embed (+ AuthoringError,+ newMigration,+ newMigrationOptions,+ )+import System.FilePath ((</>))++defaultMigrationsDir :: FilePath+defaultMigrationsDir = "migrations"++newMigrationFile :: FilePath -> String -> IO (Either AuthoringError FilePath)+newMigrationFile migrationsDirectory description =+ case newMigrationOptions manifestPath Nothing (migrationTemplate description) of+ Left definitionError -> pure (Left definitionError)+ Right options -> newMigration options+ where+ manifestPath = migrationsDirectory </> "manifest"++migrationTemplate :: String -> ByteString+migrationTemplate description =+ Text.Encoding.encodeUtf8+ ( Text.unlines+ [ "-- " <> Text.pack description+ , ""+ , "-- Kiroku migrations are forward-only and applied exactly once by pg-migrate."+ , "-- Qualify every object with the kiroku schema and append corrections"+ , "-- as new manifest entries instead of editing released payloads."+ , ""+ ]+ )
test/Main.hs view
@@ -1,229 +1,396 @@-module Main where+{-# LANGUAGE MultilineStrings #-} -import Codd (ApplyResult (SchemasNotVerified), CoddSettings (..))-import Codd.Parsing (connStringParser)-import Codd.Representations.Types (DbRep (..))-import Codd.Types (ConnectionString, SchemaAlgo (..), SchemaSelection (..), SqlSchema (..), TxnIsolationLvl (..), singleTryPolicy)-import Data.Aeson (Value (Null))-import Data.Aeson qualified as Aeson-import Data.Attoparsec.Text (endOfInput, parseOnly)-import Data.Map qualified as Map+module Main (main) where++import Control.Concurrent.Async (concurrently)+import Control.Exception (finally)+import Control.Monad (forM_)+import Data.ByteString (ByteString)+import Data.ByteString qualified as ByteString+import Data.Either (isLeft)+import Data.Foldable (toList)+import Data.Int (Int64)+import Data.List (sort)+import Data.List.NonEmpty (NonEmpty (..)) import Data.Text (Text)-import Data.Text qualified as T-import Data.Time (secondsToDiffTime)-import Data.Vector qualified as Vector+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import Database.PostgreSQL.Migrate+import Database.PostgreSQL.Migrate.History.Codd+import Database.PostgreSQL.Migrate.Internal (migrationChecksumBytes)+import Database.PostgreSQL.Migrate.Test (withMigratedDatabase) import EphemeralPg qualified as Pg-import Hasql.Connection.Settings qualified as Conn-import Hasql.Decoders qualified as D-import Hasql.Encoders qualified as E-import Hasql.Pool qualified as Pool-import Hasql.Pool.Config qualified as Pool.Config+import Hasql.Connection qualified as Connection+import Hasql.Connection.Settings qualified as Settings+import Hasql.Decoders qualified as Decoders+import Hasql.Encoders qualified as Encoders import Hasql.Session qualified as Session-import Hasql.Statement (Statement, preparable)-import Kiroku.Store-import Kiroku.Store.Migrations (runKirokuMigrationsNoCheck)+import Hasql.Statement (Statement)+import Hasql.Statement qualified as Statement+import Kiroku.Store.Migrations+import Kiroku.Store.Migrations.History.Codd+import Kiroku.Store.Migrations.New+import Numeric qualified+import System.Directory (doesDirectoryExist, doesFileExist)+import System.FilePath ((</>))+import System.IO.Temp (withSystemTempDirectory) import Test.Hspec main :: IO ()-main =- hspec $- describe "codd migration spike" $- it "applies Kiroku migrations, opens the store without startup DDL, and is repeatable" $ do- result <- Pg.withCached $ \db -> do- let connStr = Pg.connectionString db- coddSettings = testCoddSettings connStr+main = hspec $ do+ describe "native Kiroku migration definition" $ do+ it "tracks the eight native files in manifest order" $ do+ directory <- findMigrationsDirectory+ manifest <- Text.lines <$> Text.IO.readFile (directory </> "manifest")+ manifest `shouldBe` Text.pack <$> nativeMigrationFiles - firstMigration <- runKirokuMigrationsNoCheck coddSettings (secondsToDiffTime 5)- firstMigration `shouldBeSchemasNotVerified` "first migration run"- assertBootstrapApplied connStr- assertSchemaPlacement connStr- assertDeadLettersTable connStr- assertDefaultUuidV7 connStr+ it "preserves every legacy payload byte recorded by migrations.lock" $ do+ directory <- findMigrationsDirectory+ lockPath <- findLockfile+ lockEntries <- parseLockfile <$> Text.IO.readFile lockPath+ forM_ (zip (toList kirokuLegacyMigrationNames) nativeMigrationFiles) $ \(legacyName, nativeName) -> do+ bytes <- ByteString.readFile (directory </> nativeName)+ lookup legacyName lockEntries `shouldBe` Just (checksumText bytes) - withStore- (defaultConnectionSettings connStr)- $ \store -> do- let stream = StreamName "migration-consumer"- event = makeEvent "MigrationConsumerChecked" (Aeson.object [("ok", Aeson.Bool True)])- appendResult <- runStoreIO store $ appendToStream stream NoStream [event]- case appendResult of- Left err -> expectationFailure ("appendToStream failed: " <> show err)- Right _ -> pure ()+ it "builds component kiroku and an eight-migration plan" $ do+ component <- requireRight kirokuMigrations+ component `seq` pure ()+ plan <- requirePlan+ let targetIds =+ [ requireRight (migrationId "kiroku" (Text.pack (dropSqlSuffix file)))+ | file <- nativeMigrationFiles+ ]+ validateHistoryMappingTargets plan kirokuCoddHistoryMappings+ `shouldBe` Right ()+ length targetIds `shouldBe` 8 - readResult <- runStoreIO store $ readStreamForward stream (StreamVersion 0) 10- case readResult of- Left err -> expectationFailure ("readStreamForward failed: " <> show err)- Right events -> Vector.length events `shouldBe` 1+ describe "native migration authoring" $ do+ it "creates the next numeric file and atomically appends the manifest" $+ withSystemTempDirectory "kiroku-native-authoring" $ \directory -> do+ ByteString.writeFile (directory </> "0007-existing.sql") "SELECT 7;\n"+ ByteString.writeFile (directory </> "manifest") "0007-existing.sql\n"+ created <- newMigrationFile directory "add widget index"+ path <- requireRight created+ path `shouldBe` directory </> "0008.sql"+ body <- ByteString.readFile path+ body `shouldSatisfy` ByteString.isInfixOf "forward-only"+ Text.lines <$> Text.IO.readFile (directory </> "manifest")+ `shouldReturn` ["0007-existing.sql", "0008.sql"] - secondMigration <- runKirokuMigrationsNoCheck coddSettings (secondsToDiffTime 5)- secondMigration `shouldBeSchemasNotVerified` "second migration run"- assertBootstrapApplied connStr- assertDefaultUuidV7 connStr- case result of- Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)- Right () -> pure ()+ it "refuses to overwrite a pre-existing inferred migration" $+ withSystemTempDirectory "kiroku-native-exclusive" $ \directory -> do+ ByteString.writeFile (directory </> "manifest") "0007-existing.sql\n"+ ByteString.writeFile (directory </> "0007-existing.sql") "SELECT 7;\n"+ ByteString.writeFile (directory </> "0008.sql") "SELECT 8;\n"+ created <- newMigrationFile directory "must not overwrite"+ created `shouldSatisfy` isLeft+ Text.IO.readFile (directory </> "manifest")+ `shouldReturn` "0007-existing.sql\n" -testCoddSettings :: Text -> CoddSettings-testCoddSettings connStr =- CoddSettings- { migsConnString = parseConnString connStr- , sqlMigrations = []- , onDiskReps = Right (DbRep Null Map.empty Map.empty)- , namespacesToCheck = IncludeSchemas [SqlSchema "kiroku"]- , extraRolesToCheck = []- , retryPolicy = singleTryPolicy- , txnIsolationLvl = DbDefault- , schemaAlgoOpts = SchemaAlgo False False False- }+ describe "fresh native databases" $ do+ it "applies all eight, verifies strictly, and reports AlreadyApplied on rerun" $ do+ plan <- requirePlan+ result <- withMigratedDatabase plan $ \connection -> do+ assertSchema connection+ let provider = providerFor connection+ rerun <- runMigrationPlanWith defaultRunOptions provider plan >>= requireMigration+ reportOutcomes rerun `shouldBe` replicate 8 AlreadyApplied+ verified <- verifyMigrationPlanWith defaultRunOptions provider plan >>= requireMigration+ case verified of+ VerificationReport verificationIssues applied _ _ -> do+ verificationIssues `shouldBe` []+ length applied `shouldBe` 8+ either (expectationFailure . show) pure result -parseConnString :: Text -> ConnectionString-parseConnString connStr =- case parseOnly (connStringParser <* endOfInput) connStr of- Left err -> error ("Could not parse ephemeral PostgreSQL connection string for codd: " <> err)- Right parsed -> parsed+ it "serializes concurrent applies through the pg-migrate advisory lock" $ do+ plan <- requirePlan+ withKirokuPg $ \database -> do+ let settings = Pg.connectionSettings database+ (first, second) <-+ concurrently+ (runMigrationPlan defaultRunOptions settings plan >>= requireMigration)+ (runMigrationPlan defaultRunOptions settings plan >>= requireMigration)+ sort [reportOutcomes first, reportOutcomes second]+ `shouldBe` sort [replicate 8 AppliedNow, replicate 8 AlreadyApplied] -makeEvent :: Text -> Value -> EventData-makeEvent typ payload =- EventData- { eventId = Nothing- , eventType = EventType typ- , payload = payload- , metadata = Nothing- , causationId = Nothing- , correlationId = Nothing- }+ describe "Codd history import" $ do+ it "imports a current codd V5 ledger, verifies, and never replays SQL" $+ importFixture "codd" -shouldBeSchemasNotVerified :: ApplyResult -> String -> Expectation-shouldBeSchemasNotVerified SchemasNotVerified _ = pure ()-shouldBeSchemasNotVerified _ label = expectationFailure (label <> " unexpectedly verified schemas")+ it "imports the legacy codd_schema ledger shape" $+ importFixture "codd_schema" -assertBootstrapApplied :: Text -> IO ()-assertBootstrapApplied connStr = do- pool <- Pool.acquire poolConfig- result <- Pool.use pool (Session.statement () bootstrapStmt)- Pool.release pool- case result of- Left err -> expectationFailure ("bootstrap verification query failed: " <> show err)- Right True -> pure ()- Right False -> expectationFailure "Kiroku bootstrap migration did not create the $all stream"- where- poolConfig =- Pool.Config.settings- [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)- , Pool.Config.size 1- ]+ it "rejects a partial legacy row before creating the target ledger" $ do+ plan <- requirePlan+ withKirokuPg $ \database -> do+ let settings = Pg.connectionSettings database+ provider = connectionProviderFromSettings settings+ withConnection settings $ \connection -> installCoddLedger connection "codd" True+ config <-+ requireRight+ (kirokuCoddSourceConfig provider True "partial fixture must fail" Confirmed)+ imported <-+ importCoddHistory defaultImportOptions config provider plan kirokuCoddHistoryMappings+ imported `shouldSatisfy` \case+ Left CoddPartialMigration{} -> True+ _ -> False+ withConnection settings $ \connection -> do+ targetExists <- useSession connection (Session.statement "pgmigrate" schemaExistsStatement)+ targetExists `shouldBe` False -bootstrapStmt :: Statement () Bool-bootstrapStmt =- preparable- "SELECT EXISTS (SELECT 1 FROM kiroku.streams WHERE stream_id = 0 AND stream_name = '$all')"- E.noParams- (D.singleRow (D.column (D.nonNullable D.bool)))+importFixture :: Text -> Expectation+importFixture sourceSchema = do+ plan <- requirePlan+ directory <- findMigrationsDirectory+ withKirokuPg $ \database -> do+ let settings = Pg.connectionSettings database+ provider = connectionProviderFromSettings settings+ withConnection settings $ \connection -> do+ applyNativeSqlFromDisk connection directory+ installCoddLedger connection sourceSchema False+ config <-+ requireRight+ (kirokuCoddSourceConfig provider True "verified Kiroku Codd cutover" Confirmed)+ first <-+ importCoddHistory defaultImportOptions config provider plan kirokuCoddHistoryMappings+ >>= requireRight+ importOutcomes first `shouldBe` replicate 7 Imported+ canaryId <- requireRight (migrationId "kiroku" "0008-schema-management-comment")+ verifiedBeforeCanary <- verifyMigrationPlan defaultRunOptions settings plan >>= requireMigration+ case verifiedBeforeCanary of+ VerificationReport verificationIssues _ _ _ ->+ verificationIssues+ `shouldBe` [PendingMigration canaryId]+ up <- runMigrationPlan defaultRunOptions settings plan >>= requireMigration+ reportOutcomes up `shouldBe` replicate 7 AlreadyApplied <> [AppliedNow]+ verifiedAfterCanary <- verifyMigrationPlan defaultRunOptions settings plan >>= requireMigration+ case verifiedAfterCanary of+ VerificationReport verificationIssues _ _ _ ->+ verificationIssues `shouldBe` []+ rerun <- runMigrationPlan defaultRunOptions settings plan >>= requireMigration+ reportOutcomes rerun `shouldBe` replicate 8 AlreadyApplied+ second <-+ importCoddHistory defaultImportOptions config provider plan kirokuCoddHistoryMappings+ >>= requireRight+ importOutcomes second `shouldBe` replicate 7 AlreadyImported+ withConnection settings $ \connection -> do+ assertSchema connection+ sourceRows <- useSession connection (Session.statement () (sourceRowCountStatement sourceSchema))+ sourceRows `shouldBe` 7+ facts <- useSession connection (Session.statement () importFactsStatement)+ facts `shouldBe` (8, 7, True) -{- | Assert that the migration installed every Kiroku table under the @kiroku@-schema and left @public@ free of Kiroku tables. The connection uses no special-@search_path@, so the schema-qualified @to_regclass@ checks prove placement-directly rather than relying on name resolution.--}-assertSchemaPlacement :: Text -> IO ()-assertSchemaPlacement connStr = do- pool <- Pool.acquire poolConfig- result <- Pool.use pool (Session.statement () placementStmt)- Pool.release pool- case result of- Left err -> expectationFailure ("schema placement query failed: " <> show err)- Right (True, True) -> pure ()- Right (kirokuPresent, publicAbsent) ->- expectationFailure- ( "expected all Kiroku tables in 'kiroku' and none in 'public'; got kirokuPresent="- <> show kirokuPresent- <> ", publicAbsent="- <> show publicAbsent- )+nativeMigrationFiles :: [FilePath]+nativeMigrationFiles =+ [ "0001-kiroku-bootstrap.sql"+ , "0002-add-subscription-dead-letters.sql"+ , "0003-notify-trigger-append-guard.sql"+ , "0004-dead-letters-event-id-index.sql"+ , "0005-index-hygiene-and-streams-fillfactor.sql"+ , "0006-stream-name-length-check.sql"+ , "0007-stream-truncate-before.sql"+ , "0008-schema-management-comment.sql"+ ]++findMigrationsDirectory :: IO FilePath+findMigrationsDirectory =+ findDirectory ["kiroku-store-migrations/migrations", "migrations"]++findLockfile :: IO FilePath+findLockfile =+ findFile ["kiroku-store-migrations/migrations.lock", "migrations.lock"]++findDirectory :: [FilePath] -> IO FilePath+findDirectory candidates = do+ existing <- filterM doesDirectoryExist candidates+ case existing of+ directory : _ -> pure directory+ [] -> expectationFailure ("could not find directory: " <> show candidates) >> pure "."++findFile :: [FilePath] -> IO FilePath+findFile candidates = do+ existing <- filterM doesFileExist candidates+ case existing of+ path : _ -> pure path+ [] -> expectationFailure ("could not find file: " <> show candidates) >> pure "."++filterM :: (value -> IO Bool) -> [value] -> IO [value]+filterM predicate = foldr step (pure []) where- poolConfig =- Pool.Config.settings- [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)- , Pool.Config.size 1- ]+ step value remaining = do+ matches <- predicate value+ values <- remaining+ pure (if matches then value : values else values) -placementStmt :: Statement () (Bool, Bool)-placementStmt =- preparable- "SELECT \- \ (to_regclass('kiroku.streams') IS NOT NULL \- \ AND to_regclass('kiroku.events') IS NOT NULL \- \ AND to_regclass('kiroku.stream_events') IS NOT NULL \- \ AND to_regclass('kiroku.subscriptions') IS NOT NULL), \- \ (to_regclass('public.streams') IS NULL \- \ AND to_regclass('public.events') IS NULL \- \ AND to_regclass('public.stream_events') IS NULL \- \ AND to_regclass('public.subscriptions') IS NULL)"- E.noParams- (D.singleRow ((,) <$> D.column (D.nonNullable D.bool) <*> D.column (D.nonNullable D.bool)))+parseLockfile :: Text -> [(FilePath, Text)]+parseLockfile contents =+ [ (Text.unpack filename, checksum)+ | line <- Text.lines contents+ , [checksum, filename] <- [Text.words line]+ ] -{- | Assert that the forward migration installed the @kiroku.dead_letters@ table-(MasterPlan 6 / EP-40) — proving codd applied the timestamped migration after the-bootstrap, not only the bootstrap itself.--}-assertDeadLettersTable :: Text -> IO ()-assertDeadLettersTable connStr = do- pool <- Pool.acquire poolConfig- result <- Pool.use pool (Session.statement () deadLettersStmt)- Pool.release pool- case result of- Left err -> expectationFailure ("dead_letters verification query failed: " <> show err)- Right True -> pure ()- Right False -> expectationFailure "Kiroku migration did not create the kiroku.dead_letters table"+checksumText :: ByteString -> Text+checksumText =+ Text.pack+ . concatMap renderByte+ . ByteString.unpack+ . migrationChecksumBytes+ . migrationFingerprint where- poolConfig =- Pool.Config.settings- [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)- , Pool.Config.size 1- ]+ renderByte byte =+ case Numeric.showHex byte "" of+ [digit] -> ['0', digit]+ digits -> digits -deadLettersStmt :: Statement () Bool-deadLettersStmt =- preparable- "SELECT to_regclass('kiroku.dead_letters') IS NOT NULL"- E.noParams- (D.singleRow (D.column (D.nonNullable D.bool)))+dropSqlSuffix :: FilePath -> String+dropSqlSuffix = reverse . drop 4 . reverse -assertDefaultUuidV7 :: Text -> IO ()-assertDefaultUuidV7 connStr = do- pool <- Pool.acquire poolConfig- result <- Pool.use pool (Session.statement () defaultUuidStmt)- versionResult <- Pool.use pool (Session.statement () serverVersionStmt)- Pool.release pool- case (result, versionResult) of- (Right eventIdText, Right version)- | T.length eventIdText > 14 && T.index eventIdText 14 == '7' -> pure ()- | otherwise ->- expectationFailure- ( "expected migration-created database default to generate UUIDv7 on PostgreSQL "- <> T.unpack version- <> ", got "- <> T.unpack eventIdText- )- (Left err, _) -> expectationFailure ("default UUID insert failed: " <> show err)- (_, Left err) -> expectationFailure ("server version query failed: " <> show err)+requirePlan :: IO MigrationPlan+requirePlan = requireRight kirokuMigrationPlan++requireRight :: (Show error) => Either error value -> IO value+requireRight = either (failure . show) pure++requireMigration :: (Show error) => Either error value -> IO value+requireMigration = requireRight++failure :: String -> IO value+failure message = expectationFailure message >> fail message++providerFor :: Connection.Connection -> ConnectionProvider+providerFor connection = connectionProvider (\action -> Right <$> action connection)++reportOutcomes :: MigrationReport -> [MigrationOutcome]+reportOutcomes MigrationReport{results} = outcome <$> toList results++importOutcomes :: HistoryImportReport -> [HistoryImportOutcome]+importOutcomes HistoryImportReport{importResults} = importOutcome <$> toList importResults++kirokuPgConfig :: Pg.Config+kirokuPgConfig = Pg.defaultConfig{Pg.user = "kiroku"}++withKirokuPg :: (Pg.Database -> IO ()) -> IO ()+withKirokuPg action = do+ started <- Pg.startCached kirokuPgConfig Pg.defaultCacheConfig+ case started of+ Left startError -> expectationFailure (show startError)+ Right database -> action database `finally` Pg.stop database++withConnection :: Settings.Settings -> (Connection.Connection -> IO value) -> IO value+withConnection settings action = do+ acquired <- Connection.acquire settings+ connection <- requireRight acquired+ action connection `finally` Connection.release connection++useSession :: Connection.Connection -> Session.Session value -> IO value+useSession connection session =+ Connection.use connection session >>= requireRight++assertSchema :: Connection.Connection -> Expectation+assertSchema connection = do+ healthy <- useSession connection (Session.statement () schemaFactsStatement)+ healthy `shouldBe` True+ oversized <- Connection.use connection (Session.statement (Text.replicate 513 "x") oversizedStreamStatement)+ oversized `shouldSatisfy` isLeft++schemaFactsStatement :: Statement () Bool+schemaFactsStatement =+ Statement.preparable+ """+ SELECT bool_and(ok)+ FROM (VALUES+ (to_regnamespace('kiroku') IS NOT NULL),+ (to_regclass('kiroku.events') IS NOT NULL),+ (to_regclass('kiroku.streams') IS NOT NULL),+ (to_regclass('kiroku.dead_letters') IS NOT NULL),+ (EXISTS (SELECT 1 FROM pg_catalog.pg_trigger WHERE tgname = 'stream_events_notify_insert' AND NOT tgisinternal)),+ (EXISTS (SELECT 1 FROM pg_catalog.pg_indexes WHERE schemaname = 'kiroku' AND indexname = 'ix_dead_letters_event_id')),+ (EXISTS (SELECT 1 FROM pg_catalog.pg_constraint WHERE conname = 'chk_streams_stream_name_length')),+ (EXISTS (SELECT 1 FROM pg_catalog.pg_attribute a JOIN pg_catalog.pg_class c ON c.oid = a.attrelid JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = 'kiroku' AND c.relname = 'streams' AND a.attname = 'truncate_before' AND NOT a.attisdropped)),+ (obj_description(to_regnamespace('kiroku'), 'pg_namespace') = 'Managed by pg-migrate component kiroku through 0008-schema-management-comment')+ ) AS checks(ok)+ """+ Encoders.noParams+ (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.bool)))++oversizedStreamStatement :: Statement Text ()+oversizedStreamStatement =+ Statement.preparable+ "INSERT INTO kiroku.streams (stream_name, stream_version) VALUES ($1, 0)"+ (Encoders.param (Encoders.nonNullable Encoders.text))+ Decoders.noResult++applyNativeSqlFromDisk :: Connection.Connection -> FilePath -> IO ()+applyNativeSqlFromDisk connection directory =+ forM_ (take 7 nativeMigrationFiles) $ \file -> do+ sql <- Text.IO.readFile (directory </> file)+ useSession connection (Session.script sql)++installCoddLedger :: Connection.Connection -> Text -> Bool -> IO ()+installCoddLedger connection sourceSchema partial =+ useSession connection (Session.script (coddFixtureSql sourceSchema partial))++coddFixtureSql :: Text -> Bool -> Text+coddFixtureSql sourceSchema partial =+ Text.unlines+ [ "CREATE SCHEMA " <> sourceSchema <> ";"+ , "CREATE TABLE " <> sourceSchema <> ".sql_migrations ("+ , " id serial NOT NULL, migration_timestamp timestamptz NOT NULL,"+ , " applied_at timestamptz, name text NOT NULL, application_duration interval,"+ , " num_applied_statements int, no_txn_failed_at timestamptz, txnid bigint, connid int"+ , ");"+ , "INSERT INTO " <> sourceSchema <> ".sql_migrations"+ , " (migration_timestamp, applied_at, name, application_duration, num_applied_statements, no_txn_failed_at, txnid, connid) VALUES"+ , Text.intercalate ",\n" (zipWith renderRow [1 :: Int ..] (toList kirokuLegacyMigrationNames)) <> ";"+ ] where- poolConfig =- Pool.Config.settings- [ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)- , Pool.Config.size 1- ]+ renderRow index filename =+ "('2026-01-01 00:00:00+00'::timestamptz + interval '"+ <> Text.pack (show index)+ <> " seconds', "+ <> appliedAt index+ <> ", '"+ <> Text.pack filename+ <> "', interval '1 second', 1, "+ <> failureAt index+ <> ", 1, 1)"+ appliedAt index+ | partial && index == 4 = "NULL"+ | otherwise = "'2026-01-01 00:01:00+00'::timestamptz + interval '" <> Text.pack (show index) <> " seconds'"+ failureAt index+ | partial && index == 4 = "'2026-01-01 00:02:00+00'::timestamptz"+ | otherwise = "NULL" -defaultUuidStmt :: Statement () Text-defaultUuidStmt =- preparable- "INSERT INTO kiroku.events (event_type, data) VALUES ('DefaultUuidGenerated', '{}'::jsonb) RETURNING event_id::text"- E.noParams- (D.singleRow (D.column (D.nonNullable D.text)))+schemaExistsStatement :: Statement Text Bool+schemaExistsStatement =+ Statement.preparable+ "SELECT to_regnamespace($1) IS NOT NULL"+ (Encoders.param (Encoders.nonNullable Encoders.text))+ (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.bool))) -serverVersionStmt :: Statement () Text-serverVersionStmt =- preparable- "SELECT current_setting('server_version_num')"- E.noParams- (D.singleRow (D.column (D.nonNullable D.text)))+sourceRowCountStatement :: Text -> Statement () Int64+sourceRowCountStatement sourceSchema =+ Statement.unpreparable+ ("SELECT count(*) FROM " <> sourceSchema <> ".sql_migrations")+ Encoders.noParams+ (Decoders.singleRow (Decoders.column (Decoders.nonNullable Decoders.int8)))++importFactsStatement :: Statement () (Int64, Int64, Bool)+importFactsStatement =+ Statement.preparable+ """+ SELECT+ (SELECT count(*) FROM pgmigrate.migrations),+ (SELECT count(*) FROM pgmigrate.history_imports),+ (SELECT bool_and(source_evidence #>> '{satisfying_evidence,0,details,adapter}' = 'codd') FROM pgmigrate.history_imports)+ """+ Encoders.noParams+ ( Decoders.singleRow+ ( (,,)+ <$> column Decoders.int8+ <*> column Decoders.int8+ <*> column Decoders.bool+ )+ )+ where+ column = Decoders.column . Decoders.nonNullable