This implementation demonstrates traditional SQL approaches to versioning and concurrency control using PostgreSQL's MVCC capabilities with manual versioning.
- PostgreSQL 12+ (for
gen_random_uuid()) psqlcommand-line client
createdb todomvcc
psql todomvcc -f schema.sql-- Base table
CREATE TABLE todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL CHECK (length(trim(title)) > 0),
completed BOOLEAN NOT NULL DEFAULT false,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
version BIGINT NOT NULL DEFAULT 1
);
-- History table (append-only)
CREATE TABLE todos_history (
id UUID NOT NULL,
title TEXT NOT NULL,
completed BOOLEAN NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
version BIGINT NOT NULL,
txid BIGINT NOT NULL DEFAULT txid_current(),
PRIMARY KEY (id, version)
);
-- Trigger: copy rows into history on every update/delete
CREATE OR REPLACE FUNCTION todos_audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO todos_history (id, title, completed, updated_at, version, txid)
VALUES (OLD.id, OLD.title, OLD.completed, OLD.updated_at, OLD.version, txid_current());
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER todos_audit
AFTER UPDATE OR DELETE ON todos
FOR EACH ROW EXECUTE FUNCTION todos_audit_trigger();INSERT INTO todos (title) VALUES ('Write spec')
RETURNING *;-- All todos
SELECT * FROM todos ORDER BY updated_at DESC;
-- Only active
SELECT * FROM todos WHERE completed = false ORDER BY updated_at DESC;
-- Only completed
SELECT * FROM todos WHERE completed = true ORDER BY updated_at DESC;-- Toggle complete + increment version
UPDATE todos
SET completed = NOT completed,
updated_at = now(),
version = version + 1
WHERE id = '12345678-1234-1234-1234-123456789abc'
RETURNING *;
-- Edit title
UPDATE todos
SET title = 'Updated title',
updated_at = now(),
version = version + 1
WHERE id = '12345678-1234-1234-1234-123456789abc'
RETURNING *;DELETE FROM todos WHERE id = '12345678-1234-1234-1234-123456789abc';-- Complete version history
SELECT * FROM todos_history
WHERE id = '12345678-1234-1234-1234-123456789abc'
ORDER BY version;
-- Include current state
SELECT id, title, completed, updated_at, version, txid, 'current' as state
FROM todos WHERE id = '12345678-1234-1234-1234-123456789abc'
UNION ALL
SELECT id, title, completed, updated_at, version, txid, 'history' as state
FROM todos_history WHERE id = '12345678-1234-1234-1234-123456789abc'
ORDER BY version;-- Todo state as of specific transaction
SELECT * FROM todos_history
WHERE id = '12345678-1234-1234-1234-123456789abc'
AND txid <= 12345
ORDER BY version DESC
LIMIT 1;
-- All todos as of specific transaction
WITH historical_state AS (
SELECT DISTINCT ON (id) *
FROM todos_history
WHERE txid <= 12345
ORDER BY id, version DESC
)
SELECT * FROM historical_state
ORDER BY updated_at DESC;Session A:
BEGIN;
SELECT * FROM todos WHERE id = '12345678-1234-1234-1234-123456789abc' FOR UPDATE;
-- (holds lock, simulating user editing)Session B:
BEGIN;
UPDATE todos
SET title = 'New title from Session B',
updated_at = now(),
version = version + 1
WHERE id = '12345678-1234-1234-1234-123456789abc';
-- This blocks until Session A commits/rollbacksBack in Session A:
UPDATE todos
SET title = 'New title from Session A',
updated_at = now(),
version = version + 1
WHERE id = '12345678-1234-1234-1234-123456789abc';
COMMIT;Result:
- Session B's update applies after Session A commits
- Version increments show the order of updates
- History table preserves both versions with transaction IDs
-- Application-level optimistic locking
UPDATE todos
SET title = 'Updated title',
updated_at = now(),
version = version + 1
WHERE id = '12345678-1234-1234-1234-123456789abc'
AND version = 5 -- Expected current version
RETURNING *;
-- If no rows affected, version conflict occurred- Readers never block writers (and vice versa)
- Snapshot isolation provides consistent reads
- Row-level locking for fine-grained control
- Application complexity: Must manage version increments
- Trigger maintenance: History tracking requires database triggers
- Concurrency handling: Need explicit locking strategies
- Storage overhead: Duplicate data in history tables
- Query complexity: Joining current and historical data
- Lost updates without proper locking or optimistic concurrency
- Version conflicts require application-level handling
- Trigger failures can break history tracking
- Transaction ID wraparound affects long-term historical queries
| Aspect | PostgreSQL + Manual Versioning | XTDB Bitemporal |
|---|---|---|
| Version Tracking | Manual version column + triggers |
Automatic transaction-time |
| History Storage | Separate _history table |
Built-in temporal storage |
| Concurrency | Locking or optimistic checks | Lock-free optimistic |
| Time Travel | Custom queries with transaction IDs | Native temporal queries |
| Setup Complexity | High (triggers, history tables) | Low (works out of the box) |
| Query Complexity | Complex JOINs for temporal data | Simple AS OF queries |
-
Setup database:
createdb todomvcc psql todomvcc -f schema.sql
-
Insert test data:
INSERT INTO todos (title) VALUES ('Learn PostgreSQL MVCC'); INSERT INTO todos (title) VALUES ('Compare with XTDB'); INSERT INTO todos (title) VALUES ('Build TodoMVCC demo');
-
Test concurrency (open two
psqlsessions and run the concurrency examples) -
Explore history:
UPDATE todos SET completed = true WHERE title = 'Learn PostgreSQL MVCC'; SELECT * FROM todos_history;
This example demonstrates the complexity of implementing versioning and audit trails manually in traditional SQL databases, highlighting why purpose-built temporal databases like XTDB can simplify application development.