Core
Changelog
Release notes for @homeostate/core: what changed in each version, newest first.
0.4.0
Minor Changes
-
39809d7: Breaking:
Changeis a discriminated union of objects instead of a[type, key, value]tuple, andChangeTypeis a const object instead of anenum. A script holds one kind of step:RecordChangefor a record, keyed by property name, andListChangeorTextChangefor an array or a string, keyed by position.getChangesreturns the script of the kind of the values it diffs, or anEditScriptfor values typed only asDiffable, andapplyStringChangestakes aTextChange[]. Narrowing a step bytype, and a pending step bykind, replaces the casts.Adjacent array items or characters deleted together are now one
deletestep with acount, whichapplyChangespasses tospliceoreditTextas one call. Clearing a 5000-item list or a 5000-character text through the Yjs backend takes onedeleteinstead of 5000, and about 1 ms instead of 100 to 250 ms.To migrate code that reads or builds changes:
- Destructure properties instead of the tuple:
const { type, key } = change. An insert or update carriesvalue; a pending step carrieskind("record","list"or"text") and its nestedchanges, which used to be its third entry. - Apply an array or string delete's
count, treating an absent one as1. A string delete's length used to be its third entry.getChangessets it on every array and string delete. ChangeType.INSERTand the other members keep their values, andChangeTypeis still the type of the four strings. Code that relied on theenumtype, rather than its values, now takes the string literals.- Annotate scripts as
RecordChange[],ListChange[],TextChange[]orEditScriptinstead ofChange[], which is a single step of any kind.
ApplyOpsis unchanged, so backends and stores that apply changes throughapplyChangesneed no change. - Destructure properties instead of the tuple:
-
a80be3b: Export
createCrdtBackend(build, options?), which builds aCrdtBackendfrom the parts that differ by CRDT library, described byCrdtBackendParts:read,transact, which runs one edit tagged as the backend's own with the library'sApplyOps,watch, which says whether each change was the backend's own, and optionallyrevision.buildreceives the text policy fromCrdtBackendOptions.text, which defaults to none. The backend it returns diffs each write againstprevious, or against the document when there is none, or against{}whentransacthad to create the synced map, withapplyChangesandjson: true, and leaves its own writes out ofsubscribe. A backend for another CRDT library no longer has to repeat these steps. -
55812ce: Breaking:
CrdtBackend.readreturns aJsonObject, the plain object the sync engine has always read, instead ofunknown.JsonObjectisRecord<string, unknown>and is exported from core.write,createMemoryBackendandMemoryBackend.receivetake aJsonObjectInput: any object but an array or another iterable. State typed with aninterfacestill fits, while arrays,Maps,Sets and primitives fail to compile. A backend no longer casts what it reads or writes, and the engine still throws aTypeErrorwhenread()returns something else at runtime.getChangesonly accepts two values of one kind: two records, two arrays or two strings. The overload that took any twoDiffables and returned anEditScriptis gone, so values of different kinds fail to compile, and at runtime they throw aTypeErrornaming both kinds instead of returning[]. Its record overload takesJsonObjectInput, so states typed with aninterfacecan be diffed.getChangesnow documents its sequential indices and UTF-16 offsets.To migrate:
- Convert what
read()returns to a state typed with aninterfacethroughunknown:backend.read() as unknown as State. An interface has no index signature, so it does not overlap aJsonObject. A state typed with atypealias converts directly. - Narrow values typed only as
Diffablebefore passing them togetChanges, and annotate the result asRecordChange[],ListChange[]orTextChange[].
- Convert what
-
d2a66d8: Resolve
whenLoadedwith aPersistenceLoadResultdescribing the initial load. Before, it resolved withundefinedafter a successful, empty, failed or partial restoration alike, so an app that awaited it and connected its sync engine could seed defaults over a stored log it had failed to read, without telling that apart from a first visit.{ status: "empty" }: nothing is stored under the key, even if the document already holds state.{ status: "loaded" }: every stored update was applied.{ status: "partial", errors }: the log was read, but at least one stored update could not be applied;errorsholds whatapplythrew for each, in log order.{ status: "failed", error }: storage could not be read;erroris what the adapter'sloadrejected with.
The errors are the objects
onErrorreceived during the initial load, which is still called as before; errors of later appends and compactions are not included.whenLoadedstill never rejects, and still resolves before the initial snapshot is written: a failed initial snapshot leaves the result as it was and makesflush()reject.PersistenceLoadResultis exported from@homeostate/core.Breaking:
Persistence.whenLoadedis nowPromise<PersistenceLoadResult>instead ofPromise<void>. Code that only awaits it needs no change. Code that returns or assigns it where aPromise<void>is expected no longer type-checks: type it asPromise<PersistenceLoadResult>, or passwhenLoaded.then(() => {}). To stop seeding defaults over a log that could not be restored, connect only for"empty"and"loaded". -
8ed9541: Report persistence failures through the promises of
Persistencemethods. Before,flush(),compact(),destroy()andclear()resolved after failed storage work, so callers had to trackonErrorto learn whether a document was saved or deleted, and anonErrorthat threw left the internal queue rejected, which could surface as unhandled rejections.compact()andclear()reject with their storage error.clear()still attempts deletion after earlier writes failed, and resolves once the document is removed.flush()anddestroy()reject while storage may be missing an update the document reported: after a failed append, initial load or initial snapshot, also one that failed before the call. They reject with the earliest such error until a compaction writes a snapshot of the document, orclear()succeeds. A failed automatic compaction, or a stored update that cannot be applied, does not make them reject.destroy()stops storing even when it rejects.onErroris called once per failure, as it happens, and promises reject with the same error object. WhatonErrorthrows is logged withconsole.error, and later storage work still runs.- A promise left unawaited never becomes an unhandled rejection.
whenLoadedstill never rejects, andstats()is unchanged.
Breaking: code that awaits
flush(),compact(),destroy()orclear()now throws when storage fails. Wrap those calls intry/catchwhere a failure should not stop the caller, for example on teardown, and drop any error flag kept inonErroronly to tell whether an operation succeeded. Code that does not await them needs no change. -
ceca144: The sync engine now guarantees that the store holds plain objects. Before it puts a remote value into the store, it copies every object whose prototype is neither
Object.prototypenornullinto a plain object of its own entries, at any depth, as it already left out__proto__keys. ACrdtBackendwhose library builds objects by assignment, so that a peer's__proto__entry becomes a prototype, no longer has to undo that inread(); theCrdtBackend.readcontract now says so.Export
jsonEqual(a, b), the equality the engine uses: whether two values are equal asJSON.stringifywould store them, comparing own keys only and ignoring__proto__. ExportkeepIdentity(previous, next), which returnsnextreusing every subtree ofpreviousit leaves unchanged, as the engine builds the store's next state, so an item that shifts in a list keeps its identity. -
aa7cbc1: Apply a remote change the backend holds before writing a local change, even when the engine has not heard of it yet. A listener subscribed to the document before
connect()that changed the store as it heard of a peer's change made the engine write over that change, diffing against apreviousthe document no longer held: the Yjs and Loro backends threw, so the store's change was reported as a backend error and dropped, and the Automerge backend brought back what the peer had deleted. The remote change now wins, as it does over a local change made while a scheduled apply is pending: what the local change did to synced keys is lost.CrdtBackendtakes an optionalrevision(): a value equal to an earlier one only if the synced subtree has not changed since, whether or notsubscribehas reported the change, andundefinedwhile the backend cannot tell. Before writing a local change, the engine reads the backend unless the revision is the one it recorded when it last synced, and applies what it finds first if that differs from what it last synced. A backend withoutrevisionis read before every local change, so a custom backend pays one read per write until it implements one.createMemoryBackendcounts its writes and receives.A local change made while a scheduled apply is pending is no longer lost when the engine has already applied those remote changes, and the write that follows
setStatepasses nopreviouswhen the backend changed during it. -
3b9d6dc: Export
SetupOptions, the options every setup helper takes: thebackend, theconnectmode,"auto"or"manual", and the engine'sSyncEngineConfig. A setup helper is the one call in a store adapter package that creates the adapter and the engine for one store; the Zustandhomeostatemiddleware's options areSetupOptions. -
cde0b51: Add
subscribeandgetSyncedStatetoSyncEngine.engine.subscribe(listener)calls the listener after every sync event, aSyncEvent:"local"once a local change is written,"remote"once a remote change is applied (once per flush withschedule, and for a local change that first runs a pending apply), and"connect"and"disconnect"onceisConnected()has changed. Events are sent synchronously and not at all when the work throws; a listener that throws is logged withconsole.errorwithout stopping the other listeners or the store's subscribers.engine.getSyncedState()returns the store's synced keys with the store's own values, connected or not, so tools no longer rebuild the engine's filter. -
074eadc: Add the
onErroroption tocreateSyncEngine, to handle what the store adapter or the backend throws once the engine is connected. Without it, a store validation error insetStatereached the provider's message handler and a failedwritereached the code that changed the store, while the engine stayed connected and nothing recorded that the two sides differed. WithonError, the engine catches those errors, from notifications, the scheduled flush anddisconnect(), and callsonError(error, source), wheresourceis"store"or"backend". Without it, errors propagate as before, andconnect()always throws.A
read()orgetState()that does not return an object now throws aTypeErrornaming the method and what it returned, such asCrdtBackend.read() must return a plain object, got string, fromconnect()or throughonError. Before, such a backend was treated as empty and overwritten by the seed, a backend that came to hold no object cleared the store's synced keys, and an adapter returningundefinedfailed withCannot convert undefined or null to object.A stopped write loop is reported to
onErroras aWriteLoopError, with the source"store"and the keys inkeys, once per set of keys and in every build. WithoutonError, the engine still warns once in development. -
7e7171a:
SyncEngineConfigtakeskeysoromit, lists of top-level keys to sync or to keep local, socreateSyncEngine(backend, adapter, { omit: ["draft"] })replaces afilterthat restates the default. They apply in both directions and on connect, on top offilter: a key is synced whenfilterkeeps it and, by name,keyslists it oromitdoes not name it. A key the backend holds thatkeysdoes not list is ignored. Both accept readonly lists, such asas constconstants.Breaking:
SyncEngineConfigandSetupOptionsare generic over the store's state,SyncEngineConfig<S>andSetupOptions<S>, andcreateSyncEnginetakesSfrom the adapter.keysandomitaccept only the state's key names, so a typo fails to compile, and giving both is a type error.filteris typed(key: keyof S & string, value: S[keyof S]) => boolean. WithoutS, the types are as before: keys are strings and valuesunknown.To migrate:
- A filter that compares its
keywith a name the state's type does not declare, such as a key only the backend holds, fails to compile; annotate the parameter,(key: string) => .... - A config declared as a bare
SyncEngineConfigand passed to an engine for a typed adapter fails to compile; declare it asSyncEngineConfig<State>. - Both are type aliases now rather than interfaces, so an
interfacecan no longer extend them; use an intersection type, such asSetupOptions<State> & MyOptions.
- A filter that compares its
Patch Changes
- 60331b4: Write what the store changes while a remote change is applied. The engine ignores store
notifications during
setState, so a key a reducer normalised, or a subscriber, reaction or hook derived, in response to a remote change stayed out of the backend, and other peers saw the old value, until the next unrelated local write. Once the apply ends, the engine now writes the store's synced state if it differs from what was applied, in one write, including for the apply onconnect()and a scheduled apply. A remote change the store keeps as it is still writes nothing back, and neither does one the store ignores, which leavesgetState()returning the same object, so a Redux store without the replace case does not revert the peer's change. A local change that runs a scheduled apply no longer writes the applied state back as well. - 567932b: Treat
NaNas equal to itself, and asnullwithjson: true, asJSON.stringifystores it.getChangesreported aNaNthat stayedNaNas changed, so a store holding one got a new state object on connect, and a backend rewrote it on every write. Withjson: true,NaNand infinite numbers now diff asnull, andtoJsonValueturns them intonull, so the Yjs, Loro and Automerge backends storenull, as the memory backend already did. - 3bdd635: Keep a stored log that holds updates the document cannot apply. Before,
createPersistencereported such an update toonError, applied the rest, then compacted the log into a snapshot of the document, which deleted the update, so damaged bytes, or another CRDT library's document under a reused key, could no longer be inspected or recovered. Now the initial load, automatic compaction andcompact()leave the log in place whenever an update in it fails to apply; the error still goes toonErroron each attempt, andwhenLoadedresolves with the other updates applied. The initial load appends a snapshot of the document instead, so what it held before it was persisted is still stored.clear()deletes such a log, after which compaction resumes.PersistableDoc.applyis now documented to throw for bytes it cannot apply. - 9a6e0c6: Stop peers from re-writing each other's changes forever. Since the engine writes what a store
changes while a remote change is applied, peers whose stores change that state again on every
apply, such as a replace reducer that stamps
updatedAtor two app versions that normalise a key in contradicting ways, kept writing to each other, growing the document's history without bound. Once another peer changes the keys the engine's last write back changed and the store changes them again, the engine now applies the remote change without writing those keys back, and warns once in development naming them. The store keeps its values for those keys, which differ from the backend's. A key the store changes anew, such as one it derives from an edit, is still written back after every remote change. - 9ce5ff0: Skip records and arrays shared by identity when building edit scripts. Local writes that reuse unchanged containers avoid traversing their contents, particularly in normalized maps, while changed values retain the same ordered edits and unchanged subtree identities.
0.3.0
Minor Changes
-
732c2c8:
applyChangesnow serves CRDT backends as well as stores, and the three supplied backends use it. It takes the state to reach instead of an edit script,applyChanges(target, current, next, ops, options?), and diffscurrentagainstnextitself;optionsgoes togetChanges.ApplyOpsgainskind, which classifies a container as a record, list or text, or a value as plain;get, which reads a child; and an optionaleditTextfor text. Every operation receives its container's path, so a backend can store each value per its text policy. A child that is not the kind of container the diff expects, such as a string held as a plain value, is replaced whole with its next value. Core also exports theContainerKindtype.To migrate a store adapter, pass the snapshot and the next state instead of
getChanges(snapshot, next), and addkindandgetto its operations. -
f77d8a6: Add document persistence, so a synced document survives reloads, offline starts, and every peer and the sync server losing it.
createPersistencein@homeostate/corekeeps a CRDT document in aPersistenceAdapter;createYjsPersistable,createLoroPersistableandcreateAutomergePersistableexpose each backend's document to it; and the new@homeostate/persist-indexeddband@homeostate/persist-local-storagepackages store it in IndexedDB or Web Storage.createMemoryPersistenceAdapterin@homeostate/core/testingis an in-memory adapter for tests. -
456fbad: Write
undefinedand functions the wayJSON.stringifydoes in every backend: object entries holding them are left out, and such array items, holes included, becomenull. Writing the same state again produces no update.- Yjs threw halfway through a write on an
undefinedarray item or a nested function, and the part already applied replicated to peers, sometimes deleting an existing item.undefinedobject values were stored as present keys. - Loro threw halfway through a write on a nested function or a sparse array, leaving a partial
subtree that replicated. It stored
undefinedasnull, and resent everyundefinedarray item on each write. - Automerge threw on every write after the store held an
undefinedarray item, so nothing synced from that store again, and stored nested functions as{}.
getChangestakesjson: trueto diff the new state by that rule, passing only the values its changes carry throughtoJsonValue, newly exported from@homeostate/core, so a write does not copy the whole state. Custom backends should set it onwrite(next). - Yjs threw halfway through a write on an
-
30e31df: Add
compact()andstats()to the handlecreatePersistencereturns.compact()merges the stored log into one snapshot now, as everycompactAfterappends do.stats()resolves with the number of stored updates and their size in bytes, once pending writes finish. -
d8e9906: Store strings as plain values unless a
textoption marks them as collaborative text. Every backend stored every string as its text type and merged concurrent replacements character by character, so two peers setting a filter to"active"and"completed"at once converged to"compctiveeted"; ids and timestamps merged the same way. Concurrent writes of a string now keep one of the written values.Mark prose that should keep merging with the backend's new
textoption, which receives each string's path from the synced root:createYjsBackend(doc, "shared", { text: (path) => path[0] === "todos" && path[2] === "title", });Yjs and Loro store other strings as plain map and list values, and Automerge as
ImmutableString.read()returns plain strings for both kinds. Documents written by earlier releases hold every string as text: they read as before, and each string is stored as the configured kind the next time it changes. Give every backend writing to a document the same policy.Core adds
getChanges(a, b, { text }), which replaces strings outside the policy with oneUPDATE; without the option every string is still diffed character by character. It also exports theTextPolicyandDiffOptionstypes, and exportsapplyStringChangesagain for backends that find text stored as a plain value. -
008091c:
CrdtBackend.writetakes an optional second argument,previous: what the synced subtree holds now, asJSON.stringifywould store it. The sync engine passes the synced state it last wrote, or the whole state it last read after a remote change, so a backend can diff against it instead of reading its document, and unchanged subtrees match the store's by identity. It passes nothing for the seed write and after a write that threw. A backend that ignorespreviousworks as before.Update synced store state immutably. The engine diffs each write against the state it last wrote, so an array or object changed in place is the same object on both sides and is not written. Adapters whose
getState()returns a fresh copy, such as the MobX adapter, are not affected.getChangeswithjson: truenow treats entries holdingundefinedor a function as absent inaas well as inb, so a diff againstpreviousnever removes an entry the document did not hold.
Patch Changes
- f901667:
SyncEngineConfigtakes an optionalschedule, which coalesces remote changes. The first remote change hands it aflush, such as(flush) => queueMicrotask(flush), and every remote change before that call is applied to the store with one backend read and onesetState, instead of one each. A local change made while an apply is pending runs it first, and the remote changes win: what the local change did to synced keys is lost.disconnect()runs a pending apply. Withoutschedule, remote changes are applied synchronously as before. - d858951: Diff faster.
getChangesskips the items and characters two arrays or strings share at either end before running its edit script, so an edit costs less the more the values have in common. One keystroke in a 20,000-character text now diffs in about 70 µs instead of 1 ms. Withjson: true, records holding no absent entries compare in one pass over their keys. - 29f8f0d: Clean up after a failed connect. When
connect()throws while subscribing to the backend or the store, the engine now drops the subscription it already made instead of leaving the backend listener applying remote changes to a store the engine reports as disconnected. A laterconnect()subscribes once to each side. - 314e268: Sync keys named after
Object.prototypemembers, and stop peers from replacing object prototypes through a__proto__key.getChangesand the sync engine compared keys within, so keys such asconstructorortoStringcounted as always present and their deletions never replicated; the Valtio adapter had the same fault. A__proto__key is now never diffed and is left out of remote state at any depth. The Yjs and Loro backends'read()turned a peer's__proto__entry into an object's prototype, so its properties, such asisAdmin, appeared on other users' state; it now returns plain objects. - 4564712: Tell a hole from a value in nested arrays.
getChangesskipped the holes of a sparse array inside an array item, so it found no change when a remote state held a value there, and the store kept the hole.
0.2.0
Minor Changes
-
a643bbe: Stop exporting
applyStringChanges. It was only used inside core;applyChangesstill applies string changes wherever they appear in the state. -
0bbf653: Move
createMemoryBackendandMemoryBackendto the@homeostate/core/testingentry, so the root entry only exports what production code uses. Mark the package side-effect free. -
fb76f77: Preserve complete Unicode code points when diffing and applying string edits. Deleting or replacing supplementary characters such as emoji no longer corrupts text, deletes adjacent characters, or throws in the supplied CRDT backends.
String
Changeoffsets remain UTF-16 code units. A string deletion can now carry a numeric UTF-16 deletion length in its third tuple entry;undefinedstill means one unit. Custom backends consuminggetChangesmust honor this length in one operation rather than delete one code unit per step. Array and object deletion semantics are unchanged.
0.1.2
Patch Changes
- 30abf27: Remove draft release warnings, update docs links
0.1.1
Patch Changes
- 8bd452e: Init release