feat(server): deleting accounts and pruning abandoned ones
deletePerson deletes a person with everything they have, in one transaction and in ADR 0001 rule 6's order: their sessions first, since a device with a valid session would re-upload everything; then their memberships and personal space, whose foreign keys cascade into every synced table and the sync engine's bookkeeping; then the person, whose identities cascade. AccountEndpoint.delete deletes the caller and tells their devices that their sessions ended. pruneAbandonedAnonymousPeople deletes, 500 at a time, anonymous people created and unused for 90 days: their sessions have expired and nobody can ever return to them. Identified people are never pruned. PruningFutureCall runs it daily at 03:17; the server schedules it at start, cancelling the previous entry by its identifier first, since scheduling always adds one. Tests, refusal first: deleting when not signed in; deleting removes the caller, their sessions, their anonymous identity and the content of their space, and nobody else's; pruning takes the unused and the never-signed-in, and spares the recently used, newcomers and identified people. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
f6390120f2
commit
abe599d0aa
11 changed files with 626 additions and 0 deletions
|
|
@ -20,4 +20,16 @@ Future<void> run(List<String> args) async {
|
|||
|
||||
// Start the server.
|
||||
await pod.start();
|
||||
|
||||
// Abandoned anonymous people are pruned daily (decided 2026-09-30).
|
||||
// Scheduling always adds an entry, so the previous one goes first.
|
||||
await pod.futureCalls.cancel(_pruning);
|
||||
await pod.futureCalls
|
||||
.callRecurring(identifier: _pruning)
|
||||
.cron('17 3 * * *')
|
||||
.pruning
|
||||
.pruneAnonymousPeople();
|
||||
}
|
||||
|
||||
/// The identifier of the daily pruning.
|
||||
const _pruning = 'prune-abandoned-anonymous-people';
|
||||
|
|
|
|||
69
halkyon_learn_server/lib/src/auth/account.dart
Normal file
69
halkyon_learn_server/lib/src/auth/account.dart
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart';
|
||||
|
||||
/// Deletes [authUserId] with everything they have, in one transaction, in
|
||||
/// the order ADR 0001 (rule 6) fixes: their sessions end first, since a
|
||||
/// device with a valid session would re-upload everything; then their
|
||||
/// personal space goes with all its content (the foreign keys cascade into
|
||||
/// every synced table and the sync engine's bookkeeping); then the person
|
||||
/// with their identities.
|
||||
Future<void> deletePerson(Session session, UuidValue authUserId) =>
|
||||
session.db.transaction((transaction) async {
|
||||
// DELETE FROM "serverpod_auth_core_session" WHERE "authUserId" = $1
|
||||
await ServerSideSession.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// DELETE FROM "offline_sync_space_members" WHERE "userUuid" = $1
|
||||
await OfflineSyncSpaceMember.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.userUuid.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// The personal space is keyed on the person's id.
|
||||
// DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
|
||||
await OfflineSyncSpace.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.uuidSpaceId.equals(authUserId),
|
||||
transaction: transaction,
|
||||
);
|
||||
// DELETE FROM "serverpod_auth_core_user" WHERE "id" = $1
|
||||
await AuthServices.instance.authUsers.delete(
|
||||
session,
|
||||
authUserId: authUserId,
|
||||
transaction: transaction,
|
||||
);
|
||||
});
|
||||
|
||||
/// Deletes the anonymous people nobody can return to, at most [limit] per
|
||||
/// call, and returns how many (decided 2026-09-30): created before and unused
|
||||
/// since 90 days before [now]. Their sessions have expired by then (the
|
||||
/// inactivity timeout, `auth_config.dart`), and an anonymous account can't
|
||||
/// sign in again. Identified people are never pruned.
|
||||
Future<int> pruneAbandonedAnonymousPeople(
|
||||
Session session, {
|
||||
required DateTime now,
|
||||
int limit = 500,
|
||||
}) async {
|
||||
// The columns hold UTC as timestamp without time zone.
|
||||
final cutoff = now
|
||||
.toUtc()
|
||||
.subtract(const Duration(days: 90))
|
||||
.toIso8601String()
|
||||
.replaceFirst('Z', '');
|
||||
final abandoned = await session.db.unsafeQuery(
|
||||
'SELECT a."authUserId" FROM "serverpod_auth_idp_anonymous_account" a '
|
||||
'WHERE a."createdAt" < @cutoff::timestamp '
|
||||
'AND NOT EXISTS (SELECT 1 FROM "serverpod_auth_core_session" s '
|
||||
'WHERE s."authUserId" = a."authUserId" '
|
||||
'AND s."lastUsedAt" >= @cutoff::timestamp) '
|
||||
'ORDER BY a."createdAt" LIMIT @limit',
|
||||
parameters: QueryParameters.named({'cutoff': cutoff, 'limit': limit}),
|
||||
);
|
||||
for (final row in abandoned) {
|
||||
await deletePerson(session, UuidValue.withValidation(row.first.toString()));
|
||||
}
|
||||
return abandoned.length;
|
||||
}
|
||||
22
halkyon_learn_server/lib/src/auth/account_endpoint.dart
Normal file
22
halkyon_learn_server/lib/src/auth/account_endpoint.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
/// The signed-in person's account.
|
||||
class AccountEndpoint extends Endpoint {
|
||||
@override
|
||||
bool get requireLogin => true;
|
||||
|
||||
/// Deletes the caller with everything they have (see [deletePerson]). The
|
||||
/// app also calls it when an anonymous account is given up (decided
|
||||
/// 2026-09-30). Their other devices learn at once that their sessions
|
||||
/// ended.
|
||||
Future<void> delete(Session session) async {
|
||||
final person = session.authenticated!.authUserId;
|
||||
await deletePerson(session, person);
|
||||
await session.messages.authenticationRevoked(
|
||||
person.uuid,
|
||||
RevokedAuthenticationUser(),
|
||||
);
|
||||
}
|
||||
}
|
||||
16
halkyon_learn_server/lib/src/auth/pruning_future_call.dart
Normal file
16
halkyon_learn_server/lib/src/auth/pruning_future_call.dart
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
||||
/// Deletes the anonymous people nobody can return to; the server schedules it
|
||||
/// daily when it starts (`server.dart`).
|
||||
class PruningFutureCall extends FutureCall {
|
||||
/// Prunes one batch and logs how many people went.
|
||||
Future<void> pruneAnonymousPeople(Session session) async {
|
||||
final pruned = await pruneAbandonedAnonymousPeople(
|
||||
session,
|
||||
now: clock.now(),
|
||||
);
|
||||
session.log('Pruned $pruned abandoned anonymous people.');
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@
|
|||
// ignore_for_file: invalid_use_of_internal_member
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'package:halkyon_learn_server/src/generated/future_calls.dart'
|
||||
as _iip9210p;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
as _iacs;
|
||||
|
|
@ -17,16 +19,24 @@ import 'package:serverpod_auth_idp_server/serverpod_auth_idp_server.dart'
|
|||
as _iais;
|
||||
import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'
|
||||
as _izehhkf5;
|
||||
import '../auth/account_endpoint.dart' as _ii6ycf6v;
|
||||
import '../auth/anonymous_idp_endpoint.dart' as _in0zita6;
|
||||
import '../auth/devices_endpoint.dart' as _iduct7ue;
|
||||
import '../auth/github_sign_in_endpoint.dart' as _iasw79m5;
|
||||
import '../auth/github_upgrade_endpoint.dart' as _ivnugbf2;
|
||||
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
|
||||
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||
|
||||
class Endpoints extends _is.EndpointDispatch {
|
||||
@override
|
||||
void initializeEndpoints(_is.Server server) {
|
||||
var endpoints = <String, _is.Endpoint>{
|
||||
'account': _ii6ycf6v.AccountEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
'account',
|
||||
null,
|
||||
),
|
||||
'anonymousIdp': _in0zita6.AnonymousIdpEndpoint()
|
||||
..initialize(
|
||||
server,
|
||||
|
|
@ -58,6 +68,22 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
null,
|
||||
),
|
||||
};
|
||||
connectors['account'] = _is.EndpointConnector(
|
||||
name: 'account',
|
||||
endpoint: endpoints['account']!,
|
||||
methodConnectors: {
|
||||
'delete': _is.MethodConnector(
|
||||
name: 'delete',
|
||||
params: {},
|
||||
call:
|
||||
(
|
||||
_is.Session session,
|
||||
Map<String, dynamic> params,
|
||||
) async => (endpoints['account'] as _ii6ycf6v.AccountEndpoint)
|
||||
.delete(session),
|
||||
),
|
||||
},
|
||||
);
|
||||
connectors['anonymousIdp'] = _is.EndpointConnector(
|
||||
name: 'anonymousIdp',
|
||||
endpoint: endpoints['anonymousIdp']!,
|
||||
|
|
@ -219,4 +245,9 @@ class Endpoints extends _is.EndpointDispatch {
|
|||
modules['serverpod_auth_core'] = _iacs.Endpoints()
|
||||
..initializeEndpoints(server);
|
||||
}
|
||||
|
||||
@override
|
||||
_is.FutureCallDispatch? get futureCalls {
|
||||
return _iip9210p.FutureCalls();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
209
halkyon_learn_server/lib/src/generated/future_calls.dart
Normal file
209
halkyon_learn_server/lib/src/generated/future_calls.dart
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||
/* To generate run: "serverpod generate" */
|
||||
|
||||
// ignore_for_file: implementation_imports
|
||||
// ignore_for_file: library_private_types_in_public_api
|
||||
// ignore_for_file: non_constant_identifier_names
|
||||
// ignore_for_file: public_member_api_docs
|
||||
// ignore_for_file: type_literal_in_constant_pattern
|
||||
// ignore_for_file: use_super_parameters
|
||||
// ignore_for_file: invalid_use_of_internal_member
|
||||
// ignore_for_file: depend_on_referenced_packages
|
||||
|
||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||
import 'dart:async' as _ida;
|
||||
import 'package:clock/clock.dart' as _io0w16m8;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import '../auth/pruning_future_call.dart' as _igevg04y;
|
||||
|
||||
/// Invokes a future call.
|
||||
typedef _InvokeFutureCall =
|
||||
Future<void> Function(String name, _is.SerializableModel? object);
|
||||
|
||||
extension ServerpodFutureCallsGetter on _is.Serverpod {
|
||||
/// Generated future calls.
|
||||
FutureCalls get futureCalls => FutureCalls();
|
||||
}
|
||||
|
||||
class FutureCalls extends _is.FutureCallDispatch<_FutureCallRef> {
|
||||
FutureCalls._();
|
||||
|
||||
factory FutureCalls() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
static final FutureCalls _instance = FutureCalls._();
|
||||
|
||||
_is.FutureCallManager? _futureCallManager;
|
||||
|
||||
String? _serverId;
|
||||
|
||||
String get _effectiveServerId {
|
||||
if (_serverId == null) {
|
||||
throw StateError('FutureCalls is not initialized.');
|
||||
}
|
||||
return _serverId!;
|
||||
}
|
||||
|
||||
_is.FutureCallManager get _effectiveFutureCallManager {
|
||||
if (_futureCallManager == null) {
|
||||
throw StateError('FutureCalls is not initialized.');
|
||||
}
|
||||
return _futureCallManager!;
|
||||
}
|
||||
|
||||
@override
|
||||
void initialize(
|
||||
_is.FutureCallManager futureCallManager,
|
||||
String serverId,
|
||||
) {
|
||||
var registeredFutureCalls = <String, _is.InvokableFutureCall>{
|
||||
'PruningPruneAnonymousPeopleFutureCall':
|
||||
PruningPruneAnonymousPeopleFutureCall(),
|
||||
};
|
||||
_futureCallManager = futureCallManager;
|
||||
_serverId = serverId;
|
||||
for (final entry in registeredFutureCalls.entries) {
|
||||
_futureCallManager?.registerFutureCall(entry.value, entry.key);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
_FutureCallRef callAtTime(
|
||||
DateTime time, {
|
||||
String? identifier,
|
||||
}) {
|
||||
return _FutureCallRef(
|
||||
(name, object) {
|
||||
return _effectiveFutureCallManager.scheduleFutureCall(
|
||||
name,
|
||||
object,
|
||||
time,
|
||||
_effectiveServerId,
|
||||
identifier,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
_FutureCallRef callWithDelay(
|
||||
Duration delay, {
|
||||
String? identifier,
|
||||
}) {
|
||||
return _FutureCallRef(
|
||||
(name, object) {
|
||||
return _effectiveFutureCallManager.scheduleFutureCall(
|
||||
name,
|
||||
object,
|
||||
DateTime.now().toUtc().add(delay),
|
||||
_effectiveServerId,
|
||||
identifier,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
_is.RecurringFutureCallDispatch<_FutureCallRef> callRecurring({
|
||||
String? identifier,
|
||||
}) {
|
||||
return _RecurringFutureCallDispatchImpl(
|
||||
_effectiveFutureCallManager,
|
||||
_effectiveServerId,
|
||||
identifier,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> cancel(String identifier) async {
|
||||
await _effectiveFutureCallManager.cancelFutureCall(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
class _RecurringFutureCallDispatchImpl
|
||||
extends _is.RecurringFutureCallDispatch<_FutureCallRef> {
|
||||
_RecurringFutureCallDispatchImpl(
|
||||
this._futureCallManager,
|
||||
this._serverId,
|
||||
this._identifier,
|
||||
);
|
||||
|
||||
final _is.FutureCallManager _futureCallManager;
|
||||
|
||||
final String _serverId;
|
||||
|
||||
final String? _identifier;
|
||||
|
||||
@override
|
||||
_FutureCallRef cron(String cronExpression) {
|
||||
return _FutureCallRef(
|
||||
(name, object) {
|
||||
return _futureCallManager.scheduleFutureCall(
|
||||
name,
|
||||
object,
|
||||
_is.Cron.parse(cronExpression).nextTime(),
|
||||
_serverId,
|
||||
_identifier,
|
||||
scheduling: _is.CronFutureCallScheduling(cron: cronExpression),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
_FutureCallRef every(
|
||||
Duration interval, {
|
||||
DateTime? start,
|
||||
}) {
|
||||
final now = _io0w16m8.clock.now().toUtc();
|
||||
return _FutureCallRef(
|
||||
(name, object) {
|
||||
return _futureCallManager.scheduleFutureCall(
|
||||
name,
|
||||
object,
|
||||
start ?? now.add(interval),
|
||||
_serverId,
|
||||
_identifier,
|
||||
scheduling: _is.IntervalFutureCallScheduling(
|
||||
interval: interval,
|
||||
start: start,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FutureCallRef {
|
||||
_FutureCallRef(this._invokeFutureCall);
|
||||
|
||||
final _InvokeFutureCall _invokeFutureCall;
|
||||
|
||||
late final pruning = _PruningFutureCallDispatcher(_invokeFutureCall);
|
||||
}
|
||||
|
||||
class _PruningFutureCallDispatcher {
|
||||
_PruningFutureCallDispatcher(this._invokeFutureCall);
|
||||
|
||||
final _InvokeFutureCall _invokeFutureCall;
|
||||
|
||||
Future<void> pruneAnonymousPeople() {
|
||||
return _invokeFutureCall(
|
||||
'PruningPruneAnonymousPeopleFutureCall',
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Prunes one batch and logs how many people went.
|
||||
class PruningPruneAnonymousPeopleFutureCall extends _is.FutureCall
|
||||
implements _is.InvokableFutureCall {
|
||||
@override
|
||||
_ida.Future<void> invoke(
|
||||
_is.Session session,
|
||||
_is.SerializableModel? object,
|
||||
) async {
|
||||
await _igevg04y.PruningFutureCall().pruneAnonymousPeople(session);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
account:
|
||||
- delete:
|
||||
anonymousIdp:
|
||||
- login:
|
||||
devices:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import 'endpoints.dart' as _iavctuc6;
|
|||
import 'protocol.dart' as _il2as5qe;
|
||||
import 'sync_tables.dart' as _ii3f3u05;
|
||||
export 'package:serverpod/serverpod.dart' hide Serverpod;
|
||||
export 'future_calls.dart' show ServerpodFutureCallsGetter;
|
||||
|
||||
/// The Serverpod server for this project.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ environment:
|
|||
resolution: workspace
|
||||
|
||||
dependencies:
|
||||
clock: ^1.1.3
|
||||
serverpod: 4.0.3
|
||||
serverpod_auth_idp_server: 4.0.3
|
||||
serverpod_offline_sync_server: 0.0.8
|
||||
|
|
|
|||
170
halkyon_learn_server/test/integration/auth/account_test.dart
Normal file
170
halkyon_learn_server/test/integration/auth/account_test.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import 'package:halkyon_learn_server/src/auth/account.dart';
|
||||
import 'package:halkyon_learn_server/src/auth/scopes.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/auth_fixtures.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
void main() {
|
||||
withServerpod('Deleting an account', (sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
late UuidValue annasStudyset;
|
||||
late UuidValue ben;
|
||||
late UuidValue bensStudyset;
|
||||
|
||||
setUp(() async {
|
||||
useServerAuth();
|
||||
session = sessionBuilder.build();
|
||||
anna = await _newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
annasStudyset = await _studysetOf(session, anna);
|
||||
ben = await _newAnonymousPerson(session);
|
||||
await signInNewDevice(session, ben);
|
||||
bensStudyset = await _studysetOf(session, ben);
|
||||
});
|
||||
|
||||
test('refuses callers who are not signed in', () async {
|
||||
await expectLater(
|
||||
endpoints.account.delete(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
),
|
||||
),
|
||||
throwsA(isA<ServerpodUnauthenticatedException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('deletes the caller with everything they have, and nobody '
|
||||
'else', () async {
|
||||
await endpoints.account.delete(
|
||||
sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(await AuthUser.db.findById(session, anna), isNull);
|
||||
expect(await _sessionsOf(session, anna), 0);
|
||||
expect(await _isAnonymous(session, anna), isFalse);
|
||||
expect(await _studysetExists(session, annasStudyset), isFalse);
|
||||
|
||||
expect(await AuthUser.db.findById(session, ben), isNotNull);
|
||||
expect(await _sessionsOf(session, ben), 1);
|
||||
expect(await _studysetExists(session, bensStudyset), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
withServerpod('Pruning abandoned anonymous people', (sessionBuilder, _) {
|
||||
late Session session;
|
||||
final now = DateTime.now().toUtc();
|
||||
DateTime daysAgo(int days) => now.subtract(Duration(days: days));
|
||||
|
||||
setUp(() {
|
||||
useServerAuth();
|
||||
session = sessionBuilder.build();
|
||||
});
|
||||
|
||||
test(
|
||||
'deletes anonymous people unused for 90 days, and only them',
|
||||
() async {
|
||||
final unused = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, unused, daysAgo(95));
|
||||
final neverSignedIn = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
final recentlyUsed = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(100),
|
||||
);
|
||||
await _lastUsed(session, recentlyUsed, daysAgo(10));
|
||||
final newcomer = await _newAnonymousPerson(
|
||||
session,
|
||||
createdAt: daysAgo(5),
|
||||
);
|
||||
final identifiedPerson = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await _lastUsed(session, identifiedPerson, daysAgo(365));
|
||||
|
||||
final pruned = await pruneAbandonedAnonymousPeople(session, now: now);
|
||||
|
||||
expect(pruned, 2);
|
||||
expect(await AuthUser.db.findById(session, unused), isNull);
|
||||
expect(await AuthUser.db.findById(session, neverSignedIn), isNull);
|
||||
for (final kept in [recentlyUsed, newcomer, identifiedPerson]) {
|
||||
expect(await AuthUser.db.findById(session, kept), isNotNull);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// A person as the app's first launch creates them: anonymous.
|
||||
Future<UuidValue> _newAnonymousPerson(
|
||||
Session session, {
|
||||
DateTime? createdAt,
|
||||
}) async {
|
||||
final person = (await AuthServices.instance.authUsers.create(session)).id;
|
||||
await AnonymousAccount.db.insertRow(
|
||||
session,
|
||||
AnonymousAccount(authUserId: person, createdAt: createdAt),
|
||||
);
|
||||
return person;
|
||||
}
|
||||
|
||||
/// Signs [person] in on a device last used at [at].
|
||||
Future<void> _lastUsed(Session session, UuidValue person, DateTime at) async {
|
||||
final device = await signInNewDevice(session, person);
|
||||
final row = await ServerSideSession.db.findById(session, device);
|
||||
await ServerSideSession.db.updateRow(session, row!.copyWith(lastUsedAt: at));
|
||||
}
|
||||
|
||||
/// A studyset in [person]'s personal space, written the way the sync engine
|
||||
/// stores it; returns its id.
|
||||
Future<UuidValue> _studysetOf(Session session, UuidValue person) async {
|
||||
// INSERT INTO "offline_sync_spaces" ("uuidSpaceId") VALUES ($1) RETURNING "id"
|
||||
final space = await session.db.unsafeQuery(
|
||||
'INSERT INTO "offline_sync_spaces" ("uuidSpaceId") '
|
||||
'VALUES (@person::uuid) RETURNING "id"',
|
||||
parameters: QueryParameters.named({'person': person.uuid}),
|
||||
);
|
||||
// INSERT INTO "studyset" ("spaceId", "payload") VALUES ($1, $2) RETURNING "id"
|
||||
final studyset = await session.db.unsafeQuery(
|
||||
'INSERT INTO "studyset" ("spaceId", "payload") '
|
||||
r"VALUES (@space, '\x7b7d'::bytea) RETURNING "
|
||||
'"id"',
|
||||
parameters: QueryParameters.named({'space': space.first.first}),
|
||||
);
|
||||
return UuidValue.withValidation(studyset.first.first.toString());
|
||||
}
|
||||
|
||||
Future<bool> _studysetExists(Session session, UuidValue id) async =>
|
||||
(await session.db.unsafeQuery(
|
||||
'SELECT 1 FROM "studyset" WHERE "id" = @id::uuid',
|
||||
parameters: QueryParameters.named({'id': id.uuid}),
|
||||
)).isNotEmpty;
|
||||
|
||||
Future<int> _sessionsOf(Session session, UuidValue person) =>
|
||||
ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
|
||||
Future<bool> _isAnonymous(Session session, UuidValue person) async =>
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
) >
|
||||
0;
|
||||
|
|
@ -15,6 +15,8 @@ import 'dart:async' as _ida;
|
|||
import 'dart:io' as _idi;
|
||||
import 'package:halkyon_learn_server/src/generated/auth/device.dart'
|
||||
as _ipdmo8cq;
|
||||
import 'package:halkyon_learn_server/src/generated/future_calls.dart'
|
||||
as _iip9210p;
|
||||
import 'package:halkyon_learn_server/src/generated/instance/instance_info.dart'
|
||||
as _iof1vv19;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
|
|
@ -154,6 +156,10 @@ void withServerpod(
|
|||
}
|
||||
|
||||
class TestEndpoints {
|
||||
late final futureCalls = _FutureCalls();
|
||||
|
||||
late final _AccountEndpoint account;
|
||||
|
||||
late final _AnonymousIdpEndpoint anonymousIdp;
|
||||
|
||||
late final _DevicesEndpoint devices;
|
||||
|
|
@ -172,6 +178,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
_is.SerializationManager serializationManager,
|
||||
_is.EndpointDispatch endpoints,
|
||||
) {
|
||||
account = _AccountEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
anonymousIdp = _AnonymousIdpEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
|
|
@ -195,6 +205,49 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
}
|
||||
}
|
||||
|
||||
class _FutureCalls {
|
||||
late final pruning = _PruningFutureCall();
|
||||
}
|
||||
|
||||
class _AccountEndpoint {
|
||||
_AccountEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<void> delete(_ist.TestSessionBuilder sessionBuilder) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'account',
|
||||
method: 'delete',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'account',
|
||||
methodName: 'delete',
|
||||
parameters: _ist.testObjectToJson({}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<void>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _AnonymousIdpEndpoint {
|
||||
_AnonymousIdpEndpoint(
|
||||
this._endpointDispatch,
|
||||
|
|
@ -445,3 +498,20 @@ class _InstanceEndpoint {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _PruningFutureCall {
|
||||
Future<void> pruneAnonymousPeople(
|
||||
_ist.TestSessionBuilder sessionBuilder,
|
||||
) async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild();
|
||||
try {
|
||||
await _iip9210p.PruningPruneAnonymousPeopleFutureCall().invoke(
|
||||
_localUniqueSession,
|
||||
null,
|
||||
);
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue