feat: Implement the scope management interface (#96)
This commit is contained in:
parent
467df68a69
commit
d89118a43a
5 changed files with 755 additions and 227 deletions
|
|
@ -1,5 +1,6 @@
|
|||
export 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
|
||||
|
||||
export 'src/business/crdt_scopes.dart';
|
||||
export 'src/business/crdt_sync.dart';
|
||||
export 'src/endpoints/crdt_sync_endpoint.dart';
|
||||
export 'src/generated/endpoints.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,197 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
|
||||
|
||||
/// Server-side service for managing CRDT shared-scope membership.
|
||||
///
|
||||
/// This is a data-invariant service, not an authorization policy. Applications
|
||||
/// call it from their own endpoints after applying their own access checks.
|
||||
class CrdtScopes {
|
||||
/// Creates a session-bound CRDT scope management service.
|
||||
CrdtScopes(this._session);
|
||||
|
||||
final Session _session;
|
||||
|
||||
/// Creates a shared scope and returns its UUID.
|
||||
///
|
||||
/// [grants], if given, adds each user as a member with its role, applied in
|
||||
/// one transaction with the scope insert. With no grants the scope is created
|
||||
/// dormant — it exists but no one can sync it until membership is granted.
|
||||
Future<UuidValue> create({
|
||||
Map<UuidValue, CrdtScopeRole>? grants,
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final scope = const Uuid().v7obj();
|
||||
await DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_session.db,
|
||||
transaction,
|
||||
(tx) async {
|
||||
final scopeId = await _insertScope(scope, tx);
|
||||
await _applyGrants(scopeId, grants ?? const {}, tx);
|
||||
},
|
||||
);
|
||||
return scope;
|
||||
}
|
||||
|
||||
/// Creates a shared scope granting [user] the given [role], in one transaction.
|
||||
Future<UuidValue> createFor(
|
||||
UuidValue user, {
|
||||
CrdtScopeRole role = CrdtScopeRole.readWrite,
|
||||
Transaction? transaction,
|
||||
}) => create(grants: {user: role}, transaction: transaction);
|
||||
|
||||
/// Grants [user] the given [role] in [scope], upserting an existing membership.
|
||||
Future<void> grant({
|
||||
required UuidValue scope,
|
||||
required UuidValue user,
|
||||
required CrdtScopeRole role,
|
||||
Transaction? transaction,
|
||||
}) => grantAll(scope, {user: role}, transaction: transaction);
|
||||
|
||||
/// Grants several members in one transaction; the bulk twin of [grant].
|
||||
Future<void> grantAll(
|
||||
UuidValue scope,
|
||||
Map<UuidValue, CrdtScopeRole> grants, {
|
||||
Transaction? transaction,
|
||||
}) {
|
||||
return DatabaseUtil.runInTransactionOrSavepoint(
|
||||
_session.db,
|
||||
transaction,
|
||||
(tx) async {
|
||||
final scopeId = await _scopeIdForUuid(scope, transaction: tx);
|
||||
if (scopeId == null) {
|
||||
throw CrdtScopeNotFoundException(scope);
|
||||
}
|
||||
await _applyGrants(scopeId, grants, tx);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Removes [user]'s membership in [scope]. A no-op if not a member.
|
||||
Future<void> revoke({
|
||||
required UuidValue scope,
|
||||
required UuidValue user,
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final encodedUser = ValueEncoder.instance.convert(user);
|
||||
final encodedScope = ValueEncoder.instance.convert(scope);
|
||||
await _session.db.unsafeExecute(
|
||||
'DELETE FROM "${CrdtScopeMember.t.tableName}" '
|
||||
'WHERE "${CrdtScopeMember.t.userUuid.columnName}" = $encodedUser '
|
||||
'AND "${CrdtScopeMember.t.scopeId.columnName}" IN ( '
|
||||
'SELECT "${CrdtScope.t.id.columnName}" '
|
||||
'FROM "${CrdtScope.t.tableName}" '
|
||||
'WHERE "${CrdtScope.t.uuidScopeId.columnName}" = $encodedScope '
|
||||
')',
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// The role [user] holds in [scope], or null if not a member.
|
||||
Future<CrdtScopeRole?> roleOf({
|
||||
required UuidValue user,
|
||||
required UuidValue scope,
|
||||
Transaction? transaction,
|
||||
}) {
|
||||
return CrdtScopeMembership.roleOf(
|
||||
_session,
|
||||
userUuid: user,
|
||||
scopeUuid: scope,
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
|
||||
/// The members of [scope] and their roles.
|
||||
Future<Map<UuidValue, CrdtScopeRole>> members(
|
||||
UuidValue scope, {
|
||||
Transaction? transaction,
|
||||
}) async {
|
||||
final encodedScope = ValueEncoder.instance.convert(scope);
|
||||
final result = await _session.db.unsafeQuery(
|
||||
'SELECT m."${CrdtScopeMember.t.userUuid.columnName}", '
|
||||
'm."${CrdtScopeMember.t.role.columnName}" '
|
||||
'FROM "${CrdtScopeMember.t.tableName}" m '
|
||||
'JOIN "${CrdtScope.t.tableName}" s '
|
||||
'ON s."${CrdtScope.t.id.columnName}" = '
|
||||
'm."${CrdtScopeMember.t.scopeId.columnName}" '
|
||||
'WHERE s."${CrdtScope.t.uuidScopeId.columnName}" = $encodedScope',
|
||||
transaction: transaction,
|
||||
);
|
||||
|
||||
return {
|
||||
for (final row in result)
|
||||
Protocol().deserialize<UuidValue>(row[0]): _roleFromDatabase(row[1]),
|
||||
};
|
||||
}
|
||||
|
||||
Future<int> _insertScope(UuidValue scope, Transaction transaction) async {
|
||||
final encodedScope = ValueEncoder.instance.convert(scope);
|
||||
final result = await _session.db.unsafeQuery(
|
||||
'INSERT INTO "${CrdtScope.t.tableName}" '
|
||||
'("${CrdtScope.t.uuidScopeId.columnName}") '
|
||||
'VALUES ($encodedScope) '
|
||||
'RETURNING "${CrdtScope.t.id.columnName}"',
|
||||
transaction: transaction,
|
||||
);
|
||||
return result.single[0] as int;
|
||||
}
|
||||
|
||||
Future<void> _applyGrants(
|
||||
int scopeId,
|
||||
Map<UuidValue, CrdtScopeRole> grants,
|
||||
Transaction transaction,
|
||||
) async {
|
||||
if (grants.isEmpty) return;
|
||||
|
||||
final encodedScopeId = ValueEncoder.instance.convert(scopeId);
|
||||
for (final grant in grants.entries) {
|
||||
final encodedUser = ValueEncoder.instance.convert(grant.key);
|
||||
final encodedRole = ValueEncoder.instance.convert(grant.value.toJson());
|
||||
await _session.db.unsafeExecute(
|
||||
'INSERT INTO "${CrdtScopeMember.t.tableName}" '
|
||||
'("${CrdtScopeMember.t.scopeId.columnName}", '
|
||||
'"${CrdtScopeMember.t.userUuid.columnName}", '
|
||||
'"${CrdtScopeMember.t.role.columnName}") '
|
||||
'VALUES ($encodedScopeId, $encodedUser, $encodedRole) '
|
||||
'ON CONFLICT ("${CrdtScopeMember.t.userUuid.columnName}", '
|
||||
'"${CrdtScopeMember.t.scopeId.columnName}") '
|
||||
'DO UPDATE SET "${CrdtScopeMember.t.role.columnName}" = $encodedRole',
|
||||
transaction: transaction,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int?> _scopeIdForUuid(
|
||||
UuidValue scope, {
|
||||
required Transaction transaction,
|
||||
}) async {
|
||||
final encodedScope = ValueEncoder.instance.convert(scope);
|
||||
final result = await _session.db.unsafeQuery(
|
||||
'SELECT "${CrdtScope.t.id.columnName}" '
|
||||
'FROM "${CrdtScope.t.tableName}" '
|
||||
'WHERE "${CrdtScope.t.uuidScopeId.columnName}" = $encodedScope '
|
||||
'LIMIT 1',
|
||||
transaction: transaction,
|
||||
);
|
||||
return result.isEmpty ? null : result.single[0] as int;
|
||||
}
|
||||
}
|
||||
|
||||
CrdtScopeRole _roleFromDatabase(Object? value) {
|
||||
if (value == null) {
|
||||
throw StateError('crdt_scope_members.role must not be null.');
|
||||
}
|
||||
return CrdtScopeRole.fromJson(value as String);
|
||||
}
|
||||
|
||||
/// Thrown when a scope-management operation targets a missing CRDT scope row.
|
||||
final class CrdtScopeNotFoundException implements Exception {
|
||||
/// Creates a [CrdtScopeNotFoundException].
|
||||
const CrdtScopeNotFoundException(this.scope);
|
||||
|
||||
/// The scope UUID that could not be found.
|
||||
final UuidValue scope;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'CrdtScopeNotFoundException: no CRDT scope row exists for "$scope".';
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_client/serverpod_offline_sync_client.dart';
|
||||
|
||||
import 'crdt_scopes.dart';
|
||||
|
||||
/// The CRDT sync configured per [Serverpod] instance.
|
||||
///
|
||||
/// Keyed by the [Serverpod] instance so each pod owns its own [CrdtSync] (and
|
||||
|
|
@ -49,14 +51,51 @@ extension CrdtSyncInitialize on Serverpod {
|
|||
}
|
||||
}
|
||||
|
||||
/// Extension to access the CRDT sync for [Session] from the [Serverpod] instance.
|
||||
/// Session-bound CRDT services configured for a [Serverpod] instance.
|
||||
///
|
||||
/// This facade is ephemeral: each `Session.crdt` access creates a small wrapper
|
||||
/// around the shared [CrdtSync] instance and the current [Session].
|
||||
class CrdtSession {
|
||||
/// Creates CRDT services bound to a session.
|
||||
CrdtSession(this._session, this._sync);
|
||||
|
||||
final Session _session;
|
||||
final CrdtSync _sync;
|
||||
|
||||
/// Returns the server-side scope management service.
|
||||
CrdtScopes get scopes => CrdtScopes(_session);
|
||||
|
||||
/// Runs a CRDT sync session with this [CrdtSession]'s [Session] bound.
|
||||
Stream<CrdtSyncStreamEvent> sync({
|
||||
required UuidValue userId,
|
||||
required Stream<CrdtSyncStreamEvent> inbound,
|
||||
required CrdtSyncPeerMode mode,
|
||||
bool once = false,
|
||||
CrdtSyncOnMergeSuccess? onMergeSuccess,
|
||||
}) {
|
||||
return _sync.sync(
|
||||
_session,
|
||||
userId: userId,
|
||||
inbound: inbound,
|
||||
once: once,
|
||||
mode: mode,
|
||||
onMergeSuccess: onMergeSuccess,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension to access CRDT services for [Session] from the [Serverpod] instance.
|
||||
extension CrdtSessionExtension on Session {
|
||||
/// Returns the CRDT sync configured for this session.
|
||||
CrdtSync get crdt =>
|
||||
_crdtSyncByServerpod[server.serverpod] ??
|
||||
(throw StateError(
|
||||
/// Returns the CRDT services configured for this session.
|
||||
CrdtSession get crdt {
|
||||
final sync = _crdtSyncByServerpod[server.serverpod];
|
||||
if (sync == null) {
|
||||
throw StateError(
|
||||
'The CrdtSync has not been initialized for this Serverpod instance. '
|
||||
'Call pod.initializeCrdtSync(...) during server startup to configure '
|
||||
'the CRDT sync.',
|
||||
));
|
||||
);
|
||||
}
|
||||
return CrdtSession(this, sync);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ class CrdtSyncEndpoint extends Endpoint {
|
|||
bool once = false,
|
||||
}) async* {
|
||||
yield* session.crdt.sync(
|
||||
session,
|
||||
userId: UuidValue.withValidation(session.authenticated!.userIdentifier),
|
||||
inbound: changes,
|
||||
once: once,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,368 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('[CRDT scopes]', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
|
||||
setUp(() {
|
||||
session = sessionBuilder.build();
|
||||
session.serverpod.initializeCrdtSync(syncTables: []);
|
||||
});
|
||||
|
||||
group('Given a database with no shared scopes,', () {
|
||||
test(
|
||||
'when createFor is called with only a user, '
|
||||
'then the user is granted readWrite access to the new scope.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
|
||||
final scope = await session.crdt.scopes.createFor(user);
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: user, scope: scope),
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
expect(
|
||||
await CrdtScopeMembership.memberScopes(session, user),
|
||||
contains(scope),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when createFor is called with a user and a role, '
|
||||
'then the user is granted that role.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
|
||||
final scope = await session.crdt.scopes.createFor(
|
||||
user,
|
||||
role: CrdtScopeRole.readOnly,
|
||||
);
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.members(scope),
|
||||
{user: CrdtScopeRole.readOnly},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when create is called without grants, '
|
||||
'then the new shared scope is dormant and has no explicit members.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
|
||||
final scope = await session.crdt.scopes.create();
|
||||
|
||||
expect(await session.crdt.scopes.members(scope), isEmpty);
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: user, scope: scope),
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
await CrdtScopeMembership.memberScopes(session, user),
|
||||
isNot(contains(scope)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when create is called with several grants, '
|
||||
'then every membership is stored with its role.',
|
||||
() async {
|
||||
final writer = const Uuid().v7obj();
|
||||
final reader = const Uuid().v7obj();
|
||||
|
||||
final scope = await session.crdt.scopes.create(
|
||||
grants: {
|
||||
writer: CrdtScopeRole.readWrite,
|
||||
reader: CrdtScopeRole.readOnly,
|
||||
},
|
||||
);
|
||||
|
||||
expect(await session.crdt.scopes.members(scope), {
|
||||
writer: CrdtScopeRole.readWrite,
|
||||
reader: CrdtScopeRole.readOnly,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when roleOf is called for a personal scope with no row, '
|
||||
'then readWrite is returned.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: user, scope: user),
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
expect(await session.crdt.scopes.members(user), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when grant is called for an unknown shared scope, '
|
||||
'then CrdtScopeNotFoundException is thrown.',
|
||||
() async {
|
||||
final missingScope = const Uuid().v7obj();
|
||||
|
||||
await expectLater(
|
||||
session.crdt.scopes.grant(
|
||||
scope: missingScope,
|
||||
user: const Uuid().v7obj(),
|
||||
role: CrdtScopeRole.readWrite,
|
||||
),
|
||||
throwsA(
|
||||
isA<CrdtScopeNotFoundException>().having(
|
||||
(error) => error.scope,
|
||||
'scope',
|
||||
missingScope,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when createFor is called inside a transaction that rolls back, '
|
||||
'then no scope or membership rows leak.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
final countsBefore = await _membershipRowCounts(session);
|
||||
|
||||
await expectLater(
|
||||
session.db.transaction((tx) async {
|
||||
await session.crdt.scopes.createFor(user, transaction: tx);
|
||||
throw StateError('rollback');
|
||||
}),
|
||||
throwsStateError,
|
||||
);
|
||||
|
||||
expect(await _membershipRowCounts(session), countsBefore);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Given a database with a dormant shared scope,', () {
|
||||
late UuidValue scope;
|
||||
|
||||
setUp(() async {
|
||||
scope = await session.crdt.scopes.create();
|
||||
});
|
||||
|
||||
test(
|
||||
'when grant is called, '
|
||||
'then the membership is inserted.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
|
||||
await session.crdt.scopes.grant(
|
||||
scope: scope,
|
||||
user: user,
|
||||
role: CrdtScopeRole.readOnly,
|
||||
);
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: user, scope: scope),
|
||||
CrdtScopeRole.readOnly,
|
||||
);
|
||||
expect(
|
||||
await session.crdt.scopes.members(scope),
|
||||
{user: CrdtScopeRole.readOnly},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when grantAll is called with several users, '
|
||||
'then all memberships are applied.',
|
||||
() async {
|
||||
final readOnlyUser = const Uuid().v7obj();
|
||||
final readWriteUser = const Uuid().v7obj();
|
||||
|
||||
await session.crdt.scopes.grantAll(scope, {
|
||||
readOnlyUser: CrdtScopeRole.readOnly,
|
||||
readWriteUser: CrdtScopeRole.readWrite,
|
||||
});
|
||||
|
||||
expect(await session.crdt.scopes.members(scope), {
|
||||
readOnlyUser: CrdtScopeRole.readOnly,
|
||||
readWriteUser: CrdtScopeRole.readWrite,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when grant is called inside a transaction that rolls back, '
|
||||
'then no membership row leaks.',
|
||||
() async {
|
||||
final user = const Uuid().v7obj();
|
||||
final countsBefore = await _membershipRowCounts(session);
|
||||
|
||||
await expectLater(
|
||||
session.db.transaction((tx) async {
|
||||
await session.crdt.scopes.grant(
|
||||
scope: scope,
|
||||
user: user,
|
||||
role: CrdtScopeRole.readWrite,
|
||||
transaction: tx,
|
||||
);
|
||||
throw StateError('rollback');
|
||||
}),
|
||||
throwsStateError,
|
||||
);
|
||||
|
||||
expect(await _membershipRowCounts(session), countsBefore);
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: user, scope: scope),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Given a database with a readOnly member on a shared scope,', () {
|
||||
late UuidValue user;
|
||||
late UuidValue scope;
|
||||
|
||||
setUp(() async {
|
||||
user = const Uuid().v7obj();
|
||||
scope = await session.crdt.scopes.create(
|
||||
grants: {user: CrdtScopeRole.readOnly},
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'when grant is called again for the same member with readWrite, '
|
||||
'then the existing membership is updated.',
|
||||
() async {
|
||||
await session.crdt.scopes.grant(
|
||||
scope: scope,
|
||||
user: user,
|
||||
role: CrdtScopeRole.readWrite,
|
||||
);
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: user, scope: scope),
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
expect(
|
||||
await session.crdt.scopes.members(scope),
|
||||
{user: CrdtScopeRole.readWrite},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Given a database with a shared scope that has one readWrite member,', () {
|
||||
late UuidValue member;
|
||||
late UuidValue nonMember;
|
||||
late UuidValue scope;
|
||||
|
||||
setUp(() async {
|
||||
member = const Uuid().v7obj();
|
||||
nonMember = const Uuid().v7obj();
|
||||
scope = await session.crdt.scopes.createFor(member);
|
||||
});
|
||||
|
||||
test(
|
||||
'when revoke is called for the member, '
|
||||
'then the membership is removed.',
|
||||
() async {
|
||||
await session.crdt.scopes.revoke(scope: scope, user: member);
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: member, scope: scope),
|
||||
isNull,
|
||||
);
|
||||
expect(await session.crdt.scopes.members(scope), isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when revoke is called for a non-member, '
|
||||
'then it completes without changing the scope members.',
|
||||
() async {
|
||||
await expectLater(
|
||||
session.crdt.scopes.revoke(scope: scope, user: nonMember),
|
||||
completes,
|
||||
);
|
||||
|
||||
expect(
|
||||
await session.crdt.scopes.members(scope),
|
||||
{member: CrdtScopeRole.readWrite},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'when roleOf is called for stored and missing memberships, '
|
||||
'then it returns the stored role or null.',
|
||||
() async {
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: member, scope: scope),
|
||||
CrdtScopeRole.readWrite,
|
||||
);
|
||||
expect(
|
||||
await session.crdt.scopes.roleOf(user: nonMember, scope: scope),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('Given a database with populated and dormant shared scopes,', () {
|
||||
late UuidValue readOnlyUser;
|
||||
late UuidValue readWriteUser;
|
||||
late UuidValue populatedScope;
|
||||
late UuidValue dormantScope;
|
||||
|
||||
setUp(() async {
|
||||
readOnlyUser = const Uuid().v7obj();
|
||||
readWriteUser = const Uuid().v7obj();
|
||||
populatedScope = await session.crdt.scopes.create(
|
||||
grants: {
|
||||
readOnlyUser: CrdtScopeRole.readOnly,
|
||||
readWriteUser: CrdtScopeRole.readWrite,
|
||||
},
|
||||
);
|
||||
dormantScope = await session.crdt.scopes.create();
|
||||
});
|
||||
|
||||
test(
|
||||
'when members is called, '
|
||||
'then explicit membership maps are returned.',
|
||||
() async {
|
||||
expect(await session.crdt.scopes.members(populatedScope), {
|
||||
readOnlyUser: CrdtScopeRole.readOnly,
|
||||
readWriteUser: CrdtScopeRole.readWrite,
|
||||
});
|
||||
expect(await session.crdt.scopes.members(dormantScope), isEmpty);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, int>> _membershipRowCounts(Session session) async {
|
||||
return {
|
||||
CrdtScope.t.tableName: await _countRows(
|
||||
session,
|
||||
CrdtScope.t.tableName,
|
||||
),
|
||||
CrdtScopeMember.t.tableName: await _countRows(
|
||||
session,
|
||||
CrdtScopeMember.t.tableName,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<int> _countRows(Session session, String tableName) => session.db
|
||||
.unsafeQuery('SELECT COUNT(*) FROM "$tableName"')
|
||||
.then((result) => (result.single[0] as num).toInt());
|
||||
Loading…
Reference in a new issue