feat: Implements foreign key handling (#68)

This commit is contained in:
Marcelo Mendonça Soares 2026-06-10 17:31:05 -03:00 • committed by GitHub
commit 4580326956
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -7,23 +7,67 @@ foreign key violations. The merge result must be associative, commutative, and
idempotent: the same merged CRDT facts must produce the same visible rows and
visible FK values regardless of arrival order, batching, or replay.
The safe model is projection-based. User rows, fields, and tombstones remain the
CRDT facts. FK repairs are deterministic materialized projections over those
facts, not new user field updates emitted opportunistically during merge.
The safe merge model is projection-based. Local ORM operations are still
authored user actions: when a local delete is intercepted for soft delete, the
database-style `ON DELETE` side effects are materialized as CRDT facts in the
same local transaction. FK repairs created only because independently authored
facts were merged are deterministic materialized projections over those facts,
not new user field updates emitted opportunistically during merge.
## Core Policy
- Keep every row as a CRDT fact. Visibility is derived from row tombstones and
schema-driven FK projection.
- Keep the user/attempted FK value as the CRDT field value.
- Materialize a separate visible FK value only when FK repair requires it. Use
`CrdtDataForeignKey` as projection metadata, not as a business conflict class.
- Supported foreign keys are single-column child references to single-column
parent references. Schemas with composite foreign keys must fail recorder
initialization instead of silently bypassing projection.
- Merge input is causally complete: the sync protocol delivers each batch as a
full causal snapshot of the sender, so every merged FK attempt's parent facts
precede or accompany the child fact. Input that violates this precondition
(for example, a non-nullable FK attempt whose parent fact is absent and whose
default target is missing) is outside the merge contract and fails the merge
transaction atomically against the database foreign key constraints.
- Local `ON DELETE` actions are authored CRDT facts. `RESTRICT` and `NO ACTION`
reject the local delete while visible children exist; `SET NULL` and
`SET DEFAULT` update child FK fields and their ordinary `CrdtDataField` HLCs;
`CASCADE` records user-delete tombstones for the visible cascade descendants.
- Keep the user/attempted FK value as the CRDT field value for authored
operations.
- During merge projection, materialize a separate visible FK value only when FK
repair requires it. `CrdtDataForeignKey.attemptedValue` is the durable FK
field-value payload for FK columns because `CrdtDataField` stores only HLC
metadata. `visibleValue`, `hasOverride`, and `overrideReason` are projection
metadata, not business conflict classes.
- Do not create ordinary user field updates for `SET NULL` or `SET DEFAULT`
repairs during merge. Doing so makes repair HLCs depend on merge timing.
- Recompute FK projection from merged facts over the affected FK closure. The
incremental closure result must match a full recomputation oracle.
repairs during merge. Doing so makes repair HLCs depend on merge timing. This
does not apply to locally initiated `SET NULL` or `SET DEFAULT` actions,
which are authored user field updates.
- For FK-affecting operations, recompute FK projection to the same fixed point
as the affected FK closure. A full graph recomputation is a valid
implementation strategy and oracle as long as it produces the same projection.
Operations that touch no child FK column, parent reference column, insert, or
tombstone must not trigger FK projection.
## Action Semantics
## Local Action Semantics
Local deletes preserve database action semantics even though the physical row is
soft-deleted instead of removed:
- `ON DELETE RESTRICT` and `NO ACTION`: the delete fails if any visible child
still references the parent. No parent tombstone is recorded for the failed
operation.
- `ON DELETE SET NULL`: visible child FK columns are updated to `null` when the
FK is nullable, and those child FK fields receive ordinary CRDT field updates.
- `ON DELETE SET DEFAULT`: visible child FK columns are updated to the column
default when the default is legal, and those child FK fields receive ordinary
CRDT field updates.
- `ON DELETE CASCADE`: visible cascade descendants receive synced
user-delete tombstones. Local cascade descendants are not hidden only as
`foreignKeyCascade` projection rows.
These local side effects are the facts that other replicas merge.
## Merge-Time Action Semantics
For each child FK whose attempted value points to a hidden or missing parent:
@ -49,32 +93,52 @@ restrict edges are evaluated first internally.
## Projection Metadata
`CrdtDataForeignKey` should represent the materialized FK projection for one
`CrdtDataField`:
`CrdtDataForeignKey` should represent the FK field payload and materialized FK
projection for one `CrdtDataField`:
- `attemptedValue`: the current user-attempted FK value carried by the CRDT
field. It may be null for nullable FKs.
field. It may be null for nullable FKs. This is durable FK field-value storage
and is part of the CRDT facts for FK columns.
- `visibleValue`: the value currently materialized into the domain row when an
override is active.
- `hasOverride`: whether the projection overrides `attemptedValue` and
materializes `visibleValue` instead.
override is active (`overrideReason` is non-null).
- `overrideReason`: the authoritative stored indicator that an override is
active, and the cause. Non-null means an override is active; null means no
override (the domain row carries `attemptedValue`). Values: `setNull` — the
parent was hidden/deleted and the column was set null by the FK action;
`setDefault` — the column was set to a default FK value; `missingParent` —
the parent is hidden or missing and cannot be repaired, so the row is hidden
by projection. The derived `hasOverride` bool (`overrideReason != null`) is
available as a convenience getter.
`visibleValue == null` is meaningful only with `hasOverride`. When
`hasOverride` is false it means "same as attempted"; when `hasOverride` is true
it means "materialize null".
`visibleValue` is meaningful only when `overrideReason` is non-null. When
`overrideReason` is null the domain row carries `attemptedValue` directly; when
`overrideReason` is non-null and `visibleValue` is null it means "materialize
null".
The resolver may use this metadata for efficient materialization and write
deduplication. It must not use it as the authority for FK conflict decisions;
the authority is the deterministic resolver over merged CRDT facts.
The resolver may use `visibleValue` and `overrideReason` for efficient
materialization and write deduplication. It must not use those projection fields
as the authority for FK conflict decisions; the authority is the deterministic
resolver over merged CRDT row, field, tombstone, and FK attempt facts.
A local full-row update (no column narrowing) that writes an FK value equal to
the materialized projected value is a passthrough of the repair, not an authored
FK change: the existing override and attempted value are preserved and the FK
field HLC does not advance. Writing a different FK value, or updating the FK
column with narrowed columns (even when the written value equals the projected
value), authors the change as an ordinary user field fact: the FK field HLC
advances, `attemptedValue` becomes the new value, and `overrideReason` becomes
null.
## Merge Pipeline
1. Merge CRDT row, field, and tombstone facts by their existing HLC/CLFlag
rules.
2. Identify rows touched by inserts, updates, deletes, FK columns, and existing
FK projection records.
3. Expand to the FK closure in both directions: parents needed by children and
children affected by parent visibility.
2. Identify whether inserts, updates, deletes, FK columns, parent reference
columns, or existing FK projection records can affect FK state. Skip FK
projection when the merge touches none of them.
3. For FK-affecting work, expand to the FK closure in both directions: parents
needed by children and children affected by parent visibility. A full graph
recomputation may be used instead of the minimal closure.
4. Compute effective row visibility and visible FK values to a fixed point.
5. Apply only the materialized differences:
- update domain FK columns when projection differs from attempted value;
@ -89,15 +153,30 @@ the authority is the deterministic resolver over merged CRDT facts.
- No visible row references a hidden or missing parent.
- No cascade delete is partially applied across a blocked nested relation.
- `SET NULL` and `SET DEFAULT` repairs are pure functions of merged facts.
- Merge-time `SET NULL` and `SET DEFAULT` repairs are pure functions of merged
facts. Local `SET NULL` and `SET DEFAULT` actions are authored field facts.
- Local cascade deletes produce synced user-delete tombstones for cascade
descendants; merge-time cascade projection may derive `foreignKeyCascade`
visibility for descendants without authored tombstones.
- Replaying the same merge batch produces no metadata churn.
- Splitting a merge into smaller batches converges to the same final projection
as merging the same operations in one batch.
- Incremental recomputation over the affected closure matches full global
recomputation.
- FK-affecting recomputation over the affected closure matches full global
recomputation; unrelated non-FK updates do not trigger FK projection.
## Test Cases
### Local CRUD Actions
- Given a visible `RESTRICT` or `NO ACTION` child, when the parent is deleted
locally, then the delete fails and no parent tombstone is recorded.
- Given a nullable `SET NULL` child, when the parent is deleted locally, then the
child FK is set to `null` as an authored field update.
- Given a valid `SET DEFAULT` child, when the parent is deleted locally, then
the child FK is set to the default as an authored field update.
- Given a valid cascade chain, when the root is deleted locally, then every
visible cascade descendant receives a user-delete tombstone.
### Restrict and No Action
1. Given a parent and a visible child with `ON DELETE RESTRICT`, when one replica
@ -132,9 +211,9 @@ the authority is the deterministic resolver over merged CRDT facts.
### Cascade
8. Given a parent with children under `ON DELETE CASCADE`, when the parent is
deleted, then all cascade descendants in the valid cascade closure become
hidden and no visible descendant references a hidden ancestor.
8. Given a parent delete fact merged from another replica with children under
`ON DELETE CASCADE`, then all cascade descendants in the valid cascade
closure become hidden and no visible descendant references a hidden ancestor.
9. Given a child already hidden by a cascade projection, when the same delete
facts are merged again, then no additional metadata churn occurs.
@ -146,9 +225,9 @@ the authority is the deterministic resolver over merged CRDT facts.
`B`, and `C` remain visible.
11. Given a fourth-degree chain with alternating cascade, restrict, set-null,
and set-default edges, when a merge touches both a root and a leaf, then
incremental recomputation produces the same visible graph and FK projection
as full recomputation.
and set-default edges, when a merge touches both a root and a leaf, then the
affected-closure result and a full fixed-point recomputation produce the
same visible graph and FK projection.
12. Given an FK cycle that the engine permits, when concurrent deletes and
updates happen around the cycle, then fixed-point evaluation terminates and
@ -165,9 +244,9 @@ the authority is the deterministic resolver over merged CRDT facts.
values, FK projection metadata, unique projection, and tombstones are the
same.
15. Given a merge touches only a non-indexed, non-FK field, when FK projection
runs, then unrelated rows are not reconsidered and the visible state outside
the affected closure remains unchanged.
15. Given a merge touches only a non-indexed, non-FK field that is not a parent
reference column, then FK projection is skipped and the visible FK graph is
unchanged.
## What These Tests Prove
@ -176,5 +255,7 @@ the authority is the deterministic resolver over merged CRDT facts.
- ACI merge behavior: batching, replay, and merge order do not change the final
visible result.
- Nested safety: cascade cannot bypass restrict/no-action descendants.
- Projection discipline: `SET NULL` and `SET DEFAULT` do not become ordinary
user CRDT writes.
- Local action fidelity: local deletes preserve database-style FK side effects
as authored CRDT facts.
- Projection discipline: merge-time `SET NULL` and `SET DEFAULT` repairs do not
become ordinary user CRDT writes.