test: Harden DST authored-state checks and workloads (#123)

This commit is contained in:
Marcelo Mendonça Soares 2026-09-17 10:39:07 -03:00 • committed by GitHub
commit ad3e1a49e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 10312 additions and 458 deletions

View file

@ -2,7 +2,7 @@ tags:
integration: {}
dst:
# A simulation drives many real SQLite transactions across replicas.
timeout: 5x
timeout: 10m
skip: 'Simulation suite: run with `dart test -P dst`'
# The simulation suite is excluded from an ordinary run. It is slow, and its

View file

@ -2,7 +2,8 @@
These suites drive several replicas through randomized operations and
adversarial delivery, then check properties the engine must hold. A run is
fully described by its seed, so a failure replays exactly.
described by its seed, rounds, workload profile, and graph width. Those inputs
replay exactly within the same source/SDK revision.
They complement the `integration/` suites rather than replacing them.
Integration tests pin specific scenarios and assert exact outcomes; these
@ -27,8 +28,10 @@ directory are untagged and run with ordinary `dart test`.
| Variable | Default | Meaning |
| --- | --- | --- |
| `DST_SEEDS` | 8 | How many seeds the sweep runs |
| `DST_ROUNDS` | 12 | Operation rounds per simulation |
| `DST_ROUNDS` | 40 | Operation rounds per simulation |
| `DST_SEED_BASE` | unix seconds | First seed; successive seeds increment |
| `DST_PROFILE` | sparse | `sparse`, `populated`, or `mixed` (even seeds populated, odd sparse) |
| `DST_GRAPH_WIDTH` | 2 | Rows per table in each populated graph, minimum 2 |
| `DST_DEBUG_TABLE` | unset | Print every merge touching this table, in delivery order |
A divergence is usually explained by which facts a replica had merged when it
@ -44,7 +47,7 @@ which defects those fixed seeds reach.
Nothing is lost to reproducibility: a seed determines its simulation entirely
and a failure prints its replay command. Tests are named by position rather
than by seed, so the suite stays stable while the schedules underneath it vary.
Pin `DST_SEED_BASE` to re-run an exact sweep.
Pin the seed, rounds, profile, and graph width to re-run an exact sweep.
The consequence to expect is that a run can fail for a defect unrelated to the
change that triggered it. That is the suite doing its job; take the seed from
@ -83,6 +86,10 @@ Determinism requires every source of variation to come from the seed.
node UUID, so an unpinned node id makes concurrent merge winners
nondeterministic.
Node UUIDs are assigned to replicas through a seeded permutation, independently
of the ordered clock offsets. Seeds can therefore explore both directions of
the HLC node tie-break while replaying the same assignment exactly.
Node identity is currently pinned by pre-creating the `CrdtNode` row and
attaching it to every space, which relies on how `OfflineSyncSpaceManager` resolves
the current node. `DstReplica._assertSeededNodeIdentity` fails loudly if that
@ -91,8 +98,9 @@ injectable node id on the engine would remove the need for the trick.
## What the adversary does
Reorders, delays, redelivers already-merged batches, and partitions replicas for
a few rounds.
Reorders, delays, redelivers already-merged batches, and isolates incoming
delivery to selected replicas for a few rounds. Sources can still send during
this receive isolation; this is not a bidirectional network partition.
Two moves are deliberately unavailable, both because the merge contract states
that input arrives as a causally complete snapshot of the sender
@ -112,6 +120,10 @@ that input arrives as a causally complete snapshot of the sender
Delay, reorder, and redelivery are the honest moves - and redelivery is how
idempotence gets probed.
When the schedule ends, quiescence drains every pending batch and collects only
facts a receiver has not yet seen. It stops introducing deliberate duplicates,
so stable replicas cannot exhaust the convergence limit merely through resends.
## Layout
| File | Role |
@ -120,9 +132,18 @@ idempotence gets probed.
| `framework/dst_world.dart` | Replicas and operation generation |
| `framework/dst_schema.dart` | Generated model adapters and declared unique indexes |
| `framework/dst_adversary.dart` | Delivery scheduling and quiescence |
| `framework/dst_snapshot.dart` | Canonical snapshots and the property oracle |
| `framework/dst_snapshot.dart` | Portable domain/authored/projection snapshots and structural oracle |
| `framework/dst_authored.dart` | Pre-write evidence, primary visibility intents, and accepted-fact retention |
| `framework/dst_rejection.dart` | Concrete refusal prediction and accepted-error matching |
| `framework/dst_workload.dart` | Populated per-space graphs and deterministic semantic transitions |
| `framework/dst_coverage.dart` | Deduplicated authored transitions and observed graph shapes |
| `framework/dst_runner.dart` | One seeded run, and failure reporting |
Every generated synchronized table participates, including `Types` with boolean,
UTC timestamp, text, integer, BigInt, finite real, binary, enum, and nullable
values. Untagged schema guards fail if a new table, FK target column, or delete
action needs generator or refusal-predictor support.
The simulated graph includes nullable and required cascade, no-action,
set-null, and set-default references, the mixed cascade/no-action chains, and
all outbound references of person and organization. Person, company, and town
@ -154,6 +175,14 @@ composite, fixed-discriminator, overlapping, FK-only composite, and space-scoped
FK/text claims. The unique oracle reads all declared tuple components and space.
A null component releases the tuple, as in SQL.
Locally observable unique collisions are expected refusals. The predictor uses
the pre-operation visible rows and submitted values, including composite tuples
and space partitions. It matches the violated index and checks that domain data,
authored metadata, and sync progress all roll back. Unexpected constraint errors
still fail the run. Conflict-resolution scenarios author competing claims on
disconnected replicas and merge them, so only independently accepted claims
enter arbitration.
The operation generator can insert, update, delete, restore a retained identity,
upsert, pass a full model back through update, insert/update/delete a batch,
swap unique tuples atomically, and perform predicate updates/deletes. Scripted regressions
@ -161,3 +190,49 @@ use `DstOperations.apply` to select the same paths directly. Reports retain
attempted and committed counts by table/action so a passing run does not hide
which paths it visited. Rejected local transactions remain separate from
committed operations.
## Workload strength and metrics
The populated profile creates every declared table and FK edge, closes the
person/company/town cycles, and drives unique conflicts, a real tuple exchange,
restore/redelete, FK retarget/detach, and a constrained refusal. These are actual
ORM transactions, each checked immediately for structure, authored preservation,
and causal monotonicity. Complete exports distribute the populated spaces before
random scheduling starts. The sparse profile retains empty-world exploration.
Every run emits one `DST_METRICS` JSON record, including failed runs. It separates
setup attempts from scheduled commits and records attempted, committed, rejected,
skipped, unexpected failures, and committed transactions whose oracle failed.
A run failing during setup reports zero scheduled activity. Paths retain table
and action; network observations include merge counts, duplicate batches, maximum
batch size, total delivered changes, and receive-isolation events.
Coverage counts distinct field/tombstone events rather than repeated snapshot
appearances. FK edges and cycles are **authored graph** observations. Unique
projection coverage reports columns, not a claim that every overlapping index
conflicted. A swap counts only a validated exchange of two different tuples.
The counters do not claim every FK transition or every unique shape was stressed.
Passing simulations require scheduled commits and merges. At 100+ rounds the
minimum is 30 scheduled commits; shorter runs are smoke checks. Populated runs
also require all declared authored FK edges and the mandatory semantic paths.
Setup commits alone cannot satisfy the scheduled-activity gate.
CI alternates sparse and populated profiles across four consecutive seeds at
200 rounds in both topologies: 4,800 scheduled operation attempts, versus the
previous 6,000 attempts spread across fifty shallow 20-round worlds. This keeps
comparable attempt volume while exploring ten times the history depth and
connected graphs. Each topology runs in its own sixty-minute CI job with four
ten-minute per-seed ceilings; a failure in one job does not cancel the other.
Both jobs also run the ownership-collision controls. The local `melos test-dst`
command runs both topologies together, so it covers the combined CI matrix.
Known engine failures remain failures; CI is intentionally not a PR merge gate.
```sh
DST_SEED_BASE=114 DST_SEEDS=1 DST_ROUNDS=200 DST_PROFILE=populated DST_GRAPH_WIDTH=2 dart test -P dst
DST_SEED_BASE=62 DST_SEEDS=1 DST_ROUNDS=200 DST_PROFILE=populated DST_GRAPH_WIDTH=3 dart test -P dst
```
The oracle remains bounded: it does not independently arbitrate all unique
winners or FK fixed points. Collector concurrency/checkpoint lifecycle, crash
recovery, space grant/revoke, and transport framing remain outside this schedule.

View file

@ -0,0 +1,75 @@
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_adversary.dart';
import 'framework/dst_random.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
test(
'Given two replicas and a schedule repeatedly redelivering one city insertion, '
'when the network drains after those duplicate deliveries, '
'then stable authored facts converge without a false non-idempotence failure.',
() async {
final ids = DstIds(DstRandom(916));
final space = ids.next();
final clock = DstClock();
final replicas = <DstReplica>[];
for (var index = 0; index < 2; index++) {
replicas.add(
await DstReplica.create(
name: 'r$index',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: clock.clock,
),
);
}
final author = replicas.first;
await author.withReplicaClock(
() => author.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(
author.session,
City(id: ids.next(), name: 'city'),
transaction: tx,
),
),
);
final expected = (await DstSnapshot.capture(author)).renderSpace(space);
final adversary = DstAdversary(_RepeatedDeliverySchedule(), replicas);
Future<void> check(DstReplica replica) async {
expect(DstOracle.invariants(await DstSnapshot.capture(replica)), isEmpty);
}
await adversary.step(check);
await adversary.step(check);
expect(adversary.duplicateBatches, greaterThan(0));
await adversary.quiesce(check);
for (final replica in replicas) {
expect((await DstSnapshot.capture(replica)).renderSpace(space), expected);
}
},
);
}
/// A concrete network schedule: always collect the first replica, always
/// choose resend, and never isolate delivery. Database/merge paths stay real.
class _RepeatedDeliverySchedule extends DstRandom {
_RepeatedDeliverySchedule() : super(916);
@override
bool chance(double probability) => switch (probability) {
DstAdversary.receiveIsolationProbability => false,
DstAdversary.collectionProbability || DstAdversary.resendProbability => true,
_ => throw StateError('Unspecified network decision with probability $probability'),
};
@override
T pick<T>(List<T> items) => items.first;
}

View file

@ -0,0 +1,610 @@
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_authored.dart';
import 'framework/dst_random.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
test(
'Given two accepted unique rows claiming the same name, '
'when the preserved losing claim is removed from storage, '
'then the independent authoring evidence and portable comparison detect the loss.',
() async {
final ids = DstIds(DstRandom(22));
final space = ids.next();
final replica = await _replica(ids, space);
final first = Unique(id: ids.next(), name: 'shared');
final second = Unique(id: ids.next(), name: 'shared');
final evidence = DstWriteEvidence(await DstSnapshot.capture(replica))
..write('unique', first.toJson())
..write('unique', second.toJson());
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(replica.session, first, transaction: tx),
),
);
final peer = await _replica(ids, space);
await peer.withReplicaClock(
() => peer.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(peer.session, second, transaction: tx),
),
);
await replica.merge(await peer.collect(space), space);
final accepted = await DstSnapshot.capture(replica);
expect(evidence.validate(accepted), isEmpty);
final oracle = DstAuthoredOracle()..accept(accepted);
expect(oracle.validate(accepted, space), isEmpty);
final attempts = await CrdtDataAttemptedValue.db.find(replica.rawSession);
expect(attempts, hasLength(1));
// Deliberate oracle mutation after a real supported write: this is a
// detector regression, not an allegation that production deletes it.
await CrdtDataAttemptedValue.db.delete(replica.rawSession, attempts);
final damaged = await DstSnapshot.capture(replica);
expect(
evidence.validate(damaged).map((v) => v.property),
contains('acceptedWrite'),
);
expect(
oracle.validate(damaged, space).map((v) => v.property),
contains('authoredRetention'),
);
expect(damaged.renderSpace(space), isNot(accepted.renderSpace(space)));
},
);
test(
'Given a live city with no authored deletion or outbound references, '
'when an oracle input falsely hides that city, '
'then the independent visibility rule rejects the snapshot.',
() async {
final ids = DstIds(DstRandom(23));
final space = ids.next();
final replica = await _replica(ids, space);
final city = City(id: ids.next(), name: 'live');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final before = await DstSnapshot.capture(replica);
final row = before.rows['city']![city.id]!;
final damaged = _copy(
before,
rows: {
...before.rows,
'city': {city.id!: (spaceUuid: space, columns: row.columns, visible: false)},
},
);
expect(DstOracle.invariants(before), isEmpty);
expect(DstOracle.invariants(damaged).map((v) => v.property), [
'legitimateVisibility',
]);
},
);
test(
'Given an acknowledged city and a later corrupted field clock, '
'when an unrelated unique insert is acknowledged, '
'then the oracle still rejects the preexisting corruption.',
() async {
final ids = DstIds(DstRandom(26));
final space = ids.next();
final replica = await _replica(ids, space);
final city = City(id: ids.next(), name: 'accepted');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final accepted = await DstSnapshot.capture(replica);
final oracle = DstAuthoredOracle()..accept(accepted);
final key = ('city', city.id!, 'name');
final fabricatedClock = accepted.fieldHlc(key)!.increment();
final beforeUnrelated = _copy(accepted, fieldHlcs: {key: fabricatedClock});
final unique = Unique(id: ids.next(), name: 'unrelated');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(replica.session, unique, transaction: tx),
),
);
final afterUnrelated = _copy(
await DstSnapshot.capture(replica),
fieldHlcs: {key: fabricatedClock},
);
oracle.accept(afterUnrelated, before: beforeUnrelated);
expect(oracle.validate(afterUnrelated, space).map((v) => v.property), [
'authoredRetention',
]);
},
);
test(
'Given a nullable FK with a persisted database default, '
'when the raw ORM inserts an omitted value and explicitly updates it to null, '
'then insertion uses the declared default and the narrowed update retains null.',
() async {
final ids = DstIds(DstRandom(27));
final space = ids.next();
final replica = await _replica(ids, space);
await replica.seedDefaultTown(space);
final child = UniqueSetDefaultChild(id: ids.next(), name: 'defaulted');
final inserted = await UniqueSetDefaultChild.db.insertRow(
replica.rawSession,
child,
);
await UniqueSetDefaultChild.db.updateRow(
replica.rawSession,
inserted.copyWith(parentId: null),
columns: (t) => [t.parentId],
);
expect(inserted.parentId, dstDefaultTownId);
expect(
(await UniqueSetDefaultChild.db.findById(
replica.rawSession,
child.id!,
))!.parentId,
isNull,
);
},
);
test(
'Given a CRDT child inserted using its persisted FK default, '
'when an explicit narrowed update clears its reference, '
'then independent evidence records the default followed by authored null.',
() async {
final ids = DstIds(DstRandom(28));
final space = ids.next();
final replica = await _replica(ids, space);
await replica.seedDefaultTown(space);
final child = UniqueSetDefaultChild(id: ids.next(), name: 'defaulted');
final insertion = DstWriteEvidence(await DstSnapshot.capture(replica))
..write('unique_set_default_child', child.toJson(), insertDefaults: true);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => UniqueSetDefaultChild.db.insertRow(
replica.session,
child,
transaction: tx,
),
),
);
final beforeUpdate = await DstSnapshot.capture(replica);
final update = DstWriteEvidence(beforeUpdate)
..write(
'unique_set_default_child',
child.copyWith(parentId: null).toJson(),
columns: {'parentId'},
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => UniqueSetDefaultChild.db.updateRow(
replica.session,
child.copyWith(parentId: null),
columns: (t) => [t.parentId],
transaction: tx,
),
),
);
final after = await DstSnapshot.capture(replica);
expect(insertion.validate(beforeUpdate), isEmpty);
expect(update.validate(after), isEmpty);
expect(
after.authoredValue(('unique_set_default_child', child.id!, 'parentId')),
isNull,
);
},
);
for (final action in ['restore', 'hidden upsert', 'visible upsert']) {
test(
'Given a nullable defaulted FK explicitly cleared before $action, '
'when that existing identity is written with a null model value, '
'then authoring evidence follows the existing-row default contract.',
() async {
final ids = DstIds(DstRandom(29));
final space = ids.next();
final replica = await _replica(ids, space);
await replica.seedDefaultTown(space);
final child = UniqueSetDefaultChild(id: ids.next(), name: 'defaulted');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await UniqueSetDefaultChild.db.insertRow(
replica.session,
child,
transaction: tx,
);
await UniqueSetDefaultChild.db.updateRow(
replica.session,
child.copyWith(parentId: null),
columns: (t) => [t.parentId],
transaction: tx,
);
if (action != 'visible upsert') {
await UniqueSetDefaultChild.db.deleteRow(
replica.session,
child,
transaction: tx,
);
}
}),
);
final before = await DstSnapshot.capture(replica);
final evidence = DstWriteEvidence(before)
..write(
'unique_set_default_child',
child.toJson(),
insertDefaults: action == 'visible upsert',
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
if (action == 'restore') {
await UniqueSetDefaultChild.db.insertRow(
replica.session,
child,
transaction: tx,
);
} else {
await replica.session.db.upsertRow(
child,
conflictColumns: [UniqueSetDefaultChild.t.id],
transaction: tx,
);
}
}),
);
final after = await DstSnapshot.capture(replica);
expect(evidence.validate(after), isEmpty);
expect(
after.authoredValue(('unique_set_default_child', child.id!, 'parentId')),
action == 'visible upsert' ? dstDefaultTownId.toString() : null,
);
},
);
}
test(
'Given a city restored after its original insertion reached another replica, '
'when the restored facts reach that replica and an empty bootstrap, '
'then different raw row anchors preserve identical canonical fields and tombstones.',
() async {
final ids = DstIds(DstRandom(30));
final space = ids.next();
final source = await _replica(ids, space);
final receiver = await _replica(ids, space);
final mirror = await _replica(ids, space);
final city = City(id: ids.next(), name: 'original');
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(source.session, city, transaction: tx),
),
);
await receiver.merge(await source.collect(space), space);
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => City.db.deleteRow(source.session, city, transaction: tx),
),
);
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(
source.session,
city.copyWith(name: 'restored'),
transaction: tx,
),
),
);
final accepted = await DstSnapshot.capture(source);
final oracle = DstAuthoredOracle()..accept(accepted);
await receiver.merge(await source.collect(space), space);
await mirror.merge(await receiver.collect(space), space);
final received = await DstSnapshot.capture(receiver);
final bootstrapped = await DstSnapshot.capture(mirror);
final key = 'city/${city.id}';
expect(received.rowHlcs[key], isNot(accepted.rowHlcs[key]));
expect(received.renderRawMetadata(), isNot(accepted.renderRawMetadata()));
expect(received.tombstones[key], accepted.tombstones[key]);
expect(received.tombstones[key]!.clFlag, 3);
expect(received.renderSpace(space), accepted.renderSpace(space));
expect(bootstrapped.renderSpace(space), accepted.renderSpace(space));
expect(oracle.validate(received, space), isEmpty);
expect(oracle.validate(bootstrapped, space), isEmpty);
},
);
test(
'Given independently upserted visible cities sharing one identity, '
'when the newer insertion reaches an existing replica and an empty bootstrap, '
'then its redundant generation-one insertion marker has the same canonical facts.',
() async {
final ids = DstIds(DstRandom(31));
final space = ids.next();
final clock = DstClock();
final receiver = await DstReplica.create(
name: 'receiver',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: clock.clock,
);
final source = await DstReplica.create(
name: 'source',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: clock.clock,
);
final mirror = await DstReplica.create(
name: 'mirror',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: clock.clock,
);
final city = City(id: ids.next(), name: 'original');
await receiver.withReplicaClock(
() => receiver.session.db.transactionForUser(
space,
(tx) => receiver.session.db.upsertRow(
city,
conflictColumns: [City.t.id],
transaction: tx,
),
),
);
clock.advance(const Duration(milliseconds: 1));
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => source.session.db.upsertRow(
city.copyWith(name: 'newer'),
conflictColumns: [City.t.id],
transaction: tx,
),
),
);
final accepted = await DstSnapshot.capture(source);
final oracle = DstAuthoredOracle()..accept(accepted);
await receiver.merge(await source.collect(space), space);
await mirror.merge(await receiver.collect(space), space);
final received = await DstSnapshot.capture(receiver);
final bootstrapped = await DstSnapshot.capture(mirror);
final key = 'city/${city.id}';
expect(accepted.tombstones[key], isNull);
expect(received.tombstones[key]!.clFlag, 1);
expect(received.tombstones[key]!.reason, CrdtDataDeletedReason.userInsert);
expect(received.rowHlcs[key], isNot(accepted.rowHlcs[key]));
expect(received.renderRawMetadata(), isNot(accepted.renderRawMetadata()));
expect(received.renderSpace(space), accepted.renderSpace(space));
expect(bootstrapped.renderSpace(space), accepted.renderSpace(space));
expect(oracle.validate(received, space), isEmpty);
expect(oracle.validate(bootstrapped, space), isEmpty);
},
);
test(
'Given an accepted live city without a delete event, '
'when a fabricated delete tombstone appears in the oracle input, '
'then the oracle rejects the unacknowledged visibility change.',
() async {
final ids = DstIds(DstRandom(32));
final space = ids.next();
final replica = await _replica(ids, space);
final city = City(id: ids.next(), name: 'live');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final accepted = await DstSnapshot.capture(replica);
final oracle = DstAuthoredOracle()..accept(accepted);
final key = 'city/${city.id}';
final damaged = _copy(
accepted,
tombstones: {
key: (
hlc: accepted.rowHlcs[key]!.increment(),
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
},
);
expect(oracle.validate(damaged, space).map((v) => v.property), [
'authoredRetention',
]);
},
);
test(
'Given an accepted live city without a newer insertion event, '
'when an insertion marker newer than every field clock appears in the oracle input, '
'then the oracle rejects the unacknowledged insertion marker.',
() async {
final ids = DstIds(DstRandom(32));
final space = ids.next();
final replica = await _replica(ids, space);
final city = City(id: ids.next(), name: 'live');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final accepted = await DstSnapshot.capture(replica);
final oracle = DstAuthoredOracle()..accept(accepted);
final key = 'city/${city.id}';
final damaged = _copy(
accepted,
tombstones: {
key: (
hlc: accepted.rowHlcs[key]!.increment(),
clFlag: 1,
reason: CrdtDataDeletedReason.userInsert,
),
},
);
expect(oracle.validate(damaged, space).map((v) => v.property), [
'authoredRetention',
]);
},
);
for (final metadata in [
'field clock',
'inherited field clock',
'tombstone clock',
'tombstone reason',
'projection reason',
]) {
test(
'Given equal-looking snapshots of a deleted unique row, '
'when only its $metadata diverges, '
'then the portable comparison detects the different metadata.',
() async {
final ids = DstIds(DstRandom(24));
final space = ids.next();
final replica = await _replica(ids, space);
final row = Unique(id: ids.next(), name: 'retained');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(replica.session, row, transaction: tx),
),
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => Unique.db.deleteRow(replica.session, row, transaction: tx),
),
);
final before = await DstSnapshot.capture(replica);
final key = ('unique', row.id!, 'name');
final rowKey = 'unique/${row.id}';
final tombstone = before.tombstones[rowKey]!;
final projection = before.projections[key]!;
final damaged = _copy(
before,
fieldHlcs: metadata == 'field clock'
? {...before.fieldHlcs, key: before.fieldHlc(key)!.increment()}
: metadata == 'inherited field clock'
? (Map.of(before.fieldHlcs)..remove(key))
: null,
rowHlcs: metadata == 'inherited field clock'
? {...before.rowHlcs, rowKey: before.rowHlcs[rowKey]!.increment()}
: null,
tombstones: metadata.startsWith('tombstone')
? {
...before.tombstones,
rowKey: (
hlc: metadata == 'tombstone clock'
? tombstone.hlc.increment()
: tombstone.hlc,
clFlag: tombstone.clFlag,
reason: metadata == 'tombstone reason'
? CrdtDataDeletedReason.userCascadeDelete
: tombstone.reason,
),
}
: null,
projections: metadata == 'projection reason'
? {
...before.projections,
key: (
attemptedValue: projection.attemptedValue,
projectionReason: CrdtProjectionReason.uniqueConflict,
),
}
: null,
);
expect(damaged.rows, before.rows);
expect(damaged.renderSpace(space), isNot(before.renderSpace(space)));
},
);
}
test(
'Given a field inheriting its insertion clock, '
'when an equal explicit field clock is added to the snapshot, '
'then portable equality accepts the equivalent sparse representation.',
() async {
final ids = DstIds(DstRandom(25));
final space = ids.next();
final replica = await _replica(ids, space);
final city = City(id: ids.next(), name: 'live');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final before = await DstSnapshot.capture(replica);
final key = ('city', city.id!, 'name');
final equivalent = _copy(
before,
fieldHlcs: {...before.fieldHlcs, key: before.fieldHlc(key)!},
);
expect(equivalent.renderSpace(space), before.renderSpace(space));
},
);
}
Future<DstReplica> _replica(DstIds ids, UuidValue space) => DstReplica.create(
name: 'replica',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().clock,
);
DstSnapshot _copy(
DstSnapshot source, {
Map<String, Map<UuidValue, DstRow>>? rows,
Map<String, Hlc>? rowHlcs,
Map<DstFieldKey, Hlc>? fieldHlcs,
Map<String, DstTombstone>? tombstones,
Map<DstFieldKey, DstProjection>? projections,
}) => DstSnapshot(
rows: rows ?? source.rows,
projections: projections ?? source.projections,
causalLengths: source.causalLengths,
rowHlcs: rowHlcs ?? source.rowHlcs,
fieldHlcs: fieldHlcs ?? source.fieldHlcs,
tombstones: tombstones ?? source.tombstones,
);

View file

@ -30,9 +30,13 @@ void main() {
index: index,
seed: seed,
rounds: config.rounds,
profile: config.profile,
graphWidth: config.graphWidth,
run: () => runDstSimulation(
seed: seed,
rounds: config.rounds,
profile: config.profile,
graphWidth: config.graphWidth,
topology: DstTopology.singleSpace,
),
);

View file

@ -36,9 +36,13 @@ void main() {
index: index,
seed: seed,
rounds: config.rounds,
profile: config.profile,
graphWidth: config.graphWidth,
run: () => runDstSimulation(
seed: seed,
rounds: config.rounds,
profile: config.profile,
graphWidth: config.graphWidth,
topology: DstTopology.overlappingSpaces,
),
);

View file

@ -0,0 +1,217 @@
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_random.dart';
import 'framework/dst_roundtrip.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
for (final crossSpace in [false, true]) {
test(
'Given a detector snapshot whose town references a mayor in ${crossSpace ? 'another' : 'the same'} space, '
'when the ownership oracle checks the reference, '
'then it ${crossSpace ? 'rejects the cross-space link' : 'accepts the space-local link'}.',
() {
final ids = DstIds(DstRandom(92));
final space = ids.next();
final parentSpace = crossSpace ? ids.next() : space;
final parent = ids.next();
final child = ids.next();
// Detector fault injection only; the invalid link is never written to
// a database or submitted as a supposedly valid production merge.
final snapshot = DstSnapshot(
rows: {
'person': {
parent: (
spaceUuid: parentSpace,
columns: {'id': parent.toString()},
visible: true,
),
},
'town': {
child: (
spaceUuid: space,
columns: {'id': child.toString(), 'mayorId': parent.toString()},
visible: true,
),
},
},
projections: {},
causalLengths: {},
);
final violations = DstOracle.noCrossSpaceLink(snapshot);
expect(
violations.map((v) => v.property),
crossSpace ? ['noCrossSpaceLink'] : isEmpty,
);
},
);
}
for (final diverged in [false, true]) {
test(
'Given replicas with unequal subscriptions and ${diverged ? 'different' : 'identical'} facts in their shared space, '
'when the observer-independence oracle compares them, '
'then it ${diverged ? 'reports the shared-space disagreement' : 'ignores facts belonging only to the other space'}.',
() async {
final ids = DstIds(DstRandom(93));
final space = ids.next();
final otherSpace = ids.next();
final clock = DstClock();
final left = await _replica(ids, clock, 'left', [space]);
final right = await _replica(ids, clock, 'right', [space, otherSpace]);
final city = City(id: ids.next(), name: 'shared');
await left.withReplicaClock(
() => left.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(left.session, city, transaction: tx),
),
);
await right.merge(await left.collect(space), space);
await right.withReplicaClock(
() => right.session.db.transactionForUser(
otherSpace,
(tx) => City.db.insertRow(
right.session,
City(id: ids.next(), name: 'other-space-only'),
transaction: tx,
),
),
);
if (diverged) {
await right.withReplicaClock(
() => right.session.db.transactionForUser(
space,
(tx) => City.db.updateRow(
right.session,
city.copyWith(name: 'unshared update'),
columns: (t) => [t.name],
transaction: tx,
),
),
);
}
final violations = DstOracle.observerIndependence({
left: await DstSnapshot.capture(left),
right: await DstSnapshot.capture(right),
}, space);
expect(
violations.map((v) => v.property),
diverged ? ['observerIndependence'] : isEmpty,
);
},
);
}
for (final next in [null, 1, 3, 4]) {
final change = switch (next) {
null => 'omits that generation',
1 => 'regresses to generation 1',
3 => 'repeats generation 3',
_ => 'advances to generation 4',
};
final rejected = next == null || next < 3;
test(
'Given an observed restore generation of 3, '
'when a later detector snapshot $change, '
'then the causal-length oracle ${rejected ? 'reports lost progress' : 'accepts monotonic progress'}.',
() {
final key = 'city/${DstIds(DstRandom(94)).next()}';
DstSnapshot snapshot(Map<String, int> flags) => DstSnapshot(
rows: {},
projections: {},
causalLengths: flags,
);
final oracle = DstCausalLength();
expect(oracle.observe('replica', snapshot({key: 3})), isEmpty);
final violations = oracle.observe(
'replica',
snapshot({key: ?next}),
);
expect(
violations.map((v) => v.property),
rejected ? ['causalLength'] : isEmpty,
);
},
);
}
for (final missingCity in [false, true]) {
test(
'Given an expected snapshot ${missingCity ? 'including a later city absent from the source' : 'matching the source'}, '
'when an empty replica receives the complete source export, '
'then the round-trip detector ${missingCity ? 'reports the missing accepted city' : 'accepts the complete facts'}.',
() async {
final ids = DstIds(DstRandom(95));
final space = ids.next();
final clock = DstClock();
final source = await _replica(ids, clock, 'source', [space]);
final accepted = await _replica(ids, clock, 'accepted', [space]);
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(
source.session,
City(id: ids.next(), name: 'first'),
transaction: tx,
),
),
);
await accepted.merge(await source.collect(space), space);
if (missingCity) {
await accepted.withReplicaClock(
() => accepted.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(
accepted.session,
City(id: ids.next(), name: 'missing accepted city'),
transaction: tx,
),
),
);
}
// Both states and the collected batch are real and causally complete.
// Supplying the newer accepted snapshot is detector fault injection
// modelling an export omission, not an invalid split merge batch.
final expected = await DstSnapshot.capture(accepted);
final violations = await exportRoundTrip(
source: source,
expected: expected,
ids: ids,
clock: clock.clock,
);
expect(
violations.map((v) => v.property),
missingCity ? ['exportRoundTrip'] : isEmpty,
);
if (missingCity) {
expect(violations.single.detail, contains('missing accepted city'));
}
},
);
}
}
Future<DstReplica> _replica(
DstIds ids,
DstClock clock,
String name,
List<UuidValue> spaces,
) => DstReplica.create(
name: name,
spaceUuids: spaces,
nodeUuid: ids.next(),
clock: clock.clock,
);

View file

@ -0,0 +1,730 @@
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_random.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
group('Given a unique winner and a later independently authored competing claim, ', () {
late UuidValue space;
late DstReplica replica;
late DstOperations operations;
late UniqueOverlapping winner;
late UniqueOverlapping competitor;
late DstFieldKey claimKey;
late DstSnapshot before;
setUp(() async {
final random = DstRandom(85);
final ids = DstIds(random);
space = ids.next();
replica = await DstReplica.create(
name: 'winner',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().clock,
);
final peer = await DstReplica.create(
name: 'competitor',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().skewed(const Duration(milliseconds: 1)),
);
winner = UniqueOverlapping(id: ids.next(), first: 'a', second: 'b', third: 'c');
competitor = UniqueOverlapping(
id: ids.next(),
first: 'a',
second: 'b',
third: 'z',
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) =>
UniqueOverlapping.db.insertRow(replica.session, winner, transaction: tx),
),
);
await peer.withReplicaClock(
() => peer.session.db.transactionForUser(
space,
(tx) =>
UniqueOverlapping.db.insertRow(peer.session, competitor, transaction: tx),
),
);
await replica.merge(await peer.collect(space), space);
before = await DstSnapshot.capture(replica);
claimKey = ('unique_overlapping', winner.id!, 'first');
expect(before.visible['unique_overlapping']![winner.id]!.columns['first'], 'a');
operations = DstOperations(random, ids);
operations.oracle.accept(before);
});
group('when the winner edits its third value in a full-row save, ', () {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.fullRowUpdate,
body: (tx, evidence, refusal) async {
final write = winner.copyWith(third: 'edited');
evidence.write('unique_overlapping', write.toJson());
await UniqueOverlapping.db.updateRow(
replica.session,
write,
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then its touched claim becomes newer and the competing record displays the name.',
() {
expect(after.fieldHlc(claimKey), greaterThan(before.fieldHlc(claimKey)!));
expect(after.authoredValue(claimKey), 'a');
expect(
after.visible['unique_overlapping']![winner.id]!.columns['first'],
'a__conflict__${winner.id}',
);
expect(
after.visible['unique_overlapping']![competitor.id]!.columns['first'],
'a',
);
expect(
after.visible['unique_overlapping']![winner.id]!.columns['third'],
'edited',
);
expect(operations.validationFailures, 0);
},
);
});
group('when the winner edits only its selected third column, ', () {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.update,
body: (tx, evidence, refusal) async {
final write = winner.copyWith(third: 'edited');
evidence.write('unique_overlapping', write.toJson(), columns: {'third'});
await UniqueOverlapping.db.updateRow(
replica.session,
write,
columns: (t) => [t.third],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test('then its excluded claim keeps its age and still displays the name.', () {
expect(after.fieldHlc(claimKey), before.fieldHlc(claimKey));
expect(after.authoredValue(claimKey), 'a');
expect(after.visible['unique_overlapping']![winner.id]!.columns['first'], 'a');
expect(
after.visible['unique_overlapping']![competitor.id]!.columns['first'],
'a__conflict__${competitor.id}',
);
expect(
after.visible['unique_overlapping']![winner.id]!.columns['third'],
'edited',
);
expect(operations.validationFailures, 0);
});
});
});
group('Given a visible projected town with a generation-one insertion marker, ', () {
late UuidValue space;
late DstReplica receiver;
late Person mayor;
late Town town;
late Town projected;
late DstSnapshot before;
late DstFieldKey key;
late String rowKey;
late DstOperations operations;
setUpAll(() async {
final ids = DstIds(DstRandom(81));
space = ids.next();
final clock = DstClock();
Future<DstReplica> replica(String name) => DstReplica.create(
name: name,
spaceUuids: [space],
nodeUuid: ids.next(),
clock: clock.clock,
);
final parentAuthor = await replica('parent');
receiver = await replica('receiver');
final source = await replica('source');
mayor = Person(id: ids.next(), name: 'mayor');
town = Town(id: ids.next(), name: 'original', mayorId: mayor.id);
await parentAuthor.withReplicaClock(
() => parentAuthor.session.db.transactionForUser(
space,
(tx) => Person.db.insertRow(parentAuthor.session, mayor, transaction: tx),
),
);
await receiver.merge(await parentAuthor.collect(space), space);
await source.merge(await parentAuthor.collect(space), space);
await receiver.withReplicaClock(
() => receiver.session.db.transactionForUser(
space,
(tx) => Town.db.insertRow(receiver.session, town, transaction: tx),
),
);
clock.advance(const Duration(milliseconds: 1));
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => source.session.db.upsertRow(
town,
conflictColumns: [Town.t.id],
transaction: tx,
),
),
);
await receiver.merge(await source.collect(space), space);
await parentAuthor.withReplicaClock(
() => parentAuthor.session.db.transactionForUser(
space,
(tx) => Person.db.deleteRow(parentAuthor.session, mayor, transaction: tx),
),
);
await receiver.merge(await parentAuthor.collect(space), space);
projected = (await Town.db.findById(receiver.session, town.id!))!;
expect(projected.mayorId, isNull);
before = await DstSnapshot.capture(receiver);
key = ('town', town.id!, 'mayorId');
rowKey = 'town/${town.id}';
expect(before.tombstones[rowKey]!.clFlag, 1);
expect(
before.tombstones[rowKey]!.reason,
CrdtDataDeletedReason.userInsert,
);
operations = DstOperations(DstRandom(82), ids);
operations.oracle.accept(before);
});
group('when a full-row upsert changes only its name, ', () {
late DstSnapshot after;
setUpAll(() async {
await operations.perform(
receiver,
space,
table: DstTable.town,
action: DstAction.upsert,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(name: 'renamed');
evidence.write('town', write.toJson());
await receiver.session.db.upsertRow(
write,
conflictColumns: [Town.t.id],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(receiver);
});
test('then authored FK preservation and canonical visibility remain valid.', () {
expect(after.fieldHlc(key), before.fieldHlc(key));
expect(after.authoredValue(key), mayor.id);
expect(after.rowHlcs[rowKey], before.rowHlcs[rowKey]);
expect(
after.canonicalTombstone('town', town.id!),
null,
);
expect(operations.oracle.validate(after, space), isEmpty);
});
});
});
group(
'Given a visible projected town restored with a renewed authored FK clock, ',
() {
late UuidValue space;
late DstReplica receiver;
late Person mayor;
late Town town;
late Town projected;
late DstSnapshot before;
late DstFieldKey key;
late String rowKey;
late DstOperations operations;
setUpAll(() async {
final ids = DstIds(DstRandom(81));
space = ids.next();
final clock = DstClock();
Future<DstReplica> replica(String name) => DstReplica.create(
name: name,
spaceUuids: [space],
nodeUuid: ids.next(),
clock: clock.clock,
);
final parentAuthor = await replica('parent');
receiver = await replica('receiver');
final source = await replica('source');
mayor = Person(id: ids.next(), name: 'mayor');
town = Town(id: ids.next(), name: 'original', mayorId: mayor.id);
await parentAuthor.withReplicaClock(
() => parentAuthor.session.db.transactionForUser(
space,
(tx) => Person.db.insertRow(parentAuthor.session, mayor, transaction: tx),
),
);
await receiver.merge(await parentAuthor.collect(space), space);
await source.merge(await parentAuthor.collect(space), space);
await receiver.withReplicaClock(
() => receiver.session.db.transactionForUser(
space,
(tx) => Town.db.insertRow(receiver.session, town, transaction: tx),
),
);
clock.advance(const Duration(milliseconds: 1));
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => source.session.db.upsertRow(
town,
conflictColumns: [Town.t.id],
transaction: tx,
),
),
);
await receiver.merge(await source.collect(space), space);
await parentAuthor.withReplicaClock(
() => parentAuthor.session.db.transactionForUser(
space,
(tx) => Person.db.deleteRow(parentAuthor.session, mayor, transaction: tx),
),
);
await receiver.merge(await parentAuthor.collect(space), space);
projected = (await Town.db.findById(receiver.session, town.id!))!;
expect(projected.mayorId, isNull);
await receiver.withReplicaClock(
() => receiver.session.db.transactionForUser(
space,
(tx) => Town.db.deleteRow(receiver.session, projected, transaction: tx),
),
);
await receiver.withReplicaClock(
() => receiver.session.db.transactionForUser(
space,
(tx) => Town.db.insertRow(receiver.session, projected, transaction: tx),
),
);
before = await DstSnapshot.capture(receiver);
key = ('town', town.id!, 'mayorId');
rowKey = 'town/${town.id}';
expect(before.tombstones[rowKey]!.clFlag, 3);
expect(
before.tombstones[rowKey]!.reason,
CrdtDataDeletedReason.userReinsert,
);
expect(before.fieldHlc(key), before.rowHlcs[rowKey]);
operations = DstOperations(DstRandom(82), ids);
operations.oracle.accept(before);
});
group('when a full-row upsert changes only its name, ', () {
late DstSnapshot after;
setUpAll(() async {
await operations.perform(
receiver,
space,
table: DstTable.town,
action: DstAction.upsert,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(name: 'renamed');
evidence.write('town', write.toJson());
await receiver.session.db.upsertRow(
write,
conflictColumns: [Town.t.id],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(receiver);
});
test(
'then authored FK preservation and canonical visibility remain valid.',
() {
expect(after.fieldHlc(key), before.fieldHlc(key));
expect(after.authoredValue(key), mayor.id);
expect(after.rowHlcs[rowKey], before.rowHlcs[rowKey]);
expect(
after.canonicalTombstone('town', town.id!),
before.tombstones[rowKey],
);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
});
},
);
group('Given a visible row with a projected unique text claim, ', () {
late UuidValue space;
late DstReplica replica;
late UniqueOverlapping loser;
late UniqueOverlapping projected;
late DstSnapshot before;
late DstFieldKey firstKey;
late DstFieldKey secondKey;
late DstFieldKey thirdKey;
late DstOperations operations;
setUp(() async {
final ids = DstIds(DstRandom(83));
space = ids.next();
replica = await DstReplica.create(
name: 'replica',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().clock,
);
final winner = UniqueOverlapping(
id: ids.next(),
first: 'a',
second: 'b',
third: 'c',
);
loser = UniqueOverlapping(
id: ids.next(),
first: 'a',
second: 'b',
third: 'z',
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) =>
UniqueOverlapping.db.insertRow(replica.session, winner, transaction: tx),
),
);
final peer = await DstReplica.create(
name: 'independent-claim',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().skewed(const Duration(milliseconds: 1)),
);
await peer.withReplicaClock(
() => peer.session.db.transactionForUser(
space,
(tx) => UniqueOverlapping.db.insertRow(peer.session, loser, transaction: tx),
),
);
await replica.merge(await peer.collect(space), space);
before = await DstSnapshot.capture(replica);
firstKey = ('unique_overlapping', loser.id!, 'first');
secondKey = ('unique_overlapping', loser.id!, 'second');
thirdKey = ('unique_overlapping', loser.id!, 'third');
expect(before.projections[firstKey]?.attemptedValue, 'a');
projected = (await UniqueOverlapping.db.findById(replica.session, loser.id!))!;
expect(projected.first, isNot('a'));
operations = DstOperations(DstRandom(84), ids);
operations.oracle.accept(before);
});
group('when an update changes its third value without a column filter, ', () {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.fullRowUpdate,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(third: 'renamed');
evidence.write('unique_overlapping', write.toJson());
await UniqueOverlapping.db.updateRow(
replica.session,
write,
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then every field clock advances while the preserved claim stays intact.',
() {
expect(
after.rows['unique_overlapping']![loser.id]!.columns['third'],
'renamed',
);
expect(after.authoredValue(firstKey), 'a');
expect(after.authoredValue(secondKey), 'b');
expect(after.fieldHlc(firstKey), greaterThan(before.fieldHlc(firstKey)!));
expect(after.fieldHlc(secondKey), greaterThan(before.fieldHlc(secondKey)!));
expect(after.fieldHlc(thirdKey), greaterThan(before.fieldHlc(thirdKey)!));
expect(operations.committed, 1);
expect(operations.validationFailures, 0);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
});
group(
'when an update changes its third value with only the third column selected, ',
() {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.update,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(third: 'renamed');
evidence.write('unique_overlapping', write.toJson(), columns: {'third'});
await UniqueOverlapping.db.updateRow(
replica.session,
write,
columns: (t) => [t.third],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then only the third field clock advances while the preserved claim stays intact.',
() {
expect(
after.rows['unique_overlapping']![loser.id]!.columns['third'],
'renamed',
);
expect(after.authoredValue(firstKey), 'a');
expect(after.authoredValue(secondKey), 'b');
expect(after.fieldHlc(firstKey), before.fieldHlc(firstKey));
expect(after.fieldHlc(secondKey), before.fieldHlc(secondKey));
expect(after.fieldHlc(thirdKey), greaterThan(before.fieldHlc(thirdKey)!));
expect(operations.committed, 1);
expect(operations.validationFailures, 0);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
},
);
group(
'when an update changes its third value with the unchanged second column also selected, ',
() {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.update,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(third: 'renamed');
evidence.write(
'unique_overlapping',
write.toJson(),
columns: {'second', 'third'},
);
await UniqueOverlapping.db.updateRow(
replica.session,
write,
columns: (t) => [t.second, t.third],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the second and third field clocks advance while the unselected claim stays intact.',
() {
expect(
after.rows['unique_overlapping']![loser.id]!.columns['third'],
'renamed',
);
expect(after.authoredValue(firstKey), 'a');
expect(after.authoredValue(secondKey), 'b');
expect(after.fieldHlc(firstKey), before.fieldHlc(firstKey));
expect(after.fieldHlc(secondKey), greaterThan(before.fieldHlc(secondKey)!));
expect(after.fieldHlc(thirdKey), greaterThan(before.fieldHlc(thirdKey)!));
expect(operations.committed, 1);
expect(operations.validationFailures, 0);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
},
);
group('when an upsert changes its third value without a column filter, ', () {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.upsert,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(third: 'renamed');
evidence.write('unique_overlapping', write.toJson());
await replica.session.db.upsertRow(
write,
conflictColumns: [UniqueOverlapping.t.id],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then every field clock advances while the preserved claim stays intact.',
() {
expect(
after.rows['unique_overlapping']![loser.id]!.columns['third'],
'renamed',
);
expect(after.authoredValue(firstKey), 'a');
expect(after.authoredValue(secondKey), 'b');
expect(after.fieldHlc(firstKey), greaterThan(before.fieldHlc(firstKey)!));
expect(after.fieldHlc(secondKey), greaterThan(before.fieldHlc(secondKey)!));
expect(after.fieldHlc(thirdKey), greaterThan(before.fieldHlc(thirdKey)!));
expect(operations.committed, 1);
expect(operations.validationFailures, 0);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
});
group(
'when an upsert changes its third value with only the third column selected, ',
() {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.upsert,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(third: 'renamed');
evidence.write('unique_overlapping', write.toJson(), columns: {'third'});
await replica.session.db.upsertRow(
write,
conflictColumns: [UniqueOverlapping.t.id],
updateColumns: [UniqueOverlapping.t.third],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then only the third field clock advances while the preserved claim stays intact.',
() {
expect(
after.rows['unique_overlapping']![loser.id]!.columns['third'],
'renamed',
);
expect(after.authoredValue(firstKey), 'a');
expect(after.authoredValue(secondKey), 'b');
expect(after.fieldHlc(firstKey), before.fieldHlc(firstKey));
expect(after.fieldHlc(secondKey), before.fieldHlc(secondKey));
expect(after.fieldHlc(thirdKey), greaterThan(before.fieldHlc(thirdKey)!));
expect(operations.committed, 1);
expect(operations.validationFailures, 0);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
},
);
group(
'when an upsert changes its third value with the unchanged second column also selected, ',
() {
late DstSnapshot after;
setUp(() async {
await operations.perform(
replica,
space,
table: DstTable.uniqueOverlapping,
action: DstAction.upsert,
body: (tx, evidence, refusal) async {
final write = projected.copyWith(third: 'renamed');
evidence.write(
'unique_overlapping',
write.toJson(),
columns: {'second', 'third'},
);
await replica.session.db.upsertRow(
write,
conflictColumns: [UniqueOverlapping.t.id],
updateColumns: [UniqueOverlapping.t.second, UniqueOverlapping.t.third],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the second and third field clocks advance while the unselected claim stays intact.',
() {
expect(
after.rows['unique_overlapping']![loser.id]!.columns['third'],
'renamed',
);
expect(after.authoredValue(firstKey), 'a');
expect(after.authoredValue(secondKey), 'b');
expect(after.fieldHlc(firstKey), before.fieldHlc(firstKey));
expect(after.fieldHlc(secondKey), greaterThan(before.fieldHlc(secondKey)!));
expect(after.fieldHlc(thirdKey), greaterThan(before.fieldHlc(thirdKey)!));
expect(operations.committed, 1);
expect(operations.validationFailures, 0);
expect(operations.oracle.validate(after, space), isEmpty);
},
);
},
);
});
}

View file

@ -0,0 +1,248 @@
import 'package:serverpod/serverpod.dart' show ClientDatabaseSession;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import '../integration/test_tools/sync_topology.dart';
typedef _Replica = ({
ClientDatabaseSession raw,
OfflineSyncDatabaseSession session,
OfflineSyncEngine sync,
});
void main() {
initTestClientSession(createSessionPerTest: false);
Future<_Replica> replica(UuidValue space) async {
final raw = await createAdditionalTestSession();
final session = OfflineSyncDatabaseSession.wraps(raw, syncTables: syncTables);
await session.db.initialize();
await session.db.transactionForUser(space, (_) async {});
return (
raw: raw,
session: session,
sync: OfflineSyncEngine(
syncTables: syncTables,
serializationManager: raw.db.serializationManager,
),
);
}
Future<Map<String, Object?>> claims(ClientDatabaseSession raw) async {
final records = await CrdtDataAttemptedValue.db.find(
raw,
include: CrdtDataAttemptedValue.include(
field: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
),
),
);
return {
for (final record in records)
'${record.field!.row!.tbl!.name}/${record.field!.row!.uuidRowId}'
'.${record.field!.column!.name}':
record.value,
};
}
Future<int?> valueOf(OfflineSyncDatabaseSession session, UuidValue id) async =>
(await UniqueNullable.db.findById(session, id))!.value;
Future<void> upsertValue(_Replica node, UuidValue space, UuidValue id, int value) =>
node.session.db.transactionForUser(
space,
(tx) => node.session.db.upsertRow(
UniqueNullable(id: id, value: value),
conflictColumns: [UniqueNullable.t.id],
transaction: tx,
),
);
Future<(UuidValue winner, UuidValue loser)> twoClaimants(
_Replica node,
UuidValue space, {
required int value,
}) async {
final winner = const Uuid().v7obj();
final loser = const Uuid().v7obj();
await node.session.db.transactionForUser(
space,
(tx) => UniqueNullable.db.insertRow(
node.session,
UniqueNullable(id: winner, value: value),
transaction: tx,
),
);
await mergeIndependentInsert(
node.session,
UniqueNullable(id: loser, value: value),
space: space,
tables: [UniqueNullable.t],
);
return (winner, loser);
}
test(
'Given two rows claiming the same nullable unique value, '
'when an independently authored later claim is merged, '
'then the loser stays visible with a released column and a preserved claim.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final (winner, loser) = await twoClaimants(node, space, value: 0);
final rows = {
for (final row in await UniqueNullable.db.find(node.session))
row.id!: row.value,
};
expect(rows, {winner: 0, loser: null});
expect(await claims(node.raw), {'unique_nullable/$loser.value': 0});
},
);
test(
'Given a visible projected loser preserving the claim 0, '
'when it is upserted to the free value 1, '
'then the row holds 1 and no preserved claim remains.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final (winner, loser) = await twoClaimants(node, space, value: 0);
await upsertValue(node, space, loser, 1);
expect(await valueOf(node.session, loser), 1);
expect(await valueOf(node.session, winner), 0);
expect(await claims(node.raw), isEmpty);
},
);
test(
'Given a visible projected loser preserving the claim 0, '
'when a full-row update writes the free value 1, '
'then the row holds 1 and no preserved claim remains.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final (winner, loser) = await twoClaimants(node, space, value: 0);
await node.session.db.transactionForUser(
space,
(tx) => UniqueNullable.db.updateRow(
node.session,
UniqueNullable(id: loser, value: 1),
transaction: tx,
),
);
expect(await valueOf(node.session, loser), 1);
expect(await valueOf(node.session, winner), 0);
expect(await claims(node.raw), isEmpty);
},
);
test(
'Given a visible projected loser preserving the claim 0, '
'when a narrowed update writes the free value 1 to that column, '
'then the row holds 1 and no preserved claim remains.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final (winner, loser) = await twoClaimants(node, space, value: 0);
await node.session.db.transactionForUser(
space,
(tx) => UniqueNullable.db.updateRow(
node.session,
UniqueNullable(id: loser, value: 1),
columns: (t) => [t.value],
transaction: tx,
),
);
expect(await valueOf(node.session, loser), 1);
expect(await valueOf(node.session, winner), 0);
expect(await claims(node.raw), isEmpty);
},
);
test(
'Given a visible projected loser upserted twice to free values, '
'when the contesting row is deleted and its claim becomes free, '
'then the loser holds the most recent value its author wrote.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final (winner, loser) = await twoClaimants(node, space, value: 0);
await upsertValue(node, space, loser, 1);
await upsertValue(node, space, loser, 2);
await node.session.db.transactionForUser(
space,
(tx) => UniqueNullable.db.deleteRow(
node.session,
UniqueNullable(id: winner, value: 0),
transaction: tx,
),
);
expect(await valueOf(node.session, loser), 2);
},
);
test(
'Given a visible projected loser upserted to the free value 1, '
'when an empty peer bootstraps from the complete export, '
'then the peer materialises the value the author wrote.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final mirror = await replica(space);
final (_, loser) = await twoClaimants(node, space, value: 0);
await upsertValue(node, space, loser, 1);
final changes = await node.sync
.collectPendingChanges(
node.raw,
checkpointsBySpaceUuid: {space: const []},
)
.toList();
await mirror.session.db.mergeChanges(changes, spaceId: space);
expect(
await valueOf(mirror.session, loser),
await valueOf(node.session, loser),
reason: 'author and peer must agree',
);
expect(await valueOf(mirror.session, loser), 1);
},
);
test(
'Given a visible row with no preserved claim on its unique column, '
'when it is upserted to a free value, '
'then the row holds the value its author wrote.',
() async {
final space = const Uuid().v7obj();
final node = await replica(space);
final uncontested = const Uuid().v7obj();
await node.session.db.transactionForUser(
space,
(tx) => UniqueNullable.db.insertRow(
node.session,
UniqueNullable(id: uncontested, value: 0),
transaction: tx,
),
);
await upsertValue(node, space, uncontested, 1);
expect(await valueOf(node.session, uncontested), 1);
expect(await claims(node.raw), isEmpty);
},
);
}

View file

@ -4,6 +4,7 @@ import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_random.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
@ -57,7 +58,7 @@ void main() {
test(
'Given a deleted unique claimant and a newer claimant of the same name, '
'when the DST restores the deleted identity, '
'then the original identity reclaims its name without deleting its peer.',
'then the renewed claim yields the name to its earlier peer.',
() async {
final random = DstRandom(4);
final ids = DstIds(random);
@ -88,9 +89,14 @@ void main() {
expect(outcome, DstOperationOutcome.applied);
final restored = await Unique.db.findById(replica.session, original.id!);
final other = await Unique.db.findById(replica.session, peer.id!);
expect(restored?.name, 'shared');
expect(other, isNotNull);
expect(other!.name, isNot('shared'));
expect(restored?.name, 'shared__conflict__${original.id}');
expect(other?.name, 'shared');
final snapshot = await DstSnapshot.capture(replica);
expect(snapshot.authoredValue(('unique', original.id!, 'name')), 'shared');
expect(
snapshot.fieldHlc(('unique', original.id!, 'name')),
snapshot.rowHlcs['unique/${original.id}'],
);
},
);
@ -434,6 +440,129 @@ void main() {
expect((insert.data as Town).mayorId, mayor.id);
},
);
for (final defaultLocation in ['missing', 'another space', 'the acting space']) {
final defaultDescription = defaultLocation == 'missing'
? 'no default target'
: 'a default target in $defaultLocation';
test(
'Given a detached defaulted FK with a local parent and $defaultDescription, '
'when the DST upserts an unrelated field, '
'then the submitted reference resolves to a visible parent in the acting space.',
() async {
final ids = DstIds(DstRandom(910));
final space = ids.next();
final otherSpace = ids.next();
final replica = await DstReplica.create(
name: 'replica',
spaceUuids: [space, otherSpace],
nodeUuid: ids.next(),
clock: DstClock().clock,
);
if (defaultLocation != 'missing') {
await replica.seedDefaultTown(
defaultLocation == 'the acting space' ? space : otherSpace,
);
}
final parent = Town(id: ids.next(), name: 'available-parent');
final child = UniqueSetDefaultChild(
id: ids.next(),
name: 'original',
parentId: parent.id,
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await Town.db.insertRow(replica.session, parent, transaction: tx);
await UniqueSetDefaultChild.db.insertRow(
replica.session,
child,
transaction: tx,
);
await UniqueSetDefaultChild.db.updateRow(
replica.session,
child.copyWith(parentId: null),
columns: (t) => [t.parentId],
transaction: tx,
);
}),
);
// This seed changes only name, leaving the full-row upsert to apply
// the unchanged null FK's persisted default.
final operations = DstOperations(DstRandom(5), ids);
final outcome = await operations.apply(
replica,
space,
table: DstTable.uniqueSetDefaultChild,
action: DstAction.upsert,
);
expect(outcome, DstOperationOutcome.applied);
final updated = (await UniqueSetDefaultChild.db.findById(
replica.session,
child.id!,
))!;
expect(updated.name, isNot(child.name));
expect(
updated.parentId,
defaultLocation == 'the acting space' ? dstDefaultTownId : parent.id,
);
expect(operations.rejections, isEmpty);
},
);
}
test(
'Given a detached defaulted FK with no visible parent or default target, '
'when the DST tries to upsert an unrelated field, '
'then it skips the unavailable reference without changing authored facts.',
() async {
final ids = DstIds(DstRandom(910));
final space = ids.next();
final replica = await _replica(ids, space);
final parent = Town(id: ids.next(), name: 'removed-parent');
final child = UniqueSetDefaultChild(
id: ids.next(),
name: 'original',
parentId: parent.id,
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await Town.db.insertRow(replica.session, parent, transaction: tx);
await UniqueSetDefaultChild.db.insertRow(
replica.session,
child,
transaction: tx,
);
await UniqueSetDefaultChild.db.updateRow(
replica.session,
child.copyWith(parentId: null),
columns: (t) => [t.parentId],
transaction: tx,
);
await Town.db.deleteRow(replica.session, parent, transaction: tx);
}),
);
final before = (await replica.collect(space)).map(dstChangeKey).toSet();
final operations = DstOperations(DstRandom(5), ids);
final outcome = await operations.apply(
replica,
space,
table: DstTable.uniqueSetDefaultChild,
action: DstAction.upsert,
);
expect(outcome, DstOperationOutcome.skipped);
expect((await replica.collect(space)).map(dstChangeKey).toSet(), before);
final retained = (await UniqueSetDefaultChild.db.findById(
replica.session,
child.id!,
))!;
expect(retained.name, child.name);
expect(retained.parentId, isNull);
},
);
}
DstAction _action(String name) =>

View file

@ -0,0 +1,224 @@
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
const space = UuidValue.raw('660e8400-e29b-41d4-a716-446655440000');
const child = UuidValue.raw('660e8400-e29b-41d4-a716-446655440001');
const attempted = UuidValue.raw('660e8400-e29b-41d4-a716-446655440002');
const fallback = UuidValue.raw('660e8400-e29b-41d4-a716-446655440003');
for (final scenario in [
(
given: 'a set-null reference recorded with a set-default reason',
table: DstTable.town,
column: 'mayorId',
domain: null,
reason: CrdtProjectionReason.foreignKeySetDefault,
childVisible: true,
parentVisible: false,
expected:
'town/$child.mayorId has reason foreignKeySetDefault, '
'which is impossible on a setNull edge',
),
(
given: 'a unique release whose domain still equals its preserved claim',
table: DstTable.uniqueSetNullChild,
column: 'parentId',
domain: attempted,
reason: CrdtProjectionReason.uniqueConflict,
childVisible: true,
parentVisible: true,
expected:
'unique_set_null_child/$child.parentId holds $attempted equal '
'to the authored value it is preserving (reason: uniqueConflict)',
),
(
given: 'a set-null repair retaining a different non-null reference',
table: DstTable.town,
column: 'mayorId',
domain: fallback,
reason: CrdtProjectionReason.foreignKeySetNull,
childVisible: true,
parentVisible: false,
expected: 'town/$child.mayorId is set-null but its domain value is $fallback',
),
(
given: 'a set-default repair pointing to a visible non-default town',
table: DstTable.company,
column: 'townId',
domain: fallback,
reason: CrdtProjectionReason.foreignKeySetDefault,
childVisible: true,
parentVisible: false,
expected:
'company/$child.townId is set-default but its domain value is '
'$fallback, not the column default $dstDefaultTownId',
),
(
given: 'a set-null override whose authored parent is visible in the same space',
table: DstTable.town,
column: 'mayorId',
domain: null,
reason: CrdtProjectionReason.foreignKeySetNull,
childVisible: true,
parentVisible: true,
expected:
'town/$child.mayorId keeps a foreignKeySetNull override while '
'its target $attempted is visible in the same space',
),
(
given: 'an unrepairable reference whose child is still visible',
table: DstTable.uniqueSetDefaultChild,
column: 'parentId',
domain: null,
reason: CrdtProjectionReason.foreignKeyMissingParent,
childVisible: true,
parentVisible: false,
expected:
'unique_set_default_child/$child.parentId is unrepairable but the row is still visible',
),
]) {
test(
'Given a detector snapshot containing ${scenario.given}, '
'when projection purity checks the preserved-value record, '
'then it reports exactly that invalid projection.',
() {
final snapshot = _projection(
space: space,
child: child,
table: scenario.table,
column: scenario.column,
domain: scenario.domain,
attempted: attempted,
reason: scenario.reason,
childVisible: scenario.childVisible,
parentVisible: scenario.parentVisible,
additionalVisibleParent: fallback,
);
final violations = DstOracle.projectionPurity(snapshot);
expect(violations, [
(property: 'projectionPurity', detail: scenario.expected),
]);
},
);
}
for (final scenario in [
(
given: 'a nullable reference to a hidden parent repaired to null',
table: DstTable.town,
column: 'mayorId',
domain: null,
reason: CrdtProjectionReason.foreignKeySetNull,
childVisible: true,
parentVisible: false,
additionalVisibleParent: null,
),
(
given: 'a reference to a hidden town repaired to the visible default town',
table: DstTable.company,
column: 'townId',
domain: dstDefaultTownId,
reason: CrdtProjectionReason.foreignKeySetDefault,
childVisible: true,
parentVisible: false,
additionalVisibleParent: dstDefaultTownId,
),
(
given:
'a hidden child preserving an unrepairable town claim with no available default',
table: DstTable.uniqueSetDefaultChild,
column: 'parentId',
domain: null,
reason: CrdtProjectionReason.foreignKeyMissingParent,
childVisible: false,
parentVisible: false,
additionalVisibleParent: null,
),
(
given: 'a visible unique loser released to null despite its parent being visible',
table: DstTable.uniqueSetNullChild,
column: 'parentId',
domain: null,
reason: CrdtProjectionReason.uniqueConflict,
childVisible: true,
parentVisible: true,
additionalVisibleParent: null,
),
(
given: 'a hidden unique row released to null despite its parent being visible',
table: DstTable.uniqueSetNullChild,
column: 'parentId',
domain: null,
reason: CrdtProjectionReason.hiddenUniqueRelease,
childVisible: false,
parentVisible: true,
additionalVisibleParent: null,
),
]) {
test(
'Given a detector snapshot containing ${scenario.given}, '
'when projection purity checks the preserved-value record, '
'then it accepts the ${scenario.reason.name} projection.',
() {
final snapshot = _projection(
space: space,
child: child,
table: scenario.table,
column: scenario.column,
domain: scenario.domain,
attempted: attempted,
reason: scenario.reason,
childVisible: scenario.childVisible,
parentVisible: scenario.parentVisible,
additionalVisibleParent: scenario.additionalVisibleParent,
);
expect(DstOracle.projectionPurity(snapshot), isEmpty);
},
);
}
}
/// Mechanical detector input only. Every relevant value is supplied by the
/// scenario, and no damaged row or projection is persisted or merged.
DstSnapshot _projection({
required UuidValue space,
required UuidValue child,
required DstTable table,
required String column,
required UuidValue? domain,
required UuidValue attempted,
required CrdtProjectionReason reason,
required bool childVisible,
required bool parentVisible,
required UuidValue? additionalVisibleParent,
}) {
final edge = dstForeignKeys.singleWhere(
(edge) => edge.child == table && edge.column == column,
);
return DstSnapshot(
rows: {
edge.parent.tableName: {
attempted: (spaceUuid: space, columns: {}, visible: parentVisible),
?additionalVisibleParent: (spaceUuid: space, columns: {}, visible: true),
},
table.tableName: {
child: (spaceUuid: space, columns: {column: domain}, visible: childVisible),
},
},
projections: {
(table.tableName, child, column): (
attemptedValue: attempted,
projectionReason: reason,
),
},
causalLengths: {},
);
}

View file

@ -0,0 +1,596 @@
import 'dart:typed_data';
import 'package:serverpod_database/serverpod_database.dart'
show DatabaseUniqueViolationException;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_random.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
group('Given two deleted people with earlier field edits known to a peer, ', () {
late DstReplica source;
late DstReplica peer;
late DstReplica fresh;
late UuidValue space;
late List<Person> people;
late CrdtMergeSet deleted;
late DstSnapshot before;
setUpAll(() async {
final ids = DstIds(DstRandom(142));
space = ids.next();
source = await _replica(ids, space);
peer = await _replica(ids, space);
fresh = await _replica(ids, space);
people = [
Person(id: ids.next(), name: 'first', surname: 'before'),
Person(id: ids.next(), name: 'second', surname: 'before'),
];
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Person.db.insert(source.session, people, transaction: tx);
await Person.db.update(
source.session,
people.map((person) => person.copyWith(surname: 'edited')).toList(),
columns: (t) => [t.surname],
transaction: tx,
);
await Person.db.delete(source.session, people, transaction: tx);
}),
);
deleted = await source.collect(space);
await peer.merge(deleted, space);
before = await DstSnapshot.capture(source);
expect(before.fieldHlcs, isNotEmpty);
});
group(
'when a batch upsert reinserts them and sync resumes after the deletion, ',
() {
late DstSnapshot after;
late DstSnapshot resumed;
late DstSnapshot bootstrapped;
late CrdtMergeSet pending;
setUpAll(() async {
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Person.db.upsert(
source.session,
people.map((person) => person.copyWith(surname: 'restored')).toList(),
conflictColumns: (t) => [t.id],
updateColumns: (t) => [t.name],
noReturn: true,
transaction: tx,
),
),
);
after = await DstSnapshot.capture(source);
pending = await source.sync
.collectPendingChanges(
source.rawSession,
checkpointsBySpaceUuid: {
space: [deleted.maxHlc!],
},
)
.toList();
await peer.merge(pending, space);
await peer.merge(deleted, space);
await peer.merge(pending.reversed.toList(), space);
resumed = await DstSnapshot.capture(peer);
await fresh.merge(await source.collect(space), space);
bootstrapped = await DstSnapshot.capture(fresh);
});
test('then all fields inherit their own new insertion timestamp.', () {
expect(after.fieldHlcs, isEmpty);
expect(after.rowHlcs.values.toSet(), hasLength(2));
for (final person in people) {
final rowKey = 'person/${person.id}';
expect(after.rowHlcs[rowKey], greaterThan(before.rowHlcs[rowKey]!));
expect(after.authoredValue(('person', person.id!, 'surname')), 'restored');
expect(
after.fieldHlc(('person', person.id!, 'surname')),
after.rowHlcs[rowKey],
);
}
});
test(
'then incremental sync carries full rows without redundant field updates.',
() {
expect(pending.inserts, hasLength(2));
expect(pending.updates, isEmpty);
expect(pending.deletes.map((change) => change.clFlag), everyElement(3));
},
);
test('then replay and the older deletion leave the restored state intact.', () {
expect(resumed.renderSpace(space), after.renderSpace(space));
expect(
resumed.tombstones.values.map((value) => value.clFlag),
everyElement(3),
);
});
test('then a fresh device reconstructs the same values and timestamps.', () {
expect(bootstrapped.renderSpace(space), after.renderSpace(space));
});
},
);
});
group(
'Given a unique record deleted, restored from its hidden model, and deleted again, ',
() {
late DstReplica source;
late DstReplica target;
late UuidValue space;
late Unique row;
late DstSnapshot before;
setUpAll(() async {
final ids = DstIds(DstRandom(42));
space = ids.next();
source = await _replica(ids, space);
target = await _replica(ids, space);
row = Unique(id: ids.next(), name: 'ordinary');
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Unique.db.insertRow(source.session, row, transaction: tx);
await Unique.db.deleteRow(source.session, row, transaction: tx);
}),
);
final hidden = (await DstTable.unique.model.find(
source.session,
includeHidden: true,
spaceUuid: space,
)).single;
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await source.session.db.insertRow(hidden, transaction: tx);
await Unique.db.deleteRow(source.session, row, transaction: tx);
}),
);
before = await DstSnapshot.capture(source);
});
group('when its complete export is merged into an empty peer, ', () {
late DstSnapshot after;
setUpAll(() async {
await target.merge(await source.collect(space), space);
after = await DstSnapshot.capture(target);
});
test(
'then the preserved name has the same renewed claim age on both devices.',
() {
final key = ('unique', row.id!, 'name');
expect(before.fieldHlc(key), before.rowHlcs['unique/${row.id}']);
expect(after.fieldHlc(key), before.fieldHlc(key));
expect(after.authoredValue(key), 'ordinary');
expect(after.renderSpace(space), before.renderSpace(space));
},
);
test('then the final deletion retains its causal generation.', () {
expect(after.tombstones['unique/${row.id}']!.clFlag, 4);
expect(after.lookupVisible(DstTable.unique, row.id!), isNull);
});
});
},
);
group(
'Given a deleted town whose missing mayor is preserved behind a null reference, ',
() {
late DstReplica source;
late DstReplica target;
late UuidValue space;
late Person mayor;
late Town town;
late Town hidden;
setUpAll(() async {
final ids = DstIds(DstRandom(43));
space = ids.next();
source = await _replica(ids, space);
target = await _replica(ids, space);
mayor = Person(id: ids.next(), name: 'mayor');
town = Town(id: ids.next(), name: 'town', mayorId: mayor.id);
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Person.db.insertRow(source.session, mayor, transaction: tx);
}),
);
await target.merge(await source.collect(space), space);
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Town.db.insertRow(source.session, town, transaction: tx),
),
);
await target.withReplicaClock(
() => target.session.db.transactionForUser(
space,
(tx) => Person.db.deleteRow(target.session, mayor, transaction: tx),
),
);
await source.merge(await target.collect(space), space);
hidden = (await Town.db.findById(source.session, town.id!))!;
expect(hidden.mayorId, isNull);
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Town.db.deleteRow(source.session, hidden, transaction: tx),
),
);
});
group('when the town is reinserted and a fresh device downloads it, ', () {
late DstSnapshot after;
late DstSnapshot downloaded;
late CrdtMergeSet exported;
setUpAll(() async {
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Town.db.insertRow(source.session, hidden, transaction: tx),
),
);
after = await DstSnapshot.capture(source);
exported = await source.collect(space);
final fresh = await _replica(DstIds(DstRandom(44)), space);
await fresh.merge(exported, space);
downloaded = await DstSnapshot.capture(fresh);
});
test(
'then its original mayor is preserved at the new insertion timestamp.',
() {
final key = ('town', town.id!, 'mayorId');
expect(after.authoredValue(key), mayor.id);
expect(after.rows['town']![town.id!]!.columns['mayorId'], isNull);
expect(after.fieldHlcs.keys.where((key) => key.$1 == 'town'), [key]);
expect(after.fieldHlc(key), after.rowHlcs['town/${town.id}']);
expect(
exported.updates.where((change) => change.uuidRowId == town.id),
isEmpty,
);
},
);
test(
'then the fresh device reconstructs the preserved reference and its age.',
() {
expect(downloaded.renderSpace(space), after.renderSpace(space));
},
);
});
},
);
group(
'Given a deleted name claim and a competing claim authored before its restoration, ',
() {
late DstReplica source;
late DstReplica competitor;
late UuidValue space;
late Unique original;
late Unique other;
late Unique hidden;
setUpAll(() async {
final ids = DstIds(DstRandom(45));
space = ids.next();
final clock = DstClock();
source = await _replica(ids, space, clock: clock);
competitor = await _replica(ids, space, clock: clock);
original = Unique(id: ids.next(), name: 'shared');
other = Unique(id: ids.next(), name: 'shared');
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Unique.db.insertRow(source.session, original, transaction: tx);
await Unique.db.deleteRow(source.session, original, transaction: tx);
}),
);
hidden = (await Unique.db.find(
source.session,
where: (t) => t.includeHiddenRows,
)).single;
await competitor.merge(await source.collect(space), space);
clock.advance(const Duration(milliseconds: 1));
await competitor.withReplicaClock(
() => competitor.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(competitor.session, other, transaction: tx),
),
);
clock.advance(const Duration(milliseconds: 1));
});
group(
'when the original identity is reinserted offline and both devices synchronize, ',
() {
late DstSnapshot restored;
late DstSnapshot merged;
late DstSnapshot peer;
setUpAll(() async {
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(source.session, hidden, transaction: tx),
),
);
restored = await DstSnapshot.capture(source);
final competitorFacts = await competitor.collect(space);
await competitor.merge(await source.collect(space), space);
await source.merge(competitorFacts, space);
merged = await DstSnapshot.capture(source);
peer = await DstSnapshot.capture(competitor);
});
test(
'then restoration drops the obsolete field record after reclaiming its value.',
() {
expect(restored.fieldHlcs, isEmpty);
expect(restored.projections, isEmpty);
expect(
restored.authoredValue(('unique', original.id!, 'name')),
'shared',
);
},
);
test(
'then the earlier competing claim wins and the restored claim keeps its new age.',
() {
final key = ('unique', original.id!, 'name');
expect(merged.rows['unique']![other.id!]!.columns['name'], 'shared');
expect(
merged.rows['unique']![original.id!]!.columns['name'],
'shared__conflict__${original.id}',
);
expect(merged.authoredValue(key), 'shared');
expect(merged.fieldHlc(key), restored.rowHlcs['unique/${original.id}']);
expect(peer.renderSpace(space), merged.renderSpace(space));
},
);
},
);
},
);
group('Given a restored person and a later name edit made by an offline peer, ', () {
late DstReplica source;
late DstReplica peer;
late UuidValue space;
late Person person;
late DstSnapshot edited;
setUpAll(() async {
final ids = DstIds(DstRandom(46));
space = ids.next();
final clock = DstClock();
source = await _replica(ids, space, clock: clock);
peer = await _replica(ids, space, clock: clock);
person = Person(id: ids.next(), name: 'original', surname: 'old');
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Person.db.insertRow(source.session, person, transaction: tx),
),
);
await peer.merge(await source.collect(space), space);
clock.advance(const Duration(milliseconds: 1));
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Person.db.deleteRow(source.session, person, transaction: tx);
await Person.db.insertRow(
source.session,
person.copyWith(name: 'restored', surname: 'restored'),
transaction: tx,
);
}),
);
clock.advance(const Duration(milliseconds: 1));
await peer.withReplicaClock(
() => peer.session.db.transactionForUser(
space,
(tx) => Person.db.updateRow(
peer.session,
person.copyWith(name: 'later edit'),
columns: (t) => [t.name],
transaction: tx,
),
),
);
edited = await DstSnapshot.capture(peer);
});
group('when the peer receives the restoration and sends its edit back, ', () {
late DstSnapshot after;
late DstSnapshot returned;
setUpAll(() async {
await peer.merge(await source.collect(space), space);
after = await DstSnapshot.capture(peer);
await source.merge(await peer.collect(space), space);
returned = await DstSnapshot.capture(source);
});
test(
'then the newer name survives while the other values come from the restoration.',
() {
final key = ('person', person.id!, 'name');
expect(after.authoredValue(key), 'later edit');
expect(after.fieldHlc(key), edited.fieldHlc(key));
expect(after.authoredValue(('person', person.id!, 'surname')), 'restored');
expect(after.tombstones['person/${person.id}']!.clFlag, 3);
expect(returned.renderSpace(space), after.renderSpace(space));
},
);
});
});
group('Given a deleted row containing dates, binary data, and other scalar values, ', () {
late DstReplica source;
late DstReplica peer;
late DstReplica fresh;
late UuidValue space;
late Types row;
late Types replacement;
setUpAll(() async {
final ids = DstIds(DstRandom(48));
space = ids.next();
source = await _replica(ids, space);
peer = await _replica(ids, space);
fresh = await _replica(ids, space);
row = Types(
id: ids.next(),
aBool: true,
aDateTime: DateTime.utc(2025, 1),
aText: 'before',
anInt: 1,
anInt64: BigInt.parse('9223372036854775806'),
aReal: 1.5,
aBlob: ByteData.sublistView(Uint8List.fromList([1, 2, 3])),
anEnum: TypesEnum.alpha,
optionalText: 'before',
optionalUuid: ids.next(),
);
replacement = row.copyWith(
aBool: false,
aDateTime: DateTime.utc(2026, 9, 17, 1, 2, 3, 456),
aText: 'restored',
anInt: -2,
anInt64: BigInt.parse('-9223372036854775807'),
aReal: 2.75,
aBlob: ByteData.sublistView(Uint8List.fromList([0, 255, 128, 39])),
anEnum: TypesEnum.gamma,
optionalText: null,
optionalUuid: ids.next(),
);
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Types.db.insertRow(source.session, row, transaction: tx);
await Types.db.deleteRow(source.session, row, transaction: tx);
}),
);
await peer.merge(await source.collect(space), space);
});
group(
'when it is reinserted with new values and downloaded by existing and fresh peers, ',
() {
late DstSnapshot after;
late DstSnapshot resumed;
late DstSnapshot downloaded;
late Types onPeer;
late CrdtMergeSet exported;
setUpAll(() async {
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => Types.db.insertRow(source.session, replacement, transaction: tx),
),
);
after = await DstSnapshot.capture(source);
exported = await source.collect(space);
await peer.merge(exported, space);
await fresh.merge(exported, space);
resumed = await DstSnapshot.capture(peer);
downloaded = await DstSnapshot.capture(fresh);
onPeer = (await Types.db.findById(peer.session, row.id!))!;
});
test(
'then every value is reconstructed with its correct type from the row snapshot.',
() {
expect(exported.updates, isEmpty);
final expected = replacement.toJson()..remove('spaceId');
expect(onPeer.toJson()..remove('spaceId'), expected);
expect(resumed.renderSpace(space), after.renderSpace(space));
expect(downloaded.renderSpace(space), after.renderSpace(space));
},
);
},
);
});
group('Given a deleted unique record with retained authored metadata, ', () {
late DstReplica source;
late UuidValue space;
late Unique row;
late Unique hidden;
late DstSnapshot before;
setUpAll(() async {
final ids = DstIds(DstRandom(47));
space = ids.next();
source = await _replica(ids, space);
row = Unique(id: ids.next(), name: 'claim');
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Unique.db.insertRow(source.session, row, transaction: tx);
await Unique.db.deleteRow(source.session, row, transaction: tx);
}),
);
hidden = (await Unique.db.find(
source.session,
where: (t) => t.includeHiddenRows,
)).single;
before = await DstSnapshot.capture(source);
});
group(
'when a later unique violation aborts the transaction that reinserted it, ',
() {
late DstSnapshot after;
Object? failure;
setUpAll(() async {
try {
await source.withReplicaClock(
() => source.session.db.transactionForUser(space, (tx) async {
await Unique.db.insertRow(source.session, hidden, transaction: tx);
await Unique.db.insertRow(
source.session,
Unique(name: 'claim'),
transaction: tx,
);
}),
);
} on Object catch (error) {
failure = error;
}
after = await DstSnapshot.capture(source);
});
test(
'then the deleted state, field clocks, and preserved claim are rolled back exactly.',
() {
expect(failure, isA<DatabaseUniqueViolationException>());
expect(after.renderRawMetadata(), before.renderRawMetadata());
expect(after.renderSpace(space), before.renderSpace(space));
},
);
},
);
});
}
Future<DstReplica> _replica(DstIds ids, UuidValue space, {DstClock? clock}) =>
DstReplica.create(
name: 'replica',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: (clock ?? DstClock()).clock,
);

View file

@ -0,0 +1,600 @@
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_authored.dart';
import 'framework/dst_random.dart';
import 'framework/dst_rejection.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
group('Given a child whose former parent is deleted, ', () {
late DstReplica replica;
late DstOperations operations;
late UuidValue space;
late Person parent;
late UniqueSetNullChild child;
late City earlier;
late DstSnapshot before;
setUp(() async {
final random = DstRandom(48);
final ids = DstIds(random);
space = ids.next();
replica = await _replica(ids, [space]);
operations = DstOperations(random, ids);
parent = Person(id: ids.next(), name: 'deleted parent');
child = UniqueSetNullChild(id: ids.next(), name: 'child', parentId: parent.id);
earlier = City(id: ids.next(), name: 'rolled back');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await Person.db.insertRow(replica.session, parent, transaction: tx);
await UniqueSetNullChild.db.insertRow(
replica.session,
child,
transaction: tx,
);
await Person.db.deleteRow(replica.session, parent, transaction: tx);
}),
);
before = await DstSnapshot.capture(replica);
});
group(
'when a save resubmits the deleted parent after an earlier valid write, ',
() {
late DstOperationOutcome outcome;
late DstSnapshot after;
setUp(() async {
outcome = await operations.perform(
replica,
space,
table: DstTable.uniqueSetNullChild,
action: DstAction.fullRowUpdate,
body: (tx, evidence, refusal) async {
evidence.write('city', earlier.toJson());
await City.db.insertRow(replica.session, earlier, transaction: tx);
final write = child.copyWith(name: 'resubmitted');
evidence.write('unique_set_null_child', write.toJson());
await UniqueSetNullChild.db.updateRow(
replica.session,
write,
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test('then the DST recognizes the refusal naming the parent by UUID.', () {
expect(outcome, DstOperationOutcome.rejected);
expect(operations.rejections, [
'Exception: Cannot reference deleted row person.id = ${parent.id}.',
]);
expect(operations.appliedPaths, isEmpty);
expect(operations.unexpectedPaths, isEmpty);
});
test('then both writes roll back without changing authored state.', () {
expect(after.renderSpace(space), before.renderSpace(space));
expect(after.renderRawMetadata(), before.renderRawMetadata());
});
},
);
});
group('Given visible unique rows claiming claim-0 through claim-3,', () {
late DstIds ids;
late UuidValue space;
late DstReplica replica;
late DstOperations operations;
late List<Unique> originals;
late DstSnapshot before;
setUp(() async {
final random = DstRandom(43);
ids = DstIds(random);
space = ids.next();
replica = await _replica(ids, [space]);
operations = DstOperations(random, ids);
originals = [
for (var i = 0; i < 4; i++) Unique(id: ids.next(), name: 'claim-$i'),
];
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => Unique.db.insert(replica.session, originals, transaction: tx),
),
);
before = await DstSnapshot.capture(replica);
});
group('when a new record claims an occupied name,', () {
late DstOperationOutcome outcome;
late DstSnapshot after;
setUp(() async {
final writes = [Unique(id: ids.next(), name: 'claim-0')];
outcome = await operations.perform(
replica,
space,
table: DstTable.unique,
action: DstAction.insert,
body: (tx, evidence, refusal) async {
for (final row in writes) {
evidence.write('unique', row.toJson());
}
await Unique.db.insertRow(replica.session, writes.single, transaction: tx);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the refusal is predicted and all domain and authored state rolls back.',
() {
expect(outcome, DstOperationOutcome.rejected);
expect(operations.rejections, hasLength(1));
expect(operations.appliedPaths, isEmpty);
expect(after.renderSpace(space), before.renderSpace(space));
expect(after.renderRawMetadata(), before.renderRawMetadata());
},
);
});
group('when a batch gives two new records the same unused name,', () {
late DstOperationOutcome outcome;
late DstSnapshot after;
setUp(() async {
final writes = [
Unique(id: ids.next(), name: 'unused'),
Unique(id: ids.next(), name: 'unused'),
];
outcome = await operations.perform(
replica,
space,
table: DstTable.unique,
action: DstAction.insertBatch,
body: (tx, evidence, refusal) async {
for (final row in writes) {
evidence.write('unique', row.toJson());
}
await Unique.db.insert(replica.session, writes, transaction: tx);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the refusal is predicted and all domain and authored state rolls back.',
() {
expect(outcome, DstOperationOutcome.rejected);
expect(operations.rejections, hasLength(1));
expect(operations.appliedPaths, isEmpty);
expect(after.renderSpace(space), before.renderSpace(space));
expect(after.renderRawMetadata(), before.renderRawMetadata());
},
);
});
group('when a batch update gives two records the same unused name,', () {
late DstOperationOutcome outcome;
late DstSnapshot after;
setUp(() async {
final writes = [
originals[1].copyWith(name: 'unused'),
originals[2].copyWith(name: 'unused'),
];
outcome = await operations.perform(
replica,
space,
table: DstTable.unique,
action: DstAction.updateBatch,
body: (tx, evidence, refusal) async {
for (final row in writes) {
evidence.write('unique', row.toJson(), columns: {'name'});
}
await Unique.db.update(
replica.session,
writes,
columns: (t) => [t.name],
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the refusal is predicted and all domain and authored state rolls back.',
() {
expect(outcome, DstOperationOutcome.rejected);
expect(operations.rejections, hasLength(1));
expect(operations.appliedPaths, isEmpty);
expect(after.renderSpace(space), before.renderSpace(space));
expect(after.renderRawMetadata(), before.renderRawMetadata());
},
);
});
group('when a predicate update gives two records the same unused name,', () {
late DstOperationOutcome outcome;
late DstSnapshot after;
setUp(() async {
final writes = [
originals[1].copyWith(name: 'unused'),
originals[2].copyWith(name: 'unused'),
];
outcome = await operations.perform(
replica,
space,
table: DstTable.unique,
action: DstAction.updateWhere,
body: (tx, evidence, refusal) async {
for (final row in writes) {
evidence.write('unique', row.toJson(), columns: {'name'});
}
await Unique.db.updateWhere(
replica.session,
columnValues: (t) => [t.name('unused')],
where: (t) => t.id.inSet(<UuidValue>{originals[1].id!, originals[2].id!}),
transaction: tx,
);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the refusal is predicted and all domain and authored state rolls back.',
() {
expect(outcome, DstOperationOutcome.rejected);
expect(operations.rejections, hasLength(1));
expect(operations.appliedPaths, isEmpty);
expect(after.renderSpace(space), before.renderSpace(space));
expect(after.renderRawMetadata(), before.renderRawMetadata());
},
);
});
});
group(
'Given two children with distinct unique references and an occupied default town,',
() {
late DstIds ids;
late UuidValue space;
late DstReplica replica;
late DstOperations operations;
late Town town;
late DstSnapshot before;
setUp(() async {
final random = DstRandom(144);
ids = DstIds(random);
space = ids.next();
replica = await _replica(ids, [space]);
operations = DstOperations(random, ids);
await replica.seedDefaultTown(space);
town = Town(id: ids.next(), name: 'current');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await Town.db.insertRow(replica.session, town, transaction: tx);
await UniqueSetDefaultChild.db.insert(replica.session, [
UniqueSetDefaultChild(
id: ids.next(),
name: 'current-child',
parentId: town.id,
),
UniqueSetDefaultChild(
id: ids.next(),
name: 'default-child',
parentId: const UuidValue.raw('550e8400-e29b-41d4-a716-446655440000'),
),
], transaction: tx);
}),
);
before = await DstSnapshot.capture(replica);
});
group(
'when deleting the other town would assign its child the occupied default,',
() {
late DstOperationOutcome outcome;
late DstSnapshot after;
setUp(() async {
outcome = await operations.perform(
replica,
space,
table: DstTable.town,
action: DstAction.delete,
body: (tx, evidence, refusal) async {
refusal.delete(DstTable.town, [town.id!]);
evidence.visibility('town', [town.id!], deleted: true);
await Town.db.deleteRow(replica.session, town, transaction: tx);
return DstOperationOutcome.applied;
},
);
after = await DstSnapshot.capture(replica);
});
test(
'then the unique refusal is predicted and the parent, children, and authored state remain unchanged.',
() {
expect(outcome, DstOperationOutcome.rejected);
expect(operations.rejections, hasLength(1));
expect(operations.appliedPaths, isEmpty);
expect(after.renderSpace(space), before.renderSpace(space));
expect(after.renderRawMetadata(), before.renderRawMetadata());
},
);
},
);
},
);
test(
'Given a cascade chain with a visible no-action blocker, '
'when the DST deletes the root, '
'then the refusal is predicted from the rows and all authored state rolls back.',
() async {
final random = DstRandom(44);
final ids = DstIds(random);
final space = ids.next();
final replica = await _replica(ids, [space]);
final root = FkChainRoot(id: ids.next(), name: 'root');
final middle = FkChainCascadeMiddle(
id: ids.next(),
name: 'middle',
rootId: root.id,
);
final blocker = FkChainRestrictBlocker(
id: ids.next(),
name: 'blocker',
cascadeMiddleId: middle.id,
);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await FkChainRoot.db.insertRow(replica.session, root, transaction: tx);
await FkChainCascadeMiddle.db.insertRow(
replica.session,
middle,
transaction: tx,
);
await FkChainRestrictBlocker.db.insertRow(
replica.session,
blocker,
transaction: tx,
);
}),
);
final before = await DstSnapshot.capture(replica);
final prediction = DstRejection(before, space)
..delete(DstTable.fkChainRoot, [root.id!]);
final operations = DstOperations(random, ids);
final outcome = await operations.apply(
replica,
space,
table: DstTable.fkChainRoot,
action: DstAction.delete,
);
final after = await DstSnapshot.capture(replica);
expect(prediction.deleteReasons(definiteOnly: true), isNotEmpty);
expect(outcome, DstOperationOutcome.rejected);
expect(after.renderSpace(space), before.renderSpace(space));
expect(operations.appliedPaths, isEmpty);
},
);
test(
'Given a company and both its town and default town, '
'when the DST deletes both towns in one batch, '
'then the unavailable batch default causes refusal without authored progress.',
() async {
final random = DstRandom(45);
final ids = DstIds(random);
final space = ids.next();
final replica = await _replica(ids, [space]);
await replica.seedDefaultTown(space);
final town = Town(id: ids.next(), name: 'current');
final company = Company(id: ids.next(), name: 'company', townId: town.id);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await Town.db.insertRow(replica.session, town, transaction: tx);
await Company.db.insertRow(replica.session, company, transaction: tx);
}),
);
final before = await DstSnapshot.capture(replica);
final operations = DstOperations(random, ids);
final outcome = await operations.apply(
replica,
space,
table: DstTable.town,
action: DstAction.deleteBatch,
);
final after = await DstSnapshot.capture(replica);
expect(outcome, DstOperationOutcome.rejected);
expect(after.renderSpace(space), before.renderSpace(space));
expect(operations.appliedPaths, isEmpty);
},
);
test(
'Given another space containing the only person and city, '
'when the DST updates and inserts required references in the empty acting space, '
'then space-local selection skips both operations without touching the other space.',
() async {
final random = DstRandom(46);
final ids = DstIds(random);
final space = ids.next();
final otherSpace = ids.next();
final replica = await _replica(ids, [space, otherSpace]);
final operations = DstOperations(random, ids);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(otherSpace, (tx) async {
await City.db.insertRow(
replica.session,
City(id: ids.next(), name: 'other-city'),
transaction: tx,
);
await Person.db.insertRow(
replica.session,
Person(id: ids.next(), name: 'other-person'),
transaction: tx,
);
}),
);
final before = await DstSnapshot.capture(replica);
final update = await operations.apply(
replica,
space,
table: DstTable.city,
action: DstAction.update,
);
final insert = await operations.apply(
replica,
space,
table: DstTable.requiredCascadeChild,
action: DstAction.insert,
);
final after = await DstSnapshot.capture(replica);
expect(update, DstOperationOutcome.skipped);
expect(insert, DstOperationOutcome.skipped);
expect(after.renderSpace(otherSpace), before.renderSpace(otherSpace));
expect(after.renderSpace(space), before.renderSpace(space));
},
);
for (final error in [
'UNIQUE constraint failed: city.name',
'FOREIGN KEY constraint failed',
]) {
test(
'Given a valid city insert and an injected database failure, '
'when SQLite raises $error, '
'then the DST surfaces the unexpected exception instead of counting a refusal.',
() async {
final random = DstRandom(47);
final ids = DstIds(random);
final space = ids.next();
final replica = await _replica(ids, [space]);
final operations = DstOperations(random, ids);
// Fault injection at the actual database boundary, after normal schema
// initialization; no mocked exception classifier or production edits.
await replica.rawSession.db.unsafeExecute(
"CREATE TRIGGER dst_fault BEFORE INSERT ON city BEGIN SELECT RAISE(ABORT, '$error'); END",
);
final result = operations.apply(
replica,
space,
table: DstTable.city,
action: DstAction.insert,
);
await expectLater(
result,
throwsA(
isA<StateError>().having((e) => e.message, 'message', contains(error)),
),
);
expect(operations.rejections, isEmpty);
expect(operations.appliedPaths, isEmpty);
expect(operations.unexpected, 1);
},
);
}
test(
'Given an accepted city insertion and a requested delete, '
'when a successful no-op snapshot is checked against that intent, '
'then authoring evidence rejects the missing tombstone progress.',
() async {
final ids = DstIds(DstRandom(48));
final space = ids.next();
final replica = await _replica(ids, [space]);
final city = City(id: ids.next(), name: 'live');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final before = await DstSnapshot.capture(replica);
final evidence = DstWriteEvidence(before)
..visibility('city', [city.id!], deleted: true);
final violations = evidence.validate(before);
expect(violations.map((v) => v.property), ['acceptedVisibility']);
},
);
for (final result in ['unchanged', 'wrong parity', 'old clock', 'accepted']) {
test(
'Given an accepted city deletion and a requested restore, '
'when a detector checks the $result restore snapshot, '
'then authoring evidence ${result == 'accepted' ? 'accepts the new odd generation and clock' : 'rejects the missing restore progress'}.',
() async {
final ids = DstIds(DstRandom(96));
final space = ids.next();
final replica = await _replica(ids, [space]);
final city = City(id: ids.next(), name: 'restored');
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(space, (tx) async {
await City.db.insertRow(replica.session, city, transaction: tx);
await City.db.deleteRow(replica.session, city, transaction: tx);
}),
);
final before = await DstSnapshot.capture(replica);
final evidence = DstWriteEvidence(before)
..visibility('city', [city.id!], deleted: false);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => City.db.insertRow(replica.session, city, transaction: tx),
),
);
final after = await DstSnapshot.capture(replica);
final key = 'city/${city.id}';
final restored = after.tombstones[key]!;
final tombstone = (
hlc: result == 'old clock' ? before.tombstones[key]!.hlc : restored.hlc,
clFlag: result == 'wrong parity' ? restored.clFlag + 1 : restored.clFlag,
reason: restored.reason,
);
// Corrupt only the detector input; persisted restore facts stay intact.
final candidate = result == 'unchanged'
? before
: DstSnapshot(
rows: after.rows,
projections: after.projections,
causalLengths: after.causalLengths,
rowHlcs: after.rowHlcs,
fieldHlcs: after.fieldHlcs,
tombstones: {...after.tombstones, key: tombstone},
);
final violations = evidence.validate(candidate);
expect(
violations.map((v) => v.property),
result == 'accepted' ? isEmpty : ['acceptedVisibility'],
);
},
);
}
}
Future<DstReplica> _replica(DstIds ids, List<UuidValue> spaces) => DstReplica.create(
name: 'replica',
spaceUuids: spaces,
nodeUuid: ids.next(),
clock: DstClock().clock,
);

View file

@ -1,16 +1,48 @@
import 'package:test/test.dart';
import 'framework/dst_random.dart';
import 'framework/dst_runner.dart';
void main() {
test(
'Given two node allocators with the same seed, '
'when each assigns three identities to ordered clock slots, '
'then the shuffled identity assignment replays exactly.',
() {
final first = DstIds(DstRandom(91)).nextShuffled(3);
final second = DstIds(DstRandom(91)).nextShuffled(3);
expect(first, second);
expect(first.toSet(), hasLength(3));
},
);
test(
'Given node allocators with seeds 1 through 16, '
'when each assigns three identities to ordered clock slots, '
'then node tie-break ordering varies independently of the clock order.',
() {
final orders = <String>{};
for (var seed = 1; seed <= 16; seed++) {
final nodes = DstIds(DstRandom(seed)).nextShuffled(3);
final ranked = nodes.toList()..sort((a, b) => a.uuid.compareTo(b.uuid));
orders.add(nodes.map(ranked.indexOf).join());
}
expect(orders, containsAll(['012', '210']));
},
);
test(
'Given a property violation from seed 114 at 80 rounds, '
'when its failure is reported, '
'then the replay command preserves the seed and round count.',
'then the replay command preserves the seed, depth, profile, and graph width.',
() {
final failure = DstPropertyFailure(
seed: 114,
rounds: 80,
profile: DstProfile.populated,
graphWidth: 3,
violations: [(property: 'foreignKeyClosure', detail: 'orphan')],
);
@ -19,7 +51,7 @@ void main() {
expect(
message,
'DST property failure (seed 114)\n'
'Replay: DST_SEED_BASE=114 DST_SEEDS=1 DST_ROUNDS=80 dart test -P dst\n'
'Replay: DST_SEED_BASE=114 DST_SEEDS=1 DST_ROUNDS=80 DST_PROFILE=populated DST_GRAPH_WIDTH=3 dart test -P dst\n'
'- foreignKeyClosure: orphan\n',
);
},
@ -28,7 +60,7 @@ void main() {
test(
'Given an unexpected exception from seed 115 at 40 rounds, '
'when its failure is reported, '
'then the replay command preserves the seed and round count.',
'then the replay command preserves the seed, depth, profile, and graph width.',
() async {
final failure = await runWithSeedReported<Object>(
index: 1,
@ -42,7 +74,7 @@ void main() {
expect(
message,
'Bad state: Simulation 1 (seed 115) failed\n'
'Replay: DST_SEED_BASE=115 DST_SEEDS=1 DST_ROUNDS=40 dart test -P dst\n'
'Replay: DST_SEED_BASE=115 DST_SEEDS=1 DST_ROUNDS=40 DST_PROFILE=sparse DST_GRAPH_WIDTH=2 dart test -P dst\n'
'Bad state: unexpected',
);
},

View file

@ -0,0 +1,36 @@
import 'package:test/test.dart';
import 'framework/dst_world.dart';
void main() {
test(
'Given the generated synchronized model catalog, '
'when the DST model population is enumerated, '
'then every synchronized table is authored and compared.',
() {
expect(
DstTable.values.map((table) => table.tableName).toSet(),
dstSyncTables.map((table) => table.tableName).toSet(),
);
expect(dstModels.keys.toSet(), DstTable.values.toSet());
},
);
test(
'Given the simulated foreign key population, '
'when its reference columns and delete actions are enumerated, '
'then every edge targets id and has an action the refusal predictor models.',
() {
expect(
dstForeignKeys.map((edge) => edge.parentColumn).toSet(),
{'id'},
reason: 'Extend DstRejection._children before adding other target columns.',
);
expect(
dstForeignKeys.map((edge) => edge.action).toSet(),
everyElement(isIn({'cascade', 'noAction', 'setNull', 'setDefault'})),
reason: 'Extend DstRejection.deleteReasons for new delete actions.',
);
},
);
}

View file

@ -0,0 +1,103 @@
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:serverpod_offline_sync_test_shared/serverpod_offline_sync_test_shared.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_random.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
test(
'Given shared-package parent and child rows authored by the DST, '
'when its predicate adapter changes the child flavor and an empty peer bootstraps, '
'then the string-backed enum and parent relation survive synchronization.',
() async {
final random = DstRandom(91);
final ids = DstIds(random);
final space = ids.next();
final source = await _replica(ids, space);
final target = await _replica(ids, space);
final operations = DstOperations(random, ids);
for (final table in [DstTable.sharedParent, DstTable.sharedChild]) {
expect(
await operations.apply(source, space, table: table, action: DstAction.insert),
DstOperationOutcome.applied,
);
}
final parent = (await SharedParent.db.find(source.session)).single;
final child = (await SharedChild.db.find(source.session)).single;
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => DstTable.sharedChild.model.updateWhere(
source.session,
{child.id!},
{'flavor': 'salted', 'parentId': parent.id!.toJson()},
tx,
),
),
);
await target.merge(await source.collect(space), space);
final received = (await SharedChild.db.find(target.session)).single;
expect(received.flavor, SharedFlavor.salted);
expect(received.parentId, parent.id);
expect((await SharedParent.db.find(target.session)).single.id, parent.id);
},
);
test(
'Given a typed row authored by the DST with structured JSON fields, '
'when its predicate adapter writes documents and a number list and a peer bootstraps, '
'then JSON and JSONB retain the supplied structured values.',
() async {
final random = DstRandom(92);
final ids = DstIds(random);
final space = ids.next();
final source = await _replica(ids, space);
final target = await _replica(ids, space);
final operations = DstOperations(random, ids);
expect(
await operations.apply(
source,
space,
table: DstTable.types,
action: DstAction.insert,
),
DstOperationOutcome.applied,
);
final row = (await Types.db.find(source.session)).single;
final document = SyncDocument(title: 'saved', enabled: false, numbers: [3, -7]);
await source.withReplicaClock(
() => source.session.db.transactionForUser(
space,
(tx) => DstTable.types.model.updateWhere(
source.session,
{row.id!},
{
'jsonDocument': document.toJson(),
'jsonbDocument': document.toJson(),
'jsonbNumbers': [13, -5],
},
tx,
),
),
);
await target.merge(await source.collect(space), space);
final received = (await Types.db.find(target.session)).single;
expect(received.jsonDocument!.toJson(), document.toJson());
expect(received.jsonbDocument!.toJson(), document.toJson());
expect(received.jsonbNumbers, [13, -5]);
},
);
}
Future<DstReplica> _replica(DstIds ids, UuidValue space) => DstReplica.create(
name: 'schema-values',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().clock,
);

View file

@ -0,0 +1,149 @@
import 'package:serverpod/serverpod.dart' show ClientDatabaseSession;
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
test(
'Given one replica holding a UUID-shaped unique text value, '
'when an empty peer bootstraps from its complete export, '
'then the peer retains the original text value.',
() async {
final space = const Uuid().v7obj();
final source = await _replica(space);
final target = await _replica(space);
final row = Unique(
id: const Uuid().v7obj(),
name: '550e8400-e29b-41d4-a716-446655440111',
);
await source.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(source.session, row, transaction: tx),
);
final changes = await source.sync
.collectPendingChanges(
source.raw,
checkpointsBySpaceUuid: {space: const []},
)
.toList();
await target.session.db.mergeChanges(changes, spaceId: space);
expect((await Unique.db.findById(target.session, row.id!))!.name, row.name);
},
);
test(
'Given a UUID-shaped unique text claim and an unrelated row, '
'when a local update authors the uppercase text on the other row, '
'then both distinct text claims remain unchanged.',
() async {
final space = const Uuid().v7obj();
final replica = await _replica(space);
final lower = Unique(
id: const Uuid().v7obj(),
name: '550e8400-e29b-41d4-a716-446655440111',
);
final other = Unique(id: const Uuid().v7obj(), name: 'placeholder');
const upper = '550E8400-E29B-41D4-A716-446655440111';
await replica.session.db.transactionForUser(
space,
(tx) => Unique.db.insert(replica.session, [lower, other], transaction: tx),
);
await replica.session.db.transactionForUser(
space,
(tx) => Unique.db.updateRow(
replica.session,
other.copyWith(name: upper),
columns: (t) => [t.name],
transaction: tx,
),
);
expect(
{for (final row in await Unique.db.find(replica.session)) row.id: row.name},
{lower.id: lower.name, other.id: upper},
);
expect(await CrdtDataAttemptedValue.db.find(replica.raw), isEmpty);
},
);
test(
'Given two replicas authoring UUID-shaped text in different letter cases, '
'when their complete exports are exchanged, '
'then both replicas retain the two distinct text claims.',
() async {
final space = const Uuid().v7obj();
final left = await _replica(space);
final right = await _replica(space);
final lower = Unique(
id: const Uuid().v7obj(),
name: '550e8400-e29b-41d4-a716-446655440111',
);
final upper = Unique(
id: const Uuid().v7obj(),
name: '550E8400-E29B-41D4-A716-446655440111',
);
await left.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(left.session, lower, transaction: tx),
);
await right.session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(right.session, upper, transaction: tx),
);
final fromLeft = await left.sync
.collectPendingChanges(
left.raw,
checkpointsBySpaceUuid: {space: const []},
)
.toList();
final fromRight = await right.sync
.collectPendingChanges(
right.raw,
checkpointsBySpaceUuid: {space: const []},
)
.toList();
await right.session.db.mergeChanges(fromLeft, spaceId: space);
await left.session.db.mergeChanges(fromRight, spaceId: space);
for (final replica in [left, right]) {
expect(
{for (final row in await Unique.db.find(replica.session)) row.id: row.name},
{lower.id: lower.name, upper.id: upper.name},
);
expect(await CrdtDataAttemptedValue.db.find(replica.raw), isEmpty);
}
},
);
}
/// Independent public ORM/collector/merge control: no DST generator, refusal
/// classifier, or expected-fact oracle participates in these expectations.
Future<
({
ClientDatabaseSession raw,
OfflineSyncDatabaseSession session,
OfflineSyncEngine sync,
})
>
_replica(UuidValue space) async {
final raw = await createAdditionalTestSession();
final session = OfflineSyncDatabaseSession.wraps(raw, syncTables: syncTables);
await session.db.initialize();
await session.db.transactionForUser(space, (_) async {});
return (
raw: raw,
session: session,
sync: OfflineSyncEngine(
syncTables: syncTables,
serializationManager: raw.db.serializationManager,
),
);
}

View file

@ -0,0 +1,220 @@
import 'dart:typed_data';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import 'framework/dst_random.dart';
import 'framework/dst_snapshot.dart';
import 'framework/dst_world.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
test(
'Given an empty synchronized scalar table, '
'when the DST authors a generated batch, '
'then both typed rows and their accepted authored facts survive bootstrap.',
() async {
final random = DstRandom(18);
final ids = DstIds(random);
final space = ids.next();
final source = await _replica(ids, space);
final target = await _replica(ids, space);
final operations = DstOperations(random, ids);
final outcome = await operations.apply(
source,
space,
table: DstTable.types,
action: DstAction.insertBatch,
);
await target.merge(await source.collect(space), space);
expect(outcome, DstOperationOutcome.applied);
final rows = await Types.db.find(source.session);
expect(rows, hasLength(2));
expect(
{for (final row in await Types.db.find(target.session)) row.id: _values(row)},
{for (final row in rows) row.id: _values(row)},
);
expect(
operations.oracle.validate(await DstSnapshot.capture(target), space),
isEmpty,
);
},
);
for (final enumValue in [TypesEnum.beta, null]) {
test(
'Given a row containing every scalar type and nullable values, '
'when the DST updateWhere adapter replaces all fields with enum ${enumValue?.name ?? 'null'} and clears optional text and UUID, '
'then the ORM stores the supplied Dart values without JSON type leakage.',
() async {
final ids = DstIds(DstRandom(19));
final space = ids.next();
final replica = await _replica(ids, space);
final row = _row(ids.next());
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => Types.db.insertRow(replica.session, row, transaction: tx),
),
);
final updated = row.copyWith(
aBool: false,
aDateTime: DateTime.utc(2030, 5, 6, 1, 2, 3, 456),
aText: 'updated',
anInt: -7,
anInt64: BigInt.parse('-9007199254740993'),
aReal: -12.625,
aBlob: ByteData.sublistView(Uint8List.fromList(List.generate(16, (i) => i))),
anEnum: enumValue,
optionalText: null,
optionalUuid: null,
);
final values = _values(updated);
await replica.withReplicaClock(
() => replica.session.db.transactionForUser(
space,
(tx) => DstTable.types.model.updateWhere(
replica.session,
{row.id!},
values,
tx,
),
),
);
expect(_values((await Types.db.findById(replica.session, row.id!))!), values);
},
);
}
test(
'Given two replicas sharing a typed row, '
'when concurrent writes overlap on its text and change separate scalar fields, '
'then reversed duplicate delivery and fresh bootstrap preserve the field winners.',
() async {
final ids = DstIds(DstRandom(20));
final space = ids.next();
final left = await _replica(ids, space);
final right = await _replica(ids, space);
final fresh = await _replica(ids, space);
final row = _row(ids.next());
await left.withReplicaClock(
() => left.session.db.transactionForUser(
space,
(tx) => Types.db.insertRow(left.session, row, transaction: tx),
),
);
await right.merge(await left.collect(space), space);
final leftUpdate = row.copyWith(
aBool: false,
aDateTime: DateTime.utc(2027, 3, 4, 5, 6, 7, 890),
aText: 'left',
anInt64: BigInt.parse('-9007199254740993'),
aBlob: ByteData.sublistView(Uint8List.fromList(List.filled(16, 255))),
);
final rightUpdate = row.copyWith(
aText: 'right',
anInt: -42,
aReal: -1.125,
anEnum: TypesEnum.gamma,
optionalText: null,
optionalUuid: null,
);
await left.withReplicaClock(
() => left.session.db.transactionForUser(
space,
(tx) => Types.db.updateRow(
left.session,
leftUpdate,
columns: (t) => [t.aBool, t.aDateTime, t.aText, t.anInt64, t.aBlob],
transaction: tx,
),
),
);
await right.withReplicaClock(
() => right.session.db.transactionForUser(
space,
(tx) => Types.db.updateRow(
right.session,
rightUpdate,
columns: (t) => [
t.aText,
t.anInt,
t.aReal,
t.anEnum,
t.optionalText,
t.optionalUuid,
],
transaction: tx,
),
),
);
final leftBefore = await DstSnapshot.capture(left);
final rightBefore = await DstSnapshot.capture(right);
final textKey = ('types', row.id!, 'aText');
final textWinner =
leftBefore.fieldHlcs[textKey]!.compareTo(rightBefore.fieldHlcs[textKey]!) > 0
? 'left'
: 'right';
final expected = _values(
leftUpdate.copyWith(
aText: textWinner,
anInt: rightUpdate.anInt,
aReal: rightUpdate.aReal,
anEnum: rightUpdate.anEnum,
optionalText: null,
optionalUuid: null,
),
);
final fromLeft = await left.collect(space);
final fromRight = await right.collect(space);
await left.merge(fromRight, space);
await right.merge(fromLeft, space);
await right.merge(fromRight, space);
await left.merge(fromLeft, space);
await left.merge(fromRight, space);
await right.merge(fromLeft, space);
await fresh.merge(await left.collect(space), space);
for (final replica in [left, right, fresh]) {
expect(_values((await Types.db.findById(replica.session, row.id!))!), expected);
}
},
);
}
Types _row(UuidValue id) => Types(
id: id,
aBool: true,
aDateTime: DateTime.utc(2026, 1, 1),
aText: 'initial',
anInt: 1,
anInt64: BigInt.parse('9007199254740993'),
aReal: 1.25,
aBlob: ByteData.sublistView(Uint8List.fromList([0, 128, 255])),
anEnum: TypesEnum.alpha,
optionalText: 'present',
optionalUuid: const UuidValue.raw('660e8400-e29b-41d4-a716-446655440000'),
);
Map<String, dynamic> _values(Types row) => {
...row.toJson()
..remove('id')
..remove('spaceId')
..remove('__className__'),
'anEnum': row.anEnum?.toJson(),
'optionalText': row.optionalText,
'optionalUuid': row.optionalUuid?.toJson(),
};
Future<DstReplica> _replica(DstIds ids, UuidValue space) async => DstReplica.create(
name: 'typed',
spaceUuids: [space],
nodeUuid: ids.next(),
clock: DstClock().clock,
);

View file

@ -400,6 +400,7 @@ void main() {
isNotEmpty,
);
},
timeout: const Timeout(Duration(minutes: 2)),
);
}

View file

@ -0,0 +1,200 @@
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_client/serverpod_offline_sync_test_client.dart';
import 'package:test/test.dart';
import '../integration/test_tools/client_session.dart';
import '../integration/test_tools/sync_topology.dart';
void main() {
initTestClientSession();
group('Given two visible rows contesting one unique name, ', () {
late UuidValue space;
late Map<UuidValue, String> before;
late Map<UuidValue, Object?> authoredBefore;
setUp(() async {
space = const Uuid().v7obj();
final first = Unique(id: const Uuid().v7obj(), name: 'contested');
final second = Unique(id: const Uuid().v7obj(), name: 'contested');
await session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(session, first, transaction: tx),
);
await mergeIndependentInsert(session, second, space: space, tables: [Unique.t]);
before = await _names();
authoredBefore = await _authoredNames();
});
group(
'when a batch exchanges the materialised names of the winner and a released loser, ',
() {
Object? failure;
setUp(() async {
final winner = before.entries
.singleWhere((row) => row.value == 'contested')
.key;
final loser = before.entries
.firstWhere((row) => row.value != 'contested')
.key;
failure = null;
try {
await session.db.transactionForUser(
space,
(tx) => Unique.db.update(
session,
[
Unique(id: winner, name: before[loser]!),
Unique(id: loser, name: before[winner]!),
],
columns: (t) => [t.name],
transaction: tx,
),
);
} on Object catch (error) {
failure = error;
}
});
test(
'then the reserved name is rejected and all original claims remain intact.',
() async {
expect(before.values.toSet(), hasLength(2));
expect(failure, isA<OfflineSyncReservedValueException>());
expect(await _names(), before);
expect(await _authoredNames(), authoredBefore);
},
);
},
);
});
group('Given three visible rows contesting one unique name, ', () {
late UuidValue space;
late Map<UuidValue, String> before;
late Map<UuidValue, Object?> authoredBefore;
setUp(() async {
space = const Uuid().v7obj();
final first = Unique(id: const Uuid().v7obj(), name: 'contested');
final second = Unique(id: const Uuid().v7obj(), name: 'contested');
await session.db.transactionForUser(
space,
(tx) => Unique.db.insertRow(session, first, transaction: tx),
);
await mergeIndependentInsert(session, second, space: space, tables: [Unique.t]);
final third = Unique(id: const Uuid().v7obj(), name: 'contested');
await mergeIndependentInsert(session, third, space: space, tables: [Unique.t]);
before = await _names();
authoredBefore = await _authoredNames();
});
group(
'when a batch exchanges the materialised names of the winner and a released loser, ',
() {
Object? failure;
setUp(() async {
final winner = before.entries
.singleWhere((row) => row.value == 'contested')
.key;
final loser = before.entries
.firstWhere((row) => row.value != 'contested')
.key;
failure = null;
try {
await session.db.transactionForUser(
space,
(tx) => Unique.db.update(
session,
[
Unique(id: winner, name: before[loser]!),
Unique(id: loser, name: before[winner]!),
],
columns: (t) => [t.name],
transaction: tx,
),
);
} on Object catch (error) {
failure = error;
}
});
test(
'then the reserved name is rejected and all original claims remain intact.',
() async {
expect(before.values.toSet(), hasLength(3));
expect(failure, isA<OfflineSyncReservedValueException>());
expect(await _names(), before);
expect(await _authoredNames(), authoredBefore);
},
);
},
);
group('when the two released losers exchange their materialised names, ', () {
late List<UuidValue> losers;
Object? failure;
setUp(() async {
losers = before.entries
.where((row) => row.value != 'contested')
.map((row) => row.key)
.toList();
failure = null;
try {
await session.db.transactionForUser(
space,
(tx) => Unique.db.update(
session,
[
Unique(id: losers[0], name: before[losers[1]]!),
Unique(id: losers[1], name: before[losers[0]]!),
],
columns: (t) => [t.name],
transaction: tx,
),
);
} on Object catch (error) {
failure = error;
}
});
test(
'then the reserved names are rejected and all original claims remain intact.',
() async {
expect(losers, hasLength(2));
expect(failure, isA<OfflineSyncReservedValueException>());
expect(await _names(), before);
expect(await _authoredNames(), authoredBefore);
},
);
});
});
}
Future<Map<UuidValue, String>> _names() async => {
for (final row in await Unique.db.find(session)) row.id!: row.name,
};
/// Public ORM evidence only, independent of the DST snapshot or authoring
/// oracle: the sparse record retains a claim while the domain is projected.
Future<Map<UuidValue, Object?>> _authoredNames() async {
final attempts = await CrdtDataAttemptedValue.db.find(
testSession,
include: CrdtDataAttemptedValue.include(
field: CrdtDataField.include(
row: CrdtDataRow.include(tbl: CrdtSchemaTable.include()),
column: CrdtSchemaColumn.include(),
),
),
);
return {
...await _names(),
for (final attempt in attempts)
if (attempt.field!.row!.tbl!.name == 'unique' &&
attempt.field!.column!.name == 'name')
attempt.field!.row!.uuidRowId: attempt.value,
};
}

Some files were not shown because too many files have changed in this diff Show more