fix: Author the column default an upsert writes for a null foreign key (#164)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Marcelo Mendonça Soares 2026-10-08 22:48:47 -03:00 • committed by GitHub
commit ee000e348c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 21728 additions and 51 deletions

View file

@ -0,0 +1,68 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "unique_set_default_child",
"schema": "public",
"addColumns": [],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_set_default_child_name",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "name"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": [
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "uniqueIndexCreated",
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
"table": "unique_set_default_child",
"columns": [
"spaceId",
"name"
],
"destrucive": false
}
]
}
}
],
"warnings": [
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "uniqueIndexCreated",
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
"table": "unique_set_default_child",
"columns": [
"spaceId",
"name"
],
"destrucive": false
}
],
"migrationApiVersion": 1
}

View file

@ -16,6 +16,7 @@ part '20260914144112288/migration.dart';
part '20260914165608984/migration.dart';
part '20260930185557154/migration.dart';
part '20261007215503877/migration.dart';
part '20261008234516445/migration.dart';
/// Migration registry for the client-side database.
class MigrationRegistry {
@ -34,6 +35,7 @@ class MigrationRegistry {
_Migration20260914165608984(),
_Migration20260930185557154(),
_Migration20261007215503877(),
_Migration20261008234516445(),
];
/// List of all client-side database migration versions.

View file

@ -2124,6 +2124,23 @@ class Protocol extends _isd.DatabaseSerializationManager {
isUnique: true,
isPrimary: false,
),
_isd.IndexDefinition(
indexName: 'unique_set_default_child_name',
tableSpace: null,
elements: [
_isd.IndexElementDefinition(
type: _isd.IndexElementDefinitionType.column,
definition: 'spaceId',
),
_isd.IndexElementDefinition(
type: _isd.IndexElementDefinitionType.column,
definition: 'name',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),

View file

@ -2123,6 +2123,23 @@ class Protocol extends _is.DatabaseSerializationManager {
isUnique: true,
isPrimary: false,
),
_isp.IndexDefinition(
indexName: 'unique_set_default_child_name',
tableSpace: null,
elements: [
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'spaceId',
),
_isp.IndexElementDefinition(
type: _isp.IndexElementDefinitionType.column,
definition: 'name',
),
],
type: 'btree',
isUnique: true,
isPrimary: false,
),
],
managed: true,
),

View file

@ -6,3 +6,7 @@ fields:
# Keep the database default without replacing explicit nulls on model reads.
parentId: UuidValue?, defaultPersist='550e8400-e29b-41d4-a716-446655440000', unique
parent: Town?, relation(field=parentId, onDelete=SetDefault, deferred)
indexes:
unique_set_default_child_name:
fields: spaceId, name
unique: true

View file

@ -0,0 +1,68 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "unique_set_default_child",
"schema": "public",
"addColumns": [],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [
{
"__className__": "serverpod.IndexDefinition",
"indexName": "unique_set_default_child_name",
"elements": [
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "spaceId"
},
{
"__className__": "serverpod.IndexElementDefinition",
"type": 0,
"definition": "name"
}
],
"type": "btree",
"isUnique": true,
"isPrimary": false
}
],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": [
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "uniqueIndexCreated",
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
"table": "unique_set_default_child",
"columns": [
"spaceId",
"name"
],
"destrucive": false
}
]
}
}
],
"warnings": [
{
"__className__": "serverpod.DatabaseMigrationWarning",
"type": "uniqueIndexCreated",
"message": "Unique index \"unique_set_default_child_name\" is added to table \"unique_set_default_child\". If there are existing rows with duplicate values, this migration will fail.",
"table": "unique_set_default_child",
"columns": [
"spaceId",
"name"
],
"destrucive": false
}
],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,189 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
CREATE UNIQUE INDEX "unique_set_default_child_name" ON "unique_set_default_child" ("spaceId", "name");
--
-- STORE COLUMN TYPES FOR MIGRATIONS
--
DROP TABLE IF EXISTS "serverpod_sqlite_schema";
CREATE TABLE "serverpod_sqlite_schema" (
"table_name" TEXT NOT NULL,
"column_name" TEXT NOT NULL,
"column_type" TEXT NOT NULL,
"column_vector_dimension" INTEGER,
PRIMARY KEY ("table_name", "column_name")
);
INSERT INTO "serverpod_sqlite_schema" VALUES
('address', 'id', 'uuid', NULL),
('address', 'inhabitantId', 'uuid', NULL),
('city', 'id', 'uuid', NULL),
('company', 'id', 'uuid', NULL),
('company', 'townId', 'uuid', NULL),
('fk_chain_cascade_middle', 'id', 'uuid', NULL),
('fk_chain_cascade_middle', 'rootId', 'uuid', NULL),
('fk_chain_middle_cascade_child', 'id', 'uuid', NULL),
('fk_chain_middle_cascade_child', 'restrictBlockerId', 'uuid', NULL),
('fk_chain_middle_set_null_child', 'id', 'uuid', NULL),
('fk_chain_middle_set_null_child', 'restrictBlockerId', 'uuid', NULL),
('fk_chain_restrict_blocker', 'id', 'uuid', NULL),
('fk_chain_restrict_blocker', 'cascadeMiddleId', 'uuid', NULL),
('fk_chain_root', 'id', 'uuid', NULL),
('fk_chain_set_null_cascade_child', 'id', 'uuid', NULL),
('fk_chain_set_null_cascade_child', 'setNullMiddleId', 'uuid', NULL),
('fk_chain_set_null_middle', 'id', 'uuid', NULL),
('fk_chain_set_null_middle', 'cascadeMiddleId', 'uuid', NULL),
('fk_chain_set_null_restrict_child', 'id', 'uuid', NULL),
('fk_chain_set_null_restrict_child', 'setNullMiddleId', 'uuid', NULL),
('fk_chain_set_null_set_null_child', 'id', 'uuid', NULL),
('fk_chain_set_null_set_null_child', 'setNullMiddleId', 'uuid', NULL),
('organization', 'id', 'uuid', NULL),
('organization', 'cityId', 'uuid', NULL),
('person', 'id', 'uuid', NULL),
('person', 'organizationId', 'uuid', NULL),
('person', 'oldCompanyId', 'uuid', NULL),
('person', 'cityId', 'uuid', NULL),
('required_cascade_child', 'id', 'uuid', NULL),
('required_cascade_child', 'parentId', 'uuid', NULL),
('required_no_action_child', 'id', 'uuid', NULL),
('required_no_action_child', 'parentId', 'uuid', NULL),
('restrict_child', 'id', 'uuid', NULL),
('restrict_child', 'parentId', 'uuid', NULL),
('shared_child', 'id', 'uuid', NULL),
('shared_child', 'parentId', 'uuid', NULL),
('shared_parent', 'id', 'uuid', NULL),
('town', 'id', 'uuid', NULL),
('town', 'cityId', 'uuid', NULL),
('town', 'mayorId', 'uuid', NULL),
('types', 'id', 'uuid', NULL),
('types', 'aBool', 'boolean', NULL),
('types', 'aDateTime', 'timestampWithoutTimeZone', NULL),
('types', 'optionalUuid', 'uuid', NULL),
('types', 'parentId', 'uuid', NULL),
('types', 'jsonDocument', 'json', NULL),
('types', 'jsonbDocument', 'jsonb', NULL),
('types', 'jsonbNumbers', 'jsonb', NULL),
('unique', 'id', 'uuid', NULL),
('unique_cascade_child', 'id', 'uuid', NULL),
('unique_cascade_child', 'parentId', 'uuid', NULL),
('unique_cascade_reference', 'id', 'uuid', NULL),
('unique_cascade_reference', 'parentId', 'uuid', NULL),
('unique_composite', 'id', 'uuid', NULL),
('unique_discriminator', 'id', 'uuid', NULL),
('unique_fk_pair', 'id', 'uuid', NULL),
('unique_fk_pair', 'leftId', 'uuid', NULL),
('unique_fk_pair', 'rightId', 'uuid', NULL),
('unique_mixed_fk', 'id', 'uuid', NULL),
('unique_mixed_fk', 'parentId', 'uuid', NULL),
('unique_no_release', 'id', 'uuid', NULL),
('unique_nullable', 'id', 'uuid', NULL),
('unique_overlapping', 'id', 'uuid', NULL),
('unique_set_default_child', 'id', 'uuid', NULL),
('unique_set_default_child', 'parentId', 'uuid', NULL),
('unique_set_null_child', 'id', 'uuid', NULL),
('unique_set_null_child', 'parentId', 'uuid', NULL),
('unique_uuid', 'id', 'uuid', NULL),
('unique_uuid', 'value', 'uuid', NULL),
('serverpod_cloud_storage', 'addedTime', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage', 'verified', 'boolean', NULL),
('serverpod_cloud_storage_direct_download', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage_direct_upload', 'expiration', 'timestampWithoutTimeZone', NULL),
('serverpod_cloud_storage_direct_upload', 'preventOverwrite', 'boolean', NULL),
('serverpod_future_call', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_future_call', 'scheduling', 'json', NULL),
('serverpod_future_call_claim', 'lastHeartbeatTime', 'timestampWithoutTimeZone', NULL),
('serverpod_health_connection_info', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_health_metric', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_health_metric', 'isHealthy', 'boolean', NULL),
('serverpod_log', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_message_log', 'slow', 'boolean', NULL),
('serverpod_migrations', 'timestamp', 'timestampWithoutTimeZone', NULL),
('serverpod_query_log', 'slow', 'boolean', NULL),
('serverpod_runtime_settings', 'logSettings', 'json', NULL),
('serverpod_runtime_settings', 'logSettingsOverrides', 'json', NULL),
('serverpod_runtime_settings', 'logServiceCalls', 'boolean', NULL),
('serverpod_runtime_settings', 'logMalformedCalls', 'boolean', NULL),
('serverpod_session_log', 'time', 'timestampWithoutTimeZone', NULL),
('serverpod_session_log', 'slow', 'boolean', NULL),
('serverpod_session_log', 'isOpen', 'boolean', NULL),
('serverpod_session_log', 'touched', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_jwt_refresh_token', 'id', 'uuid', NULL),
('serverpod_auth_core_jwt_refresh_token', 'authUserId', 'uuid', NULL),
('serverpod_auth_core_jwt_refresh_token', 'scopeNames', 'json', NULL),
('serverpod_auth_core_jwt_refresh_token', 'lastUpdatedAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_jwt_refresh_token', 'createdAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_profile', 'id', 'uuid', NULL),
('serverpod_auth_core_profile', 'authUserId', 'uuid', NULL),
('serverpod_auth_core_profile', 'createdAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_profile', 'imageId', 'uuid', NULL),
('serverpod_auth_core_profile_image', 'id', 'uuid', NULL),
('serverpod_auth_core_profile_image', 'userProfileId', 'uuid', NULL),
('serverpod_auth_core_profile_image', 'createdAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_session', 'id', 'uuid', NULL),
('serverpod_auth_core_session', 'authUserId', 'uuid', NULL),
('serverpod_auth_core_session', 'scopeNames', 'json', NULL),
('serverpod_auth_core_session', 'createdAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_session', 'lastUsedAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_session', 'expiresAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_user', 'id', 'uuid', NULL),
('serverpod_auth_core_user', 'createdAt', 'timestampWithoutTimeZone', NULL),
('serverpod_auth_core_user', 'scopeNames', 'json', NULL),
('serverpod_auth_core_user', 'blocked', 'boolean', NULL),
('crdt_data_attempted_value', 'value', 'jsonb', NULL),
('crdt_data_fields', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_data_rows', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_data_rows', 'uuidRowId', 'uuid', NULL),
('crdt_data_tombstone', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('crdt_nodes', 'uuidNodeId', 'uuid', NULL),
('crdt_nodes', 'lastHlc', 'jsonb', NULL),
('crdt_schema_columns', 'isNullable', 'boolean', NULL),
('offline_sync_integrity_violations', 'uuidRowId', 'uuid', NULL),
('offline_sync_integrity_violations', 'ownerSpaceUuid', 'uuid', NULL),
('offline_sync_integrity_violations', 'incomingSpaceUuid', 'uuid', NULL),
('offline_sync_integrity_violations', 'uuidNodeId', 'uuid', NULL),
('offline_sync_integrity_violations', 'hlcDatetime', 'timestampWithoutTimeZone', NULL),
('offline_sync_integrity_violations', 'firstSeenAt', 'timestampWithoutTimeZone', NULL),
('offline_sync_integrity_violations', 'lastSeenAt', 'timestampWithoutTimeZone', NULL),
('offline_sync_space_members', 'userUuid', 'uuid', NULL),
('offline_sync_space_nodes', 'lastReceivedHlc', 'jsonb', NULL),
('offline_sync_spaces', 'uuidSpaceId', 'uuid', NULL);
--
-- MIGRATION VERSION FOR serverpod_offline_sync_test
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync_test', '20261008234516445', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261008234516445', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', (unixepoch('now', 'subsecond') * 1000))
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = (unixepoch('now', 'subsecond') * 1000);
COMMIT;

View file

@ -16,3 +16,4 @@
20260914165608984
20260930185557154
20261007215503877
20261008234516445

View file

@ -0,0 +1,183 @@
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/crdt_probes.dart';
import '../integration/test_tools/sync_topology.dart';
/// Serverpod reads a null insert value as a request for the column default, so
/// an upsert supplying null for `unique_set_default_child.parentId` writes
/// `550e8400-…`. That is an authored write of the default, not the full-row
/// passthrough of `docs/projection-model.md`, which only translates back a
/// projected value the caller echoed unchanged.
///
/// The two coincide exactly when a set-default repair is already displaying the
/// default over a hidden parent, which is the state the convergence simulation
/// reached at seed 1791399000: `unique_set_default_child.upsert … accepted
/// "550e8400-…" but retained "00000000-000b-…"`.
void main() {
initTestClientSession(createSessionPerTest: false);
const defaultTownId = UuidValue.raw('550e8400-e29b-41d4-a716-446655440000');
group(
'Given a set-default child displaying the column default for a hidden parent,',
() {
late SyncNode node;
late Town parent;
late UniqueSetDefaultChild child;
/// The clock the column authored its value at, which is the row's
/// insertion clock while no field record exists.
Future<Hlc> parentIdHlc() async {
final field = await CrdtDataField.db.findFirstRow(
node.offlineSync,
where: (t) =>
t.row.uuidRowId.equals(child.id) & t.column.name.equals('parentId'),
include: CrdtDataField.include(node: CrdtNode.include()),
);
return field?.hlc ?? await rowHlc(child.id!, databaseSession: node.offlineSync);
}
setUpAll(() async {
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
parent = Town(id: const Uuid().v7obj(), name: 'parent');
child = UniqueSetDefaultChild(
id: const Uuid().v7obj(),
name: 'child',
parentId: parent.id,
);
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
await Town.db.insert(node.offlineSync, [
parent,
Town(id: defaultTownId, name: 'default'),
], transaction: tx);
await UniqueSetDefaultChild.db.insertRow(
node.offlineSync,
child,
transaction: tx,
);
});
// A peer deleted the parent after this replica authored the reference,
// so the repair is projected rather than authored locally.
final childHlc = await rowHlc(child.id!, databaseSession: node.offlineSync);
await node.offlineSync.db.mergeChanges([
CrdtMergeDelete(
uuidSpaceId: testCrdtUserId,
tableName: Town.t.tableName,
uuidRowId: parent.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: childHlc.datetime.add(const Duration(milliseconds: 1)),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
], spaceId: testCrdtUserId);
});
test(
'when reading the repaired child, '
'then the repair displays the default over the authored parent.',
() async {
final repaired = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
final attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
expect(repaired!.parentId, defaultTownId);
expect(attempt?.value.toString(), parent.id.toString());
expect(attempt?.projectionReason, CrdtProjectionReason.foreignKeySetDefault);
},
);
group('when a full-row upsert supplies null for the parent,', () {
late Hlc before;
late Hlc after;
late CrdtDataAttemptedValue? attempt;
late UniqueSetDefaultChild? stored;
late UniqueSetDefaultChild? restored;
late CrdtMergeSet changes;
setUpAll(() async {
before = await parentIdHlc();
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueSetDefaultChild.db.upsertRow(
node.offlineSync,
UniqueSetDefaultChild(id: child.id, name: 'edited'),
conflictColumns: (t) => [t.id],
transaction: tx,
),
);
stored = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
after = await parentIdHlc();
changes = await node.sync
.collectPendingChanges(
node.raw,
checkpointsBySpaceUuid: {testCrdtUserId: const []},
)
.toList();
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Town.db.insertRow(node.offlineSync, parent, transaction: tx),
);
restored = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
});
test('then the default it wrote becomes the authored value.', () {
expect(stored!.name, 'edited');
expect(stored!.parentId, defaultTownId);
expect(attempt, isNull);
});
test('then the write advances the authored clock of the column.', () {
expect(after, greaterThan(before));
});
test('then outbound sync exports the default, not the replaced parent.', () {
final insert = changes.inserts.singleWhere(
(change) => change.uuidRowId == child.id,
);
expect((insert.data as UniqueSetDefaultChild).parentId, defaultTownId);
expect(
changes.updates
.where(
(change) =>
change.uuidRowId == child.id && change.columnName == 'parentId',
)
.map((change) => change.value.toString()),
everyElement(defaultTownId.toString()),
);
});
test('then restoring the old parent leaves the written default.', () {
expect(restored!.parentId, defaultTownId);
});
});
},
);
}

View file

@ -627,14 +627,15 @@ void main() {
expect(rows, hasLength(1));
});
test('then CRDT fields are tracked for the default updated columns.', () async {
test('then the conflict columns keep their original implicit clocks.', () async {
final fields = await CrdtDataField.db.find(
session,
where: (t) => t.row.uuidRowId.equals(uniqueComposite.id),
include: CrdtDataField.include(column: CrdtSchemaColumn.include()),
);
expect(fields.map((e) => e.column!.name).toSet(), {'scope', 'value'});
// Both domain columns are conflict keys, so Serverpod's default
// update set contains no authored domain write.
expect(fields, isEmpty);
});
});
});

View file

@ -21,7 +21,7 @@ void main() {
});
withServerpod(
'PostgreSQL membership visibility',
'[PostgreSQL membership visibility]',
rollbackDatabase: RollbackDatabase.disabled,
serverDirectory: serverDirectory,
configOverride: (config) => config.copyWith(

View file

@ -0,0 +1,187 @@
import 'dart:io';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
import 'package:serverpod_offline_sync_test_server/src/generated/protocol.dart'
as server;
import 'package:test/test.dart';
import '../test_tools/crdt_probes.dart';
import '../test_tools/postgres_migrations.dart';
import '../test_tools/serverpod_test_tools.dart';
void main() {
final serverDirectory = Directory(
'${Directory.systemTemp.path}/offline_sync_upsert_${const Uuid().v4()}',
);
setUpAll(() => preparePostgresMigrations(serverDirectory));
tearDownAll(() async {
if (serverDirectory.existsSync()) await serverDirectory.delete(recursive: true);
});
withServerpod(
'[PostgreSQL upsert default authoring]',
rollbackDatabase: RollbackDatabase.disabled,
serverDirectory: serverDirectory,
configOverride: (config) => config.copyWith(
database: PostgresDatabaseConfig.embedded(
dataPath: '${serverDirectory.path}/postgres',
name: 'serverpod_test',
maxConnectionCount: 6,
),
),
(sessionBuilder, _) {
late OfflineSyncDatabaseSession session;
setUpAll(() async {
session = OfflineSyncDatabaseSession.wraps(
sessionBuilder.build(),
syncTables: server.syncTables,
);
await session.db.initialize();
});
group('Given a projected-default child and a child with an available parent,', () {
late UuidValue space;
late server.Town parent;
late server.Town otherParent;
late server.Town insertParent;
late server.UniqueSetDefaultChild child;
late server.UniqueSetDefaultChild skipped;
late Hlc originalClock;
const defaultTownId = UuidValue.raw('550e8400-e29b-41d4-a716-446655440000');
setUpAll(() async {
space = const Uuid().v7obj();
parent = server.Town(id: const Uuid().v7obj(), name: 'parent');
otherParent = server.Town(id: const Uuid().v7obj(), name: 'other');
insertParent = server.Town(id: const Uuid().v7obj(), name: 'insert parent');
child = server.UniqueSetDefaultChild(
id: const Uuid().v7obj(),
name: 'child',
parentId: parent.id,
);
skipped = server.UniqueSetDefaultChild(
id: const Uuid().v7obj(),
name: 'skipped',
parentId: otherParent.id,
);
await session.db.transactionForUser(space, (tx) async {
await server.Town.db.insert(session, [
parent,
otherParent,
insertParent,
server.Town(id: defaultTownId, name: 'default'),
], transaction: tx);
await server.UniqueSetDefaultChild.db.insert(
session,
[child, skipped],
transaction: tx,
);
});
originalClock = await rowHlc(child.id!, databaseSession: session);
await session.db.mergeChanges([
CrdtMergeDelete(
uuidSpaceId: space,
tableName: server.Town.t.tableName,
uuidRowId: parent.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: originalClock.datetime.add(const Duration(milliseconds: 1)),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
], spaceId: space);
});
group('when a mixed-ID batch skips, updates by name, and inserts rows,', () {
late List<server.UniqueSetDefaultChild> saved;
late CrdtDataAttemptedValue? attempt;
late CrdtDataField? field;
late server.UniqueSetDefaultChild? restored;
late server.UniqueSetDefaultChild? unchanged;
setUpAll(() async {
saved = await session.db.transactionForUser(
space,
(tx) => server.UniqueSetDefaultChild.db.upsert(
session,
[
server.UniqueSetDefaultChild(
name: skipped.name,
parentId: otherParent.id,
),
server.UniqueSetDefaultChild(
id: const Uuid().v7obj(),
name: child.name,
),
server.UniqueSetDefaultChild(
name: 'inserted',
parentId: insertParent.id,
),
],
conflictColumns: (t) => [t.spaceId, t.name],
updateWhere: (t) => t.name.equals(child.name),
transaction: tx,
),
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: session,
);
field = await CrdtDataField.db.findFirstRow(
session,
where: (t) =>
t.row.uuidRowId.equals(child.id) & t.column.name.equals('parentId'),
include: CrdtDataField.include(node: CrdtNode.include()),
);
unchanged = await server.UniqueSetDefaultChild.db.findById(
session,
skipped.id!,
);
await session.db.transactionForUser(
space,
(tx) => server.Town.db.insertRow(session, parent, transaction: tx),
);
restored = await server.UniqueSetDefaultChild.db.findById(
session,
child.id!,
);
});
test(
'then the existing row authors the default despite the different input ID.',
() {
expect(
saved.map((row) => row.name),
unorderedEquals([child.name, 'inserted']),
);
expect(saved.singleWhere((row) => row.name == child.name).id, child.id);
expect(attempt, isNull);
expect(field!.hlc, greaterThan(originalClock));
expect(restored!.parentId, defaultTownId);
},
);
test('then the skipped row retains its parent.', () {
expect(unchanged!.parentId, otherParent.id);
});
});
});
},
);
}

View file

@ -0,0 +1,400 @@
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 '../test_tools/client_session.dart';
import '../test_tools/crdt_probes.dart';
import '../test_tools/sync_topology.dart';
void main() {
initTestClientSession(createSessionPerTest: false);
group('Given a child displaying the column default for its hidden parent,', () {
late SyncNode node;
late Town parent;
late UniqueSetDefaultChild child;
late Hlc originalClock;
late UniqueSetDefaultChild? before;
late CrdtDataAttemptedValue? originalAttempt;
const defaultTownId = UuidValue.raw('550e8400-e29b-41d4-a716-446655440000');
setUp(() async {
node = await syncNode(await createAdditionalTestSession(), testSyncTables);
parent = Town(id: const Uuid().v7obj(), name: 'parent');
child = UniqueSetDefaultChild(
id: const Uuid().v7obj(),
name: 'child',
parentId: parent.id,
);
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
await Town.db.insert(node.offlineSync, [
parent,
Town(id: defaultTownId, name: 'default'),
], transaction: tx);
await UniqueSetDefaultChild.db.insertRow(
node.offlineSync,
child,
transaction: tx,
);
});
originalClock = await _parentIdHlc(node, child.id!);
await node.offlineSync.db.mergeChanges([
CrdtMergeDelete(
uuidSpaceId: testCrdtUserId,
tableName: Town.t.tableName,
uuidRowId: parent.id!,
uuidNodeId: const Uuid().v7obj(),
hlcDatetime: originalClock.datetime.add(const Duration(milliseconds: 1)),
hlcCounter: 0,
clFlag: 2,
reason: CrdtDataDeletedReason.userDelete,
),
], spaceId: testCrdtUserId);
before = await UniqueSetDefaultChild.db.findById(node.offlineSync, child.id!);
originalAttempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
});
group(
'when an upsert supplies null and matches its unique name without an ID,',
() {
late UniqueSetDefaultChild? saved;
late UniqueSetDefaultChild? restored;
late UniqueSetDefaultChild? peerChild;
late CrdtDataAttemptedValue? attempt;
late CrdtDataAttemptedValue? peerAttempt;
late Hlc updatedClock;
setUp(() async {
saved = await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueSetDefaultChild.db.upsertRow(
node.offlineSync,
UniqueSetDefaultChild(name: child.name),
conflictColumns: (t) => [t.spaceId, t.name],
transaction: tx,
),
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
updatedClock = await _parentIdHlc(node, child.id!);
final peer = await syncNode(
await createAdditionalTestSession(),
testSyncTables,
);
await pushChanges(node, peer);
peerChild = await UniqueSetDefaultChild.db.findById(
peer.offlineSync,
child.id!,
);
peerAttempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: peer.offlineSync,
);
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Town.db.insertRow(node.offlineSync, parent, transaction: tx),
);
restored = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
});
test(
'then it replaces the hidden claim on the existing row with the default.',
() {
expect(before!.parentId, defaultTownId);
expect(originalAttempt?.value.toString(), parent.id.toString());
expect(
originalAttempt?.projectionReason,
CrdtProjectionReason.foreignKeySetDefault,
);
expect(saved!.id, child.id);
expect(saved!.parentId, defaultTownId);
expect(attempt, isNull);
},
);
test('then the authored foreign-key clock advances.', () {
expect(updatedClock, greaterThan(originalClock));
});
test('then an empty peer receives the authored default.', () {
expect(peerChild!.parentId, defaultTownId);
expect(peerAttempt, isNull);
});
test('then restoring the old parent leaves the authored default.', () {
expect(restored!.parentId, defaultTownId);
});
},
);
group('when an upsert matches its name and echoes the displayed default,', () {
late CrdtDataAttemptedValue? attempt;
late Hlc updatedClock;
late UniqueSetDefaultChild? restored;
setUp(() async {
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueSetDefaultChild.db.upsertRow(
node.offlineSync,
UniqueSetDefaultChild(name: child.name, parentId: defaultTownId),
conflictColumns: (t) => [t.spaceId, t.name],
transaction: tx,
),
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
updatedClock = await _parentIdHlc(node, child.id!);
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Town.db.insertRow(node.offlineSync, parent, transaction: tx),
);
restored = await UniqueSetDefaultChild.db.findById(node.offlineSync, child.id!);
});
test(
'then the original authored parent and its clock survive the passthrough.',
() {
expect(attempt?.value.toString(), parent.id.toString());
expect(updatedClock, originalClock);
expect(restored!.parentId, parent.id);
},
);
});
group(
'when an upsert supplies null for the foreign key used only as its conflict key,',
() {
late CrdtDataAttemptedValue? attempt;
late Hlc updatedClock;
late UniqueSetDefaultChild? restored;
setUp(() async {
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueSetDefaultChild.db.upsertRow(
node.offlineSync,
UniqueSetDefaultChild(id: child.id, name: 'edited'),
conflictColumns: (t) => [t.parentId],
transaction: tx,
),
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
updatedClock = await _parentIdHlc(node, child.id!);
await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => Town.db.insertRow(node.offlineSync, parent, transaction: tx),
);
restored = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
});
test('then the foreign key is not authored by the name-only update.', () {
expect(attempt?.value.toString(), parent.id.toString());
expect(updatedClock, originalClock);
expect(restored!.name, 'edited');
expect(restored!.parentId, parent.id);
});
},
);
group('and another child has an available parent,', () {
late Town otherParent;
late Town insertParent;
late UniqueSetDefaultChild skipped;
late Hlc skippedClock;
late UuidValue inputId;
setUp(() async {
otherParent = Town(id: const Uuid().v7obj(), name: 'other');
insertParent = Town(id: const Uuid().v7obj(), name: 'insert parent');
skipped = UniqueSetDefaultChild(
id: const Uuid().v7obj(),
name: 'skipped',
parentId: otherParent.id,
);
inputId = const Uuid().v7obj();
await node.offlineSync.db.transactionForUser(testCrdtUserId, (tx) async {
await Town.db.insert(node.offlineSync, [
otherParent,
insertParent,
], transaction: tx);
await UniqueSetDefaultChild.db.insertRow(
node.offlineSync,
skipped,
transaction: tx,
);
});
skippedClock = await _parentIdHlc(node, skipped.id!);
});
group(
'when a batch skips one input, updates by name with a different ID, and inserts another,',
() {
late List<UniqueSetDefaultChild> saved;
late CrdtDataAttemptedValue? attempt;
late UniqueSetDefaultChild? stored;
late UniqueSetDefaultChild? unchanged;
late Hlc updatedClock;
late Hlc unchangedClock;
setUp(() async {
saved = await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueSetDefaultChild.db.upsert(
node.offlineSync,
[
UniqueSetDefaultChild(name: skipped.name, parentId: otherParent.id),
UniqueSetDefaultChild(id: inputId, name: child.name),
UniqueSetDefaultChild(name: 'inserted', parentId: insertParent.id),
],
conflictColumns: (t) => [t.spaceId, t.name],
updateWhere: (t) => t.name.equals(child.name),
transaction: tx,
),
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
stored = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
unchanged = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
skipped.id!,
);
updatedClock = await _parentIdHlc(node, child.id!);
unchangedClock = await _parentIdHlc(node, skipped.id!);
});
test(
'then the accepted existing row authors the default under its stored ID.',
() {
expect(
saved.map((row) => row.name),
unorderedEquals([child.name, 'inserted']),
);
expect(saved.singleWhere((row) => row.name == child.name).id, child.id);
expect(saved.map((row) => row.id), isNot(contains(inputId)));
expect(stored!.parentId, defaultTownId);
expect(attempt, isNull);
expect(updatedClock, greaterThan(originalClock));
},
);
test(
'then the rejected input leaves its row and authored clock unchanged.',
() {
expect(unchanged!.parentId, otherParent.id);
expect(unchangedClock, skippedClock);
},
);
},
);
group(
'when a batch retargets the child and skips a later input with the same key,',
() {
late List<UniqueSetDefaultChild> saved;
late UniqueSetDefaultChild? stored;
late CrdtDataAttemptedValue? attempt;
setUp(() async {
saved = await node.offlineSync.db.transactionForUser(
testCrdtUserId,
(tx) => UniqueSetDefaultChild.db.upsert(
node.offlineSync,
[
UniqueSetDefaultChild(name: child.name, parentId: insertParent.id),
UniqueSetDefaultChild(name: child.name, parentId: defaultTownId),
],
conflictColumns: (t) => [t.spaceId, t.name],
updateWhere: (t) => t.parentId.equals(defaultTownId),
transaction: tx,
),
);
stored = await UniqueSetDefaultChild.db.findById(
node.offlineSync,
child.id!,
);
attempt = await attemptedValue(
rowId: child.id!,
columnName: 'parentId',
databaseSession: node.offlineSync,
);
});
test('then the accepted input supplies the authored parent.', () {
expect(saved, hasLength(1));
expect(saved.single.id, child.id);
expect(stored!.parentId, insertParent.id);
expect(attempt, isNull);
});
},
);
});
});
}
Future<Hlc> _parentIdHlc(SyncNode node, UuidValue childId) async {
final field = await CrdtDataField.db.findFirstRow(
node.offlineSync,
where: (t) => t.row.uuidRowId.equals(childId) & t.column.name.equals('parentId'),
include: CrdtDataField.include(node: CrdtNode.include()),
);
return field?.hlc ?? await rowHlc(childId, databaseSession: node.offlineSync);
}