TemporalRepository save semantics: stable-live-row + historical-copy #13

Closed
opened 2026-04-29 22:52:35 +02:00 by uwe.admin · 4 comments
Owner

Why

TemporalRepository<TDto>::save(...) currently implements close-then-insert semantics: when an entity is updated, the prior live row's valid_until is flipped from SENTINEL to now(), then a new row is inserted with the updated values and valid_until = SENTINEL. The PK id (version UUID) of the live row changes on every update.

The downstream consumer (fewo-webapp#459) accepted a stable-live-row + historical-copy design instead:

UPDATE(a):

  • b = clone(a)
  • T = now()
  • a.valid_from = T
  • b.valid_until = T
  • actually change the columns of a

DELETE(a):

  • a.valid_until = now()

With CASCADE, the FKs show that the entity they reference is no longer valid.

This way the live row's PK never changes; FKs continue to resolve to the same row identity across updates; ON UPDATE CASCADE on every child propagates valid_until flips on delete.

Scope

Switch TemporalRepository<TDto>::save(...) to:

  1. Look up the currently live row for entity_id. If absent, this is a fresh insert — set valid_from=now, valid_until=SENTINEL, save, return.
  2. Otherwise:
    • Clone the existing live row into b. Set b.id = newId() (new PK), b.valid_until = now(). Save b — that's the historical copy.
    • Update the existing live row in place: copy the new dto's mutable fields onto it, set valid_from = now(), leave valid_until = SENTINEL. Save it (same PK as before).

softDelete(...) is unchanged conceptually — set valid_until = now() on the live row. With ON UPDATE CASCADE wired up by the consumer's schema, child FKs follow the change.

findByEntityIdAt(...) and history(...) are read paths and don't change.

Inner adapter contract impact

The inner Repository<TDto> was previously expected to treat save as upsert keyed by (entity_id, valid_from). The new flow still issues two saves per update — one for the historical copy (new PK), one for the live row (existing PK). The keying remains (entity_id, valid_from) as long as we use the row PK as a tiebreaker; the doc on TemporalRepository will be updated to document this clearly.

Acceptance

  • TemporalRepository<TDto>::save(dto) follows the new flow.
  • Existing tests in test_repository_decorators.cpp updated to assert: live PK is stable across updates; historical row PK is fresh; live row's valid_until == SENTINEL after every update.
  • New test: stable-PK invariant — capture live.id after first save, assert it matches after a second save.
  • README updated to describe the new write semantics under the repo/TemporalRepository.hpp row.

Out of scope

  • The composite-FK schema and ON UPDATE CASCADE — those live in the consumer (fewo-webapp#459). The decorator stays schema-agnostic; the inner adapter's save is whatever the consumer's DbClient does.
  • Changes to applyDecoratorMigrations / RESHAPE_STEPS. Those are still composite-unique on (entity_id, valid_until) either way.

Effort

Small (~30–50 LOC + test updates).

Blocks

fewo-webapp#459 PR 1 needs this to land first; it's pinned via authkit version bump.

## Why `TemporalRepository<TDto>::save(...)` currently implements **close-then-insert** semantics: when an entity is updated, the prior live row's `valid_until` is flipped from SENTINEL to `now()`, then a *new* row is inserted with the updated values and `valid_until = SENTINEL`. The PK `id` (version UUID) of the live row changes on every update. The downstream consumer (`fewo-webapp#459`) accepted a **stable-live-row + historical-copy** design instead: > UPDATE(a): > - b = clone(a) > - T = now() > - a.valid_from = T > - b.valid_until = T > - actually change the columns of a > > DELETE(a): > - a.valid_until = now() > > With CASCADE, the FKs show that the entity they reference is no longer valid. This way the live row's PK never changes; FKs continue to resolve to the same row identity across updates; ON UPDATE CASCADE on every child propagates `valid_until` flips on delete. ## Scope Switch `TemporalRepository<TDto>::save(...)` to: 1. Look up the currently live row for `entity_id`. If absent, this is a fresh insert — set `valid_from=now`, `valid_until=SENTINEL`, save, return. 2. Otherwise: - Clone the existing live row into `b`. Set `b.id = newId()` (new PK), `b.valid_until = now()`. Save `b` — that's the historical copy. - Update the existing live row in place: copy the new dto's mutable fields onto it, set `valid_from = now()`, leave `valid_until = SENTINEL`. Save it (same PK as before). `softDelete(...)` is unchanged conceptually — set `valid_until = now()` on the live row. With ON UPDATE CASCADE wired up by the consumer's schema, child FKs follow the change. `findByEntityIdAt(...)` and `history(...)` are read paths and don't change. ## Inner adapter contract impact The inner `Repository<TDto>` was previously expected to treat `save` as upsert keyed by `(entity_id, valid_from)`. The new flow still issues two saves per update — one for the historical copy (new PK), one for the live row (existing PK). The keying remains `(entity_id, valid_from)` as long as we use the row PK as a tiebreaker; the doc on `TemporalRepository` will be updated to document this clearly. ## Acceptance - `TemporalRepository<TDto>::save(dto)` follows the new flow. - Existing tests in `test_repository_decorators.cpp` updated to assert: live PK is stable across updates; historical row PK is fresh; live row's `valid_until == SENTINEL` after every update. - New test: stable-PK invariant — capture `live.id` after first save, assert it matches after a second save. - README updated to describe the new write semantics under the `repo/TemporalRepository.hpp` row. ## Out of scope - The composite-FK schema and ON UPDATE CASCADE — those live in the consumer (`fewo-webapp#459`). The decorator stays schema-agnostic; the inner adapter's `save` is whatever the consumer's DbClient does. - Changes to `applyDecoratorMigrations` / `RESHAPE_STEPS`. Those are still composite-unique on `(entity_id, valid_until)` either way. ## Effort Small (~30–50 LOC + test updates). ## Blocks `fewo-webapp#459` PR 1 needs this to land first; it's pinned via authkit version bump.
Author
Owner

Agent Evaluation

Feasibility: High. The decorator already does close-then-insert in ~30 LOC of save(). Switching to stable-live + historical-copy is a straightforward rewrite — same primitives (findByEntityId, save), different orchestration. Tests already exercise the temporal flow; updating them to assert stable PK is a minor addition.

Impact: Unblocks fewo-webapp#459 PR 1 (and every per-table migration after it). Without this change, the schema migration cannot ship — close-then-insert on a composite-FK schema with stable child references is incoherent.

Effort: Small (~30–50 LOC + test updates). Single decorator method, one new test, README tweak.

Recommendation: Accept.

Implementation plan

  1. include/oatpp-authkit/repo/TemporalRepository.hpp — rewrite save(dto):
    • If no live row exists for entity_id, behave as today (allocate id if null, set valid_from = now, valid_until = SENTINEL, save).
    • If a live row exists: clone it, give the clone a fresh id, set clone.valid_until = now(), save the clone (historical copy). Then update the live row in place — copy the new dto's mutable fields onto the existing live row, set valid_from = now(), leave valid_until = SENTINEL, save (same id).
  2. test/test_repository_decorators.cpp — update test_save_closes_prior_version_and_inserts_new to assert: live row's id is preserved across the second save; the new row's id is the historical copy. Existing assertions on valid_from/valid_until stay.
  3. README — update the repo/TemporalRepository.hpp row to describe the new write semantics. Note ON UPDATE CASCADE on consumer-side child FKs as the propagation mechanism for delete.
  4. Bump CMake version 0.7.0 → 0.8.0 (semantic break — save no longer reallocates the live PK).

No decision needed

Single approach — implementation plan stands.

## Agent Evaluation **Feasibility:** High. The decorator already does close-then-insert in ~30 LOC of `save()`. Switching to stable-live + historical-copy is a straightforward rewrite — same primitives (`findByEntityId`, `save`), different orchestration. Tests already exercise the temporal flow; updating them to assert stable PK is a minor addition. **Impact:** Unblocks `fewo-webapp#459` PR 1 (and every per-table migration after it). Without this change, the schema migration cannot ship — close-then-insert on a composite-FK schema with stable child references is incoherent. **Effort:** Small (~30–50 LOC + test updates). Single decorator method, one new test, README tweak. **Recommendation:** Accept. ### Implementation plan 1. **`include/oatpp-authkit/repo/TemporalRepository.hpp`** — rewrite `save(dto)`: - If no live row exists for `entity_id`, behave as today (allocate id if null, set `valid_from = now`, `valid_until = SENTINEL`, save). - If a live row exists: clone it, give the clone a fresh `id`, set `clone.valid_until = now()`, save the clone (historical copy). Then update the live row in place — copy the new dto's mutable fields onto the existing live row, set `valid_from = now()`, leave `valid_until = SENTINEL`, save (same `id`). 2. **`test/test_repository_decorators.cpp`** — update `test_save_closes_prior_version_and_inserts_new` to assert: live row's `id` is preserved across the second save; the new row's `id` is the historical copy. Existing assertions on `valid_from`/`valid_until` stay. 3. **README** — update the `repo/TemporalRepository.hpp` row to describe the new write semantics. Note ON UPDATE CASCADE on consumer-side child FKs as the propagation mechanism for delete. 4. **Bump CMake version** 0.7.0 → 0.8.0 (semantic break — `save` no longer reallocates the live PK). ### No decision needed Single approach — implementation plan stands.
Author
Owner

Evaluated #13 (Small) — recommend Accept; stable-live + historical-copy rewrite of TemporalRepository::save.

Evaluated #13 (Small) — recommend Accept; stable-live + historical-copy rewrite of TemporalRepository::save.
uwe.admin added the
evaluated
label 2026-04-29 22:55:05 +02:00
u.schuster added the
accepted
label 2026-04-29 23:58:29 +02:00
Author
Owner

Implemented in commit 792e509TemporalRepository<TDto>::save now does stable-live + historical-copy. On update: clone live (via oatpp reflection through polymorphicDispatcher->getProperties()), assign fresh id + valid_until=now, save → INSERT historical; then set dto's id=live.id (preserve PK) + valid_from=now + valid_until=SENTINEL, save → UPDATE live in place by PK. Inner contract changes from "upsert keyed by (entity_id, valid_from)" to "upsert keyed by id". TemporalFieldTraits gains an id() accessor; OATPP_AUTHKIT_REGISTER_TEMPORAL grows to 5 args. Tests updated to assert stable live PK and fresh historical PK across updates — all 10 ctest cases pass. Version bumped 0.7.0 → 0.8.0 (semantic break). README updated.

Implemented in commit 792e509 — `TemporalRepository<TDto>::save` now does stable-live + historical-copy. On update: clone live (via oatpp reflection through `polymorphicDispatcher->getProperties()`), assign fresh `id` + `valid_until=now`, save → INSERT historical; then set dto's `id=live.id` (preserve PK) + `valid_from=now` + `valid_until=SENTINEL`, save → UPDATE live in place by PK. Inner contract changes from "upsert keyed by (entity_id, valid_from)" to "upsert keyed by id". `TemporalFieldTraits` gains an `id()` accessor; `OATPP_AUTHKIT_REGISTER_TEMPORAL` grows to 5 args. Tests updated to assert stable live PK and fresh historical PK across updates — all 10 ctest cases pass. Version bumped 0.7.0 → 0.8.0 (semantic break). README updated.
Author
Owner

Implemented #13 — stable-live + historical-copy semantics shipped in commit 792e509, tagged v0.8.0; all 10 tests pass including new stable-PK assertion.

Implemented #13 — stable-live + historical-copy semantics shipped in commit 792e509, tagged v0.8.0; all 10 tests pass including new stable-PK assertion.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: uwe.admin/oatpp-authkit#13
No description provided.