winche_database 7.0.0
winche_database: ^7.0.0 copied to clipboard
Type-safe Dart client for Winche Database — an offline-first, real-time document store over a single WebSocket connection.
Changelog #
7.0.0 #
Breaking: this release discards every existing local store — again. Two
independent changes move the path, and neither migrates: storageKey became a
digest in winche_core 0.2.0, and the store gained a per-package subdirectory.
On first launch under 7.0 every user starts from an empty cache and any writes
that had not yet synced are lost, silently and with nothing in the UI to notice
it. Drain pending writes before upgrading if that matters to you — check
db.hasPendingWrites and wait for it to clear while 6.0 is still installed.
That this is the second consecutive release to say so is deliberate rather than careless: both path changes were landed together, in one release, precisely so users pay the cost once. Doing the subdirectory change later would have orphaned everyone a second time.
Requires winche_core ^0.2.0 #
The floor moves from ^0.1.0, which does not unify with ^0.2.0 — an app
cannot hold winche_database 6.0.0 and any 0.2.0-based package at the same
time. Verified against the published 0.2.0 from pub.dev, not a path override.
Changed #
-
Breaking: the local store moved to
<root>/winche/<storageKey>/database/index.db(web: IndexedDB databasewinche_<storageKey>_database). Previously<root>/winche/<storageKey>/db.dbandwinche_<storageKey>.The layout follows a stack-wide convention: an identity gets one directory, and each Winche package takes a subdirectory of its own beneath it holding an
index.db. Forgetting a user becomes a single recursive delete of<root>/winche/<storageKey>, whatever mix of Winche packages an app uses — previously that was one delete per package, each with its own naming rule to remember.storageKeyitself also changed, inwinche_core0.2.0: it is now a 128-bit SHA-256 digest of the identity id, rendered as 32 lowercase hex characters, rather than the id itself. It cannot be collapsed by a case-insensitive filesystem, it yields a usable path for any id a backend issues, its length is fixed however long the id, and the user's id no longer lands on disk. -
Breaking:
WincheExceptionis nowWincheProtocolException. Every status subclass —PermissionDeniedException,UnauthenticatedException,NotFoundException,AlreadyExistsException,FailedPreconditionException,AbortedException,InvalidQueryException,InvalidArgumentException,DeadlineExceededException,InternalException,UnavailableException— keeps its name and now extends it.WincheException.fromErrorisWincheProtocolException.fromError.statusanddetailsare unchanged;messageis inherited rather than declared here.winche_core0.2.0 introduced its ownWincheExceptionas the root for the whole stack, and two classes of that name in two libraries cannot both be imported unprefixed — Dart only reconciles a duplicate name when it is literally the same declaration, so keeping the name and extending core's was not available. The new name is the better one regardless:on WincheProtocolExceptionasks "did the database backend reject this?",on WincheExceptionasks "did any Winche SDK fail?", and those are different questions.Migration: replace
on WincheExceptionwithon WincheProtocolExceptionwherever you mean backend failures. Leaving it ason WincheExceptionstill compiles, against core's root, and silently widens the catch — including overWincheUnboundException, which you almost certainly do not want handled next to aPERMISSION_DENIED. -
Breaking:
WincheUnboundExceptionmoved towinche_coreand is not re-exported here. Importpackage:winche_core/winche_core.dartfor it, which an app using this SDK already imports forWinche.initializeApp. "Nobody is signed in" is a condition every Winche service shares, so it belongs where every service can name it — onecatchfor an app using two Winche packages, and a third package does not make it three.It is now a
WincheException(core's root) and therefore catchable byon WincheException; it is still not aWincheProtocolException, because it never crosses the wire. Being signed out is fixed by signing in, not by handling it where server errors are handled.
Fixed #
- Documentation:
.snapshots()no longer carries a "Known gap" warning that has been wrong since 6.0.0. The README still claimed it throws a rawTypeErrorwhile unbound. It returns normally and emitsWincheUnboundExceptionas a stream error, which is what 6.0.0 changed and whattest/facade/unbound_listen_test.dartpins. The README now documents the real behaviour with aStreamBuilderexample, since the point of that fix was that the call site is usually insidebuild().
6.0.0 #
Breaking: this release discards every existing local store. The on-disk layout moved from
<root>/winche_<namespace>.db to <root>/winche/<storageKey>/, and no migration is performed. On
first launch under 6.0 every user starts from an empty cache and any writes that had not yet synced
are lost. Drain pending writes before upgrading if that matters to you.
Changed #
- Breaking: connectivity is a level you observe, not a thing you steer.
connectionStatesnow hands every new subscriber the current state before forwarding changes, suppresses consecutive duplicates, and never completes on a failed dial — so aStreamBuilderbuilt at any moment, including after a user switch, renders the truth immediately instead of waiting for the next transition.connectionStateis also readable while unbound, reportingdisconnectedrather than throwingWincheUnboundException: a widget that renders a connection chip must be able to build before anyone signs in. Reconnection is unconditional, and a session dials as soon as it binds rather than waiting for a read, a listener, or a queued write. - Breaking:
winche_databaseis now built onwinche_core.WincheDatabaseis aWincheDatabaseService— construct it once viaWinche.initializeApp(...)and thenWincheDatabase.instance(orinstanceFor(app)), notWincheDatabase(config). Core owns the session lifecycle: it builds a session for whichever identity is currently signed in (announced by aWincheAuthService, e.g. a real auth package, orScriptedAuthServicefrompackage:winche_core/testing.dart) and disposes it on sign-out or when the identity changes.winche_databaseitself has no sign-in surface and never sees a token directly — it reads one from the session on every (re)dial. - Breaking:
WincheDatabaseConfigkeeps only tuning, set viaWincheDatabase.instance.config = ...immediately after obtaining the instance (it now throws aStateErroronce the database has been used — opened its store or dialled its socket — because construction is lazy and that window is what makes "immediately after.instance" reliable). The surviving fields are unchanged:pingInterval,autoReconnect,maxBackoff,maxFrameBytes,inMemory,conflictPolicy,maxCachedDocuments,cacheSizeBytes. Four fields left, each replaced by something on the core side:uri→WincheOptions.databaseEndpoint, passed once toWinche.initializeApp.tokenProvider→ gone; the session's token is read from whicheverWincheAuthServiceis registered with the app.namespaceResolver→ gone; the store is scoped by the signed-in identity itself, not a value you supply — see the on-disk layout change above.directoryResolver→WincheOptions.directoryResolver, also passed once toWinche.initializeAppand shared by every Winche service under that app.
- Breaking:
WincheDatabase.close(),isClosedandreconnect()are gone. There is nothing left to call them on: the session backing the facade is owned entirely by core, torn down automatically on sign-out and rebuilt automatically on sign-in or a user switch, and re-dialled automatically when the auth service reports a token rotation. Token rotation is now a nudge (the existing session re-dials in place), not a rebuild — so it no longer tears down and reopens the local store the way an explicitreconnect()implied. - Breaking: calling the database while no identity is signed in now throws
WincheUnboundExceptioninstead of running against an unscoped/default store. It fires from every member that actually touches the session —.get(),.set(),.update(),.delete(),.commit(),runTransaction,waitForPendingWrites, and so on. Nuance:doc()andbatch()are lazy factories — building a reference or a batch is synchronous local bookkeeping, so they never throw; the exception surfaces on the first call that actually needs the session.WincheUnboundExceptionis deliberately not aWincheException(it never crosses the wire), soon WincheExceptiondoes not catch it — gate on sign-in state instead of handling it as a server error. Known gap:.snapshots()does not yet follow this rule — calling it while unbound throws a rawTypeError(null-check failure) rather thanWincheUnboundException, because_LiveListener's constructor force-unwraps the session. Gate.snapshots()on sign-in state yourself until this is fixed. - Breaking: a user switch now completes every
snapshots()stream (onDone), because a listener is displaying one identity's data and must not silently start showing the next identity's documents. The app is expected to resubscribe — in practice this falls out of the same rebuild that already reacts to a sign-in state change.connectionStates,syncEventsandreconnectsdescribe the connection rather than any one identity's data, so they survive a user switch instead: they go quiet (connectionStatesemitsConnectionState.disconnected) rather than ending. - The
inMemory×namespaceResolvervalidation from 5.0 (rejecting a persistent store configured without a namespace, and rejecting a namespace supplied alongsideinMemory: true) is gone because it is no longer expressible — there is nonamespaceResolverleft to validate againstinMemory.
Removed #
- Breaking:
WincheDatabase.reconnects. It carried no informationconnectionStateslacks — it fired at exactly the two points where the state reachedreadyon a re-dial, and deliberately not on the first connect, so it wasconnectionStates.where(ready)minus its first element. Derive it if you want it:db.connectionStates.where((s) => s == ConnectionState.ready).skip(1). - Breaking:
WincheDatabaseConfig.autoReconnect. Reconnection is unconditional; an app cannot stop the SDK from recovering. - Breaking:
TransportandConnectionConfigare no longer exported. No consumer implements a transport, andConnectionConfig.channelFactoryis an injection seam that should not be part of the public surface. - Breaking:
WincheDatabase.listenEventsandreleaseSubscription. Both were dead — nothing called them — and both returnedServerFrame, a type the barrel does not export, so a caller could not name the return value.
Fixed #
- Offline writes now sync when the connection comes back. Two defects had to
line up for this to fail, and neither was covered by tests. First, a queue
restored from disk had no drain trigger at all:
notifyEnqueuedfires only in the session that enqueued a write, and the oldreconnectssignal could not fire for a first connect. Second and worse, a failed first dial made recovery impossible:connect()made exactly one attempt and threw without entering the reconnect loop, and the transport'sreconnectsgetter completed its stream on that failure, leaving the sync controller permanently deaf for the life of the session. So "open the app offline, write something, network returns" never synced. Draining is now driven by the connection state reachingready, which covers the first connect, every reconnect, and binding onto an already-live socket. Verified end to end against the .NET sample server, including two users queueing writes with the server down: each user's writes drain on their own sign-in and only then, leaving the other's queue untouched. - A
WriteBatch.commit()can no longer be split across two frames.applyWritesassigned abatchIdand then enqueued each write in a loop with two await points per iteration, notifying the drain only afterwards. A drain firing between iterations read a partial batch and sent it, so the server could apply half of an atomic commit. Reachable before this release whenever a reconnect landed mid-batch; draining on connection state made it routine, because the firstreadytypically arrives while a batch is still being enqueued. The queue now reports that a multi-insert is in flight and the drain skips while it is — the coordinator drains once the batch is durable, so no trigger is lost. - A closed connection can no longer be revived by a reconnect already in
flight.
close()marked the stateclosed, but the reconnect loop setreconnectingat entry without checking, and_setStatehad no guard against adding to a closed controller. A loop scheduled moments before teardown could therefore resurrect the connection, keep dialling a socket nobody owned, and throwStateErrorfrom inside a callback where nothing catches it. Previously masked byautoReconnect: false; unconditional reconnection made it reachable on any teardown racing a drop. - A disposed database releases its status subscribers. The relay behind
connectionStatesforwarded values and errors but not completion, so disposal left every subscriber attached to an open controller. - Breaking:
snapshots()reports an unbound database as a stream error rather than throwing at the call site. It resolved the session in a constructor initializer list, so it could only fail by throwing — and its call site is typically aStreamBuilderinsidebuild(), where an identity change landing between a rebuild and the app updating its own state tore down the widget tree instead of reaching thehasErrorbranch. It was also the only entry point that behaved this way:get/set/update/deleteareasync, so an unbound database rejects their Future. The session is now bound when the stream is listened to. A disposed session still completes withdone— the documented signal for an identity swap under a live listener — and only the absence of one is an error.
5.0.0 #
Added #
WincheDatabaseConfig.namespaceResolver— required for a persistent store; scopes it to one identity (winche_<namespace>.db). The local store is single-tenant: the document cache, pending-write queue, resume tokens and query membership carry no identity, so a shared store let a second user on the same device read the previous user's cached documents and replay their un-synced writes under the new token (rejected withPERMISSION_DENIED, and dropped). Switching users is nowawait db.close()+ a new database; each user's queued writes stay on disk and drain when they sign back in. Resolved lazily and cached, likedirectoryResolver— it pins the identity for the lifetime of the instance.WincheDatabase.reconnect()— drops the socket and re-dials, re-readingtokenProvider. The token rides on the WebSocket upgrade, so it was previously impossible to apply a rotated token to a live connection: the client kept using the old one until the socket happened to drop. Listeners resubscribe in place, including any that had died permanently on aPERMISSION_DENIED/UNAUTHENTICATEDsubscribe.SyncPausedsync event andWriteFailed.writes(see below).
Fixed #
- A listener no longer loses its initial snapshot to a frame race. A client
learns its
subscriptionIdfrom the subscribe response, so it could only register a frame listener after that response landed — but a server may push the firstlisten.snapshotbefore it, andProtocolConnectiondropped frames for an unregistered subscription id. The listener then sat on its cache-first emission forever, never going live. Observed against the .NET sample server for any query carrying anorderBy(which reordered the two frames), and it broke the Flutter example app's record list. Frames arriving ahead of their subscription are now buffered and replayed, in arrival order, when the listener attaches; the buffer is bounded so unclaimed subscription ids cannot grow it. close()no longer races live listeners. Closing the database while asnapshots()listener was active tore down the socket and the local store at the same time; the socket teardown drove one last listener emission, which read a store that had already closed and threw an uncatchableBad state: database is closed.close()now tears down in dependency order — live listeners, then the transport, then the sync controller, then the store — and every listener emission is gated on the database still being open. Livesnapshots()streams now complete withdoneon close.- The sync controller waits for an in-flight drain to unwind before the store is
closed underneath it, and
LazyLocalStoredegrades to no-ops afterclose()so a straggling callback can never surface a store error. WsTransportno longer re-dials a fresh socket if an operation is issued afterdispose(); it fails withUnavailableExceptioninstead.set/update/delete/batch.commitno longer block on the server. The write coordinator awaited the drain it kicked off, so an "optimistic acknowledgement" actually waited for the round-trip whenever the connection was up — it only appeared instant offline, where the request fails fast. They now return as soon as the write is durably queued and the local view reflects it, with the drain running in the background as documented. WatchsyncEvents(orwaitForPendingWrites()) for the server outcome.- An
UNAUTHENTICATEDwrite is no longer destroyed. The drain treated any non-conflict status as terminal, deleting the unit from the queue — so an expired token silently discarded un-synced work. It now halts the drain (like being offline), leaves the queue untouched, and reportsSyncPaused; the next reconnect resumes it.PERMISSION_DENIEDis still terminal, butWriteFailednow carries the droppedPendingWrites inwritesso the work is recoverable.
Changed #
- Breaking:
WincheDatabase.close()returnsFuture<void>and should be awaited. It is idempotent, and resolves only once the local store is really closed — await it before opening another database over the same file (e.g. when switching users). Existingdb.close();call sites keep compiling. - Breaking:
Transport.dispose()returnsFuture<void>(wasvoid) andTransport.reconnect()is new. Only affects customTransportimplementations. - Breaking:
SyncEventgained theSyncPausedvariant — exhaustiveswitches over it need a new arm.WriteFailedgained a requiredwritesargument (only affects code constructing the event, not consumers reading it). - Breaking: a persistent
WincheDatabasenow requiresnamespaceResolver, and its database file moves fromwinche.dbtowinche_<namespace>.db. There is no migration. Existing caches simply rebuild themselves, but any un-synced writes sitting in the old queue are orphaned — drain the queue (waitForPendingWrites()) before shipping this upgrade if that matters.inMemory: trueis unaffected. - New
WincheDatabase.isClosed.
4.2.0 #
- Deletion reconciliation: server-side deletes are now tombstoned locally, so a
deleted document disappears from every listener,
get, and cache read and never resurfaces — online or offline. Adds thedeletedlisten-delta change kind and bumps the wire protocol to v2;listen/doc.listenframes now advertiseprotocol: 2, and the server only emitsdeletedto clients on v2. - Membership-based offline reads: each live query records the exact ordered set
of documents the server last reported for it (
TargetCache). Offline reads and a listener's cache-first emission serve that set against the cache + pending overlay, solimit/offset/ filter queries stay correct offline instead of re-deriving over the whole collection (which could resurface out-of-window or stale-but-locally-matching documents). - Resume across restarts: with durable persistence, listeners persist their
resume token (
ResumeTokenStore) and query membership. On relaunch a listener emits its last-known results immediately and resumes the server subscription with the stored token — going live without re-downloading when nothing changed, or taking a fresh snapshot when the token is stale. Newlisten.currentserver frame signals a covered resume (live and up to date, no documents). WithinMemory: true, resume state lasts only for the session. - Optional bounded cache: new
WincheDatabaseConfig.maxCachedDocumentsandcacheSizeBytescaps (both default null = unbounded). When a cap is exceeded the least-recently-used documents not referenced by an active listener or a pending write are evicted; an evicted document is re-fetched on next read (eviction is not deletion). Caps are also enforced against already-persisted documents on startup. See the README's "Cache management" section. - Conflict handling: under the automatic policies (
clientWins/serverWins), a write that can never be resolved — e.g. anupdateto a since-deleted document that always fails withNOT_FOUND— is now reported asWriteFailedand removed from the queue instead of being retried forever.
4.1.0 #
- Query parity with the server (PROTOCOL §4.1): added
QueryReference.offset(n)andQueryReference.limitToLast(n).offsetskips leading results and composes withlimit;limitToLastreturns the last N of the result window in ascending order, requires at least oneorderBy, and cannot be combined withlimitoroffset(validated locally, mirroring the server'sINVALID_ARGUMENT). Both are honoured for one-shot reads and livesnapshots()alike, since results are evaluated by the local query engine. - Write parity (PROTOCOL §3.2):
DocumentReference.set,WriteBatch.set, andTransaction.setnow acceptmergeFields— a dotted-path field mask. Only the masked paths are written; a masked path absent from the data deletes it. Mutually exclusive withmerge. The pending-write overlay applies the same mask semantics, so offline optimistic state matches the server. - Internal: the query and single-document live listeners now share a common
base, split by layer —
_LiveListener(facade: snapshots + cache overlay) and_LiveFeed(server-subscription lifecycle: reconnect/resume/teardown). The concrete types are_QueryListener/_DocumentListenerover_QueryFeed/_DocumentFeed, inlive_listener.dartandlive_feed.dart. No public API or behavior change.
4.0.0 #
- Breaking: the durable persistence backend is now sembast instead of
Hive.
HiveLocalStoreis removed and replaced bySembastLocalStore; thehive_cedependency is dropped in favour ofsembast/sembast_web. This removes Hive's 255-character key limit, so long/deeply-nested document paths are stored as-is. Persistence remains on by default, with the samedirectoryResolvercontract (required on native, ignored on web/IndexedDB). No data migration is provided.
3.0.0 #
- Breaking:
WriteBatch.setandTransaction.setnow accept typedT dataand convert it through the reference's converter, mirroringDocumentReference.set. Untyped references use the identity converter, so map-based call sites are unchanged; typed-converter call sites must now pass aTinstead of a pre-builtMap.
2.0.0 #
- Breaking:
WincheDatabasenow takes a singleWincheDatabaseConfig— connection options + local-store selection + conflict policy in one object. Replaces the previousWincheDatabase(ConnectionConfig, {store, inMemory, ...})constructor. - Breaking: persistence is now on by default (Hive). On native platforms a
directoryResolveris required; the Hive directory is resolved lazily on first store access (web uses IndexedDB, no path needed). SetinMemory: truefor the previous non-persistent behavior. directoryResolverlets the Hive directory be resolved lazily, so apps no longer need toawait HiveLocalStore.open(...)before constructing the database.- Added
LazyLocalStore, aLocalStoredecorator that opens its underlying store on first use (memoized; safe under concurrent first-callers). WincheDatabase.close()now also closes the database-owned local store.- Custom store injection moved to
WincheDatabase.withStore(connectionConfig, store).
1.1.0 #
ConnectionConfig.tokenProvidernow accepts an async callback (FutureOr<String> Function()), so auth tokens can be fetched or refreshed asynchronously on each (re)dial. Synchronous providers continue to work unchanged.
1.0.0 #
Initial release.
- Offline-first document store over a single WebSocket connection.
- Typed values: null, bool, int, double (incl.
NaN/Infinity), string, bytes, timestamp, reference, geo-point, arrays, and nested maps. - Writes: set / merge-set / update / delete with field transforms (increment, server timestamp, array union/remove, min/max) and preconditions.
- Queries: filters, ordering, limits, cursors, client-side projection (
select), andcount. - Real-time document and query listeners.
- Optimistic transactions with automatic retry.
- Local cache + pending-write overlay + background sync, backed by an in-memory or durable (Hive) store.
- Authentication at the WebSocket upgrade via an
?access_token=query parameter; token rotation by reconnect.