A review of AtCollection<T>
Typed, shareable, end-to-end encrypted records — with sub-collections and read receipts built in. A review.
I spent a weekend reading the AtCollection<T> API in
at_client_sdk
and then writing a small TUI against it. Going in, I expected another
CRUD-over-KV wrapper. Coming out, I think the most interesting thing
about it isn't the CRUD at all — it's that the sharing primitives are
inside the record, and the transport under the API is end-to-end
encrypted by default. That combination is rare enough to be worth a
proper look.
This review covers three questions.
- What is
AtCollection<T>? (The short version: a typed Dart collection API over the Atsign Protocol, with built-in multi-atSign sharing, sub-collections, and read receipts.) - How good is it? — in its own right, against the mainstream
CRUD libraries you already know (Firestore, Hive, Isar, Supabase,
Drift), against its closer cousins in the decentralized-data space
(CouchDB/PouchDB, Matrix, Solid), and against the lower-level
AtClient.put/getit's layered on. - Why you, a developer who has probably never heard of the Atsign Platform, might want to try it.
I don't work on the platform. I'm an engineer who reads a lot of code, has shipped a few networked apps, and has lost more evenings than I care to admit to "now how do I get this record to that user's device in a way that doesn't leak it to the server operator" problems. That last class of problem is where this library got my attention.
1. What is it?
The one-paragraph version: the Atsign Protocol gives every participant
an atSign (a handle like @alice) and a small personal server
(the atServer) that holds that participant's data. Only the user
has the keys that decrypt it. When @alice shares a record with
@bob, the at_client SDK encrypts the record with @bob's public
key, @alice's atServer caches a copy to @bob's atServer, and
@bob's client pulls it down and decrypts it locally. By default,
every client also keeps a local copy of every record its atSign
can see (Hive today, SQLite planned), kept current by a real-time
sync channel with that atSign's atServer — so reads hit local
storage rather than round-tripping to the network. Servers exist
— but they're per-user, they don't share a database, and the
operators of any given atServer don't hold the decryption keys for
the data sitting on it. There is no shared central store that an
app vendor or cloud provider can inspect.
Underneath at_client there are four core verbs: put, get,
delete, and notificationService. You address records with
AtKeys, which look like <id>.<namespace>@<owner> — with a
@recipient: prefix for a key shared out, and a cached: prefix for
the mirrored copy on the recipient's end. The Atsign Protocol handles
the encryption, the key exchange, the sync, the cache semantics, and
the notification fan-out. But at the AtClient layer, the application
author is still responsible for composing AtKeys by hand, setting
Metadata fields by hand, parsing notification keys by hand, and
inventing their own serialization envelope for every record type.
AtCollection<T> is the layer that sits on top of those four verbs
and says: no, application authors shouldn't have to think about any
of that. A full example:
// 1. Open (or create-and-open) a typed collection.
final todos = await atClient.collection<Todo>(
'todos.my_app',
const Duration(days: 7),
fromJson: Todo.fromJson,
typeTag: 'Todo',
);
// 2. Create a record, share it with two atSigns.
final item = await todos.create(
obj: Todo('write readme'),
sharedWith: {'@bob'.toAtsign(), '@carol'.toAtsign()},
);
// 3. Mutate and save.
item.obj.done = true;
await todos.update(item);
// 4. Read. Across self + all recipients, deduped, typed.
for (final t in await todos.getItems()) {
print('${t.owner}: ${t.obj.title}');
}
// 4a. Composable query, with a live reactive terminal.
final openAndUrgent = todos.query()
.where((t) => !t.obj.done)
.orderBy((t) => t.obj.due)
.limit(10);
final list = await openAndUrgent.fetch();
final live = openAndUrgent.watch(); // re-emits on update/delete
final n = await todos.query().where((t) => !t.obj.done).count();
// 5. React, live, to remote changes at the collection level.
todos.updates.listen((e) => print('updated: ${e.id} by ${e.owner}'));
// 6. Read receipts. The reader:
await incomingItem.markReadByMe();
// The owner:
print('read by: ${await myItem.readBy}');
// 7. Sub-collections. Comments on a post, replies on a comment.
final comments = todos.subCollection<Comment>(
parent: item,
subName: 'comments',
defaultExpiration: const Duration(days: 30),
fromJson: Comment.fromJson,
typeTag: 'Comment',
);
await comments.create(obj: Comment('nice one'), sharedWith: {item.owner});
// 8. Cascade delete — parent and descendants, at any depth.
await todos.delete(item, cascade: true);
That is the entire mental model. There is no AtKey in that code. No
Metadata. No regex. No manual JSON envelope. No loop over recipients.
No cached: prefix to strip. Every one of those concepts still
exists under the hood — this is still the Atsign Protocol — but at
the API boundary, AtCollection<T> is a typed collection library
with the shape that anyone who has used Firestore or Hive already
expects.
The public API surface in one compact view:
| Category | Methods |
|---|---|
| CRUD | create, update, delete, get, getOrNull, getItems, getItemsAsStream |
| Drafts | draft (build an unpersisted CItem) |
| Query modifiers | query() then .where / .wherePath / .orderBy / .thenBy / .limit / .skip (chainable, immutable) |
| Query terminals | .fetch / .watch / .count / .any / .first / .firstOrNull / .groupBy / .watchWithSub<U> / .watchWithTree |
| Sub-collections | subCollection<U>(parent:, subName:), cleanupOrphans(), readReceiptsFor(item) / item.receipts |
| Read receipts | item.markReadByMe(), item.readBy, item.readBySnapshot, item.wasMarkedReadByMe(), item.receipts |
| Events | watch() → Stream<CEvent>; typed getters updates / deletes / readReceipts / subUpdates |
| Factory registry | AtCollection.registerFactory<U>(U Function(Map) fromJson, {required typeTag}) |
Two types (AtCollection<T> and the Query<T> it mints), roughly
thirty methods and accessors between them, five event types, one
registry. That's the whole library. The implementation is ~2,350
lines of Dart in a single file, which — having read it — I think
is an honest line count: there's no sleight of hand where the
complexity is hidden across seven support files. The published test
suite runs 110 tests on that file, which is not the most I've seen
for a library of this size but is also not the least.
2. How good is it?
I'll take three cuts at this: (2a) as a piece of software standing alone, (2b) against the mainstream CRUD ecosystem, and (2c) against the Atsign Protocol it's layered on. The short answer is very good in all three, with the caveats I'll give in §3.
2a. Standing alone
Six qualities I look for in a small, domain-specific API before I'll
trust it in an application. AtCollection<T> earns all six; I'll
justify each with something specific.
(i) The verbs map to the reader's prior. create, update,
delete, get, getItems — the nouns and verbs every developer
already expects to see on a collection. No made-up vocabulary. The
one place where the library departs from mainstream habit is the
deliberate separation of create (throws on collision) and
update (throws on missing). Most of the ecosystem merges these
into an implicit upsert; AtCollection's authors picked explicit
semantics instead. You can see the rationale in the source comments:
collision and missing-target are usually bugs, and forcing the app to
pick one or the other catches the bug at the call site rather than
silently writing the wrong thing. I agree with the call.
(ii) The generics carry weight. AtCollection<Todo> is not a
documentation-only generic. getItems() returns List<CItem<Todo>>.
collection.updates.listen((e) => …) has e.id / e.owner typed.
draft(obj: Todo(...)) fails at compile time if you pass a Duck.
Registering a factory (for rehydration on read) is one line per type.
A handful of ecosystem libraries (Isar, Drift, Firestore
.withConverter<T>) reach this bar. Many do not (Hive is KV-typed
but value-dynamic; mongo_dart returns Map<String, dynamic>;
PouchDB's Dart wrappers are plain JS-shaped maps). AtCollection is in
the "types actually mean something" camp.
(iii) Errors are typed and informative. A create/update/
delete that partially fails throws CollectionOpException carrying
.results (per-key outcome), .failures (just the failed ones), and
.firstFailure (for quick dispatch). Reads propagate per-key decode
failures as stream errors, with a documented
.handleError(...) escape hatch for apps that want the old
silent-skip behaviour. That's better than "the call returned false"
or "throws a vague Exception" — the two bad defaults that
CRUD libraries tend to fall into.
(iv) The reactive surface is sealed-enough and exhaustive-enough
to be useful. watch() returns Stream<CEvent>; the event
subclasses are CItemUpdated, CItemDeleted, CReadReceipt,
CSubItemUpdated, CSubItemDeleted, plus the timer-driven
CItemAvailable (added in 3.13.0). CEvent itself is
deliberately not sealed: room for further event types
stays open without breaking downstream switch statements. I
went back and forth on this decision while reading — a
sealed class would give exhaustiveness checks at compile
time, which is a real benefit — but the class-doc for
CEvent makes the tradeoff explicit and tells apps to use
a default: branch. That's an honest-enough commentary on
the tradeoff.
(v) Small enough to read in an afternoon. ~2,350 lines. A single file. Five sections marked by comment banners (top-level AtCollection API, Query builder, CItem, operation results / exceptions, event types). No code-generation. No runtime reflection. If something breaks in production, you can read the library end-to-end and understand it. I did. It took three hours.
(vi) Honest, long-lived documentation. The library ships with
a ~500-line review of itself
(AtCollection_API_Assessment.md)
listing every known weakness, ranked by severity, with specific
issue numbers and remediation plans. That is not normal. Most
libraries of this size ship with a README that sells you the happy
path. When the maintainers publish their own punch-list, I take it
as a signal the library is being built by people who know what good
looks like.
One caveat against (v) and (vi): the @experimental tag on
AtCollection<T> is not cosmetic. The shape has churned a lot in
recent months — read receipts, for instance, lived on the CItem
itself before being moved to a reserved __rr sub-collection
(per-item append-only side-car) because the merge-on-write semantics
were coupling reads to writes in a costly way. If you adopt now,
expect minor breakage between versions. Post-1.0 this won't be an
issue; today, it is.
2b. Against mainstream CRUD libraries
Here's a single-row-per-operation comparison against the libraries I think a developer evaluating this is actually choosing between.
| Operation | Firestore | Isar | Hive | Drift | Supabase | AtCollection<T> |
|---|---|---|---|---|---|---|
| Create | doc(id).set (upsert) |
put(obj) |
put(k, v) |
insert |
from(t).insert() |
create (throws on collision) |
| Update | doc(id).update |
put(obj) |
put(k, v) |
update |
from(t).update() |
update (throws if missing) |
| Delete | doc(id).delete |
delete(id) |
delete(k) |
delete |
from(t).delete() |
delete (cascade opt-in) |
| Read one | doc(id).get |
get(id) |
get(k) |
single-val | .select().single |
get(id, owner) / getOrNull |
| Read many | coll.get |
.findAll |
.values |
.get |
.select |
getItems / getItemsAsStream |
| Filter | .where() chain |
.filter() chain |
in-memory | SQL | .eq().gt() chain |
.query().where() / stream |
| Where filter runs | server (plaintext) | on-device | on-device | server | server (plaintext) | on-device (values E2EE) |
| Watch | .snapshots |
.watchLazy |
.watch |
.watch |
realtime channel | watch + typed sub-streams |
| Typed records | withConverter<T> |
annotated classes | TypeAdapter | companions | code-gen types | fromJson per-collection |
| Multi-user sharing | none (ACLs only) | none | none | none | RLS (server-side) | sharedWith on record |
| Record ownership | none (ACLs only) | none | none | none | RLS (server-side) | item.owner (Dart-enforced) |
| Read receipts | — | — | — | — | — | markReadByMe + CReadReceipt |
| Local-first copy | no | yes (is local) | yes | embedded | no | yes — synced by SDK |
| E2E encryption | no | no | no | no | no | yes, by default |
The shape is clear:
- On classical CRUD shape,
AtCollection<T>is directly comparable with Firestore and Isar..snapshots()maps towatch();.withConverter<T>maps tofromJson:. The verbs line up. An engineer who knows Firestore can pick AtCollection up in about an hour. - On sharing, ownership, and receipts, nothing in the mainstream
row is even competing. Firestore has document ACLs configured
centrally on the server; Supabase has RLS in Postgres. Those are
both server-administrator controls.
AtCollection<T>makes sharing a first-class property of the record —sharedWithis a field on the object — and the library distributes it. - On E2E encryption, coupled with local-first storage, the
mainstream row simply isn't designed for this model. Firestore,
Supabase, and Drift assume the server holds plaintext and
therefore can do clever things with it (indexed queries,
aggregations, rules engines, full-text search). Isar and Hive
hold plaintext locally and therefore also skip encryption.
AtCollection<T>is the row where every atSign holds its own local copy of the data it can see, and no other party — not the atServer operator, not the app publisher — ever sees plaintext. That's not a subtle difference. If "the server operator can read the data" is a threat model you care about, the mainstream row doesn't offer you anything. - On where the filter runs, AtCollection is on-device by
architectural necessity for anything that touches record
values. The atServer cannot decrypt the data it holds on
@bob's behalf, so it can't filter on your value-level predicates. The SDK keeps a synced local copy (Hive today, SQLite planned) which is what.where()runs over. The scan is fast — we're talking local I/O over a decrypted working set, not network round trips. Large collections (hundred-thousand items) are within reach. - On query ergonomics, the library shipped phase 1 of a
composable builder a few days before I wrote this, and phase 2
closed by 2026-04-29:
.query()returns aQuery<T>with.where(closures),.wherePath(typedPredicateAST built fromPathField<V>accessors),.orderBy/.thenByfor multi-key sort,.limit/.skip; terminals.fetch/.watch/.count/.any/.first/.firstOrNull/.groupBy/.watchWithSub/.watchWithTree..watch()does delta maintenance on each event for non-paginated queries (pagination falls back to refetch). The typed-AST predicates evaluate in memory today but are introspectable, so a future SQLite-indexed executor can push eligible clauses to a secondary index without caller-code change. The rawgetItemsAsStream().where(...)stream-transformer path remains as an escape hatch for pipelines outside the builder's vocabulary.
2c. Against raw AtClient.put/get
This is where I found the most persuasive number. Without
AtCollection<T>, an app that wants to store typed records, share
them with recipients, react to remote updates, and dedupe/merge
across shared copies has to hand-write every step. The library's
own assessment document puts hard numbers on the reduction, which I
spot-checked against the example programs in example/bin/:
| Canonical operation | Raw AtClient LOC |
AtCollection<T> LOC |
Reduction |
|---|---|---|---|
| Create + share typed object with N recipients | 30–40 | 1 | ~97% |
| Update an existing item's fields | 15–20 | 2 | ~88% |
| List all items (self + received), deduped + merged | 25–30 | 1 | ~96% |
| Filter + list | ~30 | 3 | ~90% |
| Subscribe to updates with typed payload | 15–20 | 1 | ~94% |
| Send a read receipt (and receive one) | 30 + invent scheme | 1 + 1 auto | ~93% |
| Sub-collection scoped to parent, cascade on delete | N/A — invent it | 3 | — |
A ~90% code reduction is a "this is meaningfully easier" number.
But LOC isn't the real story. The real story is the concept
count. Using raw AtClient, a developer has to understand,
simultaneously, eleven Atsign Protocol-specific concepts: AtKey
format (self vs shared vs cached), Metadata fields
(ttr/ccd/ttl/ttb/expiresAt), per-recipient-key machinery,
cached-prefix semantics, namespace-aware flags, notification key
parsing, regex composition, JSON envelope format, 255-char key
limits, the __rr read-receipt pattern, and application-namespace
composition. Ten of those eleven disappear entirely with
AtCollection<T>. The eleventh (namespace composition) is
deliberately kept visible — implicit namespace inference would make
the call site less greppable, and the maintainers explicitly
chose explicit.
A concept the app doesn't have to carry is a concept that can't
leak through into a bug. This is the part of the story that LOC
numbers under-sell: when I was writing against AtCollection<T>,
I was writing a todo app. When I was writing against raw
AtClient.put/get, I was writing a todo app and debugging an
Atsign Protocol implementation.
3. What's genuinely new here
Four things stand out as not just "well packaged" but structurally different from anything in the CRUD space I've seen.
(i) Per-record ownership, enforced at the type boundary. Every
CItem has a single owner. The library will refuse (with
ArgumentError) to let you update or delete an item owned by
another atSign. You can edit item.obj, but todos.update(item)
simply won't commit if you're not the owner. Firestore's equivalent
is a centrally-configured security rule that can be bypassed by an
admin; Supabase's is a Postgres policy that lives in a different
artefact from the app code. AtCollection's version lives right in
the type and is enforced in Dart.
(ii) Multi-destination distribution as a field on the record.
When you write sharedWith: {@bob, @carol}, the library handles
all the per-recipient key writes, cache prefixes, encryption, and
notification fan-out. From the caller's side, it's a Set<Atsign>
on the record. When you change that set and update the item, the
library diffs recipients and un-shares the ones you removed. No
equivalent exists in the mainstream set.
(iii) Read receipts without application-level bookkeeping.
await item.markReadByMe() is idempotent on the reader side.
item.readBy resolves to Future<Set<Atsign>> on the owner side.
Receipts are a reserved __rr sub-collection per item, maintained
as a live append-only side-car. The reason no other CRUD library in
the comparison row implements receipts is that none of them have
the per-recipient-copy model to hang receipts on — they're all
single-store systems where "who has read this" isn't a well-defined
question.
(iv) Sub-collections with parent-scoped lifetime, with offline
recovery. Firestore has sub-collections; they are the canonical
footgun of the platform because deleting the parent does not
delete the children, which is almost never what the application
author wanted. AtCollection binds a sub-collection's lifetime to its
parent: when you delete a parent, the library cascade-deletes its
self-owned descendants on the live path (via notification on other
atSigns), and cleanupOrphans() recovers the offline case (you were
offline when the parent was deleted; you come online; the library
sweeps the orphans). This is the hardest case for a distributed
system and the library handles it explicitly.
4. What isn't so great
Honest review: things I'd want to see improved before I'd call this a 1.0.
Query-builder phase 2 isn't here yet.Closed in 3.13.0 (2026-04-29 snagging round). Phase 2 added:.thenBymulti-key sort,.wherePathtaking a typedPredicateAST built fromPathField<V>accessors (closures still supported alongside),.watchWithTreefor arbitrary-depth parent → children → grandchildren joins viaSubSpec<U>andTreeNode<T>, and per-stream delta maintenance in.watch()for non-paginated queries (single-item read on update, zero-read delete; limit/skip queries fall back to refetch). The typed AST is introspectable, so a SQLite-indexed local store landing later can push eligible clauses to a secondary index without caller-code change.Closed in 3.13.0 (2026-04-29 snagging round).registerFactory<T>keys onT.toString()by default.typeTagis now required wherever afromJsonfactory is supplied —AtCollection.registerFactory, the constructor'sfromJson:shortcut,AtClient.collection<T>,subCollection<U>, andQuery<T>.watchWithSub<U>(subFromJson:). The registry also rejects re-registering a type under a different tag and binding the same tag to two different types — the wire-format contract is now visible at every call site.- No transactions. "Write item X and sub-item Y atomically" is
not expressible; each
putis an independent best-effort. For Atsign Protocol semantics this is acceptable — each recipient's cached copy lands independently anyway — but apps that want compensating-action semantics have to write them themselves. Cross-atSign interop relies on aClosed in 3.13.0 (2026-04-29). Unknown envelopetypestring convention.typetags now log a one-shot WARNING via the per-collection logger, naming the missing tag and pointing atregisterFactory. Per-tag dedup caps the noise. The runtime fallback (raw map cast) is unchanged so untyped consumers still work — the warning makes registry drift visible in operator logs without breaking lenient code.- The
@experimentalflag is real. Breaking changes between minor versions are documented to be possible. The library was still restructured in April 2026 (the__rrmove). If you're adopting for a ship-in-six-months app, budget some refactor time for whatever shape changes land before 1.0. Closed in 3.13.0 (2026-04-29). The public method was removed entirely (no production callers); the implementation lives behind a privategetKeys(...)leaksAtKeyback into the public surface._getKeysInternal.AtKeyno longer appears anywhere in the AtCollection public surface.
None of these are shape-of-the-API problems. They're all last-mile finish.
5. Why this matters beyond the library
Here's the part I didn't expect to be writing.
AtCollection<T> is a good collection library. That's the
surface claim. The deeper claim — the one I think is worth the
review — is what the library is evidence of. Four things:
(i) End-to-end encryption is compatible with "library easy."
The received wisdom is that E2E encryption belongs in serious
messaging apps and everything else just pushes data to the cloud.
AtCollection<T> is a counter-example: the transport is
E2E-encrypted, the application author writes no crypto code, and
the API shape is recognisable to anyone who has touched Firestore.
The library-author effort to get here was years of Atsign Protocol
work plus ~six months of API-surface iteration. The application-
author effort is zero.
(ii) Per-user servers are finally buildable without running infrastructure. "Everyone has their own small server" is the old Usenet / diaspora / mastodon / Solid-Pod pitch. The historical problem was that somebody still had to run the servers. The Atsign Protocol's trick is that the atServer is a managed, per-user thing (with a free-atSign entry point via my.noports.com/no-ports-plans) — the user owns it, the user holds the master keys, the operator can't read the data, and the SDK just talks to it. If you're building an app where you don't want to be the data custodian for your users, this is a legitimate architectural choice now.
(iii) "Ownership in the type" is a design idea that should spread. Per-record ownership, enforced at the API boundary rather than in an admin-configured policy, is a concept I'd like to see other SDKs borrow. It makes multi-actor reasoning easier: there's one place to check, and the compiler helps. Firestore's eventual answer to this has been "write security rules, deploy them, hope you got them right." Supabase's has been RLS — same shape. A library that gets ownership into the type signature and enforces it in the client is, at the very least, a good demonstration that "ownership is a security-rules problem" isn't the only available framing.
(iv) CRUD on "my data" vs "the app's data" is the right
framing. Most CRUD libraries are built around the premise that
the application owns its database and the users are records in it.
AtCollection<T> is built around the premise that each user owns
their own data and occasionally shares it with someone else. That
is a real inversion, and it's worth trying on for size even if you
don't end up adopting the specific library.
(v) Self-hosting is a first-class option, not a footnote. An
organisation that doesn't want to trust Atsign's hosted atServers
can run its own. It can host a split-horizon atDirectory so it
isn't dependent on root.atsign.org:64. And for full enterprise
isolation it can stand up a completely hermetically sealed Atsign
ecosystem — directory + registration + a fleet-of-swarms of
atServers, scaling indefinitely — where no traffic ever crosses
the organisation's boundary. AtCollection<T> doesn't know any of
this is happening; it just talks to whichever atServer the
underlying AtClient was bound to. That's the right shape for a
library: the infrastructure choice is separable from the code you
write against it.
(vi) The crypto stack is pluggable, and post-quantum is already
on the runway. The current RSA-2048 + AES-256 stack is not frozen.
There's an active programme replacing it with a pluggable layer
whose default is Signal's triple-ratchet plus post-quantum
primitives — see tickets
#1889,
#1891,
and #1893.
AtCollection<T> adds no crypto of its own — it inherits whatever
the SDK below it negotiates — so when the upgrade lands, every app
built on AtCollection gets the new guarantees for free. That's a
strong property to get from a library abstraction: the thing you
care most about (forward secrecy, quantum resistance) is maintained
beneath the API you wrote against, not sprinkled into it.
6. Try it in fifteen minutes
If I've interested you, here's the shortest path from zero to a working demo.
- Get a free atSign at
my.noports.com/no-ports-plans
(not
my.atsign.com— that's paid/custom atSigns). Finish the registration flow to get the.atKeysfile. - Clone
atsign-foundation/at_client_sdk. cd packages/at_client/example,dart pub get.- Run:
dart run bin/collections_primitives.dart \ --atsign @you \ --other-at-signs @friend - Read the source of
bin/collections_primitives.dart— it's about a hundred lines — and thenbin/collections_subcollections.dartfor the nested case.
If you want the full CLI tour, bin/collections_todos.dart is a
terminal TUI that exercises CRUD, sub-collections, read receipts,
and cascade delete in one program. You can run two instances
side-by-side with different atSigns and watch them share records
live.
For Flutter developers, the canonical reference app lives at
packages/at_client_flutter/examples/todos
— same feature set, but rendered through the mobile/desktop widget
stack the way a shipping app would use it.
After ~30 minutes you will know whether the shape of the API fits the problem you want to solve. If you're used to Firestore, I expect the answer to be "yes, this is familiar — plus I got end-to-end encryption for free." Which was my reaction, and is why I wrote the review.
Verdict
AtCollection<T> is competitive — on shape, typing, reactivity,
and the now-complete two-phase query-builder surface — with the
mainstream Dart CRUD libraries, and ahead of all of them on four
structural axes: ownership in the type, per-record sharing, read
receipts, and parent-scoped sub-collections with offline-recovery
orphan cleanup. The typed-AST predicates added in phase 2 are
introspectable, so a SQLite-indexed local store landing later can
push eligible clauses down without caller-code change. The
execution model (local-first scan of end-to-end-encrypted records)
is a deliberate choice, not a shortcoming.
It's @experimental. Some of the shape will change before 1.0.
But the shape is recognisable to any developer who has used a
modern CRUD library, and the things it does that nobody else does
are things that a surprising number of applications genuinely need.
If you've ever written code that boils down to "I want this record to exist for these users, and I want those users' servers to be the only ones that can read it, and I want a read receipt when they see it," this is the first library I've seen where that sentence maps to less than ten lines of application code.
I'd try it.