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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ All notable changes to this project will be documented in this file.
- **`RefetchesOnMount<C, W>` and `SubmitsOnce<W>`, two view-layer mixins closing gaps in magic's singleton-controller and async-submit model.** `RefetchesOnMount`, mixed onto a `MagicStatefulViewState`, fire-and-forget refetches a controller's data on every mount (not only the first, since controllers fire `onInit` once per instance for the app's lifetime); `SubmitsOnce`, mixed onto a form's `State`, guards a submit handler against a second tap while the first write is in flight, resetting in a `finally` so a throwing submit re-arms the button. (`lib/src/ui/refetches_on_mount.dart`, `lib/src/ui/submits_once.dart`, `doc/basics/views.md`, `skills/magic-framework/`)
- **`Env.filled(key, fallback)` and `Env.getOrFail(key)`, guards for a value a blank silently corrupts.** `Env.get`/`env()` only fall back when a key is entirely absent, so a present-but-blank or quote-only value silently resolved to `''` and has shipped as a blank browser tab title and a link pointing at a path with no origin. `Env.filled` treats absent, blank, and quote-only the same way, stripping one wrapping quote pair and surrounding whitespace from a present value. `Env.getOrFail` mirrors Laravel's `Env::getOrFail`, throwing a `StateError` only when the key is entirely missing. (`lib/src/foundation/env.dart`, `doc/getting-started/configuration.md`, `skills/magic-framework/`)
- **`MagicTest.loadTranslations(locale, {directory})`, a real translation catalogue for a widget test.** Reads `<directory>/<locale>.json` off disk, flattens it with the app's own flatten rule (`JsonAssetLoader.flatten`, now public), and installs it before awaiting the translator's load, so `trans()` resolves real catalogue strings in a test instead of rendering the raw dotted key. (`lib/src/testing/magic_test.dart`, `doc/testing/getting-started.md`, `skills/magic-framework/`)
- **`SyncFeed`, `SyncLedger` and `CreateSyncCursorsTable`, a push-then-pull sync skeleton over one REST resource.** `SyncFeed.run({scope, account})` sends everything a subclass's `pending()` reports written since this device's own push mark (`POST '$resource/sync'`, batched at `batchSize`), then walks pull pages (`GET resource`, up to `maxPages`) handing each row to the subclass's `adoptRow()`, and never throws: an exception surfaces as a `SyncReport.failure` string, logged via `Log.error`. Only the push mark advances locally, and only past a shared mark once every row carrying it has been sent (marks need not be unique, and a batch boundary may land in the middle of a run of rows that share one); a run halted by a throw after a batch landed reports that batch's rows as `pushed` instead of zero. The pull cursor is the server's opaque text handed straight back, because a row adopted from the server carries the originating device's own clock. `SyncLedger` is the bookmark store behind it (`sync_cursors`, upserted by delete-then-insert inside a `SAVEPOINT` so it nests inside a caller's own transaction), and `CreateSyncCursorsTable` creates that table; magic has no migration discovery, so an app lists it in its own `Migrator().run([...])` call. Scope derivation, any additional salt, and when a feed runs are left to the app. (`lib/src/sync/sync_feed.dart`, `lib/src/sync/sync_ledger.dart`, `lib/src/sync/create_sync_cursors_table.dart`, `doc/digging-deeper/sync.md`, `skills/magic-framework/`)
- **`Str.ascii(value)` and `Str.squish(value)`, two more of Laravel's `Str` helpers ported.** `ascii` folds Latin letters (accents, the Romanian comma-below letters, `ẞ`) to their plain ASCII base for a search key, Latin only, diverging from Laravel's `Str::ascii`, which transliterates every script it has a table for and would otherwise collapse a non-Latin word to `?` or a phonetic guess. `squish` trims and collapses every run of whitespace to one space, matching Laravel's `Str::squish`, over Dart's `\s` class plus two Hangul filler code points a rendered blank can carry without registering as whitespace. (`lib/src/support/str.dart`, `doc/digging-deeper/helpers.md`, `skills/magic-framework/`)
- **The default `User-Agent`'s app-name folding (`NetworkServiceProvider`, via `Str.ascii`) now also folds `ș`, `ț`, `ẞ`, and the Angstrom sign U+212B to their plain ASCII base.** They used to fall through `Str.ascii`'s table untouched and were then dropped by the printable-ASCII filter that follows it.
- **`AppLifecycle.states()`, the app lifecycle as a `Stream<AppLifecycleState>` for a reader built before a `WidgetsBinding` necessarily exists.** A dependency constructed inside a service provider's `register()` runs before the app has bound anything, so reaching for `WidgetsBinding.instance` there throws; `states()` defers that lookup to the moment a listener subscribes, adding its own observer to the binding on `listen` and removing it on `cancel`, so nothing outlives its reader. Prefer Flutter's own `AppLifecycleListener` once a binding is guaranteed to exist. (`lib/src/support/app_lifecycle.dart`, `doc/digging-deeper/helpers.md`, `skills/magic-framework/`)

### Changed

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ The same Facades, the same Eloquent syntax, the same Service Provider lifecycle
| 🌍 | **Localization** | JSON-based i18n with `:attribute` placeholders. |
| 🎨 | **Wind UI** | Built-in [Wind](https://wind.fluttersdk.com) Tailwind-syntax styling with `className` strings. |
| 📡 | **Broadcasting** | Laravel Echo equivalent: real-time WebSocket channels via the `Echo` facade with presence support and `Echo.fake()`. |
| 🔄 | **Offline Sync** | `SyncFeed` runs a push-then-pull loop over any REST resource, bookmarked by `SyncLedger`'s `sync_cursors` table (`CreateSyncCursorsTable`). |
| 🧪 | **Testing** | First-class fakes: `Http.fake()`, `Auth.fake()`, `Cache.fake()`, `Vault.fake()`, `Log.fake()`, `Echo.fake()`. No mockito needed. |
| 🧰 | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, 15 generators, plus `make:component` for design-first component workflows and `design:sync` / `design:lint` to drive the Wind theme from a `DESIGN.md`. |

Expand Down
3 changes: 2 additions & 1 deletion doc/contributing/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Pages live under `doc/` in one of these subdirectories. Do not create new subdir
| `security/` | Authentication, authorization, encryption, vault |
| `database/` | Getting started with the DB facade, migrations, seeding |
| `eloquent/` | Eloquent ORM getting started, mutators, serialization |
| `digging-deeper/` | Broadcasting, cache, events, validation, localization, logging, file storage, file picker, launch, session, encryption, carbon |
| `digging-deeper/` | Broadcasting, cache, events, validation, localization, logging, file storage, file picker, launch, session, encryption, carbon, helpers, sync |
| `testing/` | Getting started with testing, HTTP tests, database testing, facade fakes |
| `packages/` | Magic CLI, devtools (dusk + telescope), and other first-party integrations |
| `contributing/` | Contribution guide (code) and this authoring guide (docs) |
Expand Down Expand Up @@ -110,6 +110,7 @@ When a facade or framework feature changes, apply this checklist before marking
| `Schema` facade / migrations | `doc/database/migrations.md` |
| `Session` facade | `doc/digging-deeper/session.md` |
| `Storage` facade | `doc/digging-deeper/file-storage.md` |
| `SyncFeed` / `SyncLedger` / sync skeleton | `doc/digging-deeper/sync.md` |
| `Vault` facade | `doc/security/vault.md` |
| Eloquent model / ORM | `doc/eloquent/getting-started.md` |
| Service providers / container | `doc/architecture/service-container.md` and `doc/getting-started/service-providers.md` |
Expand Down
32 changes: 31 additions & 1 deletion doc/digging-deeper/helpers.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Helpers

`Str`, `Number`, and `Arr` are static namespace helpers modelled on Laravel's Support layer, and `Cast` is magic's own, together covering locale-aware casing, locale-aware number formatting, dot-path map access, and defensive type reading for loosely-typed wire data.
`Str`, `Number`, and `Arr` are static namespace helpers modelled on Laravel's Support layer, and `Cast` is magic's own, together covering locale-aware casing, locale-aware number formatting, dot-path map access, and defensive type reading for loosely-typed wire data. `AppLifecycle` is a small, unrelated static namespace for reading the app lifecycle as a stream from code that runs before a `WidgetsBinding` necessarily exists.

- [Str](#str)
- [Number](#number)
- [Arr](#arr)
- [Cast](#cast)
- [Composing Arr and Cast](#composing-arr-and-cast)
- [AppLifecycle](#applifecycle)

<a name="str"></a>
## Str
Expand Down Expand Up @@ -35,6 +36,19 @@ Str.unwrap('"x', '"'); // 'x', prefix-only match still strips
Str.unwrap('[value]', '[', ']'); // 'value'
```

`Str.ascii(value)` folds Latin-1 Supplement and Latin Extended-A letters, the Romanian comma-below letters `Ș ș Ț ț`, `ẞ`, and the Angstrom sign U+212B to their plain ASCII base, for building a search key. Combining marks (U+0300-U+036F) are always dropped, whatever letter they decorate. A Latin letter outside that coverage (Vietnamese, the rest of Latin Extended-B/Additional) and every other script pass through untouched; magic folds Latin only, unlike Laravel's `Str::ascii`, which transliterates every script it has a table for and would otherwise collapse a non-Latin word to `?` or a phonetic guess.

```dart
Str.ascii('çalışan izleyiciler'); // 'calisan izleyiciler'
Str.ascii('Ångström'); // 'Angstrom'
```

`Str.squish(value)` trims `value` and collapses every run of whitespace to one space, mirroring Laravel's `Str::squish`. The whitespace class is Dart's `\s` plus the two Hangul filler code points (U+3164, U+1160) a rendered blank can carry without registering as `\s`; both ends of `value` are checked against the same class, so a boundary and a middle occurrence of the same code point fold the same way.

```dart
Str.squish(' hello world '); // 'hello world'
```

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

Expand Down Expand Up @@ -126,3 +140,19 @@ Cast.idOrNull(true); // null
final priority = Cast.intOr(Arr.get(payload, 'meta.priority'), 0);
final label = Cast.stringOrNull(Arr.get(payload, 'meta.label'));
```

<a name="applifecycle"></a>
## AppLifecycle

`AppLifecycle.states()` exposes the app lifecycle as a `Stream<AppLifecycleState>`, for a reader constructed before a `WidgetsBinding` necessarily exists. A dependency built inside a service provider's `register()` runs before the app has bound anything, so reaching for `WidgetsBinding.instance` at construction time throws; `AppLifecycle.states()` defers that lookup to the moment a listener actually subscribes.

```dart
final subscription = AppLifecycle.states().listen((state) {
if (state == AppLifecycleState.paused) Log.info('app paused');
});

// Later, when done:
await subscription.cancel();
```

Each subscription owns its own observer: it is added to the binding on `listen` and removed on `cancel`, so nothing outlives its reader and nothing before the first `listen` touches the binding at all. Prefer Flutter's own `AppLifecycleListener` when the reader is a widget-lifetime object; reach for `AppLifecycle.states()` only when construction has to happen before a binding is guaranteed to exist.
136 changes: 136 additions & 0 deletions doc/digging-deeper/sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Sync

The sync skeleton is a push-then-pull loop over one resource, for an app that keeps a local store consistent with a server across many devices.

- [Introduction](#introduction)
- [The Wire Protocol](#the-wire-protocol)
- [Writing a Feed](#writing-a-feed)
- [Registering the Ledger's Table](#registering-the-ledgers-table)
- [The Two-Clock Rule](#the-two-clock-rule)
- [The Savepoint Note](#the-savepoint-note)
- [What Stays App-Side](#what-stays-app-side)

<a name="introduction"></a>
## Introduction

`SyncFeed` runs one resource's sync: it pushes everything this device wrote since its last push, then, only when that push had no failure, pulls everything the server has past this device's last pull. It never throws, and answers a `SyncReport` (`pushed`, `adopted`, `failure`), logged through `Log.error` on the way out rather than propagated, because a sync is a convenience layered on top of a local store that already works without it.

```dart
final SyncReport report = await ItemsSyncFeed().run(scope: 'team-42', account: userId);

if (!report.complete) {
Log.warning('items sync did not finish: ${report.failure}');
}
```

`SyncLedger` is the bookkeeping `SyncFeed` reads and writes between runs, backed by the `sync_cursors` table `CreateSyncCursorsTable` creates.

<a name="the-wire-protocol"></a>
## The Wire Protocol

**Push.** `POST '$resource/sync'` with `{envelopeKey: [row.data, ...]}`, batched at `SyncFeed.batchSize` (default 500) rows per request. The push response is read the same way a pull page is (`{"data": [...]}`), so a write this device lost to a conflicting write elsewhere adopts the winner immediately, without waiting for the next pull.

**Pull.** `GET resource` with query `{scope, cursor}`, `cursor` omitted when there is none yet. The pull response is `{"data": [...], "cursor": <opaque text>, "has_more": bool}`. `cursor` is taken from the body rather than derived from the last row: the pair it encodes is the server's business, and a client that built one would be guessing at a tie-break it cannot see. A run walks pages until `has_more` is false or `SyncFeed.maxPages` (default 100) is reached.

Push runs first, and the pull runs only after a push with no failure: a failed push returns before the pull ever starts. The push makes the server's answer to the pull already reflect this device's own writes, so one round leaves the device consistent. Pulling first would leave a device that had just resolved a conflict holding a stale view until the next run.

<a name="writing-a-feed"></a>
## Writing a Feed

A feed subclasses `SyncFeed` and supplies three things: where its bookmarks live (`feed`), which endpoint it talks to (`resource`, `envelopeKey`), and how to read and write its own rows (`pending`, `adoptRow`).

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

class ItemsSyncFeed extends SyncFeed {
@override
String get feed => 'items';

@override
String get resource => 'items';

@override
String get envelopeKey => 'items';

@override
Future<List<SyncPushRow>> pending({
required String account,
required int sinceMillis,
required String scope,
}) async {
final List<Map<String, dynamic>> rows = DB.select(
'SELECT * FROM items WHERE scope = ? AND updated_at_ms > ? ORDER BY updated_at_ms ASC',
<Object?>[scope, sinceMillis],
);

return [
for (final row in rows)
(data: row, mark: row['updated_at_ms'] as int),
];
}

@override
Future<bool> adoptRow({
required String account,
required Map<String, dynamic> row,
}) async {
final int? remoteMark = Cast.intOrNull(row['updated_at_ms']);
if (remoteMark == null) return false;

final List<Map<String, dynamic>> existing = DB.select(
'SELECT updated_at_ms FROM items WHERE id = ?',
<Object?>[row['id']],
);

if (existing.isNotEmpty && (existing.single['updated_at_ms'] as int) >= remoteMark) {
return false;
}

DB.statement(
'INSERT OR REPLACE INTO items (id, scope, updated_at_ms, payload) VALUES (?, ?, ?, ?)',
<Object?>[row['id'], row['scope'], remoteMark, row['payload']],
);

return true;
}
}
```

`pending` answers rows oldest first: the push advances its mark to the last row of each batch it sends, so a batch that did not end on its newest row would move the mark past rows it never sent. `adoptRow` answers `false` for a row this client cannot read (a malformed payload) rather than throwing, so one bad row does not stop the rest of a page from landing. Use `Cast.intOrNull`/`doubleOrNull`/`boolOrNull` inside `adoptRow` when the wire value's exact numeric type (`int` vs `double` on web) is not guaranteed.

<a name="registering-the-ledgers-table"></a>
## Registering the Ledger's Table

`CreateSyncCursorsTable` creates the `sync_cursors` table `SyncLedger` reads and writes by default. Magic has no migration discovery, so list it explicitly alongside the app's own migrations:

```dart
await Migrator().run([
CreateUsersTable(),
CreateSyncCursorsTable(),
]);
```

The table carries five columns (`scope`, `feed`, `account`, `pull_cursor`, `push_mark`) with no `id()`, no `timestamps()`, and no unique index: `SyncLedger` upserts by delete-then-insert, so an app that already keeps a table of this shape can point a custom `SyncLedger(table: '...')` at it and skip this migration.

<a name="the-two-clock-rule"></a>
## The Two-Clock Rule

A `SyncFeed` run tracks two clocks, and only one of them advances locally. The push mark is this device's own `updated_at`, epoch millis, deciding which rows are worth sending next time. The pull cursor is the server's, opaque text handed straight back unread.

Only the push mark advances here; the pull cursor never substitutes for it. A row adopted from the server carries the ORIGINATING device's clock, so advancing this device's push mark to that value would step over a local row written earlier and never sent. The cost is small: an adopted row is echoed back to the server exactly once, on the next run, where the server's own `>=` comparison rejects it and the mark then covers it.

<a name="the-savepoint-note"></a>
## The Savepoint Note

`SyncLedger.write` issues its delete-then-insert inside a `SAVEPOINT`/`RELEASE` pair rather than `DB.transaction`, because a bare `BEGIN` does not nest and a feed's own sync run may already be executing inside a caller's outer transaction. A savepoint does nest: opened with no transaction open it behaves like one, and opened inside an existing transaction it joins that transaction and rolls back with it.

That means a caller whose outer transaction fails after this savepoint released loses the bookmark write too. The next run simply resends the tail the server's own `>=` check already absorbs: one extra round trip, never a duplicate write.

<a name="what-stays-app-side"></a>
## What Stays App-Side

The skeleton is deliberately narrow. Three things stay the app's own responsibility:

- **Scope derivation.** `scope` is an opaque string `SyncFeed.run` passes straight through to the ledger and the wire calls; how an app derives it (a team id, a workspace id) is not this package's concern.
- **Salt.** Any value mixed into a feed's own local identifiers or cache keys beyond `scope` and `account` is the app's own.
- **Orchestration.** When and how often a feed runs (on a timer, on reconnect, on app resume) is left to the app; `SyncFeed.run` answers one call, it does not schedule itself.
6 changes: 6 additions & 0 deletions lib/magic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export 'src/support/number.dart';
export 'src/support/str.dart';
export 'src/support/cast.dart';
export 'src/support/arr.dart';
export 'src/support/app_lifecycle.dart';

// Sync
export 'src/sync/sync_feed.dart';
export 'src/sync/sync_ledger.dart';
export 'src/sync/create_sync_cursors_table.dart';

// Routing
export 'src/routing/magic_platform_page.dart';
Expand Down
Loading
Loading