with one click
db
// Database schema and query conventions for Thunder. Use when changing schema scripts, defining SQL queries, updating store constants, or reviewing deployment-scoped persistence rules.
// Database schema and query conventions for Thunder. Use when changing schema scripts, defining SQL queries, updating store constants, or reviewing deployment-scoped persistence rules.
| name | db |
| description | Database schema and query conventions for ThunderID. Use when changing schema scripts, defining SQL queries, updating store constants, or reviewing deployment-scoped persistence rules. |
This document explains the database schema design principles and conventions used in ThunderID. AI agents and contributors should follow these conventions when generating or modifying database schemas and queries.
ThunderID uses three logically separated databases. Each database owns a specific category of data.
| Database | Responsibility |
|---|---|
configdb | Identity configuration data Ex: applications, authentication flows, roles, identity providers |
runtimedb | Runtime temporal data which holds the state of the authentication flows: authorization codes, flow contexts, WebAuthn sessions |
userdb | Identity data: users, groups, indexed user attributes |
Although the databases are logically separated, they share consistent schema design principles documented here.
Tables use UUID v7 as primary key values. UUID v7 provides:
Primary key columns are named ID.
Do not use composite names such as USER_ID or APPLICATION_ID for a primary key column. Use ID consistently.
-- Correct
CREATE TABLE "APPLICATION" (
ID VARCHAR(36) PRIMARY KEY,
...
);
-- Incorrect
CREATE TABLE APPLICATION (
APPLICATION_ID VARCHAR(36) PRIMARY KEY,
...
);
Association (join) tables that model many-to-many relationships use composite primary keys formed from the relevant foreign key columns. These tables do not include a separate surrogate ID column.
-- Example: role assignments use a composite primary key
CREATE TABLE "ROLE_ASSIGNMENT" (
DEPLOYMENT_ID VARCHAR(255) NOT NULL,
ROLE_ID VARCHAR(36) NOT NULL,
ASSIGNEE_TYPE VARCHAR(5) NOT NULL CHECK (ASSIGNEE_TYPE IN ('user', 'group')),
ASSIGNEE_ID VARCHAR(36) NOT NULL,
PRIMARY KEY (ROLE_ID, DEPLOYMENT_ID, ASSIGNEE_TYPE, ASSIGNEE_ID),
FOREIGN KEY (ROLE_ID) REFERENCES "ROLE" (ID) ON DELETE CASCADE
);
This approach:
Foreign keys reference UUID primary keys directly. Integer-based foreign keys are not used anywhere in the schema.
The system does not introduce:
INT-based surrogate identifiers.This avoids unnecessary lookup overhead and architectural complexity when joining across tables or deployments.
The schema does not use auto-increment integer identifiers. UUID v7 identifiers serve as the only primary key mechanism across all tables.
ThunderID supports multi-deployment scenarios where a single database instance may serve data from multiple independent deployments. The DEPLOYMENT_ID column enforces isolation between these deployments.
Every table includes a DEPLOYMENT_ID column defined as VARCHAR(255) NOT NULL.
CREATE TABLE "IDP" (
DEPLOYMENT_ID VARCHAR(255) NOT NULL,
ID VARCHAR(36) PRIMARY KEY,
NAME VARCHAR(255) NOT NULL,
...
);
Although UUID v7 identifiers are globally unique, queries must still filter by DEPLOYMENT_ID to prevent data from leaking across deployments.
DEPLOYMENT_ID is the last parameter in all parameterized queries. Follow these patterns consistently.
Use uppercase table names wrapped in double quotes in schema scripts and embedded SQL.
"TABLE_NAME", not bare identifiers.CREATE TABLE, CREATE INDEX ... ON, FOREIGN KEY ... REFERENCES, and all SELECT / INSERT / UPDATE / DELETE statements."ROLE" and "GROUP" consistent with the rest of the schema.Add DEPLOYMENT_ID as the last column in the column list and the last parameter in VALUES.
INSERT INTO "IDP" (ID, NAME, DESCRIPTION, TYPE, PROPERTIES, DEPLOYMENT_ID)
VALUES ($1, $2, $3, $4, $5, $6)
Add AND DEPLOYMENT_ID = $N as the final condition in the WHERE clause.
SELECT ID, NAME, DESCRIPTION, TYPE, PROPERTIES
FROM "IDP"
WHERE ID = $1 AND DEPLOYMENT_ID = $2
Add AND DEPLOYMENT_ID = $N as the last condition in the WHERE clause.
UPDATE "IDP"
SET NAME = $2, DESCRIPTION = $3, TYPE = $4, PROPERTIES = $5
WHERE ID = $1 AND DEPLOYMENT_ID = $6
Add AND DEPLOYMENT_ID = $N as the last condition in the WHERE clause.
DELETE FROM "IDP"
WHERE ID = $1 AND DEPLOYMENT_ID = $2
Include DEPLOYMENT_ID in JOIN conditions and WHERE clauses.
SELECT f.ID, f.HANDLE, f.NAME, fv.NODES
FROM "FLOW" f
INNER JOIN "FLOW_VERSION" fv
ON f.ID = fv.FLOW_ID
AND f.DEPLOYMENT_ID = fv.DEPLOYMENT_ID
AND f.ACTIVE_VERSION = fv.VERSION
WHERE f.ID = $1 AND f.DEPLOYMENT_ID = $2
Indexes in ThunderID are designed to match real query patterns. The process for defining or revising indexes is:
Composite indexes should place DEPLOYMENT_ID first when the query always filters by deployment. This allows the index to be used for deployment-scoped queries even when additional columns are not included.
-- Composite index for deployment + OU-based lookups
CREATE INDEX idx_user_ou_deployment ON "USER" (DEPLOYMENT_ID, OU_ID);
Tables in runtimedb that include an EXPIRY_TIME column should have a dedicated index on that column to support efficient cleanup queries.
CREATE INDEX idx_authz_code_expiry_time ON "AUTHORIZATION_CODE" (EXPIRY_TIME);
Use these rules for all temporary runtime tables in runtimedb.
EXPIRY_TIME column.ON DELETE CASCADE, deleting an expired owner row also removes related association rows automatically.EXPIRY_TIME column unless the association has an independent expiry lifecycle.backend/dbscripts/runtimedb/postgres-cleanup.sql and backend/scripts/cleanup_runtime_db.sh.Required column in each runtime table:
EXPIRY_TIME TIMESTAMP NOT NULL
When selecting runtime data, compare EXPIRY_TIME with current time and keep DEPLOYMENT_ID as the last parameter:
SELECT AUTH_ID, REQUEST_DATA, EXPIRY_TIME
FROM "AUTHORIZATION_REQUEST"
WHERE AUTH_ID = $1 AND EXPIRY_TIME > $2 AND DEPLOYMENT_ID = $3
Use the existing cleanup artifacts in this repository:
backend/dbscripts/runtimedb/postgres-cleanup.sql: defines the PostgreSQL stored procedure cleanup_expired_runtimedb_data (UTC-based cleanup).backend/scripts/cleanup_runtime_db.sh: provides scheduled/manual cleanup support for PostgreSQL and SQLite.Keep these two files in sync with the current set of runtime tables.
CREATE OR REPLACE PROCEDURE cleanup_expired_runtimedb_data()
LANGUAGE plpgsql
AS $$
DECLARE
v_now TIMESTAMP := NOW() AT TIME ZONE 'UTC';
BEGIN
DELETE FROM "FLOW_CONTEXT" WHERE EXPIRY_TIME < v_now;
DELETE FROM "AUTHORIZATION_CODE" WHERE EXPIRY_TIME < v_now;
DELETE FROM "AUTHORIZATION_REQUEST" WHERE EXPIRY_TIME < v_now;
DELETE FROM "WEBAUTHN_SESSION" WHERE EXPIRY_TIME < v_now;
DELETE FROM "ATTRIBUTE_CACHE" WHERE EXPIRY_TIME < v_now;
END;
$$;
Queries are defined as DBQuery values from internal/system/database/model. Each query requires a unique ID for traceability.
var queryGetIDPByID = model.DBQuery{
ID: "IPQ-IDP_MGT-02",
Query: "SELECT ID, NAME, DESCRIPTION, TYPE, PROPERTIES FROM \"IDP\" WHERE ID = $1 AND DEPLOYMENT_ID = $2",
}
When query syntax differs between PostgreSQL and SQLite, define both variants using the Query and SQLiteQuery fields on DBQuery.
var queryUpsertTranslation = dbmodel.DBQuery{
ID: "I18N-06",
Query: `INSERT INTO "TRANSLATION" (MESSAGE_KEY, LANGUAGE_CODE, NAMESPACE, VALUE, DEPLOYMENT_ID)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (DEPLOYMENT_ID, NAMESPACE, MESSAGE_KEY, LANGUAGE_CODE)
DO UPDATE SET VALUE = excluded.VALUE, UPDATED_AT = NOW()`,
SQLiteQuery: `INSERT INTO "TRANSLATION" (MESSAGE_KEY, LANGUAGE_CODE, NAMESPACE, VALUE, DEPLOYMENT_ID)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (DEPLOYMENT_ID, NAMESPACE, MESSAGE_KEY, LANGUAGE_CODE)
DO UPDATE SET VALUE = excluded.VALUE, UPDATED_AT = datetime('now')`,
}
Query IDs follow the pattern <PREFIX>-<DOMAIN>_MGT-<SEQUENCE>, for example:
IPQ-IDP_MGT-02 — identity provider query, sequence 2.ASQ-USER_MGT-04 — user management query, sequence 4.AZQ-ARS-02 — authorization request store query, sequence 2.Use a consistent prefix per store and increment the sequence number for each new query in that store.
postgres.sql) and SQLite (sqlite.sql) in each database directory under backend/dbscripts/.| Agent Check | Required Convention |
|---|---|
| Primary key format | Use UUID v7 values. |
| Primary key column name | Use ID (do not use entity-specific PK names like USER_ID). |
| Association table key strategy | Use composite primary key from foreign key columns; do not add surrogate ID. |
| Foreign key type | Reference UUID keys directly; do not introduce integer key layers. |
| Auto-increment usage | Do not use auto-increment IDs. |
| Multi-deployment isolation | Include DEPLOYMENT_ID VARCHAR(255) NOT NULL in every table. |
| Query parameter order | Keep DEPLOYMENT_ID as the last parameter in parameterized queries. |
| Runtime table expiry column | For runtime owner tables, require EXPIRY_TIME TIMESTAMP NOT NULL. |
| Association table expiry column | Omit EXPIRY_TIME when lifecycle is inherited via ON DELETE CASCADE; add it only if association rows expire independently. |
| Expired data cleanup | Use backend/dbscripts/runtimedb/postgres-cleanup.sql and backend/scripts/cleanup_runtime_db.sh; keep both updated when runtime tables change. |
| Query declaration format | Define queries as DBQuery values with unique query IDs. |
| Table identifier format | Use uppercase table names in double quotes in schema scripts and embedded SQL. |
[HINT] Download the complete skill directory including SKILL.md and all related files