serverpod-offline-sync/docs/unique-constraint-invariants.md

286 lines
13 KiB
Markdown
Raw Normal View History

# Preserving unique constraint invariants while merging
## Summary
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
deterministic conflict-free visible values.
This is the `flag` policy. It is intentionally simple:
- all rows remain CRDT facts;
- all rows involved in a unique conflict remain visible domain rows;
- 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.
This policy is the only unique-conflict policy in space for the current
implementation.
## Local writes
Local inserts, updates, upserts, and restores obey the database's unique
constraints. Two records cannot claim the same occupied value or non-null
composite tuple in one local write. The database enforces this against stored
rows and other rows in the batch; no additional domain reads are needed to
pre-check uniqueness. A rejected write rolls back its domain changes and sync
metadata together. Ordinary `ignoreConflicts` and upsert predicates still apply.
Claims accepted independently on disconnected replicas can still conflict when
merged. The policy below preserves those accepted claims and chooses their
visible values. Passing an unchanged displayed alternative through a full-row
save preserves the underlying claim and follows the normal field-touch rules.
## Current Policy: Flag
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:
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.
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.
4. Rewrite each loser's visible releasable unique-indexed value to a
deterministic conflict-free value:
- nullable values of a supported candidate type may be released to `null`;
- text values receive a stable `__conflict__<rowId>` suffix;
- UUID values receive a deterministic synthetic UUID.
5. Keep every conflict-group row visible. Application code can decide whether
and how to surface the changed visible value as a business conflict.
For a composite unique index, fixed partition columns such as `spaceId` are not
released, and not every remaining indexed column has to be releasable. The index
is supported when at least one non-`spaceId` column can be released
deterministically. Stable discriminator columns may remain unchanged; changing
the releasable column or columns is enough to make the unique tuple conflict-free.
Supported candidate types are defined centrally under
[Dynamic validation](projection-model.md#dynamic-validation);
nullability alone does not make a type supported.
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.
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.
### Reserved generated values
Authored values in unique text columns must not end in
`__conflict__<UUID>`, `__hidden__<UUID>`, or `__park__<UUID>`, where the UUID uses
the hexadecimal `8-4-4-4-12` shape. The marker is lowercase; hexadecimal letters
can use either case. These names belong exclusively to projection, including
hidden-row release and temporary writes. Non-unique text is unrestricted.
Non-nullable unique UUID columns that are not foreign keys reserve UUID version
8 for generated alternatives (`xxxxxxxx-xxxx-8xxx-xxxx-xxxxxxxxxxxx`). Conflict,
hidden-row, and temporary parking values use the same deterministic hash inputs
as before, with the version marker set to 8. Authored version-8 values in these
columns are rejected; ordinary UUID versions, including 4, 5, and 7, remain
supported. Primary keys, foreign keys, nullable UUID columns, and non-unique UUID
columns do not use synthetic UUID release and are not subject to this rule.
Local writes and incoming sync facts enforce this rule with
`OfflineSyncReservedValueException`. Rejected transactions do not advance
authored data or sync progress. A full-row update/upsert can echo its own
unchanged materialized alternative: it keeps the original authored claim and
uses the normal field-touch semantics. Explicitly authoring an alternative on
another record is rejected, including exchanges of displayed alternatives.
This keeps the existing deterministic text suffix construction. The resolver does
not search currently occupied values for a free alternative. The original claim
is what gets exported, so ordinary conflict projection and bootstrap remain
valid under the input rule.
## 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
flag = show all conflict-component rows; losers show released unique values
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
```
The `flag` winner/loser policy therefore remains independent of FK action
semantics even though the shared planner feeds FK-safe candidates into unique
resolution.
## 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.
7. Given a space-scoped composite unique index contains a stable non-releasable
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.
### Deferred Merge And Overwrite Policies
8. Given overwrite is enabled for a unique index, when a loser has visible
restrict children, then FK projection retargets those children through the
row alias or blocks the projection deterministically; no visible orphan is
produced.
9. Given merge is enabled for a unique index, when loser rows contain newer
non-key field values, then field folding follows documented per-column
policies and every replica computes the same winner row.
10. Given a row participates in multiple unique conflict groups, when merge or
overwrite projection runs, then row aliases are deterministic and do not form
cycles.
11. Given FK repair changes a unique-indexed visible value, when combined unique
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.
- Space control: future `merge` and `overwrite` behavior is documented but not
treated as supported until the projection algorithm can handle second-order FK
effects.