test: Harden DST authored-state checks and workloads (#123)
This commit is contained in:
parent
16d15eebe5
commit
ad3e1a49e4
47 changed files with 10312 additions and 458 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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',
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
|
|
@ -400,6 +400,7 @@ void main() {
|
|||
isNotEmpty,
|
||||
);
|
||||
},
|
||||
timeout: const Timeout(Duration(minutes: 2)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue