Database table schemas
This page lists every table the framework creates, with its columns and the DDL for each database. To create the tables yourself, start with Managing the schema with a migration tool. All stores support six dialects: H2, HSQLDB, MySQL, MariaDB, PostgreSQL, and SQL Server. Wherever MariaDB reuses the MySQL DDL verbatim, its collapsible block says so instead of repeating the statement.
Managing the schema with a migration tool
The framework creates its tables at startup.
To create them with Flyway, Liquibase or another tool, switch that off — each section below names its Schema creation property — and apply the bundled scripts before the application starts.
The framework still inserts its own rows at startup (the partition count, each read-model topic’s counter), whoever created the tables.
Where the scripts are
| Module (JAR) | Path | Scripts |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The dialect folders are postgres, mysql (also used for MariaDB), h2, hsqldb and sqlserver.
The saga scripts also have a mariadb folder, and the domain-event metadata scripts are named schema-postgresql.sql, schema-mysql.sql, schema-mariadb.sql, schema-h2.sql, schema-hsqldb.sql and schema-sqlserver.sql.
consumer-checkpoints-schema.sql sits directly in db/messaging/ and is shared by all dialects except SQL Server, which has its own copy in db/messaging/sqlserver/.
consumer-undelivered-messages-schema.sql sits directly in db/messaging/ too and is shared by PostgreSQL, H2 and HSQLDB; MySQL (also used for MariaDB) and SQL Server have their own copies in db/messaging/mysql/ and db/messaging/sqlserver/.
consumer-skipped-messages-schema.sql has the same three copies.
event-store-schema.sql and snapshot-schema.sql name their table by the placeholders ${event_store_table} and ${aggregate_snapshots_table}, which the framework fills at startup once per table: with the shared names below for every aggregate without @EventTable, and with each aggregate’s own names otherwise.
Index and constraint names are built from the same placeholder.
An aggregate declaring @EventTable uses its own names, for example BANK_ACCOUNT_EVENTS and BANK_ACCOUNT_SNAPSHOTS for a BankAccount aggregate, with spring.ddd.eventsourcing.jdbc.table-prefix before a derived one.
A migration tool must fill them too, as described below.
|
create-saga-table.sql names its table by the placeholder ${saga_instances_table}, which the framework fills at startup once per instance table: with the shared name for every saga without @SagaTable, and with each @SagaTable target otherwise, spring.ddd.saga.jdbc.table-prefix before a derived one.
|
The scripts under db/messaging/, db/cqrs/, db/eventsourcing/ and db/saga/ name each table by a placeholder, which the framework fills at startup with the configured name: jdbc.table-prefix before jdbc.table-names.<table> of the table’s area.
Index and constraint names are built from the same placeholder, idx_${partition_members_table}_instance_id for example.
MySQL and SQL Server keep an index name’s case as written and compare index names case-insensitively, so a derived index name reads in mixed case, for example idx_PARTITION_MEMBERS_instance_id; this is cosmetic.
A migration tool must fill them too: give the tool the names as placeholder values (see the examples below) or copy the script and replace each placeholder with the table’s name.
The DDL on this page shows every placeholder filled with its default name.
A placeholder’s value is the table’s full name, its area’s prefix included.
With spring.ddd.messaging.jdbc.table-prefix: ACME_, ${consumers_table} takes ACME_CONSUMERS.
|
Two applications can share one schema only if every area has its own jdbc.table-prefix in both applications.
An area left unprefixed in both is shared between them and mixes their data; for the messaging area that includes the topics' high-water marks.
A name written in an annotation (@EventTable, @QueryProjection(table = …), @SagaTable) takes no prefix, so each application writes its own there.
|
| Placeholder | Property | Default |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
the projection’s name with |
none |
query-table-schema.sql also contains ${query_columns}, the column definitions of the projection’s @QueryField properties; write them yourself in the copy of the script.
What to take care of
-
Apply a position script before the table that draws from it:
event-store-global-position-schema.sqlbefore any event table,event-capture-global-position-schema.sqlbeforeevent-capture-records-schema.sql. -
PARTITION_METADATAis needed by the event store and by the capture topic. -
On PostgreSQL keep the global-position sequence at
CACHE 1, or the high-water mark refuses to advance. -
Write events only through the framework: a row whose position is drawn any other way can be passed by the high-water mark while its transaction is still open.
-
Apply
read-model-topic-schema.sqlandtopic-compaction-schema.sqlto every datasource holding a persistent projection with listeners. -
On PostgreSQL every datasource holding read-model topics also needs
high-water-mark-store-schema.sql, and its read-model positions sequence (READ_MODEL_CAPTURE_POSITIONSby default) must stay atCACHE 1, or the read-model topics' high-water mark refuses to advance. -
A query table’s read models are the states of their messages in the read-model records table (
READ_MODEL_CAPTURE_RECORDSby default), so keep a query table in the same database as that table, and give itsmessage_idthe type of itsid. -
Never delete from the read-model records table yourself: it holds the only copy of every query projection’s read models. A query table row whose message is gone reads as no read model until its next change, so rebuild the projection instead. Compaction and tombstone retention delete from it on their own schedules.
-
Tables of JPA entities and JPA read models are Hibernate’s; validate them with
spring.jpa.hibernate.ddl-auto: validate.
Flyway example
spring:
flyway:
locations: classpath:db/migration # your own migration directory
placeholders:
event_store_global_position_table: ACME_EVENT_STORE_GLOBAL_POSITION
ddd:
eventsourcing:
jdbc:
initialize-schema: never
table-prefix: ACME_
Copy the bundled scripts out of the JAR (under db/eventsourcing/<dialect>/) and place the results inside your Flyway migration directory as versioned migrations — Flyway does not pick them up from their bundled location, because those filenames do not follow its V naming convention.
Flyway applies all scripts in filename order, so name event-store-global-position-schema.sql with a lower version prefix than the event and snapshot scripts (for example V1global_position.sql, V2bank_account_events.sql, V3bank_account_snapshots.sql).
Flyway replaces ${...} placeholders in a migration from spring.flyway.placeholders.<name>.
A shared name therefore needs only its value, the full name as in the example above.
A placeholder takes one value for every script, so for a table named per aggregate or per saga copy the script once per table and replace its placeholder in the copy.
Liquibase example
Create a changelog that applies the scripts in the correct order, with one changeset pair per distinct aggregate table name.
Liquibase replaces ${...} in the content of a sql or sqlFile change from the changelog parameters, which Spring Boot takes from spring.liquibase.parameters.<name>, so the bundled position script can be applied from the JAR as it is:
# db/changelog/db.changelog-master.yaml
databaseChangeLog:
- changeSet:
id: 1-global-position
author: spring-ddd
changes:
- sqlFile:
path: db/eventsourcing/postgres/event-store-global-position-schema.sql
relativeToChangelogFile: false
- changeSet:
id: 2-bank-account-events
author: spring-ddd
changes:
- sqlFile:
path: db/migrations/bank_account_events.sql (1)
relativeToChangelogFile: false
- changeSet:
id: 3-bank-account-snapshots
author: spring-ddd
changes:
- sqlFile:
path: db/migrations/bank_account_snapshots.sql (2)
relativeToChangelogFile: false
| 1 | A copy of event-store-schema.sql with ${event_store_table} replaced by ACME_BANK_ACCOUNT_EVENTS. |
| 2 | A copy of snapshot-schema.sql with ${aggregate_snapshots_table} replaced by ACME_BANK_ACCOUNT_SNAPSHOTS. |
Then point Liquibase at the changelog and disable auto-creation:
spring:
liquibase:
change-log: classpath:db/changelog/db.changelog-master.yaml
parameters:
event_store_global_position_table: ACME_EVENT_STORE_GLOBAL_POSITION # fills the position script
ddd:
eventsourcing:
jdbc:
initialize-schema: never
table-prefix: ACME_
Event store
Module: spring-ddd-eventsourcing-jdbc.
Schema creation: spring.ddd.eventsourcing.jdbc.initialize-schema (default always).
The event store creates one event table per @EventSourced aggregate and, when snapshots are enabled, one snapshot table per aggregate.
Tables are created only when at least one @EventSourced aggregate is detected.
Event-store table
Stores the ordered event stream for a single aggregate type.
The table name is derived in SCREAMING_SNAKE_CASE from the aggregate class name, or overridden with @EventTable.
The representative name account_event is used in the DDL below.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) | CHAR(36) |
Primary key, a time-ordered UUIDv7. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB and SQL Server; CHAR(36) on MySQL/MariaDB. |
|
VARCHAR(255) |
Not null. Serialised aggregate identity. |
|
VARCHAR(255) |
Not null. Fully-qualified aggregate class name. |
|
BIGINT |
Not null. Per-aggregate monotonic sequence starting at 0. Unique composite constraint with |
|
VARCHAR(255) |
Not null. Fully-qualified event class name. |
|
INT |
Not null, default 0. Schema revision for upcasting. |
|
CLOB | JSON | LONGTEXT | NVARCHAR(MAX) |
Not null. JSON event payload. JSON on PostgreSQL; CLOB on H2/HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. Kept exactly as written on every engine. |
|
CLOB | JSON | LONGTEXT | NVARCHAR(MAX) |
Nullable. Optional event metadata. Same type as |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME(6) | DATETIME2 |
Not null, defaults to current timestamp. TIMESTAMPTZ on PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB. |
|
SMALLINT |
Not null. Stable FNV-1a bucket of the aggregate id in |
|
BIGINT |
Not null. Total-order position drawn from the global sequence or table. Indexed, and indexed again as the second column of |
Override: @EventTable(name = "…") on the aggregate class.
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS account_event (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload JSON NOT NULL,
metadata JSON,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
CREATE INDEX IF NOT EXISTS idx_account_event_global
ON account_event (global_position);
CREATE INDEX IF NOT EXISTS idx_account_event_partition
ON account_event (partition_key, global_position);
-- H2
CREATE TABLE IF NOT EXISTS account_event (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload CLOB NOT NULL,
metadata CLOB,
occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
CREATE INDEX IF NOT EXISTS idx_account_event_global
ON account_event (global_position);
CREATE INDEX IF NOT EXISTS idx_account_event_partition
ON account_event (partition_key, global_position);
-- HSQLDB
CREATE TABLE IF NOT EXISTS account_event (
id VARCHAR(36) PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT DEFAULT 0 NOT NULL,
payload CLOB NOT NULL,
metadata CLOB,
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
CREATE INDEX IF NOT EXISTS idx_account_event_global
ON account_event (global_position);
CREATE INDEX IF NOT EXISTS idx_account_event_partition
ON account_event (partition_key, global_position);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS account_event (
id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload LONGTEXT NOT NULL,
metadata LONGTEXT,
occurred_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL,
UNIQUE KEY uq_account_event_aggregate_sequence (aggregate_id, sequence_no),
UNIQUE KEY uq_account_event_global (global_position),
INDEX idx_account_event_aggregate (aggregate_id, aggregate_type, sequence_no),
INDEX idx_account_event_partition (partition_key, global_position)
);
-- SQL Server
IF OBJECT_ID(N'account_event', N'U') IS NULL
CREATE TABLE account_event (
id VARCHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload NVARCHAR(MAX) NOT NULL,
metadata NVARCHAR(MAX),
occurred_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
partition_key SMALLINT NOT NULL,
global_position BIGINT NOT NULL,
CONSTRAINT uq_account_event_sequence UNIQUE (aggregate_id, sequence_no)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_aggregate'
AND object_id = OBJECT_ID(N'account_event'))
CREATE INDEX idx_account_event_aggregate
ON account_event (aggregate_id, aggregate_type, sequence_no);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_global'
AND object_id = OBJECT_ID(N'account_event'))
CREATE INDEX idx_account_event_global
ON account_event (global_position);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_partition'
AND object_id = OBJECT_ID(N'account_event'))
CREATE INDEX idx_account_event_partition
ON account_event (partition_key, global_position);
Snapshot table
Stores periodic aggregate snapshots to avoid replaying the full event stream on load.
Created only when spring.ddd.eventsourcing.snapshot.enabled is true (default).
The table name defaults to the shared snapshot table (spring.ddd.eventsourcing.jdbc.table-names.aggregate-snapshots), or is overridden with @EventTable(snapshots = "…").
The representative name account_event_snapshots is used below.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) | CHAR(36) |
Primary key, a time-ordered UUIDv7. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB and SQL Server; CHAR(36) on MySQL/MariaDB. |
|
VARCHAR(255) |
Not null. Serialised aggregate identity. |
|
VARCHAR(255) |
Not null. Fully-qualified aggregate class name. |
|
BIGINT |
Not null. Event sequence number at the time the snapshot was taken. |
|
INT |
Not null, default 1. Snapshot schema revision. |
|
CLOB | JSON | LONGTEXT | NVARCHAR(MAX) |
Not null. Serialised aggregate state. JSON on PostgreSQL; CLOB on H2/HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME(6) | DATETIME2 |
Not null, defaults to current timestamp. TIMESTAMPTZ on PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB. |
Override: @EventTable(snapshots = "…") on the aggregate class.
Schema gate: spring.ddd.eventsourcing.snapshot.enabled (default true).
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload JSON NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
-- H2
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id UUID PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload CLOB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
-- HSQLDB
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id VARCHAR(36) PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT DEFAULT 1 NOT NULL,
payload CLOB NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
UNIQUE (aggregate_id, sequence_no)
);
CREATE INDEX IF NOT EXISTS idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS account_event_snapshots (
id CHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload LONGTEXT NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY uq_account_event_snapshots_sequence (aggregate_id, sequence_no),
INDEX idx_account_event_snapshots_lookup (aggregate_id, aggregate_type, snapshot_version, sequence_no)
);
-- SQL Server
IF OBJECT_ID(N'account_event_snapshots', N'U') IS NULL
CREATE TABLE account_event_snapshots (
id VARCHAR(36) NOT NULL PRIMARY KEY,
aggregate_id VARCHAR(255) NOT NULL,
aggregate_type VARCHAR(255) NOT NULL,
sequence_no BIGINT NOT NULL,
snapshot_version INT NOT NULL DEFAULT 1,
payload NVARCHAR(MAX) NOT NULL,
created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
CONSTRAINT uq_account_event_snapshots_sequence UNIQUE (aggregate_id, sequence_no)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_account_event_snapshots_lookup'
AND object_id = OBJECT_ID(N'account_event_snapshots'))
CREATE INDEX idx_account_event_snapshots_lookup
ON account_event_snapshots (aggregate_id, aggregate_type, snapshot_version, sequence_no DESC);
EVENT_STORE_GLOBAL_POSITION
Global position counter shared by all per-aggregate event tables.
PostgreSQL uses a native sequence, drawn by the event store’s insert only after the appending transaction has its transaction id; the global_position column has no default.
MySQL, MariaDB, H2, HSQLDB, and SQL Server use a single-row counter table instead, locked by each append until it commits and restored when the append rolls back, so their positions never have a gap (see Choosing a database).
| Object | Type | Notes |
|---|---|---|
|
SEQUENCE or TABLE |
PostgreSQL: |
DDL — all dialects
-- PostgreSQL
CREATE SEQUENCE IF NOT EXISTS EVENT_STORE_GLOBAL_POSITION START WITH 1 CACHE 1;
-- H2 / HSQLDB
CREATE TABLE IF NOT EXISTS EVENT_STORE_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
MERGE INTO EVENT_STORE_GLOBAL_POSITION t
USING (VALUES (1, 0)) AS s (id, val) ON t.id = s.id
WHEN NOT MATCHED THEN INSERT (id, val) VALUES (s.id, s.val);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS EVENT_STORE_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
INSERT INTO EVENT_STORE_GLOBAL_POSITION (id, val) VALUES (1, 0)
ON DUPLICATE KEY UPDATE id = id;
-- SQL Server
IF OBJECT_ID(N'EVENT_STORE_GLOBAL_POSITION', N'U') IS NULL
CREATE TABLE EVENT_STORE_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
INSERT INTO EVENT_STORE_GLOBAL_POSITION (id, val)
SELECT 1, 0
WHERE NOT EXISTS (SELECT 1 FROM EVENT_STORE_GLOBAL_POSITION WITH (UPDLOCK, HOLDLOCK) WHERE id = 1);
The PostgreSQL CACHE 1 clause is load-bearing, not tuning: the high-water mark reads the sequence’s last value as the highest position handed out, and a larger cache reports values a session has reserved but hands out later, whose events the mark would then pass.
The framework owns this sequence and enforces the clause at schema init (an idempotent ALTER SEQUENCE), so it also corrects a sequence left over with a wider cache.
When you manage the schema yourself, keep the clause: on a sequence with a larger cache the detector fails every cycle with an IllegalStateException naming the fix, logged at WARN on the first failure and at DEBUG afterwards, and the high-water mark stays where it is until the sequence is CACHE 1 again.
After setting the cache back to 1, restart every application that appends events: a session that reserved values under the wider cache still hands them out until its connection closes.
Capture topic
Module: spring-ddd-messaging-topic-jdbc.
Schema creation: spring.ddd.messaging.jdbc.initialize-schema (default always), while spring.ddd.messaging.capture.event.enabled (default true).
A topic of domain events, in position order, published outside the event store — independent of the event store’s per-aggregate tables, and excluding event-sourced event types, which already have the event store’s own topic.
The capture tap appends to it as events are published.
Unlike the event store, it has retention: every message at or below the topic start (the topic_start of its high-water-mark row) has been deleted.
A projection or saga that only needs the total order of everything published, rather than one aggregate’s own stream, reads this topic instead of every event table individually.
EVENT_CAPTURE_RECORDS
Stores one row per captured event, already partitioned and already encoded by the time it reaches this table.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) | CHAR(36) |
Primary key, a time-ordered UUIDv7. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB and SQL Server; CHAR(36) on MySQL/MariaDB. Never read back; it exists only so a captured row carries the same kind of identity as an event-store row. |
|
BIGINT |
Not null. This topic’s own total-order position, numbered independently of the event store’s |
|
SMALLINT |
Not null. The event’s partition, stamped before the append and never recomputed. Indexed together with |
|
VARCHAR(255) |
Not null. The stored event-type identifier the codec registered it under, e.g. |
|
INT |
Not null, default 0. Schema revision for upcasting. |
|
CLOB | JSON | LONGTEXT | NVARCHAR(MAX) |
Not null. The encoded event payload. |
|
CLOB | JSON | LONGTEXT | NVARCHAR(MAX) |
Nullable. The encoded metadata envelope, or absent when the event carried none. Same type as |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME(6) | DATETIME2 |
Not null. When the event occurred, carried over from the message rather than defaulted by the database. TIMESTAMPTZ on PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB. Stored as the UTC wall clock on every engine whose column carries no time zone (H2, HSQLDB, MySQL/MariaDB, SQL Server), so the age a retention policy measures does not depend on the zone of the JVM that wrote or deleted it. |
|
VARCHAR(255) | NVARCHAR(255) |
Nullable. The event’s message key, e.g. an account id, as the string it is partitioned by; null for a message without one. A materialization of one read model reads by it, through |
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS EVENT_CAPTURE_RECORDS (
id UUID PRIMARY KEY,
global_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload JSON NOT NULL,
metadata JSON,
occurred_at TIMESTAMPTZ NOT NULL,
message_key VARCHAR(255),
UNIQUE (global_position)
);
CREATE INDEX IF NOT EXISTS idx_event_capture_records_partition
ON EVENT_CAPTURE_RECORDS (partition_key, global_position);
CREATE INDEX IF NOT EXISTS idx_event_capture_records_key
ON EVENT_CAPTURE_RECORDS (message_key, global_position);
-- H2
CREATE TABLE IF NOT EXISTS EVENT_CAPTURE_RECORDS (
id UUID PRIMARY KEY,
global_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload CLOB NOT NULL,
metadata CLOB,
occurred_at TIMESTAMP NOT NULL,
message_key VARCHAR(255),
UNIQUE (global_position)
);
CREATE INDEX IF NOT EXISTS idx_event_capture_records_partition
ON EVENT_CAPTURE_RECORDS (partition_key, global_position);
CREATE INDEX IF NOT EXISTS idx_event_capture_records_key
ON EVENT_CAPTURE_RECORDS (message_key, global_position);
-- HSQLDB
CREATE TABLE IF NOT EXISTS EVENT_CAPTURE_RECORDS (
id VARCHAR(36) PRIMARY KEY,
global_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT DEFAULT 0 NOT NULL,
payload CLOB NOT NULL,
metadata CLOB,
occurred_at TIMESTAMP NOT NULL,
message_key VARCHAR(255),
UNIQUE (global_position)
);
CREATE INDEX IF NOT EXISTS idx_event_capture_records_partition
ON EVENT_CAPTURE_RECORDS (partition_key, global_position);
CREATE INDEX IF NOT EXISTS idx_event_capture_records_key
ON EVENT_CAPTURE_RECORDS (message_key, global_position);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS EVENT_CAPTURE_RECORDS (
id CHAR(36) NOT NULL PRIMARY KEY,
global_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload LONGTEXT NOT NULL,
metadata LONGTEXT,
occurred_at DATETIME(6) NOT NULL,
message_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin,
UNIQUE KEY uq_event_capture_records_global_position (global_position),
INDEX idx_event_capture_records_partition (partition_key, global_position),
INDEX idx_event_capture_records_key (message_key, global_position)
);
-- SQL Server
IF OBJECT_ID(N'EVENT_CAPTURE_RECORDS', N'U') IS NULL
CREATE TABLE EVENT_CAPTURE_RECORDS (
id VARCHAR(36) NOT NULL PRIMARY KEY,
global_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
event_type VARCHAR(255) NOT NULL,
event_revision INT NOT NULL DEFAULT 0,
payload NVARCHAR(MAX) NOT NULL,
metadata NVARCHAR(MAX),
occurred_at DATETIME2 NOT NULL,
message_key NVARCHAR(255) COLLATE Latin1_General_100_BIN2,
CONSTRAINT uq_event_capture_records_global_position UNIQUE (global_position)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_event_capture_records_partition'
AND object_id = OBJECT_ID(N'EVENT_CAPTURE_RECORDS'))
CREATE INDEX idx_event_capture_records_partition
ON EVENT_CAPTURE_RECORDS (partition_key, global_position);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_event_capture_records_key'
AND object_id = OBJECT_ID(N'EVENT_CAPTURE_RECORDS'))
CREATE INDEX idx_event_capture_records_key
ON EVENT_CAPTURE_RECORDS (message_key, global_position);
EVENT_CAPTURE_GLOBAL_POSITION
Global position counter for the capture topic, numbered independently of the event store’s own global-position counter (EVENT_STORE_GLOBAL_POSITION by default).
On PostgreSQL it is a native sequence, drawn only after the appending transaction has its transaction id, so concurrent appends are never serialized against each other; the sequence must stay CACHE 1, for the same reason as the event store’s, and the framework enforces the clause the same way.
On MySQL, MariaDB, H2, HSQLDB, and SQL Server it is a single-row counter table instead, locked by each append until it commits and restored when the append rolls back — appends against these engines serialize on this row.
| Object | Type | Notes |
|---|---|---|
|
SEQUENCE or TABLE |
PostgreSQL: |
DDL — all dialects
-- PostgreSQL
CREATE SEQUENCE IF NOT EXISTS EVENT_CAPTURE_GLOBAL_POSITION START WITH 1 CACHE 1;
-- H2 / HSQLDB
CREATE TABLE IF NOT EXISTS EVENT_CAPTURE_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
MERGE INTO EVENT_CAPTURE_GLOBAL_POSITION t
USING (VALUES (1, 0)) AS s (id, val) ON t.id = s.id
WHEN NOT MATCHED THEN INSERT (id, val) VALUES (s.id, s.val);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS EVENT_CAPTURE_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
INSERT INTO EVENT_CAPTURE_GLOBAL_POSITION (id, val) VALUES (1, 0)
ON DUPLICATE KEY UPDATE id = id;
-- SQL Server
IF OBJECT_ID(N'EVENT_CAPTURE_GLOBAL_POSITION', N'U') IS NULL
CREATE TABLE EVENT_CAPTURE_GLOBAL_POSITION (
id TINYINT NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
INSERT INTO EVENT_CAPTURE_GLOBAL_POSITION (id, val)
SELECT 1, 0
WHERE NOT EXISTS (SELECT 1 FROM EVENT_CAPTURE_GLOBAL_POSITION WITH (UPDLOCK, HOLDLOCK) WHERE id = 1);
Read-model topics
Module: spring-ddd-cqrs-jdbc, included by the JDBC and the JPA CQRS starters.
Schema creation: spring.ddd.messaging.jdbc.initialize-schema (default always), decided per datasource.
While spring.ddd.messaging.capture.read-model.enabled is true (the default), every persistent projection with listeners has a read-model topic in the datasource of its read models.
The @QueryProjection stores read each read model from the records table (READ_MODEL_CAPTURE_RECORDS by default), so the tables are created in their datasource too, even when no projection there has a topic.
Each apply appends the read model’s state after the apply to the projection’s topic, or a tombstone carrying a deleted read model’s last state, in the transaction that writes the read model, so the two commit together.
An append only inserts.
Compaction deletes a read model’s superseded messages shortly after a newer message of its key commits, outside the apply, every minute by default (spring.ddd.messaging.capture.read-model.compaction.schedule); until then a consumer reading behind may meet them, applies them in order and converges on the latest.
Compaction keeps its progress in the compaction table (TOPIC_COMPACTION by default), one row per topic in the topic’s datasource.
A tombstone stays until tombstone retention deletes it, which it does only when spring.ddd.messaging.capture.read-model.retention.tombstones.max-age is set, the tombstone is older than that, and both compaction and every consumer of the topic have passed it.
A consumer gone from the application holds it back until it is unsubscribed from the topic.
On PostgreSQL every topic in a datasource draws its positions from one sequence, so appends never wait for each other, and a topic’s positions have holes where other topics' appends or rolled-back ones drew.
The topics' high-water mark is one row of the high-water-mark table (HIGH_WATER_MARK_STORE by default) in the same datasource, under the name read-model-topics.
A message becomes readable once that mark passes it, which takes up to two polls of spring.ddd.messaging.high-water-mark.polling.interval after its commit, so each step from an upstream projection to a downstream one waits that long.
On the other databases each topic numbers its own positions, and its appends are serialized on its counter row until each commits.
READ_MODEL_CAPTURE_RECORDS
Stores one row per message, for every topic in the datasource.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) | CHAR(36) |
Primary key, a time-ordered UUIDv7. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB and SQL Server; CHAR(36) with the |
|
VARCHAR(255) | NVARCHAR(255) |
Not null. The projection’s name. NVARCHAR(255) with a binary collation on SQL Server. |
|
BIGINT |
Not null. The message’s position in its topic, drawn from the datasource’s positions sequence on PostgreSQL and from the topic’s row of the positions table elsewhere. Unique per topic. Compaction deletes superseded messages, and on PostgreSQL other topics draw from the same sequence, so a topic’s positions have holes. |
|
SMALLINT |
Not null. The partition the read-model key hashes into, stamped when the message is appended. Indexed with |
|
VARCHAR(255) | NVARCHAR(255) |
Not null. The read model’s key, as the projection’s store keys it. Indexed with |
|
VARCHAR(255) | NVARCHAR(255) |
Not null. The read model’s type. NVARCHAR(255) on SQL Server. |
|
INT |
Not null, default 0. Always 0 for now. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Not null. The read model’s state after the apply, serialized with the read-model JSON mapper; a tombstone’s last state. TEXT on PostgreSQL and H2, LONGVARCHAR on HSQLDB, LONGTEXT on MySQL and MariaDB, NVARCHAR(MAX) on SQL Server. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Null when the message that caused the apply carried no metadata. Otherwise that message’s metadata, which the downstream listeners receive with the read model, serialized as JSON. Same type as |
|
INT |
Not null. How many applies along a chain led to the message: |
|
BOOLEAN | BIT |
Not null. Whether the message is a tombstone. BIT on SQL Server. |
|
TIMESTAMP | TIMESTAMPTZ | DATETIME(6) | DATETIME2 |
Not null. When the message was appended. TIMESTAMPTZ on PostgreSQL; DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP on H2/HSQLDB. Stored as the UTC wall clock on every engine whose column carries no time zone. |
READ_MODEL_CAPTURE_POSITIONS
On PostgreSQL a sequence, START WITH 1 CACHE 1, shared by every topic in the datasource; CACHE 1 is load-bearing, as for the global-position sequences.
At startup a sequence behind the highest position stored or held by the topics' high-water mark, because it was recreated, is moved past it.
Elsewhere a table with one row per topic, seeded at startup for every persistent projection with listeners; a seed never overwrites an existing row.
There, an appending transaction increments its topic’s row and holds it locked until it commits, and a rollback restores it, so the committed value is the topic’s readable end.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) | NVARCHAR(255) |
Primary key. The projection’s name. NVARCHAR(255) with a binary collation on SQL Server. |
|
BIGINT |
Not null. The topic’s last position handed out. |
DDL — all dialects
-- PostgreSQL
CREATE SEQUENCE IF NOT EXISTS READ_MODEL_CAPTURE_POSITIONS START WITH 1 CACHE 1;
ALTER SEQUENCE IF EXISTS READ_MODEL_CAPTURE_POSITIONS CACHE 1;
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_RECORDS (
id UUID PRIMARY KEY,
topic VARCHAR(255) NOT NULL,
topic_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_key VARCHAR(255) NOT NULL,
read_model_type VARCHAR(255) NOT NULL,
read_model_revision INT NOT NULL DEFAULT 0,
payload TEXT NOT NULL,
metadata TEXT,
cascade_depth INT NOT NULL,
deleted BOOLEAN NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL,
CONSTRAINT uq_read_model_capture_records_position UNIQUE (topic, topic_position)
);
CREATE INDEX IF NOT EXISTS idx_read_model_capture_records_partition
ON READ_MODEL_CAPTURE_RECORDS (topic, partition_key, topic_position);
CREATE INDEX IF NOT EXISTS idx_read_model_capture_records_key
ON READ_MODEL_CAPTURE_RECORDS (topic, read_model_key, topic_position);
-- H2
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_POSITIONS (
topic VARCHAR(255) NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_RECORDS (
id UUID PRIMARY KEY,
topic VARCHAR(255) NOT NULL,
topic_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_key VARCHAR(255) NOT NULL,
read_model_type VARCHAR(255) NOT NULL,
read_model_revision INT NOT NULL DEFAULT 0,
payload TEXT NOT NULL,
metadata TEXT,
cascade_depth INT NOT NULL,
deleted BOOLEAN NOT NULL,
occurred_at TIMESTAMP NOT NULL,
CONSTRAINT uq_read_model_capture_records_position UNIQUE (topic, topic_position)
);
CREATE INDEX IF NOT EXISTS idx_read_model_capture_records_partition
ON READ_MODEL_CAPTURE_RECORDS (topic, partition_key, topic_position);
CREATE INDEX IF NOT EXISTS idx_read_model_capture_records_key
ON READ_MODEL_CAPTURE_RECORDS (topic, read_model_key, topic_position);
-- HSQLDB
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_POSITIONS (
topic VARCHAR(255) NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_RECORDS (
id VARCHAR(36) PRIMARY KEY,
topic VARCHAR(255) NOT NULL,
topic_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_key VARCHAR(255) NOT NULL,
read_model_type VARCHAR(255) NOT NULL,
read_model_revision INT DEFAULT 0 NOT NULL,
payload LONGVARCHAR NOT NULL,
metadata LONGVARCHAR,
cascade_depth INT NOT NULL,
deleted BOOLEAN NOT NULL,
occurred_at TIMESTAMP NOT NULL,
CONSTRAINT uq_read_model_capture_records_position UNIQUE (topic, topic_position)
);
CREATE INDEX IF NOT EXISTS idx_read_model_capture_records_partition
ON READ_MODEL_CAPTURE_RECORDS (topic, partition_key, topic_position);
CREATE INDEX IF NOT EXISTS idx_read_model_capture_records_key
ON READ_MODEL_CAPTURE_RECORDS (topic, read_model_key, topic_position);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_POSITIONS (
topic VARCHAR(255) NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
) DEFAULT CHARSET = utf8mb4;
CREATE TABLE IF NOT EXISTS READ_MODEL_CAPTURE_RECORDS (
id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
topic VARCHAR(255) NOT NULL,
topic_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
read_model_type VARCHAR(255) NOT NULL,
read_model_revision INT NOT NULL DEFAULT 0,
payload LONGTEXT NOT NULL,
metadata LONGTEXT,
cascade_depth INT NOT NULL,
deleted BOOLEAN NOT NULL,
occurred_at DATETIME(6) NOT NULL,
UNIQUE KEY uq_read_model_capture_records_position (topic, topic_position),
INDEX idx_read_model_capture_records_key (topic, read_model_key, topic_position),
INDEX idx_read_model_capture_records_partition (topic, partition_key, topic_position)
) DEFAULT CHARSET = utf8mb4;
-- SQL Server
IF OBJECT_ID(N'READ_MODEL_CAPTURE_POSITIONS', N'U') IS NULL
CREATE TABLE READ_MODEL_CAPTURE_POSITIONS (
topic NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL PRIMARY KEY,
val BIGINT NOT NULL
);
IF OBJECT_ID(N'READ_MODEL_CAPTURE_RECORDS', N'U') IS NULL
CREATE TABLE READ_MODEL_CAPTURE_RECORDS (
id VARCHAR(36) NOT NULL PRIMARY KEY,
topic NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
topic_position BIGINT NOT NULL,
partition_key SMALLINT NOT NULL,
read_model_key NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
read_model_type NVARCHAR(255) NOT NULL,
read_model_revision INT NOT NULL DEFAULT 0,
payload NVARCHAR(MAX) NOT NULL,
metadata NVARCHAR(MAX),
cascade_depth INT NOT NULL,
deleted BIT NOT NULL,
occurred_at DATETIME2 NOT NULL,
CONSTRAINT uq_read_model_capture_records_position UNIQUE (topic, topic_position)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_read_model_capture_records_partition'
AND object_id = OBJECT_ID(N'READ_MODEL_CAPTURE_RECORDS'))
CREATE INDEX idx_read_model_capture_records_partition
ON READ_MODEL_CAPTURE_RECORDS (topic, partition_key, topic_position);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_read_model_capture_records_key'
AND object_id = OBJECT_ID(N'READ_MODEL_CAPTURE_RECORDS'))
CREATE INDEX idx_read_model_capture_records_key
ON READ_MODEL_CAPTURE_RECORDS (topic, read_model_key, topic_position);
TOPIC_COMPACTION
How far compaction has deleted each topic’s superseded messages: one row per topic, in the datasource the topic lives in.
The read-model topics create it with their own tables, under the same schema-creation setting; its script ships in spring-ddd-messaging-topic-jdbc.
A topic has no row until its first compaction run records progress, and tombstone retention deletes none of its tombstones until then.
It is the topic’s own progress, not a consumer’s checkpoint: unsubscribing a consumer never touches it.
Test database cleanup empties it with the read-model topics' messages.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) | NVARCHAR(255) |
Primary key. The topic’s name, |
|
BIGINT |
Not null. The position through which every superseded message of the topic is deleted. Only ever rises. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS TOPIC_COMPACTION (
topic_name VARCHAR(255) NOT NULL PRIMARY KEY,
compacted_through BIGINT NOT NULL
)
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS TOPIC_COMPACTION (
topic_name VARCHAR(255) NOT NULL PRIMARY KEY,
compacted_through BIGINT NOT NULL
) DEFAULT CHARSET = utf8mb4
-- SQL Server
IF OBJECT_ID(N'TOPIC_COMPACTION', N'U') IS NULL
CREATE TABLE TOPIC_COMPACTION (
topic_name NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL PRIMARY KEY,
compacted_through BIGINT NOT NULL
)
Consumers
Module: spring-ddd-messaging-consumer-jdbc.
Schema creation: spring.ddd.messaging.jdbc.initialize-schema (default always).
CONSUMERS
One row per consumer, such as a projection: whether it is running, paused by an operator, halted by the framework or being rebuilt, how often it was rebuilt, and, for a projection, the declaration its read models were derived under.
Every instance running the consumer reads the same row, and every change of its state is one write of that row.
The row is created when the consumer first subscribes to a topic, or when a projection first records its declaration, and removed when the consumer is unsubscribed from the last topic it has checkpoints on; a consumer without a row is running and was never rebuilt.
CONSUMER_CHECKPOINTS, CONSUMER_UNDELIVERED_MESSAGES and CONSUMER_SKIPPED_MESSAGES reference the row by its id, under foreign keys: the database refuses to delete a consumer’s row while any of their rows reference it.
A projection whose declaration alone is cleared takes the declaration it runs with at its next start, silently.
On databases whose collation ignores case, accents or trailing spaces, consumer and type names must differ in more than that, as for CONSUMER_CHECKPOINTS.
| Column | Type | Notes |
|---|---|---|
|
CHAR(36) | VARCHAR(36) |
Primary key, a UUID the framework generates. CHAR(36) with the |
|
VARCHAR(32) |
Not null. The kind of consumer, e.g. |
|
VARCHAR(255) |
Not null. The consumer’s name, e.g. |
|
VARCHAR(16) |
Nullable. |
|
VARCHAR(32) |
Nullable. Why the consumer is halted: |
|
BIGINT |
Nullable. How often the consumer was rebuilt; |
|
VARCHAR(255) |
Nullable. For a projection whose read models are not in memory, what derives them, compared at every start: four
hashes, then, after a |
|
TIMESTAMP |
Not null. When the consumer entered its current |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS CONSUMERS (
id CHAR(36) NOT NULL PRIMARY KEY,
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
state VARCHAR(16) NULL,
halt_reason VARCHAR(32) NULL,
epoch BIGINT NULL,
declaration_fingerprint VARCHAR(255) NULL,
state_since TIMESTAMP NOT NULL,
CONSTRAINT uq_consumers UNIQUE (consumer_type, consumer_name)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS CONSUMERS (
id CHAR(36) CHARACTER SET ascii NOT NULL PRIMARY KEY,
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
state VARCHAR(16) NULL,
halt_reason VARCHAR(32) NULL,
epoch BIGINT NULL,
declaration_fingerprint VARCHAR(255) NULL,
state_since DATETIME(6) NOT NULL,
UNIQUE KEY uq_consumers (consumer_type, consumer_name)
);
-- SQL Server
IF OBJECT_ID(N'CONSUMERS', N'U') IS NULL
CREATE TABLE CONSUMERS (
id VARCHAR(36) NOT NULL PRIMARY KEY,
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
state VARCHAR(16) NULL,
halt_reason VARCHAR(32) NULL,
epoch BIGINT NULL,
declaration_fingerprint VARCHAR(255) NULL,
state_since DATETIME2 NOT NULL,
CONSTRAINT uq_consumers UNIQUE (consumer_type, consumer_name)
);
CONSUMER_CHECKPOINTS
How far each consumer, such as a projection, has read each partition of the topics it consumes.
A consumer resumes after its checkpoint, so every position after it is delivered again after a restart.
A checkpoint only moves forward, and only over positions whose applies have all completed, even when later positions have already been applied.
Over positions that hold nothing for the consumer to apply, a checkpoint is saved at most once a minute, so it may trail what the consumer has read by up to a minute’s worth of positions; after a restart those are read again, and nothing is applied twice.
Deleting from a topic stops at the slowest checkpoint of the consumers subscribed to it, and a checkpoint that has recorded no position yet holds the whole topic.
Every such checkpoint holds that line until its consumer is unsubscribed from the topic, which removes its rows; none expires on its own.
A consumer that went past history its topic no longer held carries an incomplete-history mark on every row of its read of that topic.
The mark survives restarts, only ever rises, and goes only with the rows, when the consumer is unsubscribed from the topic.
Whether a consumer is running, paused by an operator or halted by the framework is kept on its row in CONSUMERS.
The messages a consumer could not apply yet are kept in CONSUMER_UNDELIVERED_MESSAGES, beside its checkpoints.
Unsubscribing the consumer from the last topic it has checkpoints on removes its row in CONSUMERS too, which leaves it running, and its rows in PARTITION_OWNERSHIP too unless the application still runs it on another topic.
Where the consumer’s writes share this table’s DataSource, a checkpoint can advance in the same transaction as the apply it records.
On databases whose collation ignores case, accents or trailing spaces — MySQL, MariaDB and SQL Server by default, and HSQLDB for trailing spaces — consumer, source and type names must differ in more than that: subscribing a consumer whose names the database does not distinguish from an existing one’s, such as account-summary and Account-Summary (case) or account-summary and account-summary ` (a trailing space), fails.
Subscribing a consumer whose names contain characters the column type cannot store fails as well.
On SQL Server with a UTF-8 or DBCS collation, a `VARCHAR(255) column holds fewer than 255 characters when names use characters stored in more than one byte, so shorter names apply there.
| Column | Type | Notes |
|---|---|---|
|
CHAR(36) | VARCHAR(36) |
Primary key, part 1. The consumer’s |
|
VARCHAR(255) |
Primary key, part 2. The name of the topic read, e.g. the event store. |
|
SMALLINT |
Primary key, part 3. The partition a record is applied in: for a projection, the partition its read-model key hashes into, whatever partition the topic stored the record in. |
|
BIGINT |
Nullable. The position up to which every record of this partition has been applied; |
|
BIGINT |
Nullable. The incomplete-history mark: the highest position after which this consumer found the topic’s history resuming when it went past history the topic no longer held. Set on every partition of the consumer’s read of that topic. |
|
BIGINT |
Nullable. The consumer’s epoch this checkpoint was last reset or advanced under; |
|
BIGINT |
Nullable. While a rebuild replays, how far this source is replayed; empty otherwise. The consumer reads no further until every checkpoint reached it and the read models the replay did not re-create are deleted. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS CONSUMER_CHECKPOINTS (
consumer_id CHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
position BIGINT NULL,
incomplete_through BIGINT NULL,
replay_through BIGINT NULL,
epoch BIGINT NULL,
PRIMARY KEY (consumer_id, source_name, partition_no),
CONSTRAINT fk_consumer_checkpoints_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS CONSUMER_CHECKPOINTS (
consumer_id CHAR(36) CHARACTER SET ascii NOT NULL,
source_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
position BIGINT NULL,
incomplete_through BIGINT NULL,
replay_through BIGINT NULL,
epoch BIGINT NULL,
PRIMARY KEY (consumer_id, source_name, partition_no),
CONSTRAINT fk_consumer_checkpoints_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
-- SQL Server
IF OBJECT_ID(N'CONSUMER_CHECKPOINTS', N'U') IS NULL
CREATE TABLE CONSUMER_CHECKPOINTS (
consumer_id VARCHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
position BIGINT NULL,
incomplete_through BIGINT NULL,
replay_through BIGINT NULL,
epoch BIGINT NULL,
PRIMARY KEY (consumer_id, source_name, partition_no),
CONSTRAINT fk_consumer_checkpoints_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
CONSUMER_UNDELIVERED_MESSAGES
The messages a consumer could not apply yet, one row per message and key: for a projection, the read model the message failed for; for a route consumer, the route key.
Each row keeps a copy of its message, which is what a retry applies, so retention and compaction delete the message from its topic without waiting for it.
The copy costs storage per row: a message held for several keys is copied once per key, and so is every message of a key held behind a dead letter, every later message of a DEAD_LETTER_KEY key included.
A consumer without undelivered messages has no rows here, and a message below the consumer’s checkpoint without a row is done.
A failing message is recorded as FAILED and retried on later polls under its consumer’s error handler’s retry policy, spring.ddd.messaging.consumer.delivery-retry by default;
the count and the next retry are kept here, so the budget survives restarts and moves to another instance.
Later messages of the same key are recorded as WAITING behind it and applied in position order once it applies; the consumer’s other keys keep flowing.
A message whose budget is spent is a dead letter, its status naming the outcome its error handler decided: HALTED halted the consumer, and resuming the consumer retries it with a fresh budget while resuming it past the poison message deletes it; DEAD_LETTER_KEY and DEAD_LETTER wait for an operator.
Once a key’s message dies as DEAD_LETTER_KEY, the key’s later messages are recorded as DEAD_LETTER_KEY too, behind it, with a failure_count of 0, and are applied in order once an operator retries or deletes it.
A rebuild deletes the consumer’s rows, and unsubscribing a consumer from a topic deletes that topic’s rows.
| Column | Type | Notes |
|---|---|---|
|
CHAR(36) | VARCHAR(36) |
Primary key, a UUID. CHAR(36) with the |
|
CHAR(36) | VARCHAR(36) |
Not null. The consumer’s |
|
VARCHAR(255) |
Not null. The topic the message lies in. |
|
VARCHAR(255) | NVARCHAR(255) |
Not null. The key the message waits for, e.g. an account id; empty for a message without a key, for one whose key could not be read, and for a route key longer than 255 characters. Compares exactly: |
|
BIGINT |
Not null. A 64-bit hash of |
|
BIGINT |
Not null. The message’s position in its topic. Unique per consumer, source, partition and key ( |
|
SMALLINT |
Not null. The partition the key is applied in, as in |
|
VARCHAR(16) |
Not null. |
|
INT |
Not null. How often the message failed; 0 while it waits, after a resume, and for a later message of a |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Nullable. When a |
|
VARCHAR(1000) | NVARCHAR(1000) |
Nullable. The last failure’s class and message, cut to 1,000 characters; |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Not null. When the message was recorded as undelivered, in UTC. |
|
BOOLEAN | BIT |
Not null. Whether the message’s keys could not be told when it was recorded: it is recorded in every partition it was read in, and nothing after it there is applied before it while it is retried, nor once it is dead, unless its status is |
|
CLOB | TEXT | LONGTEXT | NVARCHAR(MAX) |
Nullable. The message as its topic stored it, which a retry applies: an event as stored, so an upcaster deployed since applies to it, and a read model’s message as published. TEXT on PostgreSQL, CLOB on H2 and HSQLDB, LONGTEXT ( |
DDL — all dialects
-- PostgreSQL (TEXT) / H2, HSQLDB (CLOB)
CREATE TABLE IF NOT EXISTS CONSUMER_UNDELIVERED_MESSAGES (
id CHAR(36) NOT NULL PRIMARY KEY,
consumer_id CHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
resolved_key VARCHAR(255) NOT NULL,
key_hash BIGINT NOT NULL,
message_position BIGINT NOT NULL,
partition_no SMALLINT NOT NULL,
status VARCHAR(16) NOT NULL,
failure_count INT NOT NULL,
next_retry_at TIMESTAMP NULL,
failure_reason VARCHAR(1000) NULL,
recorded_at TIMESTAMP NOT NULL,
unresolved BOOLEAN NOT NULL,
message_copy TEXT NULL,
CONSTRAINT uq_consumer_undelivered_messages
UNIQUE (consumer_id, source_name, message_position, partition_no, key_hash),
CONSTRAINT fk_consumer_undelivered_messages_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS CONSUMER_UNDELIVERED_MESSAGES (
id CHAR(36) CHARACTER SET ascii NOT NULL PRIMARY KEY,
consumer_id CHAR(36) CHARACTER SET ascii NOT NULL,
source_name VARCHAR(255) NOT NULL,
resolved_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
key_hash BIGINT NOT NULL,
message_position BIGINT NOT NULL,
partition_no SMALLINT NOT NULL,
status VARCHAR(16) NOT NULL,
failure_count INT NOT NULL,
next_retry_at DATETIME(6) NULL,
failure_reason VARCHAR(1000) NULL,
recorded_at DATETIME(6) NOT NULL,
unresolved BOOLEAN NOT NULL,
message_copy LONGTEXT CHARACTER SET utf8mb4 NULL,
UNIQUE KEY uq_consumer_undelivered_messages
(consumer_id, source_name, message_position, partition_no, key_hash),
CONSTRAINT fk_consumer_undelivered_messages_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
-- SQL Server
IF OBJECT_ID(N'CONSUMER_UNDELIVERED_MESSAGES', N'U') IS NULL
CREATE TABLE CONSUMER_UNDELIVERED_MESSAGES (
id VARCHAR(36) NOT NULL PRIMARY KEY,
consumer_id VARCHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
resolved_key NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
key_hash BIGINT NOT NULL,
message_position BIGINT NOT NULL,
partition_no SMALLINT NOT NULL,
status VARCHAR(16) NOT NULL,
failure_count INT NOT NULL,
next_retry_at DATETIME2 NULL,
failure_reason NVARCHAR(1000) NULL,
recorded_at DATETIME2 NOT NULL,
unresolved BIT NOT NULL,
message_copy NVARCHAR(MAX) NULL,
CONSTRAINT uq_consumer_undelivered_messages
UNIQUE (consumer_id, source_name, message_position, partition_no, key_hash),
CONSTRAINT fk_consumer_undelivered_messages_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
CONSUMER_SKIPPED_MESSAGES
The messages a consumer skipped, so that every instance passes over them: one an operator deleted, one the consumer was resumed past as a poison message, and one its error handler decided to skip.
A skip is kept for the key it was made for, or for every key of a message whose keys could not be told.
A partition that meets a skipped message later, on any instance, passes over it without recording it, spending a budget on it or halting.
A consumer that skipped nothing has no rows here; every poll reads the consumer’s rows with one probe of idx_consumer_skipped_messages_position.
A row is deleted once every partition that can still meet the message has its checkpoint at or past it and no undelivered message lies at its position; a partition still held back by a message whose keys could not be told, or one whose instance is down, keeps it.
A rebuild deletes the consumer’s rows, and unsubscribing a consumer from a topic deletes that topic’s rows.
| Column | Type | Notes |
|---|---|---|
|
CHAR(36) | VARCHAR(36) |
Primary key, a UUID derived from the consumer, the topic, the partition, the position and the key, so a skip recorded twice is one row. CHAR(36) with the |
|
CHAR(36) | VARCHAR(36) |
Not null. The consumer’s |
|
VARCHAR(255) |
Not null. The topic the message lies in. |
|
SMALLINT |
Not null. The partition the message was skipped in. On a topic with one position space, such as the event store, a skip holds in every partition. |
|
BIGINT |
Not null. The message’s position in its topic. Indexed with |
|
VARCHAR(255) | NVARCHAR(255) |
Not null. The key the message was skipped for, as in |
|
BOOLEAN | BIT |
Not null. Whether the message was skipped for every key: its keys could not be told, so an instance that can tell them later applies none of them. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS CONSUMER_SKIPPED_MESSAGES (
id CHAR(36) NOT NULL PRIMARY KEY,
consumer_id CHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
message_position BIGINT NOT NULL,
resolved_key VARCHAR(255) NOT NULL,
every_key BOOLEAN NOT NULL,
CONSTRAINT fk_consumer_skipped_messages_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
CREATE INDEX IF NOT EXISTS idx_consumer_skipped_messages_position
ON CONSUMER_SKIPPED_MESSAGES (consumer_id, source_name, message_position);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS CONSUMER_SKIPPED_MESSAGES (
id CHAR(36) CHARACTER SET ascii NOT NULL PRIMARY KEY,
consumer_id CHAR(36) CHARACTER SET ascii NOT NULL,
source_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
message_position BIGINT NOT NULL,
resolved_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
every_key BOOLEAN NOT NULL,
INDEX idx_consumer_skipped_messages_position (consumer_id, source_name, message_position),
CONSTRAINT fk_consumer_skipped_messages_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
-- SQL Server
IF OBJECT_ID(N'CONSUMER_SKIPPED_MESSAGES', N'U') IS NULL
CREATE TABLE CONSUMER_SKIPPED_MESSAGES (
id VARCHAR(36) NOT NULL PRIMARY KEY,
consumer_id VARCHAR(36) NOT NULL,
source_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
message_position BIGINT NOT NULL,
resolved_key NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
every_key BIT NOT NULL,
CONSTRAINT fk_consumer_skipped_messages_consumer FOREIGN KEY (consumer_id) REFERENCES CONSUMERS (id)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_consumer_skipped_messages_position'
AND object_id = OBJECT_ID(N'CONSUMER_SKIPPED_MESSAGES'))
CREATE INDEX idx_consumer_skipped_messages_position
ON CONSUMER_SKIPPED_MESSAGES (consumer_id, source_name, message_position);
Rebuild epochs
Module: spring-ddd-cqrs-jdbc.
Schema creation: spring.ddd.cqrs.jdbc.initialize-schema (default always).
READ_MODEL_EPOCHS
Created in every datasource holding a persistent projection’s read models, whether or not spring.ddd.messaging.capture.read-model.enabled is on.
A row is written with each read model a projection creates, in the same transaction, and removed with it: it names the rebuild whose replay created the read model, or 0 for one created outside a rebuild.
A rebuild counts a read model as re-created once its row carries the rebuild’s count; once its replay is complete it deletes the read models whose rows carry a lower one, unless the replay met missing history.
A read model written past the projection, so without a row, is never deleted by a rebuild.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) | NVARCHAR(255) |
Primary key, with |
|
VARCHAR(255) | NVARCHAR(255) |
Primary key, with |
|
BIGINT |
Not null. The count of the rebuild whose replay created the read model, |
DDL — all dialects
-- PostgreSQL, H2, HSQLDB
CREATE TABLE IF NOT EXISTS READ_MODEL_EPOCHS (
projection VARCHAR(255) NOT NULL,
read_model_key VARCHAR(255) NOT NULL,
epoch BIGINT NOT NULL,
CONSTRAINT pk_read_model_epochs PRIMARY KEY (projection, read_model_key)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS READ_MODEL_EPOCHS (
projection VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
read_model_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
epoch BIGINT NOT NULL,
PRIMARY KEY (projection, read_model_key)
) DEFAULT CHARSET = utf8mb4;
-- SQL Server
IF OBJECT_ID(N'READ_MODEL_EPOCHS', N'U') IS NULL
CREATE TABLE READ_MODEL_EPOCHS (
projection NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
read_model_key NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
epoch BIGINT NOT NULL,
CONSTRAINT pk_read_model_epochs PRIMARY KEY NONCLUSTERED (projection, read_model_key)
);
Read models
Module: spring-ddd-cqrs-jdbc-query.
Schema creation: spring.ddd.cqrs.jdbc.initialize-schema (default always).
Query table
Each @QueryProjection owns one table, which no other projection shares.
It is named after the projection unless table names it: every character of the name other than an ASCII letter, a digit or becomes , so account-summary is stored in account_summary.
Table and index names are at most 63 characters long.
A row holds one read model: its key, the id of its latest message in the projection’s read-model topic, and a copy of each @QueryField value in a column of its own, indexed as idx_<table>_<column>.
The read model itself is the state that message carries in the read-model records table; every read looks it up there by the message’s id.
Keys compare exactly, so keys that differ only in case are two read models; keys that differ only in trailing spaces are one on MySQL, MariaDB, SQL Server and HSQLDB.
An existing table is never altered, but it is checked at startup against the projection’s declaration, whether the framework or the application created it.
The startup fails, naming the table, the column, both shapes and the remedy, when:
-
the table is missing and
spring.ddd.cqrs.jdbc.initialize-schemadid not create it; -
it lacks
read_model_idormessage_id, so it is not a query table: set a distincttable; -
its
message_idcannot hold a message id as the records table’siddoes — a UUID on PostgreSQL and H2, a character type of at least 36 characters elsewhere, not NVARCHAR on SQL Server; -
a
@QueryFieldcolumn is missing, or cannot hold every value of its kind; -
a
@QueryField(unique = true)column has no unique index of its own, or a column not declared unique has one.
For the last three, drop the table and delete the projection’s rows from CONSUMER_SKIPPED_MESSAGES, CONSUMER_UNDELIVERED_MESSAGES, CONSUMER_CHECKPOINTS and CONSUMERS, in that order: the table is created again in the declared shape — by spring.ddd.cqrs.jdbc.initialize-schema, or by your own DDL where that creates nothing — and the projection fills it from the start of its topics.
ProjectionRebuild cannot do this: the application does not start, and a rebuild keeps the table.
Read models the refill does not re-create stay on the read-model topic, so a projection that reads it keeps them.
A column passes when it holds every value the store writes, so an application’s own DDL may choose a wider type:
| Kind | Passes |
|---|---|
|
any character type of at least 255 characters, e.g. VARCHAR(500) or TEXT |
|
BIGINT |
|
DECIMAL or NUMERIC with at least as many integer digits and as much scale as declared, or PostgreSQL’s unconstrained NUMERIC; e.g. DECIMAL(24,4) for DECIMAL(19,2) |
|
DATE for a date; TIME, or TIMESTAMP (DATETIME on MySQL and MariaDB, DATETIME2 on SQL Server), with at least 6 fractional digits for a time or an instant |
|
BOOLEAN, BIT, or TINYINT on MySQL and MariaDB |
Only the connection’s current schema is looked in. A unique index with INCLUDE columns counts as a multi-column index, so its column counts as having no unique index of its own.
Columns the projection does not declare are ignored, and a plain index is not required.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. The read model’s key in its canonical string form, e.g. |
|
UUID | CHAR(36) | VARCHAR(36) |
Not null. The id of the read model’s latest message in the read-model records table, whose state is the read model as JSON; a property added with a default, or removed, needs no migration. Every save points it at the new message, so a save or delete of a read model that changed since it was read conflicts and is retried. Same type as the read-model records table’s |
one per |
by kind |
Nullable. The property’s value, copied on every save. |
DDL — all dialects, for a projection account-summary with @QueryField(unique = true) val iban: String and @QueryField(type = QueryFieldType.NUMBER) val balance: BigDecimal
-- PostgreSQL / H2 (HSQLDB: message_id VARCHAR(36))
CREATE TABLE IF NOT EXISTS account_summary (
read_model_id VARCHAR(255) NOT NULL,
message_id UUID NOT NULL,
iban VARCHAR(255),
balance DECIMAL(38,10),
PRIMARY KEY (read_model_id)
);
CREATE UNIQUE INDEX idx_account_summary_iban ON account_summary (iban);
CREATE INDEX idx_account_summary_balance ON account_summary (balance);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS account_summary (
read_model_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
message_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
iban VARCHAR(255),
balance DECIMAL(38,10),
PRIMARY KEY (read_model_id)
) DEFAULT CHARSET = utf8mb4;
CREATE UNIQUE INDEX idx_account_summary_iban ON account_summary (iban);
CREATE INDEX idx_account_summary_balance ON account_summary (balance);
-- SQL Server
IF OBJECT_ID(N'account_summary', N'U') IS NULL
CREATE TABLE account_summary (
read_model_id NVARCHAR(255) COLLATE Latin1_General_100_BIN2 NOT NULL,
message_id VARCHAR(36) NOT NULL,
iban NVARCHAR(255),
balance DECIMAL(38,10),
PRIMARY KEY (read_model_id)
);
CREATE UNIQUE INDEX idx_account_summary_iban ON account_summary (iban) WHERE iban IS NOT NULL;
CREATE INDEX idx_account_summary_balance ON account_summary (balance);
Sagas
Module: spring-ddd-saga-jdbc.
Schema creation: spring.ddd.saga.jdbc.initialize-schema (default always).
SAGA_INSTANCES
Stores the serialised state of each saga instance.
The table name defaults to SAGA_INSTANCES (spring.ddd.saga.jdbc.table-names.saga-instances), or is set per saga with @SagaTable on the saga class.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) | CHAR(36) |
Primary key, a time-ordered UUIDv7. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB and SQL Server; CHAR(36) on MySQL/MariaDB. |
|
VARCHAR(255) |
Not null. Fully-qualified saga class name. Unique together with |
|
VARCHAR(255) |
Not null. Saga identity value. Unique together with |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Not null. Serialised saga state. TEXT on H2 and PostgreSQL; LONGVARCHAR on HSQLDB; LONGTEXT on MySQL/MariaDB; NVARCHAR(MAX) on SQL Server. |
|
BIGINT |
Not null. Optimistic-lock version. A new instance starts at a random version, so an instance started again under the id of a deleted one never repeats a version the earlier one held. |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Nullable. The completion timestamp; |
Override: spring.ddd.saga.jdbc.table-names.saga-instances, or @SagaTable on the saga class.
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS SAGA_INSTANCES (
id UUID NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json TEXT NOT NULL,
version BIGINT NOT NULL,
completed_at TIMESTAMP,
UNIQUE (saga_type, saga_id)
)
-- HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_INSTANCES (
id VARCHAR(36) NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json LONGVARCHAR NOT NULL,
version BIGINT NOT NULL,
completed_at TIMESTAMP,
UNIQUE (saga_type, saga_id)
)
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_INSTANCES (
id CHAR(36) NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json LONGTEXT NOT NULL,
version BIGINT NOT NULL,
completed_at DATETIME(6),
UNIQUE (saga_type, saga_id)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_INSTANCES')
CREATE TABLE SAGA_INSTANCES (
id VARCHAR(36) NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
state_json NVARCHAR(MAX) NOT NULL,
version BIGINT NOT NULL,
completed_at DATETIME2,
UNIQUE (saga_type, saga_id)
)
SAGA_DEADLINES
Stores pending saga deadlines (scheduled timeouts).
The table name defaults to SAGA_DEADLINES; its index is idx_<table>_due_at.
| Column | Type | Notes |
|---|---|---|
|
UUID | VARCHAR(36) | CHAR(36) |
Primary key, a time-ordered UUIDv7. UUID on H2 and PostgreSQL; VARCHAR(36) on HSQLDB and SQL Server; CHAR(36) on MySQL/MariaDB. |
|
VARCHAR(255) |
Not null. |
|
VARCHAR(255) |
Not null. |
|
VARCHAR(255) |
Not null. Deadline name within the saga. Unique together with |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Not null. When the deadline fires. Indexed for efficient polling. DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Nullable. Optional payload attached to the deadline. |
|
TEXT | LONGVARCHAR | LONGTEXT | NVARCHAR(MAX) |
Nullable. Saga metadata snapshot at deadline scheduling time. |
Override: spring.ddd.saga.jdbc.table-names.saga-deadlines.
DDL — all dialects
-- PostgreSQL / H2
CREATE TABLE IF NOT EXISTS SAGA_DEADLINES (
id UUID NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at TIMESTAMP NOT NULL,
payload_json TEXT,
metadata_json TEXT,
UNIQUE (saga_type, saga_id, name)
);
CREATE INDEX IF NOT EXISTS idx_SAGA_DEADLINES_due_at
ON SAGA_DEADLINES (due_at);
-- HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_DEADLINES (
id VARCHAR(36) NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at TIMESTAMP NOT NULL,
payload_json LONGVARCHAR,
metadata_json LONGVARCHAR,
UNIQUE (saga_type, saga_id, name)
);
CREATE INDEX IF NOT EXISTS idx_SAGA_DEADLINES_due_at
ON SAGA_DEADLINES (due_at);
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_DEADLINES (
id CHAR(36) NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at DATETIME(6) NOT NULL,
payload_json LONGTEXT,
metadata_json LONGTEXT,
UNIQUE (saga_type, saga_id, name),
INDEX idx_SAGA_DEADLINES_due_at (due_at)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_DEADLINES')
CREATE TABLE SAGA_DEADLINES (
id VARCHAR(36) NOT NULL PRIMARY KEY,
saga_type VARCHAR(255) NOT NULL,
saga_id VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
due_at DATETIME2 NOT NULL,
payload_json NVARCHAR(MAX),
metadata_json NVARCHAR(MAX),
UNIQUE (saga_type, saga_id, name)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_SAGA_DEADLINES_due_at'
AND object_id = OBJECT_ID(N'SAGA_DEADLINES'))
CREATE INDEX idx_SAGA_DEADLINES_due_at
ON SAGA_DEADLINES (due_at);
SAGA_DEADLINE_LOCKS
Distributed lock table that ensures only one node polls for expired deadlines at a time, under the lock name saga-deadline-delivery.
It is created with the deadline table unless the application supplies its own deadline lease.
The table name defaults to SAGA_DEADLINE_LOCKS.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. Name of the lock. |
|
VARCHAR(255) |
Not null. Node identifier holding the lock. |
|
TIMESTAMP | DATETIME(6) | DATETIME2 |
Not null. Lock expiry. DATETIME(6) on MySQL/MariaDB; DATETIME2 on SQL Server; TIMESTAMP elsewhere. |
Override: spring.ddd.saga.jdbc.table-names.saga-deadline-locks.
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE_LOCKS (
lock_name VARCHAR(255) NOT NULL,
owner VARCHAR(255) NOT NULL,
expires_at TIMESTAMP NOT NULL,
PRIMARY KEY (lock_name)
)
-- MySQL
CREATE TABLE IF NOT EXISTS SAGA_DEADLINE_LOCKS (
lock_name VARCHAR(255) NOT NULL,
owner VARCHAR(255) NOT NULL,
expires_at DATETIME(6) NOT NULL,
PRIMARY KEY (lock_name)
)
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'SAGA_DEADLINE_LOCKS')
CREATE TABLE SAGA_DEADLINE_LOCKS (
lock_name VARCHAR(255) NOT NULL,
owner VARCHAR(255) NOT NULL,
expires_at DATETIME2 NOT NULL,
PRIMARY KEY (lock_name)
)
High-water mark and partitioning
Module: spring-ddd-messaging-topic-jdbc (HIGH_WATER_MARK_STORE, PARTITION_METADATA) and spring-ddd-messaging-consumer-jdbc (PARTITION_INSTANCES, PARTITION_OWNERSHIP, PARTITION_MEMBERS).
Schema creation: HIGH_WATER_MARK_STORE by spring.ddd.messaging.jdbc.initialize-schema; PARTITION_METADATA by the same switch while event capture (spring.ddd.messaging.capture.event.enabled) is on and, where an event store uses it, spring.ddd.eventsourcing.jdbc.initialize-schema (each default always).
HIGH_WATER_MARK_STORE
One row per topic, holding that topic’s high-water mark: the gapless, commit-safe end up to which the topic is read.
The event store is one such topic; every source the framework advances a mark for has a row of its own, with its own lease.
On PostgreSQL the read-model topics of a datasource share one row, read-model-topics, in that datasource’s own HIGH_WATER_MARK_STORE.
On MySQL, MariaDB, H2, HSQLDB, and SQL Server the mark follows the committed event-store global-position counter, which only ever holds committed positions.
On PostgreSQL the mark first records the sequence’s last handed-out value as a pending end position, together with a transaction id assigned right after reading it, and moves to that end position once every transaction with a lower id has ended.
Every append took its transaction id before its position, so a position appended through the event store is passed only once its transaction has committed or rolled back.
One instance at a time advances the mark under a lease whose expiry is computed on the database clock.
A topic’s row is seeded at 0 when missing.
The same row records the topic’s start once messages have been deleted; only the capture topic has retention, and the event store’s row keeps a start of 0 for good.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. The topic’s name, the same identity a consumer records its checkpoint under. |
|
BIGINT |
Not null. The highest position up to which every position is committed or provably abandoned. Never moves backwards. |
|
BIGINT |
Nullable. PostgreSQL only: the highest position handed out when last read, waiting for the transactions that may still hold positions up to it to end; |
|
BIGINT |
Nullable. PostgreSQL only: the transaction id assigned right after |
|
BIGINT |
Not null, default 0. The position the topic is retained after: every message at or below it has been deleted, and |
|
VARCHAR(255) |
Nullable. The instance currently allowed to advance the mark; |
|
TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIME2 |
Nullable. Database-clock expiry of the lease. TIMESTAMP WITH TIME ZONE on PostgreSQL, H2, and HSQLDB; DATETIME(6) in UTC on MySQL/MariaDB; DATETIME2 on SQL Server. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS HIGH_WATER_MARK_STORE (
topic_name VARCHAR(255) NOT NULL,
high_water_mark BIGINT NOT NULL,
pending_end BIGINT NULL,
pending_horizon BIGINT NULL,
topic_start BIGINT DEFAULT 0 NOT NULL,
lease_owner VARCHAR(255) NULL,
lease_expires_at TIMESTAMP WITH TIME ZONE NULL,
PRIMARY KEY (topic_name)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS HIGH_WATER_MARK_STORE (
topic_name VARCHAR(255) NOT NULL,
high_water_mark BIGINT NOT NULL,
pending_end BIGINT NULL,
pending_horizon BIGINT NULL,
topic_start BIGINT NOT NULL DEFAULT 0,
lease_owner VARCHAR(255) NULL,
lease_expires_at DATETIME(6) NULL,
PRIMARY KEY (topic_name)
);
-- SQL Server
IF OBJECT_ID(N'HIGH_WATER_MARK_STORE', N'U') IS NULL
CREATE TABLE HIGH_WATER_MARK_STORE (
topic_name VARCHAR(255) NOT NULL,
high_water_mark BIGINT NOT NULL,
pending_end BIGINT NULL,
pending_horizon BIGINT NULL,
topic_start BIGINT NOT NULL DEFAULT 0,
lease_owner VARCHAR(255) NULL,
lease_expires_at DATETIME2 NULL,
PRIMARY KEY (topic_name)
);
PARTITION_METADATA
Singleton table (always one row with id = 1) that persists and guards the fixed partition count N.
Every database that holds a partitioned topic, the event store’s or the capture topic’s, keeps its own row.
When both topics write to the same database, they share that row, and each verifies it against the configured count.
On first startup the row is seeded from spring.ddd.partitioning.partitions.
On every later startup the configured value is verified against the persisted value.
Startup fails fast if they differ, because the partition count is immutable once initialized.
The event store creates the table with its own tables when at least one @EventSourced aggregate is detected.
The capture topic creates it with its own tables.
The row is seeded and verified whatever the schema-creation setting.
| Column | Type | Notes |
|---|---|---|
|
SMALLINT |
Primary key. Always |
|
INT |
Not null. The fixed partition count |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB / MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_METADATA (
id SMALLINT NOT NULL,
partitions INT NOT NULL,
PRIMARY KEY (id)
)
-- SQL Server
IF OBJECT_ID(N'PARTITION_METADATA', N'U') IS NULL
CREATE TABLE PARTITION_METADATA (
id SMALLINT NOT NULL,
partitions INT NOT NULL,
PRIMARY KEY (id)
)
PARTITION_INSTANCES
Which instances are live: one row per running instance that holds partition leases, refreshed by its coordinator every heartbeat interval with a single statement, however many consumers and partitions it holds. The instance’s memberships and every partition lease it holds are live exactly while its row is; once the row has lapsed, another instance takes its partitions over within one rebalance interval. A graceful stop deletes the row, whether the instance stops or only its last consumer that holds partition leases does; after a crash it lapses, and another instance’s rebalance deletes it. The row is written before the instance’s first membership or claim, and only while it runs a consumer that holds partition leases.
With schema creation off, the table must exist before the first consumer that holds partition leases starts: otherwise the startup fails, naming the table and the script that creates it.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(255) |
Primary key. The instance’s id, random for every run; never its host name, which a restarted instance keeps. As |
|
VARCHAR(255) |
Nullable. For operators: the host the instance runs on; |
|
TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIME2 |
Not null. For operators: when the row last became live. |
|
TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIME2 |
Not null. For operators: the last heartbeat that refreshed the row. |
|
TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIME2 |
Not null. Database-clock time at which the instance lapses without a heartbeat. TIMESTAMP WITH TIME ZONE on PostgreSQL, H2, and HSQLDB; DATETIME(6) in UTC on MySQL/MariaDB; DATETIME2 in UTC on SQL Server. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS PARTITION_INSTANCES (
instance_id VARCHAR(255) NOT NULL,
host_name VARCHAR(255) NULL,
started_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_heartbeat TIMESTAMP WITH TIME ZONE NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
PRIMARY KEY (instance_id)
);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_INSTANCES (
instance_id VARCHAR(255) NOT NULL,
host_name VARCHAR(255) NULL,
started_at DATETIME(6) NOT NULL,
last_heartbeat DATETIME(6) NOT NULL,
expires_at DATETIME(6) NOT NULL,
PRIMARY KEY (instance_id)
);
-- SQL Server
IF OBJECT_ID(N'PARTITION_INSTANCES', N'U') IS NULL
CREATE TABLE PARTITION_INSTANCES (
instance_id VARCHAR(255) NOT NULL,
host_name VARCHAR(255) NULL,
started_at DATETIME2 NOT NULL,
last_heartbeat DATETIME2 NOT NULL,
expires_at DATETIME2 NOT NULL,
PRIMARY KEY (instance_id)
);
PARTITION_OWNERSHIP
Which instance may apply each partition of a consumer, such as a projection: a lease with an owner and a generation, held by at most one instance at a time.
A lease has no expiry of its own: it is live while its owner’s row in PARTITION_INSTANCES is, so a row here is written only when a lease is claimed, taken over, handed back or released.
A projection whose read models are in the database holds one lease per partition: by default its partitions are spread over the instances running it, and one set to distribution: single-owner holds all of them together.
Every apply that shares the checkpoint’s transaction first checks the lease under the generation it claimed and locks its row, so the apply of an instance that has lost the lease rolls back, and no other instance takes the lease over until the apply commits.
Taking over a free lease, or one whose owner has lapsed, raises its generation.
A graceful stop releases the lease.
A consumer’s row is inserted by its first claim, and its rows are deleted when it is unsubscribed from the last topic it has checkpoints on and the application no longer runs it on any topic.
With schema creation off, the table must exist before the first projection whose read models are in the database starts: otherwise the startup fails, naming the table and the script that creates it.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(32) |
Primary key, part 1. The kind of consumer, e.g. |
|
VARCHAR(255) |
Primary key, part 2. The consumer’s name, e.g. |
|
SMALLINT |
Primary key, part 3. The partition leased, as in |
|
VARCHAR(255) |
Nullable. The instance holding the lease, as in |
|
BIGINT |
Not null, default 0. Rises by one with every takeover. An apply passes its check only under the generation it claimed, so a former holder cannot commit once another instance has taken over. |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS PARTITION_OWNERSHIP (
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
instance_id VARCHAR(255) NULL,
generation BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_type, consumer_name, partition_no)
);
CREATE INDEX IF NOT EXISTS idx_partition_ownership_instance_id ON PARTITION_OWNERSHIP (instance_id);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_OWNERSHIP (
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
instance_id VARCHAR(255) NULL,
generation BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_type, consumer_name, partition_no),
INDEX idx_partition_ownership_instance_id (instance_id)
);
-- SQL Server
IF OBJECT_ID(N'PARTITION_OWNERSHIP', N'U') IS NULL
CREATE TABLE PARTITION_OWNERSHIP (
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
partition_no SMALLINT NOT NULL,
instance_id VARCHAR(255) NULL,
generation BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (consumer_type, consumer_name, partition_no)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_partition_ownership_instance_id'
AND object_id = OBJECT_ID(N'PARTITION_OWNERSHIP'))
CREATE INDEX idx_partition_ownership_instance_id ON PARTITION_OWNERSHIP (instance_id);
PARTITION_MEMBERS
Which instances currently run each consumer, such as a projection: one row per instance and consumer, written when the instance joins the consumer and deleted when it leaves.
A row is live while its instance’s row in PARTITION_INSTANCES is.
The coordinator assigns a consumer’s partitions by rendezvous hashing over the live members only, so a consumer’s single-owner lease lands on one of the instances that run it.
A graceful stop deletes the instance’s rows; after a crash they lapse with its instance row, and another instance’s rebalance deletes them.
A consumer’s row is inserted when the consumer registers with the coordinator; a heartbeat does not touch this table.
With schema creation off, the table must exist before the first consumer that holds partition leases starts.
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(32) |
Primary key, part 1. The kind of consumer, e.g. |
|
VARCHAR(255) |
Primary key, part 2. The consumer’s name, e.g. |
|
VARCHAR(255) |
Primary key, part 3. The id of the instance that runs the consumer, as in |
DDL — all dialects
-- PostgreSQL / H2 / HSQLDB
CREATE TABLE IF NOT EXISTS PARTITION_MEMBERS (
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
PRIMARY KEY (consumer_type, consumer_name, instance_id)
);
CREATE INDEX IF NOT EXISTS idx_partition_members_instance_id ON PARTITION_MEMBERS (instance_id);
-- MySQL / MariaDB
CREATE TABLE IF NOT EXISTS PARTITION_MEMBERS (
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
PRIMARY KEY (consumer_type, consumer_name, instance_id),
INDEX idx_partition_members_instance_id (instance_id)
);
-- SQL Server
IF OBJECT_ID(N'PARTITION_MEMBERS', N'U') IS NULL
CREATE TABLE PARTITION_MEMBERS (
consumer_type VARCHAR(32) NOT NULL,
consumer_name VARCHAR(255) NOT NULL,
instance_id VARCHAR(255) NOT NULL,
PRIMARY KEY (consumer_type, consumer_name, instance_id)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_partition_members_instance_id'
AND object_id = OBJECT_ID(N'PARTITION_MEMBERS'))
CREATE INDEX idx_partition_members_instance_id ON PARTITION_MEMBERS (instance_id);
Durable domain-event metadata
Module: spring-ddd-starter-domain-events-modulith-jdbc.
Schema creation: spring.modulith.events.jdbc.schema-initialization.enabled (owned by Spring Modulith).
This table is created alongside the Spring Modulith EVENT_PUBLICATION table and stores serialised MessageMetadata keyed to the Modulith publication ID.
It enables metadata (tenant ID, actor, clock) to survive an application restart and be restored when Modulith resubmits outstanding publications.
Spring Modulith’s event publication tables, this one included, take no table-prefix.
EVENT_PUBLICATION_METADATA
| Column | Type | Notes |
|---|---|---|
|
VARCHAR(36) |
Primary key. Matches |
|
TIMESTAMP WITH TIME ZONE | DATETIME(6) | DATETIMEOFFSET |
Nullable. Mirrors the Modulith completion date for retention alignment. |
|
CLOB | LONGVARCHAR | TEXT | LONGTEXT | NVARCHAR(MAX) |
Not null. Serialised |
DDL — all dialects
-- PostgreSQL
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE TIMESTAMP WITH TIME ZONE,
METADATA TEXT NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
CREATE INDEX IF NOT EXISTS idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
-- H2
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE TIMESTAMP(9) WITH TIME ZONE,
METADATA CLOB NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
CREATE INDEX IF NOT EXISTS idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
-- HSQLDB
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE TIMESTAMP WITH TIME ZONE,
METADATA LONGVARCHAR NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
CREATE INDEX IF NOT EXISTS idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);
-- MySQL
CREATE TABLE IF NOT EXISTS EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE DATETIME(6) NULL,
METADATA LONGTEXT NOT NULL,
PRIMARY KEY (PUBLICATION_ID),
INDEX idx_event_pub_meta_completion (COMPLETION_DATE)
);
-- MariaDB — same DDL as MySQL above
-- SQL Server
IF OBJECT_ID(N'EVENT_PUBLICATION_METADATA', N'U') IS NULL
CREATE TABLE EVENT_PUBLICATION_METADATA (
PUBLICATION_ID VARCHAR(36) NOT NULL,
COMPLETION_DATE DATETIMEOFFSET,
METADATA NVARCHAR(MAX) NOT NULL,
PRIMARY KEY (PUBLICATION_ID)
);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'idx_event_pub_meta_completion'
AND object_id = OBJECT_ID(N'EVENT_PUBLICATION_METADATA'))
CREATE INDEX idx_event_pub_meta_completion
ON EVENT_PUBLICATION_METADATA (COMPLETION_DATE);