2026-06-06 00:55:10 -03:00
|
|
|
# Preserving unique constraint invariants while merging
|
|
|
|
|
|
|
|
|
|
## Summary
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
Concurrent inserts or updates can make multiple visible CRDT rows claim the same
|
|
|
|
|
unique value. The current merge model resolves this without hiding rows because
|
|
|
|
|
of the unique conflict itself: losing unique claims are materialized with
|
2026-06-06 00:55:10 -03:00
|
|
|
deterministic conflict-free visible values.
|
|
|
|
|
|
|
|
|
|
This is the `flag` policy. It is intentionally simple:
|
|
|
|
|
|
|
|
|
|
- all rows remain CRDT facts;
|
2026-09-04 15:37:09 -03:00
|
|
|
- all rows involved in a unique conflict remain visible domain rows;
|
2026-06-06 00:55:10 -03:00
|
|
|
- one deterministic winner keeps the claimed unique value;
|
|
|
|
|
- each loser keeps its own row identity and receives a conflict-free visible
|
|
|
|
|
unique value;
|
|
|
|
|
- no business conflict object is synced;
|
|
|
|
|
- no foreign key retargeting is needed for unique conflicts.
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
This policy is the only unique-conflict policy in space for the current
|
2026-06-06 00:55:10 -03:00
|
|
|
implementation.
|
|
|
|
|
|
|
|
|
|
## Current Policy: Flag
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
Candidate construction, hidden-row release, canonical attempted-value lookup,
|
|
|
|
|
and restoration are defined by
|
|
|
|
|
[Rebuildable FK and unique projection model](projection-model.md#exact-projection-order).
|
|
|
|
|
The `flag` policy operates only on the visible conflict groups supplied by that
|
|
|
|
|
shared planner.
|
|
|
|
|
|
|
|
|
|
For each conflict group among visible rows:
|
2026-06-06 00:55:10 -03:00
|
|
|
|
|
|
|
|
1. Compute each row's unique claim timestamp from the HLC of the unique-indexed
|
|
|
|
|
field or fields. For an insert, use the insert HLC and any incoming field HLCs
|
|
|
|
|
for the indexed fields.
|
|
|
|
|
2. Pick the winner deterministically by oldest unique-claim HLC, using the same
|
|
|
|
|
full HLC ordering as the rest of CRDT merge. This includes node id as the HLC
|
|
|
|
|
tie-break. Row id is only a final fallback for identical HLCs.
|
2026-09-04 15:37:09 -03:00
|
|
|
3. Materialize the winner's canonical claim. This lets a previously released
|
|
|
|
|
visible loser reclaim its authored value after the prior winner is hidden or
|
|
|
|
|
changes its claim.
|
2026-07-03 10:49:23 -03:00
|
|
|
4. Rewrite each loser's visible releasable unique-indexed value to a
|
|
|
|
|
deterministic conflict-free value:
|
2026-09-04 15:37:09 -03:00
|
|
|
- nullable values of a supported candidate type may be released to `null`;
|
2026-06-06 00:55:10 -03:00
|
|
|
- text values receive a stable `__conflict__<rowId>` suffix;
|
|
|
|
|
- UUID values receive a deterministic synthetic UUID.
|
2026-09-04 15:37:09 -03:00
|
|
|
5. Keep every conflict-group row visible. Application code can decide whether
|
|
|
|
|
and how to surface the changed visible value as a business conflict.
|
2026-06-06 00:55:10 -03:00
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
For a composite unique index, fixed partition columns such as `spaceId` are not
|
2026-07-03 10:49:23 -03:00
|
|
|
released, and not every remaining indexed column has to be releasable. The index
|
2026-09-14 13:39:07 -03:00
|
|
|
is supported when at least one non-`spaceId` column can be released
|
2026-07-03 10:49:23 -03:00
|
|
|
deterministically. Stable discriminator columns may remain unchanged; changing
|
|
|
|
|
the releasable column or columns is enough to make the unique tuple conflict-free.
|
2026-09-04 15:37:09 -03:00
|
|
|
Supported candidate types are defined centrally under
|
|
|
|
|
[Dynamic validation](projection-model.md#dynamic-validation);
|
|
|
|
|
nullability alone does not make a type supported.
|
2026-07-03 10:49:23 -03:00
|
|
|
|
2026-07-04 21:16:37 -03:00
|
|
|
For the global foreign-key-only unique indexes that Serverpod emits for synced
|
|
|
|
|
relations, every indexed FK column must be nullable under the current `flag`
|
|
|
|
|
policy. Required FK columns cannot be released without violating either the
|
|
|
|
|
non-null constraint, the FK constraint, or the unique constraint itself.
|
|
|
|
|
|
2026-06-06 00:55:10 -03:00
|
|
|
The rewritten value is a materialized data value, not a synced conflict class.
|
|
|
|
|
The core CRDT protocol does not need to expose unique conflict objects to users.
|
|
|
|
|
|
|
|
|
|
## Why Flag Is The Current Default
|
|
|
|
|
|
|
|
|
|
The `flag` policy avoids the hard second-order effects caused by hiding or
|
|
|
|
|
merging rows:
|
|
|
|
|
|
|
|
|
|
- no child row needs to be moved from one parent row id to another;
|
|
|
|
|
- `ON DELETE RESTRICT` children do not accidentally keep a unique loser alive;
|
|
|
|
|
- cascade chains are not triggered by unique conflict resolution;
|
|
|
|
|
- non-key field updates on both rows remain attached to their original rows;
|
|
|
|
|
- the algorithm stays local to the conflicting unique index and affected rows.
|
|
|
|
|
|
|
|
|
|
This keeps merge behavior associative, commutative, and idempotent without
|
|
|
|
|
requiring a combined unique/FK fixed-point projection.
|
|
|
|
|
|
|
|
|
|
## Deferred Policies
|
|
|
|
|
|
|
|
|
|
The following policies are plausible future schema-level options, but they are
|
|
|
|
|
not part of the current implementation because they require a substantially more
|
|
|
|
|
complex projection algorithm.
|
|
|
|
|
|
|
|
|
|
Both policies should be projections over the canonical `flag` result, not
|
|
|
|
|
separate unique-resolution algorithms. The `flag` layer computes conflict
|
|
|
|
|
components, winners, losers, and released loser values once from CRDT facts:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
unique facts -> conflict components -> winner + losers + released loser values
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Future policies then interpret that canonical result:
|
|
|
|
|
|
|
|
|
|
```text
|
2026-09-04 15:37:09 -03:00
|
|
|
flag = show all conflict-component rows; losers show released unique values
|
2026-06-06 00:55:10 -03:00
|
|
|
overwrite = use flag winner; alias/hide losers in the visible projection
|
|
|
|
|
merge = overwrite projection + deterministic field folding into winner
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
`merge` and `overwrite` must use the original unique claims and conflict
|
|
|
|
|
components from the `flag` projection. They must not infer conflicts from only
|
|
|
|
|
the already-released materialized values.
|
|
|
|
|
|
|
|
|
|
### Overwrite
|
|
|
|
|
|
|
|
|
|
`overwrite` would keep a deterministic winner visible and hide the losers.
|
|
|
|
|
|
|
|
|
|
To be safe, this cannot simply tombstone the loser. A hidden loser may already
|
|
|
|
|
be referenced by visible children, including children using `ON DELETE RESTRICT`
|
|
|
|
|
or deeper mixed cascade/restrict chains. A safe overwrite policy therefore needs
|
|
|
|
|
an internal row-alias projection:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
loserRowId -> winnerRowId
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Foreign key projection would then consume the alias map and materialize child FK
|
|
|
|
|
values through the winner where legal. Without this aliasing step, overwrite can
|
|
|
|
|
produce visible orphans or order-dependent outcomes.
|
|
|
|
|
|
|
|
|
|
### Merge
|
|
|
|
|
|
|
|
|
|
`merge` would also pick a deterministic winner and alias loser rows to it, but
|
|
|
|
|
would additionally fold selected loser fields into the winner.
|
|
|
|
|
|
|
|
|
|
This needs explicit deterministic per-column policies, such as:
|
|
|
|
|
|
|
|
|
|
- `winnerWins`
|
|
|
|
|
- `latestFieldWinsAcrossMergedRows`
|
|
|
|
|
- `preferNonNullLatest`
|
|
|
|
|
- `ignore`
|
|
|
|
|
|
|
|
|
|
These policies must be schema-defined, deterministic on every replica, and
|
|
|
|
|
included in schema compatibility checks. Arbitrary app callbacks inside core
|
|
|
|
|
merge would not be safe unless they are deterministic, versioned, and available
|
|
|
|
|
identically on every replica.
|
|
|
|
|
|
|
|
|
|
## Why Merge And Overwrite Are Deferred
|
|
|
|
|
|
|
|
|
|
`merge` and `overwrite` combine unique projection with foreign key projection.
|
|
|
|
|
The resolver would need to compute, at minimum:
|
|
|
|
|
|
|
|
|
|
- unique conflict groups;
|
|
|
|
|
- winners and losers for each group;
|
|
|
|
|
- row aliases from loser ids to winner ids;
|
|
|
|
|
- hidden-row projection;
|
|
|
|
|
- field projections for merged rows;
|
|
|
|
|
- foreign key repairs over aliases;
|
|
|
|
|
- nested cascade/restrict/set-null/set-default effects;
|
|
|
|
|
- fixed-point recomputation when FK repairs touch unique-indexed values.
|
|
|
|
|
|
|
|
|
|
That interaction can grow quickly with graph depth, shared children, cycles, and
|
|
|
|
|
rows participating in multiple unique indexes. The implementation would need a
|
|
|
|
|
full recomputation oracle and incremental-closure equivalence tests before it is
|
|
|
|
|
safe enough for real data.
|
|
|
|
|
|
|
|
|
|
For now, the `flag` policy gives a safer and smaller invariant:
|
|
|
|
|
|
|
|
|
|
```text
|
|
|
|
|
unique projection changes values, not row identity or row visibility
|
|
|
|
|
```
|
|
|
|
|
|
2026-09-04 15:37:09 -03:00
|
|
|
The `flag` winner/loser policy therefore remains independent of FK action
|
|
|
|
|
semantics even though the shared planner feeds FK-safe candidates into unique
|
|
|
|
|
resolution.
|
2026-06-06 00:55:10 -03:00
|
|
|
|
|
|
|
|
## Invariants
|
|
|
|
|
|
|
|
|
|
- No visible duplicate remains for a unique index after projection.
|
|
|
|
|
- Unique conflict resolution is deterministic from merged CRDT facts.
|
|
|
|
|
- Merge replay does not create additional metadata churn.
|
|
|
|
|
- Splitting the same operations into different sync batches converges to the
|
|
|
|
|
same visible result.
|
|
|
|
|
- Unique projection does not emit app-visible CRDT conflict objects.
|
|
|
|
|
- Current `flag` projection does not hide rows or retarget foreign keys.
|
|
|
|
|
|
|
|
|
|
## Test Cases
|
|
|
|
|
|
|
|
|
|
### Current Flag Policy
|
|
|
|
|
|
|
|
|
|
1. Given two replicas concurrently insert different rows with the same unique
|
|
|
|
|
value, when they merge, then both rows remain visible, the deterministic
|
|
|
|
|
winner keeps the original value, and the loser receives a deterministic
|
|
|
|
|
conflict-free value.
|
|
|
|
|
|
|
|
|
|
2. Given an older insert conflicts with a newer update on another row, when they
|
|
|
|
|
merge, then the row with the older unique-field claim keeps the unique value,
|
|
|
|
|
regardless of the other row's insert time.
|
|
|
|
|
|
|
|
|
|
3. Given two rows concurrently update the same unique field to the same value,
|
|
|
|
|
when they merge, then both rows remain visible and only the deterministic
|
|
|
|
|
winner keeps the claimed value.
|
|
|
|
|
|
|
|
|
|
4. Given the same conflict arrives in one batch or several smaller batches, when
|
|
|
|
|
all operations have merged, then the same row keeps the unique value and the
|
|
|
|
|
same rows receive the same conflict-free values.
|
|
|
|
|
|
|
|
|
|
5. Given a conflicting unique value is a nullable unique column, when the loser
|
|
|
|
|
can be made conflict-free by setting the materialized value to null, then the
|
|
|
|
|
loser remains visible and the unique index remains valid.
|
|
|
|
|
|
|
|
|
|
6. Given a conflicting unique value is a UUID column that is not an FK column,
|
|
|
|
|
when the loser is released, then the materialized UUID is deterministic from
|
|
|
|
|
the table, column, original value, and loser row id.
|
|
|
|
|
|
2026-09-14 13:39:07 -03:00
|
|
|
7. Given a space-scoped composite unique index contains a stable non-releasable
|
2026-07-03 10:49:23 -03:00
|
|
|
discriminator and a releasable text column, when a loser is released, then the
|
|
|
|
|
discriminator remains unchanged and the text column receives the deterministic
|
|
|
|
|
conflict suffix.
|
|
|
|
|
|
2026-06-06 00:55:10 -03:00
|
|
|
### Deferred Merge And Overwrite Policies
|
|
|
|
|
|
2026-07-03 10:49:23 -03:00
|
|
|
8. Given overwrite is enabled for a unique index, when a loser has visible
|
2026-06-06 00:55:10 -03:00
|
|
|
restrict children, then FK projection retargets those children through the
|
|
|
|
|
row alias or blocks the projection deterministically; no visible orphan is
|
|
|
|
|
produced.
|
|
|
|
|
|
2026-07-03 10:49:23 -03:00
|
|
|
9. Given merge is enabled for a unique index, when loser rows contain newer
|
2026-06-06 00:55:10 -03:00
|
|
|
non-key field values, then field folding follows documented per-column
|
|
|
|
|
policies and every replica computes the same winner row.
|
|
|
|
|
|
2026-07-03 10:49:23 -03:00
|
|
|
10. Given a row participates in multiple unique conflict groups, when merge or
|
2026-06-06 00:55:10 -03:00
|
|
|
overwrite projection runs, then row aliases are deterministic and do not form
|
|
|
|
|
cycles.
|
|
|
|
|
|
2026-07-03 10:49:23 -03:00
|
|
|
11. Given FK repair changes a unique-indexed visible value, when combined unique
|
2026-06-06 00:55:10 -03:00
|
|
|
and FK projection runs, then the fixed-point result matches a full
|
|
|
|
|
recomputation oracle.
|
|
|
|
|
|
|
|
|
|
These deferred tests should not be implemented until the row-alias and combined
|
|
|
|
|
projection model exists.
|
|
|
|
|
|
|
|
|
|
## What These Tests Prove
|
|
|
|
|
|
|
|
|
|
- Current implementation safety: the `flag` policy preserves uniqueness without
|
|
|
|
|
row hiding or FK retargeting.
|
|
|
|
|
- Convergence: same CRDT facts produce the same visible unique values.
|
|
|
|
|
- Idempotence: repeated merges do not keep rewriting the same conflict.
|
2026-09-14 13:39:07 -03:00
|
|
|
- Space control: future `merge` and `overwrite` behavior is documented but not
|
2026-06-06 00:55:10 -03:00
|
|
|
treated as supported until the projection algorithm can handle second-order FK
|
|
|
|
|
effects.
|