Documentation
fsds/prism-journal-system-implementation-plan.md
Journal System Implementation Plan
IMMEDIATE FIX REQUIRED: Identifier.Id Migration
Problem
The migration 20251209215907_ChangeIdentifierIdToGuid.cs fails with:
cannot alter type of a column used by a view or rule
Root Cause
Views in bbu schema reference prism.identifiers table:
bbu.bbu_oblpn_shipments(directly referencesprism.identifiers)bbu.bbu_oblpn_components(directly referencesprism.identifiers)bbu.basket_dwell_detail(directly referencesprism.identifiers)- Plus dependent views:
bbu_oblpn_status_daily,bbu_oblpn_load_progress,bbu_oblpn_component_mix,bbu_oblpn_location_backlog,basket_dwell_by_timeframe,basket_location_map
Solution
Update the Prism migration to:
- Drop all BBU analytics views in reverse dependency order
- Drop identity and change column type
- The BBU seeder will recreate views on next startup (via
SupersetAnalyticsViewsSql.CreateScripts)
File to Modify
engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/Migrations/20251209215907_ChangeIdentifierIdToGuid.cs
Updated Migration SQL
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
-- Drop BBU analytics views that depend on prism.identifiers
-- (Must be in reverse dependency order)
DROP VIEW IF EXISTS bbu.location_return_performance;
DROP VIEW IF EXISTS bbu.basket_location_map;
DROP VIEW IF EXISTS bbu.basket_dwell_by_timeframe;
DROP VIEW IF EXISTS bbu.basket_dwell_detail;
DROP VIEW IF EXISTS bbu.basket_dwell_by_location;
DROP VIEW IF EXISTS bbu.bbu_oblpn_location_backlog;
DROP VIEW IF EXISTS bbu.bbu_oblpn_component_mix;
DROP VIEW IF EXISTS bbu.bbu_oblpn_load_progress;
DROP VIEW IF EXISTS bbu.bbu_oblpn_status_daily;
DROP VIEW IF EXISTS bbu.bbu_oracle_ingestion_daily_summary;
DROP VIEW IF EXISTS bbu.bbu_oblpn_components;
DROP VIEW IF EXISTS bbu.bbu_oblpn_shipments;
DROP VIEW IF EXISTS bbu.bbu_oracle_ingestion_runs;
-- Drop the identity property first (required before type change)
ALTER TABLE prism.identifiers ALTER COLUMN id DROP IDENTITY IF EXISTS;
-- Change the type to uuid, generating new GUIDs for existing rows
ALTER TABLE prism.identifiers ALTER COLUMN id TYPE uuid USING gen_random_uuid();
");
}
Note: The BBU seeder (SupersetAnalyticsSeeder) will recreate all views on next application startup.
Executive Summary
The journal system is partially implemented. ADR 035 defines a solid architecture where all journal tables reside in the prism schema, but only the Prism component has journal tables. There are approximately 40+ domain entities across other components that need journal tables but don't have them.
User Decisions
- Approach: Phased - fix infrastructure first, then component-by-component
- Operational Data: Skip high-volume tables (TagReadProcessing, CameraReadProcessing, etc.)
- Analyzers: Fix them to use correct JournalForAttribute and JournalTable
- SQL Deployment: Via EF Core migrations using migrationBuilder.Sql()
- Schema Attribute: Add explicit
Schema = "prism"to all journal entity Table attributes - JournalForAttribute: Use
[JournalFor(typeof(SourceEntity))]withpartialclass to enable source generator - Naming Convention: Use simplified
{table}_journalnaming (e.g.,users_journalnotidentity_users_journal) - Base Class Location: Move JournalTable from Prism.Database to Prism.Abstractions
- Temporal Functions: Deploy *_at.sql functions alongside journal infrastructure
- Migration Ownership: Each component creates its own journal tables (in prism schema) via its own migrations
Current State Analysis
What's Working
- ADR 035 - Well-documented architecture for centralized journals in
prismschema - JournalTable base class -
strata/core/src/Acsis.Dynaplex/JournalTable.cs - Source Generator -
JournalEntityGeneratorautomatically copies properties from source entities - journal_writer.sql - Generic PostgreSQL trigger function exists
- Prism journal entities - PassportLog, IdentifierLog, AttributeDefinitionLog, AttributeAssignmentLog, AttributeOptionLog, AttributeOptionCollectionLog, AttributeValueLog
- Component journal entities - Identity (5), Catalog (3), Printing (3), Events (1), IoT (1)
- Temporal query functions -
*_at.sqlfunctions for point-in-time queries - Slash command -
/generate-journal-entityupdated for source generator pattern
What's Broken/Incomplete
- Roslyn analyzers are outdated - Looking for
AuditOfAttributeandJournalRowBasewhich don't exist - No triggers deployed - The journal_writer function exists but no triggers call it
- ~40 missing journal tables - Critical domain entities have no audit trail
- Schema attribute missing - Existing journal entities don't have
Schema = "prism"in Table attribute - Documentation inconsistency - Docs say Order starts at 6, but JournalTable uses 100-106
Gap Analysis by Component
HIGH PRIORITY (Security/Compliance Critical)
| Component | Entity | Table | Status |
|---|---|---|---|
| identity | User | users | ❌ Missing |
| identity | Role | roles | ❌ Missing |
| identity | Tenant | tenants | ❌ Missing |
| identity | Group | groups | ❌ Missing |
HIGH PRIORITY (Core Business Operations)
| Component | Entity | Table | Status |
|---|---|---|---|
| catalog | Item | items | ❌ Missing |
| spatial | Location | locations | ❌ Missing |
| spatial | Movement | movements | ❌ Missing |
| transport | Shipment | shipments | ❌ Missing |
MEDIUM PRIORITY (Important Configuration)
| Component | Entity | Table | Status |
|---|---|---|---|
| catalog | ItemType | item_types | ❌ Missing |
| catalog | ItemStatus | item_statuses | ❌ Missing |
| spatial | Address | addresses | ❌ Missing |
| spatial | Region | regions | ❌ Missing |
| transport | Delivery | deliveries | ❌ Missing |
| workflow | Workflow | workflows | ❌ Missing |
| workflow | WorkflowRun | workflow_runs | ❌ Missing |
| printing | LabelTemplate | label_templates | ❌ Missing |
| printing | Printer | printers | ❌ Missing |
| iot | Device | devices | ❌ Missing |
| bbu | Customer | customers | ❌ Missing |
| events | Event | events | ❌ Missing |
EXCLUDED (High Volume/Operational - User Decision)
These tables are intentionally excluded from journaling per user decision:
- bbu.TagReadProcessing, CameraReadProcessing, MovementDeduplication
- iot.DeviceConnectionEvent
- printing.PrintJob
Implementation Approach (Phased)
Phase 1: Fix Infrastructure
- Move JournalTable to Abstractions - Enable other components to reference it
- Fix Roslyn analyzers - Update to use
JournalForAttributeandJournalTable - Fix existing Prism journal entities - Add
Schema = "prism"to Table attributes - Deploy journal_writer() function - Currently not deployed!
- Deploy temporal query functions - passports_at, identifiers_at, etc.
- Add triggers for existing journal tables - 7 tables in Prism need triggers
Phase 2: Identity Component (Security Critical)
- Create UserLog, RoleLog, TenantLog, GroupLog
- Configure in IdentityDb context
- Create and deploy triggers
- Add EF Core migrations
Phase 3: Catalog Component (COMPLETED)
Journal Entity Files (using source generator):
engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/ItemLog.cs✅engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/ItemTypeLog.cs✅engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/ItemCategoryLog.cs✅
Phase 4: Printing Component (COMPLETED)
Journal Entity Files (using source generator):
engines/printing/src/Acsis.Dynaplex.Engines.Printing.Database/PrinterLog.cs✅engines/printing/src/Acsis.Dynaplex.Engines.Printing.Database/LabelTemplateLog.cs✅engines/printing/src/Acsis.Dynaplex.Engines.Printing.Database/LabelTemplateFieldLog.cs✅
Phase 5: Events Component (COMPLETED)
Journal Entity Files (using source generator):
engines/events/src/Acsis.Dynaplex.Engines.Events.Database/EventLog.cs✅
Phase 6: IoT Component (COMPLETED)
Journal Entity Files (using source generator):
engines/iot/src/Acsis.Dynaplex.Engines.Iot.Database/DeviceLog.cs✅
Phase 7: Remaining Components (TODO)
Spatial:
- LocationLog, LocationCategoryLog, MovementLog, MovementPropertyLog, AddressLog, RegionLog
Transport:
- ShipmentLog, DeliveryLog, ShipmentItemAllocationLog
Workflow:
- WorkflowLog, WorkflowCategoryLog, WorkflowEventLog, WorkflowRunLog
BBU:
- CustomerLog
System Environment:
- OrganizationLog, SchedulerLog, TenantVocabularyLog, VocabTermLog
Note: Some entities use legacy integer IDs (long/int/short) instead of Guids. The journal system handles these correctly. See ADR-035 for the complete list.
Technical Tasks Per Entity
For each journal entity, the following work is required:
1. Create Journal Entity Class (Using Source Generator)
engines/{component}/src/Acsis.Dynaplex.Engines.{Component}.Database/{Entity}Log.cs
- Apply
[JournalFor(typeof(SourceEntity))]attribute - Make class
partial - Inherit from
JournalTable - Add
[Table(TABLE_NAME, Schema = "prism")] - Define
TABLE_NAMEconstant:"{table}_journal"(e.g.,"users_journal") - DO NOT manually copy properties - source generator handles this
2. Configure in DbContext
- Add
DbSet<{Entity}Log> - Configure composite key
(Id, SeqId) - Register trigger with
.HasTrigger()
3. Create Trigger SQL
CREATE TRIGGER tr_{table}_journal
AFTER INSERT OR UPDATE ON {schema}.{table}
FOR EACH ROW EXECUTE FUNCTION prism.journal_writer();
4. Add Migration
- Generate EF Core migration for new journal table
- Include trigger creation in migration
Files to Modify/Create
Phase 1: Infrastructure Fixes
1. Move JournalTable base class:
- FROM:
engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/JournalTable.cs - TO:
engines/prism/src/Acsis.Dynaplex.Engines.Prism.Abstractions/Database/JournalTable.cs - Update namespace from
Acsis.Dynaplex.Engines.Prism.DatabasetoAcsis.Dynaplex.Engines.Prism.Abstractions.Database - Update all references in Prism.Database journal entities
2. Roslyn Analyzers (FIX):
strata/analyzers/src/Acsis.RoslynAnalyzers/JournalEntityClassAnalyzer.cs- Change
AuditGen.AuditOfAttribute→Acsis.Dynaplex.JournalForAttribute - Change
JournalRowBase→JournalTable - Update skip list:
SeqId, ValidFrom, ValidTo, InitiatedByUserId, InvalidatedByUserId, InitiatedByPermissionId, InvalidatedByPermissionId
- Change
strata/analyzers/src/Acsis.RoslynAnalyzers/JournalEntityCodeFixProvider.cs- Same fixes as analyzer
3. Prism Journal Entities (FIX Schema attribute + namespace):
engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/PassportLog.csengines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/IdentifierLog.csengines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/AttributeDefinitionLog.csengines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/AttributeAssignmentLog.csengines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/AttributeOptionLog.csengines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/AttributeOptionCollectionLog.csengines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/AttributeValueLog.cs
4. Prism Migration (NEW - deploy infrastructure):
engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/Migrations/YYYYMMDD_AddJournalInfrastructure.cs- Deploy
prism.journal_writer()function (from Routines/journal_writer.sql) - Deploy temporal functions:
passports_at(),identifiers_at(),attribute_definitions_at(),
attribute_options_at(),attribute_options_collections_at(),attribute_values_at() - Create triggers for all 7 existing journal tables:
tr_passports_journal,tr_identifiers_journal,tr_attribute_definitions_journal,
tr_attribute_assignments_journal,tr_attribute_options_journal,
tr_attribute_option_collections_journal,tr_attribute_values_journal
- Deploy
Phase 2: Identity Component (COMPLETED)
Journal Entity Files (using source generator):
engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/UserLog.cs✅engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/RoleLog.cs✅engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/TenantLog.cs✅engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/GroupLog.cs✅engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/PermissionLog.cs✅
DbContext Modifications:
engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/IdentityDb.cs- Add DbSets for journal entities
- Configure composite keys
- Register triggers
New Migration:
engines/identity/src/Acsis.Dynaplex.Engines.Identity.Database/Migrations/- Create journal tables in prism schema
- Create triggers
Subsequent Phases (Catalog, Spatial, Transport, etc.)
Follow same pattern as Identity - create journal entities, modify DbContext, add migration.
Execution Notes
- Use source generator pattern - Create minimal partial class with
[JournalFor(typeof(Entity))] - Use
/generate-journal-entityslash command for guided creation - Naming: Use
{table}_journalformat (e.g.,users_journal, notidentity_users_journal) - Location: Journal entities go in
.Databaseproject alongside source entities - SQL triggers go in migrations using
migrationBuilder.Sql() - Test each phase before moving to next
- One PR per phase for easier review
Source Generator Reference
Location: strata/source-generators/src/Acsis.Dynaplex.Strata.SourceGenerators/JournalEntityGenerator.cs
Template:
using System.ComponentModel.DataAnnotations.Schema;
using Acsis.Dynaplex;
namespace Acsis.Dynaplex.Engines.{Component}.Database;
/// <summary>
/// Journal entity for <see cref="{Entity}"/>. Properties are auto-generated.
/// </summary>
[JournalFor(typeof({Entity}))]
[Table(TABLE_NAME, Schema = "prism")]
public partial class {Entity}Log : JournalTable {
public const string TABLE_NAME = "{table}_journal";
}
To view generated code, add to .csproj:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Generated/**/*.cs" />
</ItemGroup>
Backfill Strategy
After journal tables and triggers are created, existing entities need initial journal entries. This is a one-time migration to establish the audit trail baseline.
Backfill Parameters
- Sentinel Date:
'2025-12-01 00:00:00+00'::timestamptzfor entities without timestamps - System User ID:
'00000000-0000-0000-0000-000000000000'::uuid(nil GUID) - Initial seq_id: 1
- valid_to:
'infinity'::timestamptz
Entities WITH Existing Timestamps
These entities have create_date or created_at columns - use actual creation time for valid_from:
| Component | Entity | Timestamp Column |
|---|---|---|
| Identity | User | create_date |
| Identity | Permission | created_at |
| Identity | UserProperty | create_date |
| Identity | UserSecurity | create_date |
| Workflow | Workflow | create_date |
| Workflow | WorkflowCategory | create_date |
| Workflow | WorkflowEvent | create_date |
| Workflow | WorkflowRun | create_date |
| IoT | Device | created_at |
| System Environment | Scheduler | create_date |
| System Environment | TenantVocabulary | created_date |
Backfill SQL Pattern (with existing timestamp):
INSERT INTO prism.users_journal
SELECT
(jsonb_populate_record(NULL::identity.users, to_jsonb(u))).*,
1::int, -- seq_id
u.create_date, -- valid_from (use actual timestamp)
'infinity'::timestamptz, -- valid_to
'00000000-0000-0000-0000-000000000000'::uuid, -- initiated_by_user_id
NULL::uuid, -- invalidated_by_user_id
NULL::uuid, -- initiated_by_permission_id
NULL::uuid -- invalidated_by_permission_id
FROM identity.users u
WHERE NOT EXISTS (
SELECT 1 FROM prism.users_journal j WHERE j.id = u.id
);
Entities WITHOUT Timestamps
These entities will use the sentinel date 2025-12-01:
| Component | Entities |
|---|---|
| Identity | Group, Role, Tenant |
| Catalog | Item, ItemCategory, ItemType, ItemStatus |
| Spatial | Location, LocationCategory, Address, Region, Movement, MovementProperty, AzureAddress*, Country* |
| Prism | Passport**, Identifier, AttributeDefinition, AttributeAssignment, AttributeOption, AttributeOptionCollection, AttributeValue |
| Transport | Shipment, Delivery, ShipmentItemAllocation |
| Events | Event |
| Printing | Printer, LabelTemplate, LabelTemplateField |
| System Environment | Organization*, VocabTerm* |
*Uses legacy integer ID (long/int/short) - journal system handles this correctly.
**Passport uses global_id instead of id - see special case below.
Backfill SQL Pattern (sentinel date):
INSERT INTO prism.items_journal
SELECT
(jsonb_populate_record(NULL::catalog.items, to_jsonb(i))).*,
1::int, -- seq_id
'2025-12-01 00:00:00+00'::timestamptz, -- valid_from (sentinel)
'infinity'::timestamptz, -- valid_to
'00000000-0000-0000-0000-000000000000'::uuid, -- initiated_by_user_id
NULL::uuid, -- invalidated_by_user_id
NULL::uuid, -- initiated_by_permission_id
NULL::uuid -- invalidated_by_permission_id
FROM catalog.items i
WHERE NOT EXISTS (
SELECT 1 FROM prism.items_journal j WHERE j.id = i.id
);
Special Case: Passport (global_id)
Passport uses global_id as its primary key instead of id:
INSERT INTO prism.passports_journal
SELECT
(jsonb_populate_record(NULL::prism.passports, to_jsonb(p))).*,
1::int,
'2025-12-01 00:00:00+00'::timestamptz,
'infinity'::timestamptz,
'00000000-0000-0000-0000-000000000000'::uuid,
NULL::uuid,
NULL::uuid,
NULL::uuid
FROM prism.passports p
WHERE NOT EXISTS (
SELECT 1 FROM prism.passports_journal j WHERE j.global_id = p.global_id
);
Special Case: Legacy Integer ID Entities
Entities with long, int, or short primary keys work the same way - the journal_writer() function handles any ID type via dynamic SQL:
-- Example: Organization (long id)
INSERT INTO prism.organizations_journal
SELECT
(jsonb_populate_record(NULL::system_environment.organizations, to_jsonb(o))).*,
1::int,
'2025-12-01 00:00:00+00'::timestamptz,
'infinity'::timestamptz,
'00000000-0000-0000-0000-000000000000'::uuid,
NULL::uuid,
NULL::uuid,
NULL::uuid
FROM system_environment.organizations o
WHERE NOT EXISTS (
SELECT 1 FROM prism.organizations_journal j WHERE j.id = o.id
);
-- Example: VocabTerm (int id)
INSERT INTO prism.vocab_terms_journal
SELECT
(jsonb_populate_record(NULL::system_environment.vocab_terms, to_jsonb(v))).*,
1::int,
'2025-12-01 00:00:00+00'::timestamptz,
'infinity'::timestamptz,
'00000000-0000-0000-0000-000000000000'::uuid,
NULL::uuid,
NULL::uuid,
NULL::uuid
FROM system_environment.vocab_terms v
WHERE NOT EXISTS (
SELECT 1 FROM prism.vocab_terms_journal j WHERE j.id = v.id
);
Backfill Execution
Backfill SQL should be added to each component's journal migration, immediately after creating the journal table and trigger. This ensures:
- Backfill runs at the right time (journal table exists, source data exists)
- Each component owns its own backfill logic
- Idempotent via
NOT EXISTSclause
Validation
After backfill, verify with:
-- Count should match source table
SELECT
(SELECT COUNT(*) FROM identity.users) as source_count,
(SELECT COUNT(*) FROM prism.users_journal WHERE seq_id = 1) as journal_count;
Testing Approach
The journal system requires comprehensive testing across multiple levels to ensure correctness, data integrity, and temporal query accuracy.
Test Categories
1. Trigger Behavior Tests
These tests verify the core journal_writer() function works correctly for all operations.
INSERT Trigger Test:
-- Setup: Set the user context
SET app.user_id = '11111111-1111-1111-1111-111111111111';
-- Insert a new entity
INSERT INTO identity.users (id, email, username, create_date)
VALUES ('22222222-2222-2222-2222-222222222222', 'test@example.com', 'testuser', NOW());
-- Verify journal entry was created
SELECT * FROM prism.users_journal
WHERE id = '22222222-2222-2222-2222-222222222222';
-- Assertions:
-- 1. seq_id = 1 (first version)
-- 2. valid_from = approximate NOW()
-- 3. valid_to = 'infinity'
-- 4. initiated_by_user_id = '11111111-1111-1111-1111-111111111111'
-- 5. invalidated_by_user_id IS NULL
-- 6. initiated_by_permission_id = value from app.permission_id (or NULL if not set)
-- 7. invalidated_by_permission_id IS NULL
-- 8. All entity columns match source
UPDATE Trigger Test:
-- Update the entity
UPDATE identity.users
SET email = 'updated@example.com'
WHERE id = '22222222-2222-2222-2222-222222222222';
-- Verify: Should now have 2 journal entries
SELECT seq_id, email, valid_from, valid_to, initiated_by_user_id, invalidated_by_user_id
FROM prism.users_journal
WHERE id = '22222222-2222-2222-2222-222222222222'
ORDER BY seq_id;
-- Assertions for seq_id=1 (old version):
-- 1. valid_to = approximate NOW() (no longer infinity)
-- 2. invalidated_by_user_id = '11111111-1111-1111-1111-111111111111'
-- 3. email = 'test@example.com' (original value)
-- Assertions for seq_id=2 (new version):
-- 1. valid_to = 'infinity'
-- 2. invalidated_by_user_id IS NULL
-- 3. email = 'updated@example.com' (new value)
DELETE Trigger Test:
-- Delete the entity
DELETE FROM identity.users
WHERE id = '22222222-2222-2222-2222-222222222222';
-- Verify: Last journal entry should be closed
SELECT seq_id, valid_from, valid_to, invalidated_by_user_id
FROM prism.users_journal
WHERE id = '22222222-2222-2222-2222-222222222222'
ORDER BY seq_id;
-- Assertions for highest seq_id:
-- 1. valid_to = approximate NOW() (no longer infinity)
-- 2. invalidated_by_user_id = '11111111-1111-1111-1111-111111111111'
2. User Context Enforcement Test
-- Clear user context
RESET app.user_id;
-- Attempt to insert (should FAIL)
INSERT INTO identity.users (id, email, username, create_date)
VALUES ('33333333-3333-3333-3333-333333333333', 'fail@example.com', 'failuser', NOW());
-- Expected error: "app.user_id must be set to a valid user id when modifying users"
3. Legacy Integer ID Tests
-- Test entities with long/int/short primary keys
SET app.user_id = '11111111-1111-1111-1111-111111111111';
-- Insert (e.g., Organization with long id)
INSERT INTO system_environment.organizations (name)
VALUES ('Test Organization');
-- Verify journal entry was created with the generated long id
SELECT o.id, oj.id, oj.seq_id, oj.name
FROM system_environment.organizations o
JOIN prism.organizations_journal oj ON o.id = oj.id
WHERE o.name = 'Test Organization';
-- Assertions: IDs match, seq_id = 1
4. Temporal Query Function Tests
Test the *_at() functions for point-in-time queries:
-- Setup: Create entity and update it with delays
SET app.user_id = '11111111-1111-1111-1111-111111111111';
-- Record timestamps
SELECT NOW() AS t1; -- Before insert
INSERT INTO identity.users (id, email, username, create_date)
VALUES ('44444444-4444-4444-4444-444444444444', 'v1@example.com', 'user_v1', NOW());
SELECT pg_sleep(0.5);
SELECT NOW() AS t2; -- After insert, before update
UPDATE identity.users
SET email = 'v2@example.com'
WHERE id = '44444444-4444-4444-4444-444444444444';
SELECT pg_sleep(0.5);
SELECT NOW() AS t3; -- After update
-- Test temporal queries
-- At t1: Should return NULL (entity didn't exist)
SELECT * FROM prism.users_at(t1)
WHERE id = '44444444-4444-4444-4444-444444444444';
-- At t2: Should return v1@example.com
SELECT email FROM prism.users_at(t2)
WHERE id = '44444444-4444-4444-4444-444444444444';
-- At t3: Should return v2@example.com
SELECT email FROM prism.users_at(t3)
WHERE id = '44444444-4444-4444-4444-444444444444';
5. Backfill Verification Tests
-- Verify all entities have at least one journal entry
-- Run for each entity type
-- Identity
SELECT 'users' as entity,
(SELECT COUNT(*) FROM identity.users) as source_count,
(SELECT COUNT(DISTINCT id) FROM prism.users_journal) as journal_count,
(SELECT COUNT(*) FROM identity.users u
WHERE NOT EXISTS (SELECT 1 FROM prism.users_journal j WHERE j.id = u.id)) as missing_count;
-- Verify all backfilled entries have seq_id = 1
SELECT COUNT(*) as invalid_seq_count
FROM prism.users_journal
WHERE seq_id != 1
AND initiated_by_user_id = '00000000-0000-0000-0000-000000000000';
-- Verify all current entries have valid_to = infinity
SELECT COUNT(*) as unclosed_count
FROM prism.users_journal j1
WHERE j1.valid_to = 'infinity'
AND NOT EXISTS (
SELECT 1 FROM identity.users u WHERE u.id = j1.id
);
6. Edge Case Tests
Rapid Successive Updates:
SET app.user_id = '11111111-1111-1111-1111-111111111111';
INSERT INTO identity.users (id, email, username, create_date)
VALUES ('55555555-5555-5555-5555-555555555555', 'rapid@example.com', 'rapid', NOW());
-- Rapid updates in quick succession
UPDATE identity.users SET email = 'v1@example.com' WHERE id = '55555555-5555-5555-5555-555555555555';
UPDATE identity.users SET email = 'v2@example.com' WHERE id = '55555555-5555-5555-5555-555555555555';
UPDATE identity.users SET email = 'v3@example.com' WHERE id = '55555555-5555-5555-5555-555555555555';
-- Verify all 4 versions are captured
SELECT seq_id, email, valid_from, valid_to
FROM prism.users_journal
WHERE id = '55555555-5555-5555-5555-555555555555'
ORDER BY seq_id;
-- Assertions:
-- 1. Should have 4 entries (seq_id 1-4)
-- 2. Only seq_id=4 should have valid_to = infinity
-- 3. Each version should have distinct valid_from/valid_to ranges
NULL Value Preservation:
-- Verify nullable columns preserve NULL correctly
SET app.user_id = '11111111-1111-1111-1111-111111111111';
INSERT INTO identity.users (id, email, username, middle_name, create_date)
VALUES ('66666666-6666-6666-6666-666666666666', 'null@example.com', 'nulltest', NULL, NOW());
SELECT middle_name FROM prism.users_journal
WHERE id = '66666666-6666-6666-6666-666666666666';
-- Assertion: middle_name should be NULL
Integration Test Script
Create a comprehensive test script at tests/journal-system-tests.sql:
-- Journal System Integration Tests
-- Run after migrations to verify complete system functionality
\echo '=== Journal System Integration Tests ==='
-- Test 1: Trigger Infrastructure
\echo 'Test 1: Verifying journal_writer function exists'
SELECT EXISTS (
SELECT 1 FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname = 'prism' AND p.proname = 'journal_writer'
) as function_exists;
-- Test 2: All triggers are installed
\echo 'Test 2: Verifying triggers are installed'
SELECT schemaname, tablename, COUNT(*) as trigger_count
FROM pg_triggers
WHERE tgfoid = (
SELECT oid FROM pg_proc WHERE proname = 'journal_writer'
)
GROUP BY schemaname, tablename
ORDER BY schemaname, tablename;
-- Test 3: Journal tables exist in prism schema
\echo 'Test 3: Verifying journal tables exist'
SELECT tablename
FROM pg_tables
WHERE schemaname = 'prism'
AND tablename LIKE '%_journal'
ORDER BY tablename;
-- Test 4: All journal tables have correct composite key
\echo 'Test 4: Verifying composite primary keys'
SELECT tc.table_name, string_agg(kcu.column_name, ', ' ORDER BY kcu.ordinal_position) as pk_columns
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = 'prism'
AND tc.table_name LIKE '%_journal'
GROUP BY tc.table_name
ORDER BY tc.table_name;
-- Test 5: Backfill completeness (sample check)
\echo 'Test 5: Backfill completeness verification'
SELECT
'identity.users' as entity,
(SELECT COUNT(*) FROM identity.users) as source,
(SELECT COUNT(DISTINCT id) FROM prism.users_journal) as journaled,
(SELECT COUNT(*) FROM identity.users) - (SELECT COUNT(DISTINCT id) FROM prism.users_journal) as gap
UNION ALL
SELECT
'catalog.items',
(SELECT COUNT(*) FROM catalog.items),
(SELECT COUNT(DISTINCT id) FROM prism.items_journal),
(SELECT COUNT(*) FROM catalog.items) - (SELECT COUNT(DISTINCT id) FROM prism.items_journal);
\echo '=== Tests Complete ==='
.NET Integration Tests
For testing journal behavior from the application layer, create integration tests:
// tests/Acsis.Dynaplex.Engines.Identity.Tests/JournalIntegrationTests.cs
[Fact]
public async Task Insert_CreatesJournalEntry()
{
// Arrange
await using var context = await CreateDbContextAsync();
context.SetCurrentUserId(TestUserId);
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com",
Username = "testuser"
};
// Act
context.Users.Add(user);
await context.SaveChangesAsync();
// Assert
var journalEntry = await context.UserLogs
.SingleOrDefaultAsync(j => j.Id == user.Id);
Assert.NotNull(journalEntry);
Assert.Equal(1, journalEntry.SeqId);
Assert.Equal("infinity", journalEntry.ValidTo.ToString());
Assert.Equal(TestUserId, journalEntry.InitiatedByUserId);
}
[Fact]
public async Task Update_ClosesOldEntryAndCreatesNew()
{
// Arrange
await using var context = await CreateDbContextAsync();
context.SetCurrentUserId(TestUserId);
var user = new User
{
Id = Guid.NewGuid(),
Email = "original@example.com",
Username = "testuser"
};
context.Users.Add(user);
await context.SaveChangesAsync();
// Act
user.Email = "updated@example.com";
await context.SaveChangesAsync();
// Assert
var entries = await context.UserLogs
.Where(j => j.Id == user.Id)
.OrderBy(j => j.SeqId)
.ToListAsync();
Assert.Equal(2, entries.Count);
// Old entry should be closed
Assert.NotEqual(DateTimeOffset.MaxValue, entries[0].ValidTo);
Assert.Equal(TestUserId, entries[0].InvalidatedByUserId);
// New entry should be current
Assert.Equal("updated@example.com", entries[1].Email);
Assert.Equal(DateTimeOffset.MaxValue, entries[1].ValidTo);
Assert.Null(entries[1].InvalidatedByUserId);
}
[Fact]
public async Task Insert_WithoutUserContext_Throws()
{
// Arrange
await using var context = await CreateDbContextAsync();
// Intentionally NOT setting user context
var user = new User
{
Id = Guid.NewGuid(),
Email = "test@example.com",
Username = "testuser"
};
// Act & Assert
context.Users.Add(user);
var exception = await Assert.ThrowsAsync<PostgresException>(
() => context.SaveChangesAsync());
Assert.Contains("app.user_id must be set", exception.Message);
}
Test Execution Checklist
Before considering the journal system complete, verify:
- All
journal_writer()triggers are installed (check pg_triggers) - All journal tables exist in prism schema
- All journal tables have composite PK (id, seq_id)
- INSERT creates journal entry with seq_id=1, valid_to=infinity
- UPDATE closes old entry (valid_to, invalidated_by_user_id) and creates new entry
- DELETE closes the current entry
- Operations without app.user_id are rejected
- Legacy integer ID entities work correctly
- Passport (global_id) works correctly
- All existing entities have been backfilled
- Temporal query functions return correct point-in-time data
- Null values are preserved correctly
- Rapid successive updates are captured correctly