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:
parent
4bf2359dda
commit
ee000e348c
19 changed files with 21728 additions and 51 deletions
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -16,3 +16,4 @@
|
|||
20260914165608984
|
||||
20260930185557154
|
||||
20261007215503877
|
||||
20261008234516445
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ void main() {
|
|||
});
|
||||
|
||||
withServerpod(
|
||||
'PostgreSQL membership visibility',
|
||||
'[PostgreSQL membership visibility]',
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
serverDirectory: serverDirectory,
|
||||
configOverride: (config) => config.copyWith(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue