Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ All notable changes to this project will be documented in this file.

### BREAKING

- **`Auth.fake()`'s login/logout now dispatch `AuthLogin`/`AuthLogout` through the real `Event` facade, same as the real guard.** A test that registered an `Event.listen` listener and drove it through `Auth.fake()` used to see nothing; it now observes the same events a real session fires. A test asserting a listener's absence of a call across a faked login/logout now needs to account for it. (`lib/src/testing/fake_auth_manager.dart`, `doc/testing/facades.md`, `doc/security/authentication.md`)
- **`state()` and `count()` return a copy, and every factory implements `Factory<T> newFactory()`.** They used to mutate the factory and return it, so `final f = UserFactory(); f.state({...}); f.make()` carried the state, and two branches off one base leaked into each other. Dart cannot construct "the same subclass" on its own, so the new abstract hook answers with the subclass constructor (`Factory<User> newFactory() => UserFactory();`). Named states move to an extension on `Factory<T>`, since a method on the subclass is out of reach after the first `state()` or `count()`; the old `state({...}) as UserFactory` cast would now throw. No factory subclass exists in this package's `lib/`, `test/` or `example/`; the `make:factory` stub and `doc/database/seeding.md` show the new shape. (`lib/src/database/seeding/factory.dart`, `assets/stubs/factory.stub`, `doc/database/seeding.md`)
- **`MagicTest.init()` resets the Gate, the Translator and the DateManager between tests.** It now calls `Gate.flush()` in `setUp` and `tearDown`, and `DateManager.reset()` plus `Translator.reset()` in `tearDown`, so an ability, a loaded catalogue or a locale no longer leaks into the next test. A suite under `MagicTest.init()` that defines abilities or loads translations in `setUpAll` loses them after the first test and must move that work to `setUp`. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`)

### Added

- **`BaseGuard` dispatches `AuthLogin`/`AuthLogout` through the `Event` facade.** `startSession` dispatches `AuthLogin(user)` last and awaits its listeners, skipped when the session ended or was replaced while the user was being cached; `logout()` dispatches `AuthLogout(previous)` after the `stateNotifier` bump and before a rethrown Vault failure, for a guest (`null` user) too, meaning "the in-memory session ended" rather than "the credentials are gone" (gate server-side release on `Auth.hasToken()`). `AuthRestored` is unchanged: only an API-confirmed sync fires it. (`lib/src/auth/guards/base_guard.dart`, `doc/security/authentication.md`, `doc/digging-deeper/events.md`, `skills/magic-framework/`)
- **`Event.listen<T extends MagicEvent>(MagicListener Function() factory)`, a registration shortcut on the `Event` facade.** Equivalent to `EventDispatcher.instance.register(T, [factory])`, so a listener can be registered without adding a mapping to `AppEventServiceProvider.listen`. `T` must be named explicitly; call it from a provider's `register()`, not `boot()`, since a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`. (`lib/src/facades/event.dart`, `doc/digging-deeper/events.md`, `skills/magic-framework/`)
- **`AuthChannelSubscription`, a reconciler for a private broadcast channel whose name depends on auth state.** `sync()` re-reads a caller-supplied `channelName()` on every call, serialised against overlapping calls, and is a no-op when the name has not changed, whatever the connection is doing (the Reverb driver recovers a drop on its own; resubscribing here would open a second socket). A name change leaves the old channel by its prefixed name, connects only when not already connected, then subscribes and wires `listeners`. `onReconnect` fires on both an `Echo.onReconnect` signal and a `connectionState` transition to `connected`. `dispose()` cancels only the reconnect-listening subscriptions. (`lib/src/broadcasting/auth_channel_subscription.dart`, `doc/digging-deeper/broadcasting.md`, `skills/magic-framework/`)
- **`Str.unwrap(value, before, [after])`, Laravel's `Str::unwrap` ported.** Strips `before` from the start and `after` (default `before`) from the end, each checked and stripped independently, so a prefix-only match (`'"x'`) loses the leading quote and stays unbalanced rather than being left alone. (`lib/src/support/str.dart`, `doc/digging-deeper/helpers.md`, `skills/magic-framework/`)
- **`CollapsesIndexedErrorKeys.collapse(wireKey)`, a static entry point for the mixin's own collapse.** For a controller that cannot mix in `CollapsesIndexedErrorKeys` (it already extends a different base) but still needs to collapse an indexed wire validation key (`items.0.name`) onto its field name (`name`). Same collapse `errorFieldFor` runs when the mixin is in place. (`lib/src/concerns/validates_requests.dart`, `doc/digging-deeper/validation.md`, `skills/magic-framework/`)
- **`Model.unguarded()`, `Model.unguard()`, `Model.reguard()` and `Model.isUnguarded`, Laravel's mass-assignment switch.** Inside `Model.unguarded(() => ...)` every `fill()` keeps every key, whatever `fillable` and `guarded` say; the guard comes back when the callback returns or throws, and a nested call leaves the outer scope unguarded. The callback must be synchronous, since an async one would run its later `fill()` calls guarded, and an assertion says so, now checked whether or not the call nests inside an outer `unguard()`/`unguarded()` scope. Outside it, `fill()` behaves exactly as before, `strict: true` included; `fromMap()` and `setRawAttributes()` are untouched. (`lib/src/database/eloquent/model.dart`)
- **`Factory.raw()`**, the merged definition and states without a model: always a `List<Map<String, dynamic>>`, one map per model, a single map when no `count()` was set. (`lib/src/database/seeding/factory.dart`)
- **`Carbon.setTestNow([testNow])` and `Carbon.hasTestNow()`, Laravel's frozen-clock testing helper.** `Carbon.now([timezone])` returns the frozen instant while one is set (timezone conversion still applies on top of it), `isToday()`, `isYesterday()`, `isTomorrow()`, `isFuture()`, `isPast()` and argument-less `diffForHumans()` measure against it, and `Carbon.setTestNow()` with no argument (or `null`) clears the freeze. A test that seeds an app's clock now has a Laravel-parity seam instead of threading a fake `DateTime` through every call site. (`lib/src/support/carbon.dart`, `doc/digging-deeper/carbon.md`, `skills/magic-framework/`)
Expand All @@ -28,6 +34,8 @@ All notable changes to this project will be documented in this file.

### Fixed

- **`ReverbBroadcastDriver.connect()` is idempotent: a second call, or a call while a reconnect is armed or in flight, no longer opens a second socket.** A second `connect()` used to assign a fresh socket over the live one and leak it, and a retry timer armed by a drop, a failed retry or a connection timeout stayed armed beside it. `connect()` now returns when connected, joins the attempt already in flight (single-flight shared with the reconnect timer), and supersedes an armed retry: it cancels the timer and runs the same reconnect work now, resubscribing every channel and firing `onReconnect`; a failed superseding attempt re-arms the retry before it throws. `AuthChannelSubscription` goes back to a plain `isConnected` gate before `Echo.connect()` instead of inferring a pending reconnect from `connectionState`. (`lib/src/broadcasting/drivers/reverb_broadcast_driver.dart`, `lib/src/broadcasting/auth_channel_subscription.dart`, `doc/digging-deeper/broadcasting.md`, `skills/magic-framework/`)
- **`ReverbBroadcastDriver` reported `reconnecting` after a socket drop even with `reconnect: false`, so a consumer of `connectionState` was told a reconnect was coming when `_scheduleReconnect` had already returned without arming one.** `_onDone` and `_onError` now report `disconnected` instead when the `reconnect` config key is off, and still report `reconnecting` when it is on. (`lib/src/broadcasting/drivers/reverb_broadcast_driver.dart`)
- **The `AbilityCallback` doc listed `bool callback(Model user)` as valid.** The gate always calls `callback(user, arguments)`, so that shape throws and the ability is denied; the doc now shows `(Model user, [dynamic arg])`. (`lib/src/auth/gate_manager.dart`)
- **`env('KEY', fallback)` returned the literal two-character string `'""'`/`"''"` for `KEY=""`/`KEY=''`, not the default and not an empty string.** `flutter_dotenv`'s own parser needs at least one character inside the quotes to strip them, so a present-but-quote-only value passed through unquoted. `Env.get` now trims a value that is exactly `'""'` or `"''"` down to `''`, matching Laravel's `Env::get` (`laravel-framework/src/Illuminate/Support/Env.php:271`): an explicit empty value is `''`, never treated as absent. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`)

Expand Down
42 changes: 41 additions & 1 deletion doc/digging-deeper/broadcasting.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Magic provides a Laravel Echo-equivalent broadcasting system for real-time WebSo
- [Activity Monitor and Heartbeat](#activity-monitor-and-heartbeat)
- [Connection Timeout](#connection-timeout)
- [Deduplication](#deduplication)
- [Auth-Scoped Subscriptions with AuthChannelSubscription](#auth-scoped-subscriptions)
- [Testing Broadcasting](#testing-broadcasting)

<a name="introduction"></a>
Expand Down Expand Up @@ -137,7 +138,7 @@ The `Echo` facade provides static access to the broadcasting system, proxying al
| `Echo.join(name)` | `BroadcastPresenceChannel` | Join a presence channel (auth + member tracking) |
| `Echo.listen(channel, event, callback)` | `BroadcastChannel` | Shorthand: subscribe + listen in one call |
| `Echo.leave(name)` | `void` | Unsubscribe from a channel |
| `Echo.connect()` | `Future<void>` | Establish the WebSocket connection |
| `Echo.connect()` | `Future<void>` | Establish the WebSocket connection; idempotent, never opens a second socket |
| `Echo.disconnect()` | `Future<void>` | Close the connection and release resources |
| `Echo.connection` | `BroadcastDriver` | The resolved default driver instance |
| `Echo.socketId` | `String?` | Server-assigned socket identifier, or `null` when disconnected |
Expand Down Expand Up @@ -553,6 +554,8 @@ The `connection_timeout` config key (default: **15 seconds**) controls how long
- A reconnect is scheduled (subject to backoff and the `reconnect` config flag).
- A `TimeoutException` is thrown from `Echo.connect()` so callers can surface an error state.

`ReverbBroadcastDriver.connect()` is idempotent, so calling it again is always safe: it returns at once when already connected, joins an attempt already in flight (another `connect()` or a timer-driven retry), and when a reconnect is armed (after a drop, a failed retry, or this timeout) it cancels the armed retry and reconnects now, resubscribing every channel and firing `onReconnect` as the timer would. A second call never opens a second socket.

```dart
'connections': {
'reverb': {
Expand All @@ -570,6 +573,43 @@ The Reverb driver maintains a ring buffer of recently seen event fingerprints (c

Configure the buffer size with `dedup_buffer_size` (default: `100`). A larger buffer consumes more memory but reduces false duplicate detection during high-throughput scenarios.

<a name="auth-scoped-subscriptions"></a>
## Auth-Scoped Subscriptions with AuthChannelSubscription

`AuthChannelSubscription` keeps a single private channel subscription in sync with a caller-supplied name, re-read on every `sync()` call. It is the seam behind a channel whose name depends on the signed-in user or team: which channel is currently subscribed, leaving the old one and standing up the replacement when the name changes, and re-firing `onReconnect` after a connection drop so a caller can refetch whatever the socket missed while it was down (Reverb does not replay).

```dart
late final subscription = AuthChannelSubscription(
channelName: () {
final teamId = Auth.user<User>()?.teamId;
return teamId == null ? null : 'teams.$teamId';
},
listeners: {
'incident.opened': (event) => refetchIncidents(),
},
onReconnect: refetchIncidents,
);

// Wire it to whatever changes the channel name, typically the guard's
// own state notifier:
Auth.stateNotifier.addListener(subscription.sync);

// Reconcile once at startup too, since the listener only fires on a change.
subscription.sync();
```

`sync()` is serialised: a call arriving while another is in flight defers and re-runs once more after the current one settles, rather than risking two live subscriptions across an await. It is a no-op when `channelName()` still answers the name it is already subscribed to, whatever the connection is doing at that moment: the Reverb driver recovers a drop and re-subscribes on its own. A name change leaves the old channel by its fully-qualified (prefixed) name, calls `Echo.connect()` when the connection is not live, then subscribes to the new channel and wires every entry of `listeners`. That connect is safe during a pending reconnect because the Reverb driver's `connect()` is idempotent (see [Connection Timeout](#connection-timeout)).

`onReconnect` fires on both an `Echo.onReconnect` signal and a `connectionState` transition to `connected`, covering a driver that announces its own recovery as well as the same recovery observed independently. `dispose()` cancels only the reconnect-listening subscriptions; it does not leave the channel or disconnect, which stay live until the next `sync()` resolves a `null` channel name. A `null` channel name disconnects the whole default connection via `Echo.disconnect()`, not just this channel, dropping any other channel the app subscribed elsewhere through `Echo`: deliberate, since a signed-out app has no business staying on the socket.

```dart
@override
void onClose() {
Auth.stateNotifier.removeListener(subscription.sync);
subscription.dispose();
}
```

<a name="testing-broadcasting"></a>
## Testing Broadcasting

Expand Down
63 changes: 43 additions & 20 deletions doc/digging-deeper/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,18 +177,23 @@ class OrderController extends MagicController {
<a name="inline-listeners"></a>
## Inline Listeners

For simple event handling, you can register listeners inline using a closure instead of creating a dedicated listener class:
`Event.listen<T>(factory)` registers a listener without adding a mapping to `AppEventServiceProvider.listen`. It takes a factory, `MagicListener Function()`, the same shape `listen` registers under the hood, not a bare closure:

```dart
Event.listen<OrderShipped>((event) {
Log.info('Order shipped: ${event.order.id}');
});
class LogOrderShipped extends MagicListener<OrderShipped> {
@override
Future<void> handle(OrderShipped event) async {
Log.info('Order shipped: ${event.order.id}');
}
}

Event.listen<OrderShipped>(() => LogOrderShipped());
```

Inline listeners are useful for quick logging, metrics, or simple side effects. For more complex logic, use dedicated listener classes.
`T` is the registration key and must be named explicitly: leave it off and Dart infers `MagicEvent`, which no dispatched event matches exactly.

> [!TIP]
> Register inline listeners in your `EventServiceProvider`'s `boot()` method to keep them organized alongside class-based listener registrations.
> Register `Event.listen` calls in your `EventServiceProvider`'s `register()` method, not `boot()`: a guard can dispatch `AuthLogin` during `AuthServiceProvider.boot`, before a later provider's `boot()` runs. Registrations last until `MagicApp.flush()`.

<a name="framework-events"></a>
## Framework Events
Expand All @@ -199,14 +204,22 @@ Magic fires several system events automatically.

| Event | Fired When |
|-------|------------|
| `AuthLogin` | User successfully logs in |
| `AuthLogout` | User logs out |
| `AuthFailed` | Authentication attempt fails |
| `AuthLogin` | A guard's `startSession` finishes: the token (when given) is persisted, the user is set and cached. Not fired on a restore. |
| `AuthLogout` | A guard's `logout()` ends the in-memory session, guest logout included. Not a promise the credentials are gone: it fires even when a Vault delete failed, so a listener releasing server-side state must gate on `Auth.hasToken()`. |
| `AuthRestored` | An API-confirmed sync (`BaseGuard`'s background `/user` fetch) sets the user; not fired for the cache-only step of `Auth.restore()`. |

> [!NOTE]
> `AuthFailed` is defined, not dispatched by the guards: no code path in `lib/` fires it automatically. Dispatch it yourself from a failed login flow, e.g. `Event.dispatch(AuthFailed(credentials, guard: 'web'));` in the `catch` branch around your `Auth.login()` call.

```dart
Event.listen<AuthLogin>((event) {
Log.info('User logged in: ${event.user.email}');
});
class LogAuthLogin extends MagicListener<AuthLogin> {
@override
Future<void> handle(AuthLogin event) async {
Log.info('User logged in: ${event.user.authIdentifier}');
}
}

Event.listen<AuthLogin>(() => LogAuthLogin());
```

### Model Lifecycle Events
Expand All @@ -222,12 +235,17 @@ Event.listen<AuthLogin>((event) {
| `ModelDeleted` | After model is deleted |

```dart
Event.listen<ModelCreated>((event) {
if (event.model is User) {
final user = event.model as User;
Log.info('New user registered: ${user.email}');
class LogNewUser extends MagicListener<ModelCreated> {
@override
Future<void> handle(ModelCreated event) async {
if (event.model is User) {
final user = event.model as User;
Log.info('New user registered: ${user.email}');
}
}
});
}

Event.listen<ModelCreated>(() => LogNewUser());
```

### Gate Events
Expand All @@ -240,9 +258,14 @@ Event.listen<ModelCreated>((event) {

```dart
// Log denied access attempts
Event.listen<GateAccessDenied>((event) {
Log.warning('Access denied: ${event.ability} for user ${event.user?.id}');
});
class LogDeniedAccess extends MagicListener<GateAccessDenied> {
@override
Future<void> handle(GateAccessDenied event) async {
Log.warning('Access denied: ${event.ability} for user ${event.user?.id}');
}
}

Event.listen<GateAccessDenied>(() => LogDeniedAccess());
```

### Database Events
Expand Down
8 changes: 8 additions & 0 deletions doc/digging-deeper/helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ Str.lower('IŞIK', locale: 'tr'); // 'ışık'
Str.initials('ismail kaya', limit: 2, capitalize: true, locale: 'tr'); // 'İK'
```

`Str.unwrap(value, before, [after])` strips `before` from the start and `after` (default `before`) from the end, each checked and stripped independently, mirroring Laravel's `Str::unwrap`. A prefix-only match (`'"x'`) loses the leading quote and is left unbalanced rather than untouched.

```dart
Str.unwrap('"quoted"', '"'); // 'quoted'
Str.unwrap('"x', '"'); // 'x', prefix-only match still strips
Str.unwrap('[value]', '[', ']'); // 'value'
```

<a name="number"></a>
## Number

Expand Down
6 changes: 6 additions & 0 deletions doc/digging-deeper/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,12 @@ class ItemsController extends MagicController

Two wire keys that collapse onto the same field (two failing elements of the same list) keep the FIRST message; the later one is dropped rather than overwriting it. A key addressing a distinct sub-key rather than a list element (`credentials.token`) is left whole, since a form with a separate error slot per sub-key needs each one kept.

A controller that already extends a different base and cannot mix in `CollapsesIndexedErrorKeys` still reaches the same collapse through the mixin's static helper, `CollapsesIndexedErrorKeys.collapse(wireKey)`:

```dart
final field = CollapsesIndexedErrorKeys.collapse('items.0.name'); // 'name'
```

<a name="server-side-validation"></a>
## Server-Side Validation

Expand Down
10 changes: 4 additions & 6 deletions doc/getting-started/service-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,10 @@ Future<void> boot() async {
// ✅ Safe to access any registered service
final config = Config.get('payment');
final auth = Auth.instance;

// Register event listeners
Event.listen<UserLoggedIn>((event) {
Log.info('User logged in: ${event.user.email}');
});


// Event listeners belong in register(), not here: the auth guard can
// dispatch AuthLogin during AuthServiceProvider.boot, before this runs.

// Perform async initialization
await initializePaymentGateway();
}
Expand Down
Loading
Loading