feat: Write a row in its own space, and a new one where the host says

A client with a persistent user wrote every change in its personal
space: an update or delete of a row in a shared space it is a member of
found no row and threw, and a new row could reach a shared space only
through transactionForUser, which a host calls at each write itself.

A write without a transaction now acts in the space it touches, through
transactionForUser, which checks membership and role as before: an
update or delete in the space its rows are stored in, a new row in the
one `newRowSpace` names (null, or no hook, for the personal space). One
call touches one space and is refused, with a StateError, where it
would touch two. A write within a transaction acts in its space, as
before; the server, without a persistent user, is unchanged.

Tested on the client: a writing member updates and deletes a shared
row, by row and by condition; a reading member is refused; a call
naming rows of two spaces is refused; new rows go where the hook says,
refused for a space only read or not a member's; a transaction of the
caller's own still decides. The integration suite passes (994 tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 12:07:49 +01:00
commit 845fb557db
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -0,0 +1,217 @@
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.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';
void main() {
initTestClientSession(withPersistentUser: true);
group(
'Given a persistent user who is a member of a shared space holding a row, '
'and a row of their own,',
() {
late UuidValue sharedSpaceUuid;
late OfflineSyncSpace sharedSpace;
late Person shared;
late Person own;
/// The space each person row is stored in, by name, read past the
/// engine.
Future<Map<String, int?>> storedSpaces() async => {
for (final person in await Person.db.find(testSession))
person.name: person.spaceId,
};
Future<void> joinAs(OfflineSyncSpaceRole role) async {
await OfflineSyncSpaceMember.db.insertRow(
session,
OfflineSyncSpaceMember(
spaceId: sharedSpace.id!,
userUuid: testCrdtUserId,
role: role,
),
);
}
setUp(() async {
sharedSpaceUuid = const Uuid().v7obj();
shared = await session.db.transactionForUser(
sharedSpaceUuid,
(tx) => Person.db.insertRow(
session,
Person(name: 'shared'),
transaction: tx,
),
);
sharedSpace = (await OfflineSyncSpace.db.findFirstRow(
testSession,
where: (t) => t.uuidSpaceId.equals(sharedSpaceUuid),
))!;
own = await Person.db.insertRow(session, Person(name: 'own'));
});
test(
'when a writing member updates and deletes the shared row without a '
'transaction, then it changes in its own space.',
() async {
await joinAs(OfflineSyncSpaceRole.readWrite);
final updated = await Person.db.updateRow(
session,
shared.copyWith(name: 'shared, changed'),
);
expect(updated.name, 'shared, changed');
expect((await storedSpaces())['shared, changed'], sharedSpace.id);
await Person.db.deleteRow(session, updated);
expect(await Person.db.findById(session, shared.id!), isNull);
expect(await Person.db.findById(session, own.id!), isNotNull);
},
);
test(
'when a writing member updates the shared rows by a condition, then '
'they change in their own space.',
() async {
await joinAs(OfflineSyncSpaceRole.readWrite);
await Person.db.updateWhere(
session,
columnValues: (t) => [t.name('renamed')],
where: (t) => t.name.equals('shared'),
);
await Person.db.deleteWhere(
session,
where: (t) => t.name.equals('renamed'),
);
expect(
[for (final p in await Person.db.find(session)) p.name],
['own'],
);
},
);
test(
'when a reading member updates or deletes the shared row, then it is '
'refused and stays as it was.',
() async {
await joinAs(OfflineSyncSpaceRole.readOnly);
await expectLater(
Person.db.updateRow(session, shared.copyWith(name: 'changed')),
throwsA(isA<OfflineSyncSpaceRoleException>()),
);
await expectLater(
Person.db.deleteRow(session, shared),
throwsA(isA<OfflineSyncSpaceRoleException>()),
);
expect((await Person.db.findById(session, shared.id!))?.name, 'shared');
},
);
test(
'when one call names rows of two spaces, then it is refused and '
'nothing changes.',
() async {
await joinAs(OfflineSyncSpaceRole.readWrite);
await expectLater(
Person.db.update(session, [
shared.copyWith(name: 'a'),
own.copyWith(name: 'b'),
]),
throwsA(isA<StateError>()),
);
expect(
{for (final p in await Person.db.find(session)) p.name},
{'shared', 'own'},
);
},
);
test(
'when the host routes a new row to the shared space, then it is '
'stored there; one it routes nowhere stays personal.',
() async {
await joinAs(OfflineSyncSpaceRole.readWrite);
session.db.newRowSpace = (row) =>
(row as Person).name.startsWith('to shared') ? sharedSpaceUuid : null;
await Person.db.insertRow(session, Person(name: 'to shared'));
await Person.db.insert(session, [Person(name: 'to shared, too')]);
await Person.db.insertRow(session, Person(name: 'mine'));
final spaces = await storedSpaces();
expect(spaces['to shared'], sharedSpace.id);
expect(spaces['to shared, too'], sharedSpace.id);
expect(spaces['mine'], spaces['own']);
},
);
test(
'when the host routes new rows of one call to two spaces, then it is '
'refused.',
() async {
await joinAs(OfflineSyncSpaceRole.readWrite);
session.db.newRowSpace = (row) =>
(row as Person).name == 'there' ? sharedSpaceUuid : null;
await expectLater(
Person.db.insert(session, [Person(name: 'there'), Person(name: 'here')]),
throwsA(isA<StateError>()),
);
expect(
{for (final p in await Person.db.find(session)) p.name},
{'shared', 'own'},
);
},
);
test(
'when the host routes a new row to a space the person may only read, '
'or is not a member of, then it is refused.',
() async {
await joinAs(OfflineSyncSpaceRole.readOnly);
final elsewhere = const Uuid().v7obj();
session.db.newRowSpace = (row) =>
(row as Person).name == 'read only' ? sharedSpaceUuid : elsewhere;
await expectLater(
Person.db.insertRow(session, Person(name: 'read only')),
throwsA(isA<OfflineSyncSpaceRoleException>()),
);
await expectLater(
Person.db.insertRow(session, Person(name: 'nowhere')),
throwsA(isA<OfflineSyncSpaceMembershipException>()),
);
expect(
{for (final p in await Person.db.find(session)) p.name},
{'shared', 'own'},
);
},
);
test(
'when a caller writes within a transaction of its own, then the '
'transaction decides the space, as before.',
() async {
await joinAs(OfflineSyncSpaceRole.readWrite);
session.db.newRowSpace = (_) => sharedSpaceUuid;
await session.db.transaction((tx) async {
await Person.db.insertRow(
session,
Person(name: 'in a transaction'),
transaction: tx,
);
});
final spaces = await storedSpaces();
expect(spaces['in a transaction'], spaces['own']);
},
);
},
);
}