# Session Scope

How a controller that caches one user's (or one team's) data is reset when the signed-in identity changes, using magic's `SessionScoped` contract and the identity key this package declares.

- [Introduction](#introduction)
- [The Leak](#the-leak)
- [The Contract](#the-contract)
- [Clear Before You Refetch](#clear-before-you-refetch)
- [Wiring the Sync](#wiring-the-sync)
- [The Identity Key](#the-identity-key)
- [Why Logout Does Not Reset](#why-logout-does-not-reset)
- [Error Isolation](#error-isolation)
- [Testing](#testing)
- [Gotchas](#gotchas)
- [Related Documentation](#related-documentation)

## Introduction

Any controller that caches data belonging to one signed-in user, or to one of that user's teams, has to be told when that user changes. Magic core ships the two pieces: the `SessionScoped` contract, which a controller (or a repository) implements to declare "my rows belong to exactly one session", and `SessionScope`, which watches `Auth.stateNotifier` and resets every scoped holder whenever the authenticated identity changes.

This package's part is the identity: `MagicStarterServiceProvider` sets `SessionScope.identity` to `<userId>:<teamId>` in `register()`, so a team switch by the same user counts as a change. Attaching stays your app's call (see [Wiring the Sync](#wiring-the-sync)).

> [!IMPORTANT]
> This is a privacy guard, not a freshness nicety. Without it, a logout followed by a login as a different user leaves the previous user's rows on screen. On a team-scoped product that is a cross-tenant data exposure.

## The Leak

Magic resolves controllers as type-keyed singletons: `Magic.findOrPut(ProjectController.new)` returns the same instance for the whole process lifetime. The initial fetch usually lives in `onInit()`, and `MagicStatefulViewState` only calls it while the controller has not been initialised yet, so `onInit()` runs once per instance lifetime, not once per mount. Follow the consequence through:

1. User A signs in, opens the projects screen. `ProjectController.onInit()` fetches A's projects.
2. A signs out. The controller instance survives: nothing disposes it, and its rows are still in memory.
3. User B signs in on the same device and opens the projects screen. The controller is already initialised, so `onInit()` does not run and no fetch happens.
4. B is looking at A's projects until the app is restarted.

The same happens without a logout: a team switch changes which rows the API returns, while every cached controller keeps the old team's data.

## The Contract

Implement magic's `SessionScoped` on every controller that caches session-scoped data. A repository or any other non-controller holder implements it too and registers itself with `SessionScope.register(holder)` (and `SessionScope.unregister(holder)` on dispose); controllers are found in `Magic.controllers` without registration.

```dart
import 'package:magic/magic.dart';

class ProjectController extends MagicController
    with MagicStateMixin<List<Project>>
    implements SessionScoped {
  static ProjectController get instance =>
      Magic.findOrPut(ProjectController.new);

  final List<Project> projects = [];

  @override
  Future<void> onInit() async {
    super.onInit();
    await loadProjects();
  }

  @override
  Future<void> resetForSession() async {
    // 1. Publish the cleared state BEFORE the refetch.
    projects.clear();
    setLoading();

    // 2. Refetch for whoever is authenticated now.
    await loadProjects();
  }
}
```

`resetForSession()` is the only member of the contract. It is called on login and on team switch, and it is expected to drop every cached row, publish that cleared state, and then refetch.

## Clear Before You Refetch

`resetForSession()` is destructive on purpose, and the clear must land before the refetch is attempted.

An ordinary `reload()` is deliberately non-destructive: when a refresh fails, keeping the last-known-good rows is right, because a five-second network blip should not blank a dashboard the user is reading. Across an identity change that behaviour is exactly wrong, since the rows on screen belong to the previous session:

| | Ordinary `reload()` | `resetForSession()` |
|:--|:--------------------|:--------------------|
| Refetch succeeds | New rows | New rows |
| Refetch fails | Previous rows stay (same user, stale data) | Must be empty (the previous rows belong to another session) |

So the order inside `resetForSession()` is load-bearing:

```dart
@override
Future<void> resetForSession() async {
  projects.clear();      // 1. Drop the previous session's rows.
  setLoading();          // 2. Publish it, so the UI is empty even if step 3 throws.
  await loadProjects();  // 3. Only now go to the network.
}
```

> [!WARNING]
> Do not write `resetForSession()` as a plain `await reload()`, and do not clear inside a `try` whose `catch` restores the old rows. A failed refetch must leave an empty screen, never a populated one.

## Wiring the Sync

Attach `SessionScope` once, from your app's service provider `boot()`, after `MagicStarterServiceProvider` has booted and as the last `Auth.stateNotifier` listener you add. The starter never attaches it for you:

```dart
import 'package:magic/magic.dart';
import 'package:magic_starter/magic_starter.dart';

class AppServiceProvider extends ServiceProvider {
  AppServiceProvider(super.app);

  @override
  void register() {}

  @override
  Future<void> boot() async {
    MagicStarter.useUserModel((data) => User.fromMap(data));
    // MagicStarter.useTeamResolver(...) goes here, see Teams.

    // Register last: notification polling, realtime subscriptions and locale
    // should already point at the new session before its data is refetched.
    SessionScope.attach();
  }
}
```

`attach()` subscribes to `Auth.stateNotifier` and records the identity the app boots with. It resets nothing at boot: no view has resolved a controller yet, so there is nothing cached to clear, and the first real change is the first one that resets.

| Member | Purpose |
|:-------|:--------|
| `SessionScope.attach()` | Subscribe to auth state changes. Idempotent; a second call while attached is a no-op. |
| `SessionScope.detach()` | Unsubscribe and forget the recorded identity. Mainly for tests. |
| `SessionScope.isAttached` | Whether a subscription is active. |
| `SessionScope.register(holder)` / `unregister(holder)` | Add or remove a non-controller holder. |
| `SessionScope.identity` | The identity resolver. This package sets it in `register()`; see below. |

## The Identity Key

The sync compares a string key, not object identity. Magic's default is the user id alone; `MagicStarterServiceProvider` replaces it in `register()` with a two-leg key:

```
<userId>:<teamId>
```

| Leg | Source | Reads `null` when |
|:----|:-------|:------------------|
| `userId` | `Auth.id()` | Never while `Auth.check()` is true |
| `teamId` | `MagicStarter.currentTeamId()`, the team resolver's current team id as a string | Teams are disabled, or no team resolver is registered |

Two legs, because a team-scoped API scopes its endpoints to the current team and not just to the user: switching teams changes every response body while `Auth.id()` stays put. Reading the team through the starter's own [team resolver](https://anilcancakir.com/magic/docs/starter/basics/teams.md#team-resolver) keeps this package independent of your `User` model.

The whole key is `null` when, and only when, nobody is signed in (`Auth.check()` is the discriminator). Two rules follow:

- An unchanged key is a no-op. `Auth.stateNotifier` also bumps on a plain session restore, and without this check every incidental bump would stampede a fresh wave of refetches.
- Only a change to a non-null key resets.

Team switching triggers the sync for free: `MagicStarter.switchTeam()` goes through `MagicStarterTeamController.switchTeam()`, which applies the fresh user the switch answer carries (falling back to `Auth.restore()` only when the answer carries none) and bumps `Auth.stateNotifier` either way. If you implement your own switch path, apply the answer's user directly rather than always calling `Auth.restore()`, which re-applies the cached user, still on the previous team.

An app that needs a different key (an organisation leg, say) sets `SessionScope.identity` in its own provider's `boot()`, after this one.

## Why Logout Does Not Reset

On logout the key goes from `1:10` to `null`, and nothing is reset. There is no identity to refetch for: a reset on logout would clear every controller and then fire a wave of requests from the login screen, where the token is already gone, so they could only come back `401`.

The stale rows sit unreachable in memory behind the auth guard, and the next login resets them before any authenticated view renders, because `Auth.login` bumps `Auth.stateNotifier` before it returns to the code that navigates. The key is still recorded on logout (as `null`), which is what makes signing back in as the same user count as a change (`null` to `1:10`) and refetch.

## Error Isolation

Each `resetForSession()` is fired independently: `Auth.stateNotifier` listeners are synchronous, so the sync does not await them, and each future carries its own error guard. One controller throwing does not stop the others, and the failure is logged rather than swallowed:

```
[SessionScope] session reset failed: <error>
```

Handle your own failures the way any other reload does (`setError()`, a fallback message). A throw that escapes leaves that one controller cleared and unrefetched: safe, but an empty screen with no explanation.

## Testing

`SessionScope` holds process-wide statics that neither `MagicApp.reset()` nor `Magic.flush()` clears, so call `detach()` in both `setUp` and `tearDown`. `detach()` keeps the identity resolver, so a test that boots the starter provider restores the one it found:

```dart
setUp(() {
  MagicApp.reset();
  Magic.flush();
  // bind 'log', 'auth' and 'magic_starter', register the team resolver...
  SessionScope.detach();
});

tearDown(() => SessionScope.detach());

test('drops the previous tenant rows when another user logs in', () async {
  await Auth.login({'token': 'a'}, userA);
  SessionScope.attach();

  final controller = Magic.put(ProjectController());
  controller.projects.addAll(projectsOfA);

  await Auth.login({'token': 'b'}, userB);
  await pumpEventQueue();

  expect(controller.projects, isNot(contains(projectsOfA.first)));
});
```

`detach()` unsubscribes from the exact notifier instance it subscribed to. That matters in tests: `Auth.stateNotifier` resolves through the container, so rebinding the guard hands back a different notifier.

## Gotchas

| Mistake | Fix |
|:--------|:----|
| Expecting the starter to attach the sync | It only declares the identity in `register()`. Call `SessionScope.attach()` yourself, last in your provider's `boot()`. |
| Writing `resetForSession()` as `await reload()` | Clear the rows and publish the cleared state first, then refetch. A failed refetch must leave an empty screen. |
| Switching teams through your own path with `Auth.restore()` | Apply the switch answer's user directly, as `MagicStarterTeamController.switchTeam()` does, or the identity flips late. |
| Caching rows in a repository | A non-controller holder is not found automatically: implement `SessionScoped` and call `SessionScope.register(holder)`. |
| Leaving `SessionScope` attached between tests | Call `SessionScope.detach()` in `setUp` and `tearDown`. |

## Related Documentation

- [State Management](https://anilcancakir.com/magic/docs/starter/guides/state-management.md): `MagicController`, `MagicStateMixin` and the singleton accessor pattern this contract sits on.
- [Teams](https://anilcancakir.com/magic/docs/starter/basics/teams.md): the team resolver that supplies the second leg of the identity key.
- [Authentication](https://anilcancakir.com/magic/docs/starter/basics/authentication.md): `Auth.stateNotifier`, login, logout and session restore.
- [Service Provider](https://anilcancakir.com/magic/docs/starter/architecture/service-provider.md): where `attach()` belongs in the boot sequence.
