at_client 3.12.0-rc.1
at_client: ^3.12.0-rc.1 copied to clipboard
The at_client library is the non-platform specific Client SDK which provides the essential methods for building an app using the atProtocol.
at_client #
The non-platform-specific client SDK for building apps on the
Atsign Protocol. An atSign owns a personal server (the
atServer); at_client talks to that server on its owner's behalf,
transparently handling key management, end-to-end encryption, sync, and
notifications.
at_client runs on both Dart (CLI / server) and Flutter
(mobile / desktop / IoT). It is intentionally platform-neutral:
the actual onboarding dialogs, secure key storage, and CLI scaffolding
live in sibling packages that depend on at_client. Flutter web
is not supported — atSign onboarding and key storage rely on
platform plugins that don't have web implementations today.
Which package do I actually want? #
| If you're building… | Start with |
|---|---|
| A Dart CLI or server app | at_cli_commons for boilerplate + at_onboarding_cli to provision atSigns |
| A Flutter app (mobile/desktop/IoT) | at_client_flutter — ships pre-built onboarding / APKAM / keychain widgets (web not supported) |
| Understanding the atSign lifecycle | at_auth — platform-neutral onboarding / authentication core with a detailed lifecycle writeup |
Core surface #
The AtClient interface (lib/src/client/at_client_spec.dart)
is the main entry point once authentication is complete.
collection<T>(namespace, defaultExpiration, {fromJson, typeTag, eventSource, cleanupOrphansOnCreation})— returns aFuture<AtCollection<T>>.fromJsonandtypeTagtravel together; pass either both or neither. AtCollections hide the low-level keystore plumbing and let you work directly with your own domain objects and types (see Collections below).notificationService— fire-and-forget and pub/sub messaging (lib/src/service/notification_service.dart)syncService— background sync between the local store and the atServerput(AtKey, value)/get(AtKey)/delete(AtKey)— low level CRUD against the keystore. You should almost never need to do this if you are using AtCollections.
Examples #
The API in at_client has evolved substantially over several years.
The authoritative examples of current usage are the worked programs
under these two directories — start there rather than with older
tutorials or blog posts:
-
Dart / CLI examples:
example/example/bin/notifications.dart— messaging viaNotificationServiceexample/bin/rpcs.dart— RPC-style method invocation between atSignsexample/bin/collections_primitives.dartand the othercollections_*.dartfiles — typed shareable records via theAtCollection<T>API
-
Flutter examples:
../at_client_flutter/example/(onboarding + auth UI) and../at_client_flutter/examples/todos/(a full shared-todos app usingAtCollection).
atSign lifecycle (short version) #
Before an app can read or write, an atSign must be registered,
onboarded, and the app must authenticate. The detailed writeup
of all three phases — including why APKAM matters for "evil app"
protection — is in at_auth's README. The
summary is:
- Register an atSign (free at my.noports.com/no-ports-plans or paid/custom at my.atsign.com). Once you have a registered atSign, the application code needs to get the CRAM key for step 2 below. Typically this is done by the app, once it knows the atSign to be onboarded, requesting that an OTP for the atSign be sent to the registered owner's email address. (There are other processes possible for all of this, but this is typical.)
- Onboard the atSign exactly once: CRAM-authenticate, generate the
master keypairs, and write them to disk (
.atKeysfile for CLI) or the device keychain (Flutter). These master AtKeys are the root of trust — end users must back them up, losing them means losing the atSign. - Authenticate subsequent apps via APKAM enrollment: the app
requests only the namespaces it needs (e.g.
{'todos': 'rw'}), the master-keys holder approves, and the atServer issues a new scoped AtKeys set. Scoped keys can be revoked at any time; a compromised scoped key can only damage data in its granted namespaces.
Collections #
For the common "CRUD on typed, shareable records" use case,
AtCollection<T> (lib/src/collections/collections.dart)
hides almost all of the AtKey / Metadata / notification-regex ceremony
behind a small set of verbs.
Sketch:
final todos = await atClient.collection<Todo>(
'todos.my_app', // fully-qualified namespace
const Duration(days: 7),
fromJson: Todo.fromJson,
typeTag: 'Todo', // wire-format identifier — required
);
final item = await todos.create(
obj: Todo('write readme'),
sharedWith: {'@bob'.toAtsign()},
);
item.obj.done = true;
await todos.update(item);
// Add @carol without rewriting the self copy:
await todos.updateSharedWith(item, {'@bob'.toAtsign(), '@carol'.toAtsign()});
await for (final e in todos.updates) {
print('updated: ${e.id} by ${e.owner}');
}
update / delete / create fire CItemUpdated / CItemDeleted
on the writing collection's event streams as soon as the local write
lands — no waiting for the network round-trip — so a UI using
Query.watch() redraws immediately. The same event re-fires on the
round-trip ~50–200 ms later (and ~10–30 ms excluding network transit
once fsync ships); Query.watch's delta path is idempotent so the
second occurrence is invisible.
AtCollection<T> executes reads on-device by default, against a
local copy that the at_client SDK keeps current via real-time
sync with the atServer. Value-level filtering is always on-device
— records are end-to-end encrypted between atSigns, so the server
never sees plaintext to filter on.
A composable Query<T> builder covers the common filter / sort /
paginate / aggregate patterns:
final overdue = todos.query()
.where((t) => !t.obj.done)
.where((t) => t.obj.due.isBefore(DateTime.now()))
.orderBy((t) => t.obj.due)
.thenBy((t) => t.obj.title) // tiebreak within same due date
.limit(20);
final list = await overdue.get(); // one-shot List
final live = overdue.watch(); // Stream<List<CItem<Todo>>>
final openCount = await todos.query().where((t) => !t.obj.done).count();
final byOwner = await todos.query().groupBy<Atsign>((t) => t.owner);
Queries are immutable values — build once, store, pass, fetch or
watch. watch() does incremental delta maintenance for
non-paginated queries (single-item read on each event, not a full
re-scan). For ad-hoc pipelines outside the builder's vocabulary,
getItemsAsStream().where(...) remains supported as an escape
hatch.
For typed, introspectable predicates that a future indexed
executor can push down to a secondary index, declare PathFields
on your domain type and use wherePath:
abstract class $Todo {
static final done = PathField<bool>(
path: ['obj', 'done'],
extract: (item) => (item.obj as Todo).done,
);
static final due = PathField<DateTime>(
path: ['obj', 'due'],
extract: (item) => (item.obj as Todo).due,
);
}
final overdue = await todos.query()
.wherePath($Todo.done.eq(false))
.wherePath($Todo.due.lt(DateTime.now()))
.get();
Multi-level parent → children → grandchildren joins use
watchWithTree:
final stream = posts.query().watchWithTree([
SubSpec<Comment>(
subName: 'comments',
subDefaultExpiration: const Duration(days: 30),
subFromJson: Comment.fromJson,
subTypeTag: 'Comment',
children: [
SubSpec<Reply>(
subName: 'replies',
subDefaultExpiration: const Duration(days: 30),
subFromJson: Reply.fromJson,
subTypeTag: 'Reply',
),
],
),
]);
// → Stream<List<TreeNode<Post>>> — branches['comments'] holds
// per-comment TreeNodes whose own branches['replies'] hold
// per-reply TreeNodes.
Timer-driven events for items written with availableAt
(scheduled visibility) and expiresAt (TTL):
// Fires when each scheduled item becomes visible.
todos.availableEvents.listen((e) {
print('Item ${e.id} just became available');
});
// Fires `leadTime` before each item expires — useful for
// reminder UIs that need to nudge the user before the atServer
// expires the record.
todos.expiringSoonEvents(leadTime: const Duration(minutes: 30))
.listen((e) {
print('Item ${e.id} expires at ${e.expiresAt}');
});
Read receipts ship built-in — one call on each side, no app-level bookkeeping:
// Reader side: idempotent, no-op on self-owned items.
await incomingItem.markReadByMe();
// Owner side: who has read this? Maintained live via events.
final readers = await myItem.readBy; // Future<Set<Atsign>>
todos.readReceipts.listen((e) => print('${e.from} read ${e.id}'));
Sub-collections are AtCollection<U> instances scoped to a parent
CItem — comments on a blog post, line-items on an invoice — with
opt-in cascade-delete:
final comments = posts.subCollection<Comment>(
parent: post,
subName: 'comments',
defaultExpiration: const Duration(days: 30),
fromJson: Comment.fromJson,
typeTag: 'Comment',
);
await comments.create(obj: Comment('nice one'), sharedWith: {/* … */});
await posts.delete(post, cascade: true); // removes comments too
Worked examples (Dart / CLI):
example/bin/collections_primitives.dartexample/bin/collections_domain_objects.dartexample/bin/collections_generic.dart(polymorphic types)example/bin/collections_binary.dart(Uint8List)example/bin/collections_subcollections.dart(parent + sub with cascade)example/bin/collections_todos.dart(full interactive TUI)
For Flutter, the canonical reference app is
../at_client_flutter/examples/todos
— same feature set as collections_todos.dart above, rendered
through the mobile / desktop widget stack the way a shipping app
would use it.
Key-length note. atServer keys are capped at 255 chars and
atSigns at 55. The absolute worst-case wire shape is the
cached-copy form
cached:<other>:<itemId>.<composedNs>@<self>, which fixes the
wrapper overhead at 118 chars (cached: + 55 + : + 55) and
leaves 137 chars for everything inside (item id + every
level of namespace). With 8-char auto-generated ids and 1 char
for the separator, composedNs is capped at 128 chars — a
budget enforced by subCollection(...) at construction time
with a hard ArgumentError, so oversized keys never reach the
wire. Plenty of room: with 1-char collection / sub-collection
names and a 15-char application namespace, the theoretical
ceiling is 11 levels (root + 10 nested sub-collections).