@@ -13,8 +13,11 @@ import 'package:meta/meta.dart';
1313// ignore: import_of_legacy_library_into_null_safe
1414import 'package:neat_cache/neat_cache.dart' ;
1515import 'package:pub_dev/admin/models.dart' ;
16+ import 'package:typed_sql/typed_sql.dart' hide AuthenticationException;
1617
1718import '../audit/models.dart' ;
19+ import '../database/database.dart' ;
20+ import '../database/schema.dart' ;
1821import '../frontend/request_context.dart' ;
1922import '../service/openid/gcp_openid.dart' ;
2023import '../service/openid/github_openid.dart' ;
@@ -440,14 +443,18 @@ class AccountBackend {
440443 tx.insert (session);
441444 return session;
442445 });
443- if (rs != null ) return rs;
446+ if (rs != null ) {
447+ await writeUserSessionToSql (rs);
448+ return rs;
449+ }
444450 }
445451
446452 // in the absence of a valid existing session, create a new one
447453 final session = UserSession .init ()
448454 ..created = now
449455 ..expires = now.add (_sessionDuration);
450456 await _db.commit (inserts: [session]);
457+ await writeUserSessionToSql (session);
451458 return session;
452459 }
453460
@@ -466,7 +473,7 @@ class AccountBackend {
466473 if (user == null || user.isModerated || user.isDeleted) {
467474 throw AuthenticationException .failed ();
468475 }
469- final data = await withRetryTransaction (_db, (tx) async {
476+ final result = await withRetryTransaction (_db, (tx) async {
470477 final session = await tx.userSessions.lookupOrNull (sessionId);
471478 if (session == null || session.isExpired ()) {
472479 throw AuthenticationException .failed ('Session has been expired.' );
@@ -491,7 +498,7 @@ class AccountBackend {
491498 ..authenticatedAt = now
492499 ..expires = now.add (_sessionDuration);
493500 tx.insert (newSession);
494- return SessionData . fromModel ( newSession);
501+ return (session : newSession, expiredSessionId : sessionId );
495502 } else {
496503 // only update the current one
497504 session
@@ -504,9 +511,16 @@ class AccountBackend {
504511 ..authenticatedAt = now
505512 ..expires = now.add (_sessionDuration);
506513 tx.insert (session);
507- return SessionData . fromModel (session);
514+ return (session: session, expiredSessionId : null );
508515 }
509516 });
517+
518+ if (result.expiredSessionId != null ) {
519+ await _deleteUserSessionFromSql (result.expiredSessionId! );
520+ }
521+ await writeUserSessionToSql (result.session);
522+
523+ final data = SessionData .fromModel (result.session);
510524 await cache.userSessionData (data.sessionId).set (data);
511525 return data;
512526 }
@@ -576,7 +590,12 @@ class AccountBackend {
576590 /// Deletes the session entry if it has already expired and
577591 /// clears the related cache too.
578592 Future <UserSession ?> lookupValidUserSession (String sessionId) async {
579- final session = await _db.userSessions.lookupOrNull (sessionId);
593+ // Read from SQL first, falling back to Datastore if the session is not present.
594+ var session = await _lookupUserSessionFromSql (sessionId);
595+ if (session == null ) {
596+ final key = _db.emptyKey.append (UserSession , id: sessionId);
597+ session = await _db.lookupOrNull <UserSession >(key);
598+ }
580599 if (session == null ) {
581600 return null ;
582601 }
@@ -587,6 +606,121 @@ class AccountBackend {
587606 return session;
588607 }
589608
609+ Future <UserSession ?> _lookupUserSessionFromSql (String sessionId) async {
610+ try {
611+ final row = await primaryDatabase.withRetry (
612+ (db) => db.userSessions.byKey (sessionId).fetch (),
613+ );
614+ if (row == null ) return null ;
615+ return _userSessionFromRow (_db, row);
616+ } catch (e, st) {
617+ _logger.warning ('SQL user session lookup failed: $sessionId ' , e, st);
618+ return null ;
619+ }
620+ }
621+
622+ /// Upserts [session] into the SQL database.
623+ Future <void > writeUserSessionToSql (UserSession session) async {
624+ await primaryDatabase.transactWithRetry ((db) async {
625+ // TODO: consider supporting a generated `upsertValue()` in typed_sql
626+ await db.userSessions
627+ .insertValue (
628+ sessionId: session.sessionId,
629+ userId: session.userId,
630+ email: session.email,
631+ name: session.name,
632+ imageUrl: session.imageUrl,
633+ created: session.created! ,
634+ expires: session.expires! ,
635+ authenticatedAt: session.authenticatedAt,
636+ csrfToken: session.csrfToken,
637+ openidNonce: session.openidNonce,
638+ accessToken: session.accessToken,
639+ grantedScopes: session.grantedScopes,
640+ )
641+ .onConflict (.primaryKey)
642+ .update (
643+ (_, _, set ) => set (
644+ userId: session.userId.asExpr,
645+ email: session.email.asExpr,
646+ name: session.name.asExpr,
647+ imageUrl: session.imageUrl.asExpr,
648+ created: session.created! .asExpr,
649+ expires: session.expires! .asExpr,
650+ authenticatedAt: session.authenticatedAt.asExpr,
651+ csrfToken: session.csrfToken.asExpr,
652+ openidNonce: session.openidNonce.asExpr,
653+ accessToken: session.accessToken.asExpr,
654+ grantedScopes: session.grantedScopes.asExpr,
655+ ),
656+ )
657+ .execute ();
658+ });
659+ }
660+
661+ Future <void > _deleteUserSessionFromSql (String sessionId) async {
662+ await primaryDatabase.withRetry (
663+ (db) => db.userSessions.delete (sessionId).execute (),
664+ );
665+ }
666+
667+ /// Copies Datastore [UserSession] entities into the SQL database.
668+ ///
669+ /// When the `expires` field is newer on the SQL row, it will keep its current
670+ /// value, since it indicates it has been updated more recently.
671+ Future <({int scanned, int updated})>
672+ copyUserSessionsFromDatastoreToSql () async {
673+ var scanned = 0 ;
674+ var updated = 0 ;
675+ await for (final session in _db.query <UserSession >().run ()) {
676+ scanned++ ;
677+ if (session.isExpired ()) continue ;
678+
679+ final row = await primaryDatabase.withRetry (
680+ (db) => db.userSessions.byKey (session.sessionId).fetch (),
681+ );
682+
683+ if (row == null || session.expires! .isAfter (row.expires)) {
684+ await writeUserSessionToSql (session);
685+ updated++ ;
686+ }
687+ }
688+ _logger.info (
689+ 'Synced UserSessions Datastore -> SQL: scanned $scanned , updated $updated .' ,
690+ );
691+ return (scanned: scanned, updated: updated);
692+ }
693+
694+ /// Copies SQL [UserSessionRow] entries back into Datastore.
695+ ///
696+ /// When the `expires` field is newer on the Datastore, it will keep its current
697+ /// value, since it indicates it has been updated more recently.
698+ Future <({int scanned, int updated})>
699+ copyUserSessionsFromSqlToDatastore () async {
700+ var scanned = 0 ;
701+ var updated = 0 ;
702+
703+ final rows = await primaryDatabase.withRetry (
704+ (db) => db.userSessions.stream ().toList (),
705+ );
706+
707+ for (final row in rows) {
708+ scanned++ ;
709+ final key = _db.emptyKey.append (UserSession , id: row.sessionId);
710+ final existing = await _db.lookupOrNull <UserSession >(key);
711+ if (existing == null ||
712+ existing.expires == null ||
713+ row.expires.isAfter (existing.expires! )) {
714+ await _db.commit (inserts: [_userSessionFromRow (_db, row)]);
715+ updated++ ;
716+ }
717+ }
718+ _logger.info (
719+ 'Synced UserSessions SQL -> Datastore: scanned $scanned , updated $updated .' ,
720+ );
721+ return (scanned: scanned, updated: updated);
722+ }
723+
590724 /// Deletes sessions associated with a [userId] or [sessionId] .
591725 Future <void > deleteUserSessions ({String ? userId, String ? sessionId}) async {
592726 if (sessionId != null ) {
@@ -681,23 +815,34 @@ class _UserSessionDataAccess {
681815
682816 _UserSessionDataAccess (this ._db);
683817
684- Future <UserSession ?> lookupOrNull (String sessionId) async {
685- final key = _db.emptyKey.append (UserSession , id: sessionId);
686- return await _db.lookupOrNull <UserSession >(key);
687- }
688-
689- /// Scans Datastore for all sessions the user has, and invalidates
690- /// them all (by deleting the Datastore entry and purging the cache).
818+ /// Scans for all sessions the user has, and invalidates
819+ /// them all.
691820 Future <void > expireAllForUserId (String userId) async {
821+ final rows = await primaryDatabase.withRetry (
822+ (db) => db.userSessions
823+ .where ((session) => session.userId.equalsValue (userId))
824+ .select ((session) => (session.sessionId,))
825+ .fetch (),
826+ );
827+ for (final sessionId in rows) {
828+ await expire (sessionId);
829+ }
830+
692831 final query = _db.query <UserSession >()..filter ('userId =' , userId);
693832 final sessionsToDelete = await query.run ().toList ();
694833 for (final session in sessionsToDelete) {
695834 await expire (session.sessionId);
696835 }
697836 }
698837
699- /// Removes the session data from the Datastore and from cache.
838+ /// Removes the session data from the SQL store, the Datastore and cache.
839+ ///
840+ /// Deletes from SQL first, then from Datastore.
700841 Future <void > expire (String sessionId) async {
842+ await primaryDatabase.withRetry (
843+ (db) => db.userSessions.delete (sessionId).execute (),
844+ );
845+
701846 final key = _db.emptyKey.append (UserSession , id: sessionId);
702847 try {
703848 await _db.commit (deletes: [key]);
@@ -708,7 +853,17 @@ class _UserSessionDataAccess {
708853 }
709854
710855 /// Removes the session data that has expiry before [ts] .
856+ ///
857+ /// Deletes from SQL first, then from Datastore. Returns the number of
858+ /// deleted Datastore entities.
711859 Future <int > expireAllBeforeTimestamp (DateTime ts) async {
860+ await primaryDatabase.withRetry (
861+ (db) => db.userSessions
862+ .where ((s) => s.expires.isBeforeValue (ts))
863+ .delete ()
864+ .execute (),
865+ );
866+
712867 final query = _db.query <UserSession >()..filter ('expires <' , ts);
713868 final count = await _db.deleteWithQuery (query);
714869 return count.deleted;
@@ -725,3 +880,20 @@ class _UserSessionTransactionDataAcccess {
725880 return await _tx.lookupOrNull <UserSession >(key);
726881 }
727882}
883+
884+ UserSession _userSessionFromRow (DatastoreDB db, UserSessionRow row) {
885+ return UserSession ()
886+ ..parentKey = db.emptyKey
887+ ..id = row.sessionId
888+ ..userId = row.userId
889+ ..email = row.email
890+ ..name = row.name
891+ ..imageUrl = row.imageUrl
892+ ..created = row.created
893+ ..expires = row.expires
894+ ..authenticatedAt = row.authenticatedAt
895+ ..csrfToken = row.csrfToken
896+ ..openidNonce = row.openidNonce
897+ ..accessToken = row.accessToken
898+ ..grantedScopes = row.grantedScopes;
899+ }
0 commit comments