mortar_client 0.2.0
mortar_client: ^0.2.0 copied to clipboard
Dart and Flutter client for Mortar, a mainland-China-friendly Backend-as-a-Service for mobile and web apps.
mortar_client (Dart / Flutter) #
Dart client for Mortar, a mainland-China-friendly Backend-as-a-Service.
Works in pure Dart, Flutter (iOS / Android / web / macOS / Windows /
Linux), and Dart server. Anywhere package:http runs.
Module coverage #
The project-scope MortarClient exposes only the app-bundle (public-key)
data plane: auth / db (from) / storage / realtime /
compute.invoke. Provisioning/operator surfaces (tables-admin,
compute.deploy/delete, cron, queue, comms, usage) are NOT on it —
provisioning is mortar.config.json (server-side) and operator reads live
on AccountClient (usage / crons / logs), reached with the account
JWT.
AccountClient also covers admin (API keys / topup as flat methods:
createKey / listKeys / revokeKey / topup).
Still TypeScript-only: cron/queue/comms/customDomains runtime
management — call the HTTP API directly until the Dart port lands. Trusted
server code can enqueue a PostgreSQL-backed job inside
DatabaseTransactions, as shown below.
Schemas: openapi.yaml.
Install #
# pubspec.yaml
dependencies:
mortar_client: ^0.1.0
Or run dart pub add mortar_client.
Project-scope client #
import 'package:mortar_client/mortar_client.dart';
final mortar = MortarClient(ClientOptions(
url: 'https://project-uuid.api.mortar.appunvs.com',
apiKey: 'mtr_live_xxx',
));
// Database — Supabase-faithful chainable query builder. Each call is
// awaitable, returns the data directly, and THROWS `MortarApiError` on error
// (the supabase-dart idiom — no `{data, error}` envelope). Rows are FLAT
// (your fields plus id / created_at / updated_at at the top level).
final todos = await mortar
.from('todos')
.select()
.eq('done', false)
.order('created_at', ascending: false)
.limit(50);
final row = await mortar.from('todos').insert({'title': 'buy milk'});
// Batch insert/upsert and filtered update/delete each execute atomically in
// one server-side PostgreSQL transaction:
await mortar.from('todos').update({'title': 'buy milk + eggs'}).eq('id', row['id']);
await mortar.from('todos').delete().eq('id', row['id']);
final one = await mortar.from('todos').select().eq('id', row['id']).single();
// Back-compat by-id forms also still work:
// mortar.from('todos').getById(id) / updateById(id, data) / deleteById(id)
// End-user auth — Supabase surface (signInWithPassword / signInWithOtp /
// verifyOtp / getUser / getSession / refreshSession / signOut /
// onAuthStateChange / resetPasswordForEmail), all throw + return data directly.
final res = await mortar.auth.signInWithPassword(email: 'alice@example.com', password: '...');
final userScoped = mortar.withUserToken(res.accessToken); // see KNOWN DIFFERENCE below
mortar.auth.onAuthStateChange((event, session) => print('auth: $event'));
await mortar.auth.signInWithOtp(email: 'alice@example.com'); // send OTP
// final s = await mortar.auth.verifyOtp(email: 'alice@example.com', token: '123456');
final me = await mortar.auth.getUser(); // GET /auth/me
final current = mortar.auth.getSession(); // in-memory session or null
await mortar.auth.resetPasswordForEmail('alice@example.com');
await mortar.auth.signOut();
// Storage — supabase-dart `storage.from(bucket)` handle
await userScoped.storage
.from('avatars')
.upload('alice.jpg', await imageFile.readAsBytes(), contentType: 'image/jpeg');
final bytes = await userScoped.storage.from('avatars').download('alice.jpg');
final url = await userScoped.storage.from('avatars').createSignedUrl('alice.jpg', 3600);
// For media lists, lazy-load visible items and reuse each signed URL until it
// nears expiry. Signing is outside the project API-concurrency budget and is
// not itself an object-egress charge; the URL downloads directly from storage.
// Realtime — supabase `mortar.channel(name)` + `channel.on('postgres_changes', ...)`
final room = mortar.channel('todos-room')
..on('postgres_changes', {'event': '*', 'table': 'todos'},
(p) => print('${p.eventType} ${p.newRecord}'));
room.subscribe();
// later: mortar.removeChannel(room);
// Realtime — low-level SSE subscription to row changes
final sub = mortar.realtime.subscribe(
'todos',
(ev) => print('${ev.op} ${ev.id}'),
onError: (e) => print('realtime err: $e'),
);
// later: sub.unsubscribe();
// Compute — invoke a deployed function
final res = await mortar.compute.invoke(name: 'hello');
print(res.body);
Trusted server code using an app/admin key can combine cross-table row writes and PostgreSQL queue enqueue in one ACID transaction:
final transactions = DatabaseTransactions(ClientOptions(
url: 'https://project-uuid.api.mortar.appunvs.com',
apiKey: Platform.environment['MORTAR_APP_KEY']!,
));
await transactions.execute([
DatabaseTransactionOperation.insert('orders', {'status': 'pending'}),
DatabaseTransactionOperation.update('inventory', productId, {'stock': 9}),
DatabaseTransactionOperation.enqueue(
'send-receipt',
payload: {'order_id': orderId},
),
]);
This boundary includes only the same PostgreSQL database. Object storage, external HTTP calls, email delivery, and deployed compute are not part of the transaction; model those as durable jobs with idempotent workers.
KNOWN DIFFERENCE vs the TS SDK. Dart auth tracks an in-memory current session (so
getSession/onAuthStateChangework) but does not persist it to disk, auto-refresh it, or auto-attach the user token to subsequent requests. After signing in, attach the token yourself:final scoped = mortar.withUserToken(res.accessToken);.
Account-scope client #
import 'package:mortar_client/mortar_client.dart';
final account = AccountClient(url: 'https://api.mortar.appunvs.com');
await account.signIn(email: 'me@example.com', password: 'hunter2');
final projects = await account.listProjects();
final created = await account.createProject(name: 'staging', tier: 'mini');
await account.updateProject(created.id, tier: 'small');
// Operator reads (control plane) — usage / crons / function logs
final usage = await account.usage(created.id);
final crons = await account.crons(created.id);
final logs = await account.logs(created.id, 'hello', limit: 100);
await account.deleteProject(created.id);
Errors #
Every method throws MortarApiError on non-2xx.
try {
await mortar.from('todos').insert({'title': 'x'});
} on MortarApiError catch (e) {
// Stable fields: code, category, requestId, retryable, retryAfterMs.
if (e.category == 'quota') {
// top up, change plan, or wait
} else {
// map the category to the app's shared recovery UI
}
}
compute.invoke returns a tenant Function's 4xx/5xx as http.Response; only a
Mortar platform failure throws MortarApiError.
Run tests #
dart pub get
dart test
License #
Apache License 2.0.