Skip to content

Fix INSERT OR REPLACE tracking in sqlite-chronicle#21

Merged
simonw merged 5 commits intomainfrom
claude/test-insert-or-replace-9sfAn
Feb 15, 2026
Merged

Fix INSERT OR REPLACE tracking in sqlite-chronicle#21
simonw merged 5 commits intomainfrom
claude/test-insert-or-replace-9sfAn

Conversation

@simonw
Copy link
Owner

@simonw simonw commented Feb 15, 2026

Check that INSERT OR REPLACE does not work correctly with the current implementation - is there a test relating to this?

pull latest code from main and merge it into this branch, then
git clone https://gist.github.com/e182ccd7cf613683f6d0877c5a931a27.git to /tmp - this has a proposed solution for insert or replace, apply that solution using red/green TDD

Run this: uvx showboat --help - then build a showboat document that demonstrates this and all other important patterns in detail, providing evidence that the triggers created by this library work as they should. Call the new file examples/trigger-demos.md

Summary

This PR fixes sqlite-chronicle to correctly track INSERT OR REPLACE operations. Previously, the library required users to use UPSERT (INSERT…ON CONFLICT DO UPDATE) instead of INSERT OR REPLACE due to SQLite's conflict resolution propagation into trigger bodies. This change implements proper support for INSERT OR REPLACE by using a snapshot table and careful trigger logic.

Key Changes

  • Snapshot table mechanism: Added _chronicle_{table_name}_snapshot table to capture the old row state before SQLite's internal DELETE during REPLACE operations.

  • BEFORE INSERT trigger: New trigger captures the current row data (if it exists) into the snapshot table before the REPLACE's implicit delete occurs.

  • Revised AFTER INSERT trigger: Replaced INSERT OR IGNORE with INSERT...WHERE NOT EXISTS to avoid SQLite's conflict resolution propagation. The trigger now:

    • Un-deletes previously deleted rows when re-inserted
    • Detects actual data changes by comparing new values against the snapshot
    • Only bumps version numbers when data actually changes
    • Creates new chronicle entries for fresh inserts
  • AFTER DELETE trigger guard: Added WHEN NOT EXISTS(SELECT 1 FROM snapshot...) clause to prevent spurious deletion markers when the DELETE is part of an internal REPLACE operation.

  • Compound primary key support: Snapshot keys use json_array() for compound PKs and simple CAST for single PKs.

  • Change detection: Uses JSON array representation of non-PK columns to accurately detect whether data actually changed, preventing no-op version bumps.

  • Upgrade path: Updated upgrade_chronicle() to handle migration of old trigger names and create the snapshot table.

Notable Implementation Details

  • The snapshot table is cleaned up after each INSERT trigger execution to avoid accumulating stale data.
  • The solution preserves __added_ms timestamps across REPLACE operations, only updating __updated_ms and __version when data actually changes.
  • Comprehensive test coverage added for single PK, compound PK, and re-insertion after deletion scenarios.

https://claude.ai/code/session_01F8BBUJDEQmdTQwjivJFf59

INSERT OR REPLACE was incorrectly tracked because SQLite propagates the
outer statement's conflict resolution strategy into trigger bodies,
turning INSERT OR IGNORE into INSERT OR REPLACE. Additionally, when
recursive_triggers is ON (as sqlite-utils enables), the implicit DELETE
within REPLACE fires the AFTER DELETE trigger, causing spurious
__deleted=1 markings and wasted version numbers.

The fix introduces:
- A snapshot table (_chronicle_<table>_snapshot) to store old row data
- A BEFORE INSERT trigger that snapshots non-PK values before REPLACE's
  internal delete (old row is still visible at this point)
- An AFTER INSERT trigger using INSERT...WHERE NOT EXISTS instead of
  INSERT OR IGNORE, with logic to handle un-delete, replace-with-change,
  and fresh insert cases
- A WHEN guard on the AFTER DELETE trigger that skips when a snapshot
  exists (indicating we're inside an INSERT OR REPLACE)

https://claude.ai/code/session_01F8BBUJDEQmdTQwjivJFf59
- Add examples/trigger-demos.md: a showboat document with 13 executable
  demos proving every trigger behavior (basic tracking, conflict
  resolution propagation, INSERT OR REPLACE variants, compound PKs,
  UPSERT, recursive_triggers compatibility, snapshot cleanup, etc.)
- Update README: remove the INSERT OR REPLACE caveat (now fully
  supported), describe the four triggers, and regenerate the cog SQL
  schema block to show the new trigger design

https://claude.ai/code/session_01F8BBUJDEQmdTQwjivJFf59
…snapshot_{table}

Avoids naming confusion if someone has an original table named
"snapshot_x" — the chronicle and snapshot helper tables now both
start with the _chronicle_ prefix family.

https://claude.ai/code/session_01F8BBUJDEQmdTQwjivJFf59
Wrap non-PK column references with quote() inside json_array() calls
so BLOB values survive JSON serialization. Without this, INSERT OR
REPLACE on a table with BLOB columns raised "JSON cannot hold BLOB
values".

Add three new tests:
- test_insert_or_replace_blob_column: BLOB insert, change, no-op
- test_insert_or_replace_null_values: NULL handling in change detection
- test_insert_or_replace_mixed_types: TEXT, INTEGER, REAL, BLOB, NULL

https://claude.ai/code/session_01F8BBUJDEQmdTQwjivJFf59
@simonw
Copy link
Owner Author

simonw commented Feb 15, 2026

@simonw simonw linked an issue Feb 15, 2026 that may be closed by this pull request
@simonw
Copy link
Owner Author

simonw commented Feb 15, 2026

I manually reviewed this all and it looks great.

@simonw simonw merged commit b2e8c03 into main Feb 15, 2026
10 checks passed
simonw added a commit that referenced this pull request Feb 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Get INSERT OR REPLACE INTO working after all

2 participants