PostgreSQL: Database Architecture and Conventions
Table of Contents
ORE Studio uses PostgreSQL as its sole persistent store. All services read and write through a shared database, each with a strictly scoped service role. This article documents the database structure, naming conventions, session settings, and the patterns every service must follow. Return to Knowledge.
Project reference
The SQL source lives entirely in ores.sql. Key paths:
| Path | Purpose |
|---|---|
ores.sql/create/ |
DDL: tables, triggers, functions, indexes, RLS policies |
ores.sql/migrate/ |
Incremental migrations (applied by migrate.sh) |
ores.sql/populate/ |
Seed data: reference tables, system accounts, flags |
ores.sql/drop/ |
Complementary DROP scripts (idempotent teardown) |
ores.sql/test/ |
pgTAP SQL unit tests |
ores.sql/create/utility/utility_functions_create.sql |
Shared utility functions (infinity timestamp, name normalisation, etc.) |
ores.sql/setup_extensions.sql |
Extension installation script |
compass db recreate |
Full database teardown and rebuild |
ores.sql/migrate.sh |
Incremental migration runner |
Schema layout
All tables share the public schema with an ores_ prefix followed by a
domain sub-prefix:
| Prefix | Domain |
|---|---|
ores_iam_* |
Identity and access management |
ores_refdata_* |
Reference data (currencies, parties, books, …) |
ores_trading_* |
Trades, instruments, lifecycle events |
ores_analytics_* |
Pricing models and configurations |
ores_compute_* |
Compute grid: apps, batches, work units, results |
ores_reporting_* |
Report definitions, instances, concurrency policies |
ores_dq_* |
Data quality: badges, FSMs, datasets, catalogues |
ores_marketdata_* |
Market series, observations, fixings |
ores_workflow_* |
Workflow instances and steps |
ores_variability_* |
System settings |
ores_assets_* |
Binary assets (images, tags) |
ores_telemetry_* |
Telemetry logs and samples |
ores_scheduler_* |
Job definitions and instances |
Roles and users
ORE Studio uses a least-privilege role model. Every service runs as a dedicated database user with DML only on its own domain tables. No service user can read or write another domain's tables directly.
| Role pattern | Privilege |
|---|---|
ores_<env>_owner |
Superuser-level within the DB; owns all objects |
ores_<env>_ddl_user |
DDL (CREATE, ALTER, DROP); used by setup scripts |
ores_<env>_<service>_service |
SELECT, INSERT, UPDATE, DELETE on own domain only |
ores_<env>_readonly_user |
SELECT on all tables; used by reporting tools |
ores_<env>_cli_user |
CLI surface DML |
ores_<env>_shell_user |
Shell surface DML |
ores_<env>_wt_user |
Wt (web) surface DML |
ores_<env>_http_user |
HTTP surface DML |
Row-Level Security enforces tenant isolation on top of these grants — see PostgreSQL Row-Level Security for details.
Session conventions
Every application connection must set the following before executing
queries. The ores.sql tenant_aware_pool does this on acquire():
-- Force UTC — all TIMESTAMPTZ values are returned as "YYYY-MM-DD HH:MM:SS+00" SELECT set_config('TimeZone', 'UTC', false); -- Set the current tenant (required by RLS policies) SELECT set_config('app.current_tenant_id', '<uuid>', false); -- Set the current party (required by some service-level policies) SELECT set_config('app.current_party_id', '<uuid>', false);
Every service must set TimeZone = UTC on every connection. Without
it, PostgreSQL uses the server's local timezone and returns
TIMESTAMPTZ values with local offsets rather than +00. All C++
timestamp parsing assumes UTC.
Bitemporal design
All reference and trading entities use a bitemporal schema: see Time and Timestamps: Architecture and Conventions for the full treatment. The short version:
- Every table has
valid_from TIMESTAMPTZ NOT NULLandvalid_to TIMESTAMPTZ NOT NULL. valid_fromis set tocurrent_timestampby the INSERT trigger.valid_tois set toores_utility_infinity_timestamp_fn()on INSERT and closed tocurrent_timestampon UPDATE or DELETE.- "Current" records are those with
valid_to = ores_utility_infinity_timestamp_fn(). ores_utility_infinity_timestamp_fn()must be used in SQL wherever the sentinel value is needed — never the bare literal.
Extensions
Extensions are installed by ores.sql/setup_extensions.sql, which is
called during database creation. Extensions are per-database in
PostgreSQL.
btree_gist (required)
Extends GiST indexes to support btree-indexable data types (integers,
timestamps, text). ORE Studio uses it for temporal exclusion
constraints: a GiST index over (id, valid_from, valid_to) prevents
two rows for the same entity from having overlapping valid-time ranges.
Without btree_gist, the exclusion constraint on bitemporal tables
cannot be created.
create extension if not exists btree_gist;
unaccent (required)
Provides a text-search dictionary that strips accents from characters
(e.g. é → e, ñ → n). Used by the ores_utility_normalise_name_fn()
helper to produce a lowercase, accent-free version of party and
counterparty names for deduplication and search. Without unaccent,
name normalisation falls back to a plain lower() which misses
accented characters.
create extension if not exists unaccent;
pgtap (optional — recommended for development)
A TAP-compliant unit-testing framework for SQL. Provides plan(),
is(), ok(), throws_ok(), and similar functions for writing
database-level tests under ores.sql/test/. If not installed, the
SQL test suite cannot run; application functionality is unaffected.
Install on Debian/Ubuntu: apt install postgresql-NN-pgtap.
create extension if not exists pgtap;
timescaledb (optional — recommended for telemetry)
A time-series database extension. ORE Studio uses it for the
telemetry tables (ores_telemetry_*): hypertables partition telemetry
logs by time, enabling efficient range queries and compression. If
TimescaleDB is not installed, the telemetry tables fall back to
regular PostgreSQL tables — all functionality works but performance
at scale degrades.
TimescaleDB requires a system-level installation
(apt install timescaledb-2-postgresql-NN) and
shared_preload_libraries = 'timescaledb' in postgresql.conf before
the extension can be created.
create extension if not exists timescaledb;
Insert trigger patterns
Every writable table has a BEFORE INSERT trigger that enforces the
invariants the application layer is allowed to assume. Triggers fall
into three structural categories with different validation contracts.
Category 1 — Domain entities
Full-CRUD entities with their own MDI windows, shell/CLI commands, and
manual chapters (currency, country, party, book, …).
Trigger structure (order is mandatory):
tenant_id—ores_iam_validate_tenant_fn(new.tenant_id)- Optional soft-FK fields (
coding_scheme_codeinline; named attributes viaores_refdata_validate_*_fn/ similar) change_reason_code—ores_dq_validate_change_reason_fn(...)- Version management block (
SELECT … FOR UPDATE, optimistic lock check,UPDATE … SET valid_to, version increment) valid_from/valid_toset by triggermodified_by—ores_iam_validate_account_username_fn(new.modified_by)performed_by—coalesce(ores_iam_current_service_fn(), current_user)
Rule: all validation (steps 1–3) must precede the version management block (step 4). This ensures the trigger fails fast — before acquiring the row lock and before closing the previous temporal record — on any bad input.
Domain entity triggers should declare security definer and
set search_path = public, pg_temp to prevent search-path injection.
Category 2 — Owned sub-entities
Records that are data owned by a parent entity and carry their own
temporal history: party_identifiers, party_contact_informations,
business_units, …
Trigger structure follows Category 1, with these additions:
- Parent UUID FK (e.g.
party_id) validated inline withif not exists (select 1 from ores_refdata_parties_tbl where id = NEW.party_id …). No separate validate function exists for UUID-keyed parent references; the inline check is the established pattern. - Type/scheme attributes (e.g.
id_scheme) validated viaores_refdata_validate_party_id_scheme_fn(...).
Sub-entities do not have an independent validate function (there is no
ores_refdata_validate_party_identifier_fn); they are not referenced as
soft-FKs by other entities.
Category 3 — Pure visibility junctions
Tables that control which entities a party can see, not data about
the party itself: party_currencies, party_countries,
party_counterparty.
These carry version management and audit columns but their entity
references (currency_iso_code, country_alpha2_code, party_id, …) are
not validated in the trigger. The validated fields are:
tenant_id—ores_iam_validate_tenant_fn(...)modified_by,performed_by— same as Categories 1 & 2change_reason_code—ores_dq_validate_change_reason_fn(...)
Why no entity FK validation? Junction rows represent an access-control
decision ("party X may see country Y"), not a business datum. An orphan
junction row — pointing to a country that does not (yet) exist — is
harmless: the join produces no rows and the UI shows nothing. Contrast
this with a book.ledger_ccy field: if the currency disappears, the
book's P&L calculation breaks. The semantic difference justifies the
asymmetry.
In practice these junctions are populated by trusted internal operations (the provisioner, bulk seeders) where the application layer already guarantees entity existence.
Validation functions (ores_refdata_validate_*_fn)
Each refdata entity that may appear as a soft-FK attribute on another entity must define a validation function:
create or replace function ores_refdata_validate_<entity>_fn( p_tenant_id uuid, p_value text ) returns text security definer set search_path = public, pg_temp as $$ -- Raises 23502 if null/empty. -- Returns p_value unchanged if no active rows exist yet (bootstrap pass-through). -- Raises 23503 with a helpful list if the value is not found among active rows. $$ language plpgsql;
These functions are:
- Called from Category 1 and 2 triggers, never from Category 3.
- Defined in the same DDL file as their entity's table (at the bottom, after the soft-delete rule).
- Dropped in the entity's
drop/file alongside the table and insert function. - Declared
security definer+set search_path = public, pg_tempunconditionally — see below.
Security: validate functions must carry security definer and
set search_path = public, pg_temp independently of their callers.
security definer prevents the function from executing as the calling
service role (which may have a manipulated search path). set search_path
is required alongside it to pin table resolution to public and pg_temp;
security definer alone, without a pinned path, is still vulnerable to
search-path injection. security definer does not propagate to callees —
each function must declare it explicitly.
Bootstrap pass-through: the pass-through check must test for active rows
(valid_to = ores_utility_infinity_timestamp_fn()), not any rows. A table
with only soft-deleted historical rows is semantically empty for validation
purposes; checking without the valid_to filter would skip the pass-through,
then fail the active-row validation with a misleading "Must be one of: (empty
list)" error. The limit 1 inside EXISTS is a no-op and is omitted.
Notification triggers
Most tables have a NOTIFY trigger that fires after INSERT, UPDATE, or
DELETE and publishes a JSON payload on a per-entity PostgreSQL channel.
The service layer listens on that channel and translates database events
into NATS messages, which are then routed to connected clients. This is
how live-update eventing works:
DB write → NOTIFY trigger → service listener → NATS event → Qt markAsStale()
Trigger files follow the naming pattern:
<domain>_<table>_notify_trigger_create.sql.
PostgreSQL channel naming
Every pg_notify channel follows the rule:
ores_{component}_{entity_plural}
where component is the domain sub-prefix (iam, refdata, trading,
analytics, compute, dq, reporting, variability, workspace,
scheduler, mq, …) and entity_plural is the snake_case plural name
of the table without its ores_{component}_ table prefix.
| Component | Example entity | PostgreSQL channel |
|---|---|---|
refdata |
currency |
ores_refdata_currencies |
refdata |
party |
ores_refdata_parties |
trading |
trade |
ores_trading_trades |
iam |
role |
ores_iam_roles |
dq |
change_reason |
ores_dq_change_reasons |
variability |
system_setting |
ores_variability_system_settings |
workspace |
workspace |
ores_workspace_workspaces |
The component prefix is required. Without it, the same entity noun in
two domains (e.g. parties in refdata and a hypothetical parties in
iam) would share a single channel, causing incorrect cross-domain
event delivery.
The codegen template sql_schema_notify_trigger.mustache generates
conforming channel names automatically. Handwritten triggers must
follow the same rule.
NATS event channel naming
The application layer re-publishes each database event onto a NATS subject visible to shell and UI clients. The naming convention there is distinct from the PostgreSQL layer:
ores.{component}.{entity}_changed
| PostgreSQL channel | NATS subject |
|---|---|
ores_refdata_currencies |
ores.refdata.currency_changed |
ores_iam_roles |
ores.iam.role_changed |
The service registers the mapping explicitly; the two names are not derived from each other automatically.
Key utility functions
Defined in ores.sql/create/utility/utility_functions_create.sql:
| Function | Returns | Purpose |
|---|---|---|
ores_utility_infinity_timestamp_fn() |
timestamptz |
Canonical "live record" sentinel. Always use this, never the bare literal. |
ores_utility_normalise_name_fn(text) |
text |
Lowercase + unaccent normalisation for search/dedup. |
ores_iam_validate_account_username_fn(text) |
text |
Validates modified_by is a known IAM account; called from write triggers. |
ores_iam_current_service_fn() |
text |
Returns the current service username; used to set performed_by. |
ores_dq_validate_change_reason_fn(uuid, text) |
text |
Validates change_reason_code against the DQ catalogue. |
See also
- ores.sql — the component that owns all DDL, migrations, and seed data.
- PostgreSQL Row-Level Security — tenant isolation and service-role separation via RLS.
- Time and Timestamps: Architecture and Conventions — bitemporality, the infinity sentinel, and the full timestamp type chain.
- Entity lifecycle — how C++ domain, mapper, service, and handler layers connect to the DB.