Partitioning & retention
Primebrick partitions append-only tables with pg_partman so retention
becomes "drop a partition" instead of DELETE — no table lock on the hot
path, and old data can live on slower storage.
What is partitioned today
Every {table}_audit audit-trail table is a native RANGE partition parent:
Code
The DDL generator (database-patch-to-sql.ts) emits this for every
@AuditTrail() entity — premake = 4 keeps four future partitions ready.
Reads always go through the parent table: all partitions are visible as one
logical table, so audit-query.ts (and any findAll/findByPage) needs
zero partition awareness.
Entity model
The entity declares a single logical @Key() — the composite DDL primary
key (id, changed_at) is a DDL detail only:
Code
See src/audit/audit-log-entity.ts for the canonical shape.
Partitioning a main table — rules
- All UNIQUE/PK constraints must include the partition key (PG
rule):
UNIQUE (uuid)→UNIQUE (uuid, created_at);PRIMARY KEY (id)→PRIMARY KEY (id, created_at). The entity keeps@Key() id+@Unique() uuid— the generated constraint just widens. - Prefer an immutable key (
created_at, notupdated_at): updates that cross a partition boundary physically move the row. A mutable watermark key (last_seen_at) is allowed only when that movement IS the retention semantic — see "activity watermark" below. - Child tables cannot FK to a partitioned parent. Because the unique
constraint must contain the partition key,
child.parent_uuid → parent.uuidhas no matching constraint. Integrity becomes application-level: the repository performs the cascade in one transaction (hardDeleteper child + parent). This is the deliberate trade-off — document it in the entity header. - Non-key filters don't prune — acceptable for append-only logs at normal volume.
- Write the
PARTITION BY RANGE+create_parentDDL in the table's patch — there are no entity decorators for partitioning; it is a pure DDL concern.
First partitioned domain table: ai.ai_conversations
ai_conversations is partitioned RANGE (last_seen_at) — the activity
watermark, not created_at. Semantics: "a conversation is dead N
months after its last event", not "N months after creation".
-
last_seen_at=max(created_at)of every child event (message, feedback). Child writes stamp it via a versionedupdate(the entity is auditable — the optimistic lock applies to bookkeeping writes too;Repository.touchwas removed because it bypassed the version contract) inside the same transaction as the child insert — the clock can never drift from the rows that justify it. -
Invariant:
conv.last_seen_at >= every child's updated_at. So a partition about to expire contains only conversations whose ENTIRE subtree is expired — retention removes each family's data in the same window ("quasi-atomic"). -
ai_messages/ai_feedbackare NOT partitioned — they die with their conversation, never independently. -
Retention job order matters: partition drop is DDL — it does NOT fire FK cascades and leaves no parent rows to JOIN. The maintenance runner must purge children FIRST, set-based on the expiring window:
CodeDo NOT purge children by bare
created_at < cutoff— children of a still-active conversation can predate the cutoff; key the purge on parent membership (last_seen_at), never on child time alone.
Retention
Retention is configured on partman.part_config, per parent:
Code
Partitions older than the window are dropped at the next
partman.run_maintenance_proc() run (retention_keep_table = false;
true detaches instead, keeping them as plain tables for archival).
Scheduler: nothing in the platform calls run_maintenance_proc()
today — pg_cron is not installed. The sanctioned path is a
service-side scheduled job calling dal.partitionMaintenance(entities) —
it owns the dedicated client internally (consumers never touch PoolClient).