Minor Changes
-
ablo connect --registerworks against any deployment. Registering your database as an Ablo data source over logical replication is now a first-class path everywhere — the gate that could refuse a new connection-string registration is gone. Pointablo connect --registerat your Postgres and Ablo begins consuming its replication stream while your application keeps owning the write path; registering the source is the enable, with no tier or flag to choose. The signeddataSource()endpoint remains the explicit fallback for databases that can’t grant replication. -
More engine failures surface as typed errors you can branch on, instead of opaque
500s. NewAbloErrorcodes:schema_provisioning_forbidden(403— a push tried to create tables in a database where the engine isn’t permitted to run DDL),request_too_large(413— a query or commit body exceeded the size limit),commit_operation_invalid(400— a malformed commit operation), andupload_not_configured(503— an upload was attempted with no blob store wired). -
@abloatai/ablo/wirenow exports the protocol schemas.commitOperationSchema,commitPayloadSchema, and the frame schemas let tooling and tests validate client↔engine frames against the same contract the engine enforces. -
Protocol version negotiation is surfaced.
@abloatai/ablo/wireexportsPROTOCOL_VERSION,MIN_SUPPORTED_PROTOCOL_VERSION,WS_CLOSE_PROTOCOL_VERSION,PROTOCOL_VERSION_HEADER,protocolVersionProblem, andERROR_CONTRACT_VERSION— the single integer the client and server compare on connect to detect an incompatible peer and fail with a clear reason instead of a cryptic wire error. -
@abloatai/ablo/coordinationexposes the conflict-policy vocabulary —defaultPolicy,capabilityPreemptPolicy, andinterpretConflictAxis— for server-side consumers building on the claim model.@abloatai/ablo/schemagains the role primitives (identityRole,entityRole, and their types and schemas) for declaring identity- and entity-scoped roles. -
Removed unused API surface. Dropped the
@abloatai/ablo/server/nextsubpath export and the unusedMutationDispatcherinterface (plus itsMockMutationDispatchertest double) and the offline-mutation-queue internals behind them — none had documented consumers. Every supported entry point (@abloatai/ablo,/ai-sdk,/react,/source,/coordination,/schema,/keys,/auth,/wire, …) is unchanged. -
Internal: the SDK’s largest modules — the
Abloclient, the data-source runtime, the transaction queue, and the WebSocket sync loop — were decomposed into cohesive leaf modules. No public runtime API or import path changed. -
CLI & docs.
ablo pushprints a calmer, information-first deploy banner (a production push still requires typing the project name to confirm). The README andAGENTS.mdnow lead with the one-path logical-replication model and clarify that sandbox is test-mode only — in production your rows stay in your database and Ablo holds only the transaction log.
Minor Changes
-
ca30064: Logical replication is now the documented default storage path, with self-service data-source registration from the CLI.
ablo connect --register— registers your database as an Ablo data source over logical replication in one step: it runs the same pre-flight replication probeablo connectuses (server reachable,REPLICATIONprivilege,wal_level=logical, publication/slot creatable), and on successPOSTs the connection to the engine’s/v1/datasources. Theek_-authed call scopes the source to your org automatically; the password is stored decomposed as a secret, never echoed back. This is the “registration is the enable” path — there is no separate tier or flag to pick.ablo initleads with logical replication — the default storage mode is nowreplication(wasendpoint); the generated env + next-steps point atablo connect/ablo connect --register. The signed-endpoint and direct modes remain as the explicit fallback / legacy options.ablo status— a data-plane diagnostic that probes whether your registered source is reachable and replicating (failure-only reporting, so it never falsely reports healthy).ablo push— minor guard/UX refinements (deploy-target clarity).- Docs (README,
docs/data-sources.md,llms.txt) rewritten to the one-path model: Ablo consumes your database’s logical-replication stream and your application owns the write path. The security wording is precise — a logical-replication connection requires theREPLICATIONprivilege (it is not a read-only SQL account), so reviews are not misled by a “read-only” claim.
Minor Changes
- Multi-agent coordination for AI SDK tools + safer
ablo push.coordinatedTool(@abloatai/ablo/ai-sdk) — one call turns an Ablo model write into a Vercel AI SDKtool()with concurrency coordination handled, so an AI agent can contribute to shared state without clobbering concurrent writers. Three strategies:merge(default — delegates to the functional update’s compare-and-swap + backoff, self-healing accumulate),claim(mutual exclusion, returns aclaimedsignal the model retries on), andqueue(SQS-style poll-acquire over HTTP). Theai-sdkentry now also documents the canonical multi-agent coordination model (surface-the-signal + back-off laws, strategy table).ablo pushguards —--dry-run/--planprints the deploy target, a model-level diff vs the deployed schema, and git state, then exits without applying. Production deploys now require a typed confirmation (and refuse an uncommitted schema unless--allow-dirty); sandbox confirms interactively.--yes/-yskips confirmation for CI.
Minor Changes
-
2807efb:
createnow returns the created row, not aCommitReceipt. The WebSocket client’screatealready returned the row (T); the HTTP client and the.model(name)accessor returned aCommitReceipt, so “create returns the thing I created” only held on one transport. Both now return the confirmed, authoritative server row (framework defaults likecreatedAt/createdByincluded). For an idempotent re-create of an existing caller-supplied id, the EXISTING row is returned (not the input). BREAKING (HTTP /.model()callers only):await ablo.<model>.create(...)now resolves to the row instead of{ status, lastSyncId, ... }. Code that ignored the return value, or that read.id(the row carriesidtoo), is unaffected; code that readlastSyncId/serverTxId/statusoff a typed model create should use the rawcommits.create(...)resource, which still returns aCommitReceipt. WebSocket-client callers are unaffected (already returnedT). -
2807efb:
deleteis idempotent — deleting an already-absent row is a no-op success, not an error. The WebSocket client’sdeletethrewentity_not_foundwhen the row wasn’t in the local pool, while the HTTP client returned without error — so “delete this” was a hard edge on one transport. Both now agree: a row that isn’t present is already gone, so the delete succeeds with no effect. This is AIP-135’s recommended behavior for client-assigned-id / declarative APIs (Ablo is exactly that), and it makes delete safe to retry and to race (two actors deleting the same row). The deliberate “loud 0-row” assertion in@ablo/slides-sdkis unchanged (it keeps its ownallowMissingopt-out). -
2807efb:
retrievereports a missing row asdata: undefinedinstead of throwing. The HTTP client previously threwmodel_not_foundfor a missing row while the WebSocket client returnedT | undefined— so the obvious read (“does this row exist?”) was a hard edge an agent had to wrap intry/catchon one transport only. Both transports now agree: an absent row is data-absence, not an error.ModelRead.datais nowT | undefined(matching the documented.data?.xusage). Taking aclaimon a row that doesn’t exist still throwsAbloNotFoundError— a claim has nothing to hold.
Patch Changes
-
Expose the functional
update(id, current => next)overload on the stateless HTTP client type (HttpModelClient/AbloHttpClient). 0.22.0 wired the functional update at runtime on every transport and added the overload toModelOperations(WebSocket) andModelClient, but theAblo({ transport: 'http' })client resolves its models toHttpModelClient, whoseupdatetype still declared only theupdate({ id, data })form. So server-side agents — the primary callers — saw a type error onupdate(id, fn)even though it worked. Add the overload to that type.
Minor Changes
-
Add the functional update form:
ablo.<model>.update(id, current => next). ThesetState(prev => next)of the data layer. Pass a function of the latest row and the SDK owns everything that used to be the caller’s problem under contention: it reads the freshest row, runs your updater, writes it as a compare-and-swap against the row’s watermark, and re-reads + re-runs on any concurrent write. No claim, no per-participant identity, and nostale_context/claim_*codes ever surface — correctness rides on the watermark, so concurrent writers reconcile instead of silently clobbering. The write either lands or throws a singleAbloContentionErroronce its reconcile budget is spent. Identical guarantee on both transports (HTTP and WebSocket share one reconcile loop). Returnnull/undefinedfrom the updater to skip the write. Tune with{ retries, signal }. Exports:AbloContentionError,ModelUpdater,ContentionOptions,DEFAULT_CONTENTION_RETRIES. The classicupdate({ id, data })form is unchanged.
Minor Changes
-
Coordination observability now fires on BOTH transports. Previously
captureClaim/captureConflictwere emitted only by the WebSocket transport, so aClaimLog(or anyobservabilityprovider) handed to a stateless HTTP client — the transport server-side agents use viaAblo({ transport: 'http' })— stayed empty, and even on WebSocket a hard commit rejection went unrecorded. Fixed:- HTTP transport now emits.
Ablo({ transport: 'http', observability })recordsclaimacquisition (captureClaim) and coordination-conflict rejections (captureConflict, codestale_context/claim_conflict/entity_claimed) on BOTH HTTP write doors (commits.createand per-modelablo.<model>.update/create/delete). The conflict names the collided rows — from the server’sconflictsdetail when present, otherwise the ops the write attempted.observabilityis now a documented option on the HTTP client. - WebSocket rejections now recorded. A commit rejected by the conflict policy (
mutation_resultsuccess: falsewith a coordination code) now callscaptureConflict, mirroring the existing notify-on-success path. SoClaimLog.collisions()no longer silently misses rejected writes.
ClaimLogbehaves identically regardless of transport —entries,collisions(), andonChangereflect the real coordination timeline for headless agent evals and live activity feeds alike. - HTTP transport now emits.
Patch Changes
- Extend the
HeldClaimreturn type to the HTTP transport. 0.20.1 fixedawait usingon the WebSocket client’sclaim()but missed the stateless HTTP client (HttpClaimApi) used by server-side agents, which still returned the looserClaim<T>. Both transports’claim()now returnHeldClaim<T>, soawait using held = await ablo.<model>.claim(...)typechecks regardless of transport.
Patch Changes
- Fix
await using held = await ablo.<model>.claim(...)failing to typecheck.claim()now returns aHeldClaim<T>— aClaim<T>withdata,release,revoke, and the async disposer madeRequired(they’re optional on the baseClaim<T>, which also models observed peer claims that lack them). A held claim is therefore assignable toAsyncDisposable, so theawait usingauto-release pattern compiles. Observed claim surfaces still return the looserClaim<T>.HeldClaimis exported.
Minor Changes
-
Reactive reads now work out of the box. A read like
useAblo((a) => a.documents.get(id))re-renders when a live delta updates the row — including in-place field updates (the common collaborative case), which previously fired no reaction and left the UI silently stale. Two changes make this work:- Models are reactive by default. Schema fields are now MobX-observable without opting in.
jsonfields stayobservable.ref(one atom for the whole blob, not a deep atom tree per node), so the default is cheap. Opt out per model withlazyObservable: falsefor very large read-only list models where the QueryView’s entry-replaced reactivity is enough. useAbloreturns a plain row snapshot (via the newModel.toReactiveSnapshot()) instead of the live model instance. Reading the fields inside the tracked function is what subscribes the reaction (MobX tracks property access, not values), and the fresh snapshot identity lets the hook detect the change. Consumers get plain row objects and never touch a MobX observable directly.
deepEqualandstableStringifyexports for comparingfield.json()values. Ajsonb-backed json field round-trips with reordered object keys (Postgresjsonbdoes not preserve key order), so a naiveJSON.stringify(a) === JSON.stringify(b)comparison is unreliable when reconciling against external state (e.g. a rich-text editor). These helpers compare key-order-insensitively. - Models are reactive by default. Schema fields are now MobX-observable without opting in.
Minor Changes
-
Claim observability — a
ClaimLogyou can print or assert on. A newobservabilityprovider hook lets you tap every claim event and stale-write collision the client sees. Handnew ClaimLog()toAblo({ observability })and it collects an ordered, readable log —formatClaim/formatConflictrender one line per event, andcollisions()returns the conflicts for eval assertions. New exports:ClaimLog,formatClaim,formatConflict,noopObservability, and the typesClaimLogEntry,ClaimEvent,ConflictEvent,SyncObservabilityProvider. SpreadnoopObservabilityto override only the hooks you care about. AWS-shaped CLI credential store +ablo config. Local CLI state is now split into two files, matching~/.aws/configvs~/.aws/credentials:config.jsonholds non-secret settings (active environment + active project) and is safe to print or let an agent read;credentials.jsonholds the keys (0600, never printed), keyed by project profile then environment. Per-project profiles follow Stripe’s model —ablo projects use <slug>selects the active profile, and a key’s project is fixed at mint so selecting a project never re-scopes an existing key.ablo statusnow reports the resolved profile and environment. Schema JSON-column reconciliation.generateJsonColumnReconciliation(new export) emits the DDL to reconcile JSON-backed columns when adopting or evolving an existing schema. Breaking (0.x):- The claim handle type
ClaimHandleis renamed toClaim, and its identifier field isid(wasclaimId). Update type imports and any code reading.claimId. - The ai-sdk
claimBroadcastMiddleware(and./ai-sdk/claim-broadcast) is removed — coordination broadcast is handled bycoordinationContextMiddleware. ImportClaimTargetfrom the package root or@abloatai/abloai-sdk’scoordination-contextinstead ofclaim-broadcast. The inline-claim option isreason(not the pre-0.12action); the ai-sdk docs are corrected to match.
- The claim handle type
Minor Changes
-
Client observability —
debug/logLevel, off by default. The SDK used to emit adebugline per model and per property during schema registration (a firehose). It now defaults to a quietwarnthreshold and exposes two newAblo()options to opt back in:logLevel: 'debug' | 'info' | 'warn' | 'error' | 'silent'—'info'surfaces coordination and connection events without the per-model registration noise;'debug'is everything. Precedence: explicitlogLevel→debug: true→ABLO_LOG_LEVELenv → defaultwarn. Supplying your ownloggerbypasses both.debug: boolean— shorthand forlogLevel: 'debug'.
info: claims that are rejected or lost (preempted/expired), and your position advancing in a claim queue, each log once per change with a readable target (documents:abc.title) — quiet lowercase lines, no shouty tags. New: canonical wire-egress contract export.errorEnvelope,statusForType, and theErrorEnvelopetype are now exported from the package root. Server consumers (e.g. a self-hosted sync server) can assert against the one source of truth for the error-envelope shape and theAbloError-subclass→HTTP-status table instead of keeping a copy that silently drifts. Structured CLI error rendering. CLI failures render as a titled block with a reason code and per-code remediation (--verbosefor the stack) instead of a console wall-of-text;AbloError.toString()produces a leak-proof one-liner.ABLO_API_KEYresolution + sandbox key scopes. The key is now resolved from.env.local/.env(not just the process env), and sandbox keys are grantedschema:pushby default soablo pushworks out of the box in a fresh sandbox.
Minor Changes
-
Bring-your-own database is now one model. Ablo connects to your Postgres and
never operates it. There used to be two confusing BYO paths, and the
connection-string one would create roles, force row-level security, transfer
table ownership, and push you to run
ablo migratebefore anything worked. That cascade is gone. Ablo now follows the shape every serious “sync over your own Postgres” engine uses (ElectricSQL, PowerSync, Zero): it reads your database via Postgres logical replication and never runs DDL, creates roles, forces RLS, or rewrites yourDATABASE_URL. You own your schema; Ablo reads it.- New:
ablo connect. One command prints the exact, copy-pasteable setup for your own Postgres — enablewal_level=logical, create theablo_publicationpublication and a least-privilegeablo_replicatorrole — andablo connect --checkvalidates readiness (wal level, publication, replication grant, replica identity). This is the single supported way to connect a real database. ablo migrateleft the happy path. It no longer creates roles, transfers ownership, or rewrites your connection string, andablo devno longer attempts a scoped-role creation on every watch loop.migrateis now an optional escape hatch for generating starter DDL (--dry-runprints the SQL).- Clearer failures.
ablo pushpermission errors lead with the server’s actual reason code and per-code remediation instead of a generic “needsschema:pushscope,” and the schema-conflict message names which environment/version a prior push came from and when. - Logical-replication runtime is in Preview. The setup (
ablo connect) and the connection model are live; the server-side WAL consumer that streams your changes is implemented and journey-tested but not yet generally available.
- New:
Patch Changes
-
Docs. The bundled SDK docs are now the single source for the documentation
site, and several pages were expanded or corrected:
- The sessions/identity model is reframed around projects — push one schema
to a project, mint an
ek_per user (your users need no Ablo account), and all of them commit to that one schema. Per-customer org isolation (schemaProject) is presented as the add-on it is, not the default. - The declarative
conflictschema axis (Axis 3) is now documented. - The agent docs were corrected to the current claim vocabulary
(
reason/queue, not the pre-0.12.0action/wait).
- The sessions/identity model is reframed around projects — push one schema
to a project, mint an
Patch Changes
-
mintUserSessionKey: name the shared-schema binding around the project. The two flat options added in 0.16.0 (schemaOwnerOrgId+schemaProjectId) are replaced by one project-centric option —schemaProject: { organizationId, projectId }— naming “the project that owns the schema” as a single concept. The wire format is unchanged (the SDK still sends the same keys), so no server redeploy is needed. Released as a patch: the replaced options shipped in 0.16.0 and have no external consumers yet.
Patch Changes
- Fix
ablo loginagainst the standalone auth server. The device flow now targets two origins instead of one: the RFC 8628 device endpoints (/api/auth/device/*) go to the identity server (auth.abloatai.com, overrideABLO_AUTH_URL), while the human approval page (/cli), sign-up, and the key-handoff route (/api/cli/provision-key) go to the dashboard host (www.abloatai.com, new overrideABLO_DASHBOARD_URL). Previously every step ran againstwww, where the device endpoints no longer resolve — producing “Couldn’t start login… Is the dashboard reachable?”. The CLI now also builds the approval URL itself rather than trusting the server’sverification_uri, which (being a relative/cli) resolved against the auth server’s origin to a 404.
Minor Changes
-
Axis 3 — declare write-conflict behaviour in the schema (new). A model can now
state what happens when a commit collides with a foreign claim or a stale snapshot —
per committer kind (
user/agent/system) — right next to its fields, using the sameoverwrite | reject | notifyvocabulary as theonStalewrite guard. It is a third axis, orthogonal topolicy(read access) andgroups(delta routing).-
conflictonmodel()— a plain, serializable disposition map. Pure data, so it round-trips through the schema registry to the server; the generic engine interprets it at the commit chokepoint (no per-model logic in the engine). -
Composable authoring helpers (new, from
@abloatai/ablo/schema) — disposition functions plus acn/cx-style combinator, so conflict policy reads like the rest of the DSL (relation.belongsTo()) and like modern config (plugins: [admin(), …]):Exports:coordination,humansOverwrite/humansReject/humansNotify,agentsOverwrite/agentsReject/agentsNotify,systemOverwrite/systemReject/systemNotify, and theConflictRuletype. -
An omitted committer kind falls through to the engine default (reject; honor
onStale: 'notify'), so this is fully additive — existing schemas are unchanged. New public typesConflictAxis(alsoAblo.Conflict.Axis) and theinterpretConflictAxisinterpreter are exported for custom policy composition.
-
-
First-party shared schema for ephemeral keys (new).
mintUserSessionKeynow acceptsschemaProjectId+schemaOwnerOrgId, binding the mintedek_to a schema owner-org + project so schema resolves org-independently (one schema serves all of an integrator’s end-user orgs) while data stays scoped toorganizationId. Requires thesk_to carryephemeral:mint-any-org; omit both for the existing per-org (BYO) behaviour.
Patch Changes
-
Loud 0-row writes: surface unmatched UPDATE/DELETE ids and add
AbloNotFoundErrorA commit now reports the ids of any UPDATE/DELETE that matched zero rows onCommitReceipt.missingIds, and the new exportedAbloNotFoundErrorlets typed write wrappers throw instead of silently treating a missed write as success. Additive and back-compatible (the field is omitted when nothing missed). This unblocks the slides-sdk name-addressing / own-your-id work, which relies on a loud failure when a stale id is written.
Minor Changes
-
Notify-instead-of-abort: non-coercive conflict handling + read-set (the “did anything I looked at change?” layer).
The principle: on a stale-context conflict the engine now surfaces the current state and lets the actor — agent or human — resolve it, instead of forcing an outcome. See
docs/concurrency-convention.md.onStaleredesigned — Stripe-aligned values (BREAKING). The mode set is now'reject' | 'overwrite' | 'notify'. Each value names its outcome:notify(new, non-coercive) — the conflicting write is held (not applied) and the commit returns aStaleNotificationcarrying the conflicting field’s current value, so the actor reconciles and re-commits rather than losing work. The rest of the batch still commits.overwrite(wasforce) — blind last-writer-wins, no signal.reject(default, unchanged) — throwsAbloStaleContextError.
onStale: 'force'→onStale: 'overwrite'.onStale: 'flag'/onStale: 'merge'→onStale: 'notify'(both removed;notifyis the single hold-and-surface mode).
StaleNotification— the new advisory signal. New public type +staleNotificationSchema:{ object: 'stale_notification', model, id, readAt, observedSyncId, conflictingFields, currentValues, writtenBy, group? }. Delivered two ways:- on the receipt —
CommitReceipt.notifications(andCommitResult.notifications); - on a new SDK event —
conflict:notified{ clientTxId, notifications }(mirrorsreconciliation:needed/sync:rollback).
reads[]) — declare what you looked at, not just what you write (new). A commit may carry batch-level read dependencies; a moved premise fires that entry’sonStaleover the whole batch (notifyholds every write + notifies,rejectaborts,overwriteproceeds). Two granularities:- Row —
{ model, id, readAt, fields? }: did this row (optionally these fields) change? - Group —
{ group, readAt }: did anything in this sync group (deck:abc,org:X) change? — the same unit a participant watches and claims.
ReadDependency+readDependencySchema; available onablo.commits.create({ operations, reads })and the lower-level write options. This closes the gap the write-target check alone could not: a premise that changed without the written row changing. Conflict policy.ConflictDecisiongains{ action: 'notify' };defaultPolicymapsonStale: 'notify'→ notify-and-hold, everything else → reject.StaleContextConflict.requestedModeis added so custom policies can honor the caller’s declared intent. -
Data Source reverse-channel connector (new). A customer Data Source can now dial out to the engine over a single outbound WebSocket (
ablo.source.v1subprotocol) instead of exposing an inbound HTTP endpoint — the deployment shape private/VPC stores need.createSourceConnector({ apiKey, handler, baseURL? })(new public API, exported from the root and/source) — opens one outbound socket (Node globalWebSocket, no new dependency), with reconnect/backoff, and serves the customer’s existing Data Sourcehandler.- Server side: a connector registry +
/v1/source/listenupgrade route bridge requests down / responses up, teed intoSourceClientthrough the storage resolver. - Trust model unchanged: the Standard-Webhooks HMAC is signed above the transport, so the socket carries the signed envelope byte-for-byte and the customer’s
verifyAbloSourceRequestis untouched. Transport changes, trust model doesn’t. - Opt-in per source via
reverse_channel_prod(migration20260622150000); gated inauthorizeUpgrade.
Minor Changes
- Claim API consistency + coordination docs
- React: document
useWatch(scoped presence + read-interest, withclaim/hydrate/pausedoptions) andusePeers(read-only presence) — previously exported but undocumented. - HTTP claim surface:
HttpClaimApiis now a mechanically derived async projection of the reactiveClaimApi(AwaitedClaimMethod), so the two transports can never drift. No behavior change — the only difference remains thePromisewrapper that statelessness forces onstate/queue/reorder. - Naming: unified the claim read verb to
stateacross every layer (the internalModelCollaboration.observeis nowstate, matching the publicablo.<model>.claim.state({ id })). - Docs: corrected the
Claimobject reference — the field isreason(serialized on the wire asaction), andcreatedAt/expiresAtarenumber(epoch-ms), not strings; corrected the claim options toreasonandqueue.
- React: document
Minor Changes
-
Schema authoring: split model routing into two orthogonal axes —
policy(row access) andgroups(sync-group routing). Breaking (schema authoring). The flat, collision-prone model options are replaced by two namespaced ones:policy— row-access / tenant isolation (named after Postgres/Supabase RLS policies: the rule that scopes which rows a tenant may read). A discriminated union onbyreplaces the oldorgScoped/scopedVia/orgColumntrio:{ by: 'column' }— row-local tenancy column (the default when omitted; column name still overridable).{ by: 'parent', fk, parent }— inherit tenancy through a foreign key when the table has no tenancy column of its own (e.g.slide_layers→slides).- Type
TenancyInputis renamedPolicyInput;policyInputSchema/resolvePolicyare now exported.
groups: { root, grants, roles }— which delta channels a row fans into (orthogonal topolicy, which governs read access). One namespaced object replaces the old flatscope/grants/entityRoles:root(wasscope) — mark a model a scope root; its records form the group<kind>:<id>. Renamed so it no longer collides with the oldscopedViatenancy sugar or the innergrants.scoperelation name.grants— a membership edge granting an identity access to a scope root.roles(wasentityRoles) — explicit non-relational record→group roles; accepts one role or an array.groupsInputSchema/GroupsInputare now exported.
config.jsonnow stores per-project profile key pairs (profiles: Record<string, ProfileKeys>) instead of a single top-level pair; older flat layouts are folded into the active profile automatically on read, so existing logins keep working.login/projectsupdated to the profile model.
Minor Changes
- Canonicalize the claim API to one vocabulary, plus DX fixes (breaking).
- BREAKING: claim phase field
action→reasonon every claim surface (Claim,ClaimHandle,ClaimCreateOptions,ModelClaim, …). The wire is unchanged (stillaction, healed on read) — no server redeploy needed. - BREAKING: claim contention flag
wait→queue(one word everywhere). - BREAKING: React hook
useParticipant→useWatch(aligns withablo.<model>.watch). ClaimDeclaration.ttlSecondsis nownumber(was aDuration).- Docs:
retrieveHTTP envelope (.data/.stamp) called out;syncGroupsreworded (provisional, not deprecated);orgScopedcross-tenant security warning; React error strings point at<AbloProvider>.
- BREAKING: claim phase field
Patch Changes
-
a35d935: Fix stream-recorded undo capturing the wrong “before” value for updates. A second
update to the same field before the first sync-ack re-captured the original
pre-session value (first-old-wins + clear-only-on-ack), so undo of a quick second
edit jumped all the way back instead of one step. The queue now re-baselines a
field’s tracked
.oldonce its before-image is frozen into the committed transaction. Also close the create/update undo asymmetry: an update whose written key had no in-place mutation produced an emptypreviousData, which made the inverse un-revertible (a create’sdeleteinverse never is). Before-image capture now falls back to the last loaded/acked snapshot. Internally, the two undo paths (stream-recorded and manualRecordingTransaction) now share one before-image implementation viaModel.capturePreviousValues/Model.consumeModifiedFields, so they can no longer drift. -
One-correct-way consolidation (breaking; no external consumers yet, so released as a patch):
- Credentials collapse to a single
apiKey— a string, or a() => Promise<string | null>that fetches a per-user token. RemovedgetToken/authEndpoint/ publicauthToken. ablo.<model>.watch(ids, { ttl })replaces the top-levelablo.participants.join({ scope })— model-scoped read-interest + presence (WebSocket only).- Read claim-gating is
ifClaimed: 'return' | 'fail'(removed'wait'); waiting is the claim primitive’s job (ablo.<model>.claim). - The stateless client is
Ablo({ transport: 'http' });createAbloHttpClientis no longer a public export (the factory uses it internally). - Read-option types renamed:
ServerReadOptions(serverretrieve/list) andLocalReadOptions(localget/getAll). defineSchemathrows a clear error on a reserved-field collision; the MCP/docs API surface is now compile-time bound to the real exported types (can’t drift).
- Credentials collapse to a single
Patch Changes
-
7f91f6e: DX hardening from a real onboarding session — onboarding, CLI, coordination, types, and docs.
Client behavior
databaseUrlis now an explicit, server-only option:Ablo(...)no longer auto-readsprocess.env.DATABASE_URL. A strayDATABASE_URL(common — Prisma/Drizzle/docker set it) no longer silently flips the client into connection-string mode; a one-time warning points at the explicit option. PassingdatabaseUrl: process.env.DATABASE_URLexplicitly is unchanged.- Claims/presence are now observable from any client (including Node agents): reading a row enters its entity sync group (read-interest) and claiming pins it (write-intent), so
ablo.<model>.claim.state({ id })reports co-participants without any manual subscribe step — whether the observer arrives before the claim (live delta) or after it (subscribe-time backfill). The claim holder now also sees its own claim viaclaim.state. Requires a coordinatedsync-serverdeploy (the subscribe-time claim backfill + the entity-scope subscription gate that lets an org-authority agent key narrow into a row’s group live server-side); the client package change alone does not deliver cross-client agent observation.
ablo initdetects thesrc/applayout (routes + the@/abloimport alias resolve correctly), writes the real stored sandbox key into.env.localinstead of a placeholder, and scaffoldsablo/register.ts(a regular module, not a collidingablo.d.ts).ablo <command> --help/-hnow prints usage instead of erroring with “unknown flag”, andmigrateis listed in the top-level help.ablo dev --no-watchnow exits after one push instead of watching forever.
- Name the client with
typeof sync(the value-inferred idiom, like tRPC’stypeof appRouter/ Drizzle’stypeof db) —ReturnType<typeof Ablo>collapses to the untyped client and should not be used. No bespoke client-type generic is needed. model_claim_not_configuredmessage clarified: claiming needs no per-model schema configuration; every model is claimable through the standard client.
- Reconciled the self-contradictory
databaseUrlstory (it is an explicit, server-only option, not auto-read from the environment; consistent casing), documented that the sandbox can host rows (apiKey only, no database), explained why a localhost Postgres can’t be the system of record, and led the connect-your-database flow withablo pull/ablo checkoverablo migrate. Fixed staleapi.mdvocabulary (object: 'claim',participantKind: 'user' | 'agent' | 'system').
-
7f91f6e: Docs: document the completed
intent→claimrename. Adds a 0.11.0 migration entry (useIntent→useClaim,Register.Intents→Register.Claims,Ablo.Intent.*→Ablo.Claim.*, and the coordinated client/server deploy for theclaim_*wire frames), auseClaimsection in the React reference, and fixes the staleparticipantKindunion to the canonical'user' | 'agent' | 'system'.
Minor Changes
- Canonical
claimvocabulary, sync-group area-of-interest, and richer claim-rejection errors.intent→claimeverywhere. The coordination primitive is now aClaimacross the public surface:useClaimreplacesuseIntent, theAblo.Claim.*namespace replacesAblo.Intent.*, and module augmentation registersClaimsinstead ofIntentson theRegisterinterface. The underlying wire frames moved fromintent_*toclaim_*— clients and servers must run aclaim_*-aware build together.- Sync-group area of interest. A client’s read interest is no longer frozen at connect: the new
update_subscriptionframe drives live re-indexing, andenterScope/leaveScope/pinScope/unpinScopelet a store narrow or widen what it streams.AreaOfInterestManageradds hysteresis (warm-TTL), claim-pinning, reconcile coalescing, and an LRU cap so narrowing the view never shrinks the write allowlist. - Richer claim-rejection errors. Rejections (over WebSocket and HTTP) now carry
heldByClaimandpolicyReason, andAbloClaimedErrorexposes a typedclaimsarray so callers can see exactly who holds the contested rows. - Coordination vocabulary consolidation. Participant identity is canonical
user|agent|system; the server stampsparticipantKindon every presence emit and clients read it, so non-human peers surface correctly.
Patch Changes
- Docs: add the 0.10.0 entry to the Version History & Migration Guide — the
test/live→sandbox/productionenvironment enum rename (key prefixes unchanged) and the newtransport: 'http'stateless client.
Minor Changes
- Rename environment enum values to
productionandsandboxwhile preserving the existing*_live_/*_test_key prefix format.
Patch Changes
- Stateless HTTP transport for server-side actors, and a canonical environment vocabulary.
Ablo({ transport: 'http' })returns a statelessAbloHttpClientfor agents, workers, and serverless — the sameablo.<model>surface and coordination plane with no websocket: each call is one HTTP round-trip and identity rides the Bearer credential. The return type narrows so stateful-only APIs (get/getAll/onChange) are compile errors instead of latent runtime gaps.- Canonical
production/sandboxenvironments (newenvironment.ts, exported from the root):sk_test_/sk_live_remain the wire-level key prefixes but now map toproduction/sandboxeverywhere — key parsing, sourcemode, and the CLI (which drops the legacy test/live config migration). - Source-mode commit scoping:
commitnow forwardsprojectId,accountScope, andenvironmentto customer storage resolvers, so per-project and sandbox/production traffic can be routed to distinct stores. - Fixes: the WebSocket bearer credential is sent in the
ablo.bearer.<token>subprotocol (never in the URL or proxy logs);Modelno longer fabricates anupdatedAtof “now” for records that arrive with onlycreatedAt.
Patch Changes
- Package metadata: set the npm description to “The Collaboration Layer For AI Agents” (matching the GitHub repo About) so it stops reverting to the old “State control API…” text on publish.
Patch Changes
- README: replace the
schema -> ablo.<model>...pseudo-diagram with a real typed snippet (create/retrieve/update/claim), and tidy the Get-started line.
Patch Changes
- Per-project axis: schemas, planes, routing, and enforcement scoped per project. Adds the control plane, per-project key scoping with identity threading, a
remove_modelgate, and the CLI/docs to drive it.
Patch Changes
- README: point the Docs / Quickstart / API header links at
docs.abloatai.com(the real docs) instead ofabloatai.com, which 307-redirects to the marketing site.
Patch Changes
Model<'name'>type helper via theRegisterbinding — name your model in one parameter (Model<'tasks'>) instead of restatingtypeof schema;Model<S, 'name'>is also supported andInferModelis deprecated. CLI: retire the staledevwording from the login outro andpushheader. Docs: cover theRegisterbinding end-to-end and document thepk_publishable key + the/v1/commitsHTTP path.- 3024593: Fix
sessions.create({ user })403 — user sessions now mint via the sk_-gated ephemeral-key doorsessions.create({ user })mints anek_user session via/auth/ephemeral-keys(was wrongly routed through/auth/capability, which rejects human participants — writes were being attributed to agents).- Control-plane calls always present your original
sk_, never the client’s exchanged sync credential. sessions.create({ agent, can })no longer requires hand-builtsyncGroups— the org anchor is the server default — and thecanallowlist is now honored at commit time (model-alias matching).- New:
ablo.organizationId(resolved afterready()),ablo status --json, typed sync-group inputs (SyncGroupInput+invalid_sync_grouprejection for malformed groups).
Patch Changes
- README: add a centered brand header (Ablo banner, tagline, doc nav links, and status badges).
Patch Changes
- Docs: version history & migration guide refinements plus changelog, audit, and link fixes.
Patch Changes
- Docs: add a Version History & Migration Guide, bring the changelog current, and sync doc trees. Drop the dormant
causedByTaskIdfrom the audit-row docs and fix theablo modeargument vocabulary.
Patch Changes
- Docs: fix the
commits.createoperation shape to the public{ action, model, data }form.
Patch Changes
- CLI quickstart simplification (3 commands).
initnow owns login,migrateis dropped from the direct-databaseUrlquickstart (dev handles it), and thedevcommand is renamed topushfor honest naming with headless-safe login. Note:ablo devis nowablo push— update any scripts. Also fixes 3 production bugs surfaced by the new end-to-end journey test harness.
Patch Changes
- Scoped-role automation + tenant-routing fix.
ablo migratenow auto-creates the RLS-gated scoped role (zero SQL) with a log-safe SCRAM-SHA-256 password verifier, plus a Neon/Supabase scoped-roledatabaseUrlrecipe. Fix a jsonb double-encode that corrupted per-tenant routing and silently fell back to the shared pool.
Patch Changes
- Sync-position correctness + CLI hardening. Consolidate five scattered sync cursors into one typed
syncPosition(persisted/applied/acked with a derivedreadFloor), fixing a claim taken right after an ack-confirmed write reading stale against that write’s own delta. Add transaction ack-confirmation, schema DDL-first-push, and a reworked CLI (config/dev/login/mode/drizzle-pull).
Patch Changes
- Onboarding: quickstart leads with your-own-database (Drizzle Data Source), drop Ablo-managed mode, add
ablo pushstep; context7 library-claim config.
Patch Changes
-
Developer-onboarding overhaul so an LLM or a person gets a working integration on the first try.
ablo initscaffolds a project that builds and is current-API. The Next.js scaffold now shipsapp/providers.tsx+ anapp/api/ablo-sessionroute, usesuseAblo(the removedwithSyncis gone), object-param verbs, and never bundles yoursk_key into the browser. The webhook receiver moved off the[...all]catch-all.- Agent docs are accurate and ship.
AGENTS.md,llms.txt, andllms-full.txtare on the 0.9.x API (object-paramcreate/update/delete/retrieve, disposableawait using claim,AbloProvider clientprop), lead withablo init, andAGENTS.mdnow ships in the package. ablo pushis self-documenting. Writing to a model the server hasn’t seen now fails with an error that tells you to runablo push(theserver_execute_unknown_model/unknown_modelmessages), instead of a cryptic “unknown model.”intentsis deprecated in favor ofclaimeverywhere the docs and the MCP scaffold/prompts teach or generate coordination; the publicablo.intentsaccessor is marked@internal.- Docs say Node 24+, and the
drizzle-ormpeer floor is>=0.44.
-
a88747a: Remove the
turnprimitive and the agent-worktasksresource from the client surface — the SDK is now purelyablo.<model>+claim. Breakingengine.beginTurn(), theTurnhandle interface, and theAblo.Turntype are removed.AbloApi.beginTurnand the HTTP client’sbeginTurnare gone too.CommitCreateOptions.causedByTaskIdis removed. (Lineage is no longer stamped from the client.)- The engine no longer exposes a
protocolaccessor or a publictaskswork-unit resource.ablo.tasksis, and always was, the schematasksmodel proxy. - The
agent().run()helper and the low-level agent/task type family are removed:AbloApi.agent(id, options)andAbloApi.tasks(theTaskResource), plus the exported typesAgent,AgentOptions,AgentRunOptions,AgentRunResult/Done/Failed/Cancelled,AgentRunStatus,AgentRunContext,AgentModelClient,AgentModelReadOptions,AgentModelMutationOptions,AgentIntentOptions,AgentIntentInput,Task,TaskResource,TaskCreateOptions,TaskCloseOptions,TaskCloseResult(and theAblo.*namespace aliases for all of them). TheAblo.Auth.Agentprincipal constructor and the schema-backedtasksmodel are unaffected.
turn/agent_taskswas a second coordination-and-attribution mechanism living alongsideclaim. It is redundant on the client:claimalready serializes writers and carries the causal link — itsintentid rides on every guarded write.- The server stamps
actor/onBehalfOf/capabilityIdonto each delta from the auth context. - Per-run token/cost is recorded in Langfuse, not the
agent_taskstable.
caused_by_task_id; new writes leave that column null. Migration Agents stop opening/closing tasks — just issueablo.<model>writes (schema-backed) orablo.commits.create(...)(schema-less) under aclaim. ReplaceAblo({ apiKey }).agent(id, opts).run(prompt, handler)with: mint a scoped credential viasessions.create({ agent }), thenclaimthe row andupdate/commits.create. The serveragent_taskstable, thecaused_by_task_iddelta column, the/api/sync/commitwire field, and theagent_actions_logcompliance hash-chain remain in place but dormant (client writes leave the field null) — they are load-bearing for the tamper-evident audit chain and historical-row audit JOINs, so they are intentionally NOT dropped. The dead/v1/tasks+/api/agent/turnroute handlers ARE removed (zero live callers).
Patch Changes
- 90b656c:
drizzleDataSourcenow takes(db, schema)and derives snake_case columns from your schema, so it composes withablo migratewith no parallel Drizzle table. Update calls fromdrizzleDataSource(db, tables)→drizzleDataSource(db, schema). Also adds thesnakeToCamelexport and provisions the adapter’sablo_outbox/ablo_idempotencytables viaablo migrate.
A single options object for every model verb, and a disposable
claim handle.Breaking Changes
-
One options object per verb.
create,update,delete, and the async serverretrieveeach take a single options object instead of positional arguments, so the id, the data, and every modifier live as named siblings:create({ data, id? }),update({ id, data, ...options }),delete({ id, ...options }),retrieve({ id, ...options }). Reactive local reads stay onget(id)(synchronous) —useAblo((ablo) => ablo.tasks.get(id)). -
claimreturns a disposable handle instead of taking a callback. The handle exposes the fresh row on.dataand is released on scope exit (await using) or explicitly via.release().claim.state,claim.queue,claim.release, andclaim.reorderalso take the options object.
A callable
claim coordination namespace and bring-your-own-database support
via a new databaseUrl option.Minor Changes
-
Callable
claimcoordination namespace. Taking a claim and inspecting its state now live under one accessor:claim(id, work)acquires a claim and runsworkwhile it’s held, andclaim.state(id),claim.queue(id),claim.release(id), andclaim.reorder(id, order)cover the surrounding lifecycle. The README leads with the problem (who is allowed to act, and in what order) and the Quick Start now demonstratesclaimdirectly. -
Bring-your-own-database via
databaseUrl. Point a project at your own Postgres withAblo({ schema, apiKey, databaseUrl }). Ablo writes synced rows back into your database, so your data stays canonical. Server-side only; defaults toprocess.env.DATABASE_URL. See the data-sources guide for setup and role requirements.
Breaking
-
The flat coordination methods
claimState,queue,release, andreorderare removed in favor of theclaimnamespace above.
Minor Changes
-
Structured error contract, schema/migration engine, and a full
abloCLI.- Structured error contract across HTTP + WS planes. A closed, canonical
error-code registry is now the
codetier of a Stripe-style error model. A single HTTP egress funnel converts every throw to a canonical{ type, code, message, doc_url, request_id, ...details }envelope; the WS plane narrows mutation/claim error codes to the same union. - Versioned contract + drift guard.
ERROR_CONTRACT_VERSION(date-based) ships inerrors.jsonand on theAblo-Versionresponse header, so consumers detect contract changes without diffing docs. Generatederrors.mdx/errors.jsonplus a CI drift guard keep the docs, OpenAPI spec, and SDK from silently diverging from the registry. - Always-on request correlation. Every response carries a
req_…request id (honoring an inboundx-request-id), stamped into the envelope’srequest_id. - OpenAPI parity. The stale
{ error, reason }schema is replaced by the canonical envelope plus a generatedErrorCodeenum.
- Schema diff + migration planning engine (
generateProvisionPlan/generateMigrationPlanin@abloatai/ablo/schema) — pure diff, classify, apply, and constant-value backfill for required-field migrations. ablo generate— emit TypeScript types from the pushed schema.- Full
abloCLI suite, Stripe-CLI-shaped:init,login/logout/status,mode [test|live],dev(push schema to the test sandbox + watch),logs(tail your scope’s commit activity), and the data-source commands below. Authentication is the OAuth 2.0 device flow;loginprovisions and stores a test and a live key, andmodeswitches the active one. - Database-URL structure (bring-your-own-database). The CLI is split by where
it writes:
ablo pull/ablo check/ablo migrateoperate on your ownDATABASE_URL—pullintrospects it to emitdefineSchema(...)from existing tables (read-only, likeprisma db pull),checkverifies tables fit the schema with no DDL, andmigrateapplies DDL toDATABASE_URL.ablo schema push/ablo devtarget the hosted test/live sandbox; the server diffs, migrates, and activates the uploaded schema.devnever touches live data.
useQuery/useOne/useMutate/useReader. UseuseAblo()+ablo.<model>.*instead. TheMutateActions,ReaderActions, andReaderFindOptionstypes are still re-exported for callers that referenced them. - Structured error contract across HTTP + WS planes. A closed, canonical
error-code registry is now the
Minor Changes
-
0f663e7: Coordination surface: fair queue, reactive wait-line, and lease renewal.
- Claims acquire through a server FIFO queue. On contention a claim waits its turn and re-reads before proceeding; reads are never blocked. Writes blocked by another participant’s claim throw a typed
AbloBusyError. ablo.<model>.queue(id)— reactive read of the wait-line behind a row: who’s queued, their action, and FIFO position. Synced to peers likeactivity(id).- Backpressure on
claim—{ wait: false }skips instead of waiting if the row is already held (claim-or-skip dedup);{ maxQueueDepth: n }bails withAbloBusyError('queue_too_deep')rather than joining a line already that deep. - Lease renewal — a held claim renews automatically while the holder’s connection is alive, so you never size a TTL; it lapses only after the holder goes silent. A queued claim that’s abandoned is dequeued (no ghost waiters).
- Reads are never gated by a claim, including for agents.
- Intent vocabulary cleanup: a waiting claim is an
Intentwithstatus: 'queued'(positioncarries its place in line). Removed the unbuiltwhenFree.
- Claims acquire through a server FIFO queue. On contention a claim waits its turn and re-reads before proceeding; reads are never blocked. Writes blocked by another participant’s claim throw a typed
-
BREAKING — API renames (apply when upgrading from 0.5.1):
- Change-listeners renamed to
.onChange(...):ablo.<model>.subscribe(cb),presence.subscribe(),intents.subscribe()→.onChange(...). (subscribeis reserved for an upcoming scope-grant verb.) - Row-access API renamed Resource → Model:
Ablo.Resource.*→Ablo.Model.*,ablo.resource(name)→ablo.model(name),ModelTarget.resource→ModelTarget.model, error coderesource_not_found→model_not_found.
- Change-listeners renamed to
Patch Changes
- Docs: add a React quick-start (provider +
useAblo), plain-language rewrite, and a “Set up with Claude Code” section.
Minor Changes
-
9154c1b: Rename intent handle methods to a clearer claim vocabulary; add
AbloProviderbootstrapMode. BREAKING — on the model intent handle (ablo.<model>.intent(id)):acquire→claim,acquireOrAwait→claimOrWait,settled→whenFree,release→finish,revoke→cancel. The lower-levelIntentHandle/IntentLeaseHandle(ablo.intents.*) are unchanged. Also:AbloProvidergains abootstrapModeprop ('full' | 'none') to skip the baseline pull on read-light pages;StaleContextConflictgains an optionalconflictingFields; README + JSDoc clarity pass and a new HTTP API section.
Minor Changes
-
Per-entity coordination intents on the model accessor.
Coordinate writes to an entity through the same accessor you read it with —
ablo.<model>.intent(id), returning aModelIntentHandle. Intent state is one self-describing object ({ object: 'intent', id, status, target, action, heldBy, participantKind, createdAt?, expiresAt? }) with a single lifecycle:status: 'active' | 'committed' | 'expired' | 'canceled'. Anactiveintent is the lock.Added
ablo.<model>.intent(id)→ModelIntentHandle<T>, besidecreate/update/retrieve/loadon every model.- Read side (any participant, synchronous + reactive):
current(the holder’s intent, ornull),status('idle'when free),settled(). - Write side (the holder):
acquire(),acquireOrAwait(), lease-guardedupdate(),release(),revoke(). AsyncDisposable:await using lock = ablo.<model>.intent(id)auto-releases on scope exit.
- Read side (any participant, synchronous + reactive):
acquireOrAwait()— serialize-on-contention: take the lease, or wait out the current holder, re-read the changed row, then take it. The caller never branches on who holds the target — it just gets the target safely. Bind it to an agent’s write-tool boundary so agents never reason about coordination.- New exports:
ModelIntentHandle,ModelIntentAcquireOptions.
Changed
acquire()is fire-and-forget over the socket — it does not throw on conflict. Resolve contention withacquireOrAwait()(wait) or readcurrentfor a reactive “who’s editing” badge, rather than catching a rejection.
Deprecated
- Participant-level
intents.claim()/onRejected()and theintent_rejectedwire frame still work but are superseded by the per-model handle. Their removal is a future breaking change.
Unreleased
Schema-driven identity sync-group composition, plus a terser capability surface.The convention for deriving a participant’s allowed sync-groups from its identity is now declared on the consumer’s schema as an open registration. Consumers with a{ regionId, customerId } identity shape declare their own roles instead of receiving any built-in prefixes from the SDK.Capability fields shed their redundant allowed prefix to match the surrounding vocabulary — capability inputs always describe what the bearer can touch, so the prefix was doing no disambiguation work for the consumer.Added
DefineSchemaOptions.identityRoles?: readonly IdentityRole[]— open registration of identity-anchored sync-group roles ondefineSchema(...). EachIdentityRoledeclares{ kind, template, extract }: a diagnostic label, a'<prefix>:{id}'template, and a pure extractor function from an opaque identity context to zero-or-more ids. No closed enum; consumers fully control both the template strings and the extraction logic.composeIdentitySyncGroups(identity, schema)exported from@abloatai/ablo/schema— walks the schema’s registeredidentityRoles, calls each extractor, and substitutes ids into templates. Stable, deduped output. Returns[]when no roles are registered.Schema.identityRoles: readonly IdentityRole[]— the registered list, accessible on everydefineSchema(...)result.- New exported types:
IdentityRole,IdentityContext.
Breaking
-
capabilities.create({ allowedSyncGroups, allowedOperations })→capabilities.create({ syncGroups, operations }). Both fields renamed at every public surface — capability create input, capability retrieve response, capability record, Identity returned fromAuthProvider. Hard rename, no alias. Update the call sites; the field semantics are unchanged.
Changed
docs/integration-guide.md§1 now showsidentityRolesin the canonicaldefineSchemaexample plus a “Declaring scope on a model” subsection coveringorgScoped/scopedVia/syncGroupFormat.docs/capabilities.md,docs/api.md,docs/mcp.md, andAGENTS.mdcross-reference theidentityRolessection and use the renamed fields throughout.
Umbrella No breaking change to
<AbloProvider> for React apps. One provider component now owns the full lifecycle — singleton rotation on auth change, Strict-Mode-safe bootstrap, beforeunload cleanup, session-expiry IndexedDB wipe, post-bootstrap hooks, mesh client construction. Replaces the ad-hoc provider glue every consumer had to write themselves.Declarative props absorb every class of lifecycle glue; the status hook returns a tagged union so impossible states are unrepresentable. The reference integration shrank from 515 LOC of hand-rolled singleton/AbortController/beforeunload/reaction-bridge wiring to a 60-LOC thin wrapper that just passes props through.Added
<AbloProvider>— umbrella provider at@abloatai/ablo/react. Props include data config (schema,url,userId,organizationId), auth (capabilityToken/apiKey/ session cookie fallback), declarative behavior (preventUnsavedChanges,lostConnectionTimeout,postBootstrap), callbacks (onSessionExpired,onError,resolveUsers), and DI escape hatches.<SyncGroupProvider id="matter:...">+useSyncGroup()— per-entity scope context.<ClientSideSuspense fallback={...}>— gate renders until the engine reportsconnected. Phase-1 non-Suspense; phase-2 upgrades to real Suspense.useSyncStatus()rewritten as a tagged union:{ name: 'initial' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'needs-auth', ... }. Impossible states are unrepresentable.useCurrentUserId()— returns theuserIdprop. Replaces downstream consumers’ defineProperty hacks on the store.useErrorListener(cb)— imperative error callback (Sentry/Datadog).useSync<R>()anduseSyncStore<T>()accept generic parameters so consumers can widen to their concrete schema types withoutas unknowncasts at call sites.BaseSyncedStore.purge()/SyncEngine.purge()— disconnect + wipe everyablo_*/ablo-*IndexedDB. Called automatically on session expiry.SyncEngine.onSessionError(listener)— subscribe to session-error events. Multiple subscribers supported.- Commit payload projection built into
TransactionQueue. Mutations are automatically projected onto the model’s schema-declared fields (dropping framework internals__class/__typename/clientId/syncStatusand anything not declared), withfield.json()values auto-stringified for TEXT columns andundefineddropped on updates. No config port, no consumer hook — the SDK derives correct wire payloads from the schema alone. Apps that previously maintained hand-rolled extractor tables can delete them entirely.
Breaking (continued)
- Removed
SyncEngineConfig.extractCreateInputandSyncEngineConfig.buildUpdateInput. The SDK’s built-in projection replaces them. Consumers who passed these inconfigOverridesshould delete the override; the default now covers 100% of identity-column mutations. TheconfigOverridesprop still exists but its remaining fields are all deprecated (see below) and scheduled for removal in v0.4.
Deprecated (vestigial — removal in v0.4)
SyncEngineConfig.modelCreatePriority,defaultCreatePriority,defaultNonCreatePriority— never read at runtime.SyncEngineConfig.batchableModels— never read at runtime.SyncEngineConfig.dedicatedDeleteModels— never read at runtime.SyncEngineConfig.preserveCaseModels— never read at runtime.SyncEngineConfig.essentialFields— used only in debug logging, no behavioral effect.SyncEngineConfig.classNameFallbackMap— dead path;ModelRegistry.registerModelsFromSchemaregisters by constructor identity, bypassing the class-name fallback entirely.
Breaking
- Removed
<SyncProvider>— folded into<AbloProvider>. Migrate by swapping the provider and passinguserId/organizationId/urlinstead of a pre-constructed store. - Removed
createAbloContext()factory and its returnedAbloProvider/useAblo/useParticipanttriple. Mesh is now always-on inside<AbloProvider>;useAblo()anduseParticipant(opts)are always available. Schema-typed mesh hooks are on the roadmap. - Removed
withSync(no-op alias ofobserver). Importobserverfrommobx-react-litedirectly if needed. - Removed
useSyncContextfrom the public surface (never used outside the SDK’s test helpers). useSyncStatus()return shape changed from six booleans to a tagged union. Migration:const { isReady } = useSyncStatus()→const status = useSyncStatus(); const isReady = status.name === 'connected'.SyncStoreContractgained six sync-status getters and asyncStatusfield. Third-party classes implementing the contract must add these (additive for callers).
Migration
useQuery / useOne / useMutate / useReader / useMutators / useUndoScope / usePresence / useIntent — call sites remain source-compatible.React bindings hardening. Fixes two infinite-loop classes that surfaced in downstream apps as React error #185 (“Maximum update depth exceeded”), and exposes sync-status reactivity as a first-class observable + hook.
Fixed
useQuery/useOneno longer loop ongetSnapshot. TheuseSyncExternalStoreadapter was returning a freshview.results.slice()on every call, which React’s post-commit consistency check interpreted as “store updated mid-render” — scheduling another render, another snapshot, another mismatch, ad infinitum. The snapshot is now cached in a ref and only refreshed inside the subscribe callback right beforeonChange()fires. Affected every tree with multiple simultaneoususeQuerysubscribers.
Added
BaseSyncedStoresync status is now properly observable.syncStatusanddataReadyare annotatedobservable;isReady,isSyncing,isOffline,isReconnecting,isError,hasUnsyncedChangesarecomputed. Before, these were plain getters over plain fields —reaction(() => store.isReady, ...)silently never fired. Existingobserver/reactioncall sites that relied on the implicitpool.sizetrigger will continue to work; new call sites should read these observables directly.useSyncStatus()React hook. Returns{ isReady, isSyncing, isOffline, isReconnecting, isError, hasUnsyncedChanges }as a reactive snapshot, bridged viauseSyncExternalStorewith a correctly-cached snapshot. Replaces hand-rolledreactionbridges in consumer providers. Seedocs/react.md.SyncStoreContractsurfaces the status getters so TypeScript autocomplete works from theuseSyncContext()return value without a cast.
Documentation
llms.txtanddocs/react.mdgained a “Common pitfalls” section covering the three traps this release addresses: don’t wrap providers inobserver(),getSnapshotmust return a cached reference, and sync-status fields are real observables (don’t watchpool.sizeas a proxy).
Migration
No breaking changes. Optional: replace any localreaction(() => store.isReady, setReady, { fireImmediately: true }) bridges in your own providers with const { isReady } = useSyncStatus() for consumers below the store provider.Mesh SDK — the canonical agent-multiplayer surface. Locked at this release; further work is consolidation, not expansion.
What’s frozen
The SDK covers exactly three integration shapes. Each has a canonical example inexamples/:- Server agent —
new Ablo({ schema })readsABLO_API_KEY, joins and works. (examples/server-agent.ts) - Browser app — server mints a scoped capability, browser holds it via
new Ablo({ schema, capabilityToken }). No API key in bundle, no session cookies, no allowed-origins registration required. Stripeclient_secretshape. (examples/browser-app.ts) - Sub-agent —
parent.join(child, opts)attenuates from the parent’s capability. (examples/sub-agent.ts)
Ergonomics (package-wide)
Abloclass —import Ablo from '@abloatai/ablo'/new Ablo({ schema }). Matchesnew Stripe()/new OpenAI()/new Anthropic()pattern.createMesh(opts)stays available as the functional alias.- Model-scoped joins —
ablo.matters.join(id, { label })desugars to the genericjoin. Proxy-based so the namespace adapts to any schema. Collisions with reserved admin fields (roles,members,audit,capabilities) throw at construction time. - Flat scope form —
scope: { matters: id }alongside the array form. asalias —{ as: session({...}) }replaces the security-jargononBehalfOf; both still accepted.- Auto-connect —
join()returns a connected participant.autoConnect: falseto opt out. - Duration strings —
ttl: '3m',ttlSeconds: '24h'accepted alongside numbers. - Descriptive generics — every public type uses
TSchema/TAgent/ModelNameinstead ofS/A/K. Zerounknownin public types.
Coordination primitives
- Presence verbs —
participant.presence.editing(target)/viewing(target)/idle(). Plusupdate({...})escape hatch for custom actions. - Intent verbs —
participant.intents.editing(target, opts)/writing(target, opts). Returns anIntentHandlewithSymbol.asyncDisposesoawait using work = ...auto-revokes. - Snapshots —
const snap = await participant.snapshot({ clauses: [id] }). Flat shape:snap.clauses[id](typed from schema viaInferModel, notunknown),snap.stamp,snap.signal(AbortSignal). - Async iterables —
for await (const peers of participant.presence),for await (const openIntents of participant.intents),for await (const delta of participant.deltas).
Env / config
ABLO_API_KEY— required for server-side use.baseURL— optional override for private deployments / local-dev (defaults towss://api.abloatai.com).organizationId— no longer required increateMesh. The API key or session binds the caller to one org; the capability mint response echoes it back.createMeshFromEnv— removed.new Ablo({ schema })auto-reads env.
Test coverage
- 53 mesh unit tests across 8 suites (
__tests__/unit/mesh/) - New E2E test
e2e-browser-capability-token.tsproves the server-mints / browser-holds flow end-to-end - Existing 12 mesh E2E tests (token refresh, watermark, chinese wall, etc.) still pass
Initial release.
Features
- Schema DSL: Zero-codegen schema definition with full TypeScript inference (
defineSchema,field,relation) - React Hooks:
useModels,useModel,useMutations,withSyncfor reactive data binding - Consumer API:
createSyncEngine()— one-liner setup that hides all internal wiring - Offline-first: IndexedDB persistence with automatic offline mutation queue and FK-safe flush
- Real-time sync: WebSocket delta streaming with optimistic updates and rollback
- AI Agent SDK:
SyncAgentfor backend/AI agent participation as first-class sync citizens - Pluggable auth:
AuthProviderinterface with built-in API key, JWT, and session providers - Security: IndexedDB cleanup on session expiry and sync group revocation
- Testing utilities:
@abloatai/ablo/testingsubpath with mocks, fixtures, and harness
Test Coverage
- 231 unit/integration/property/contract tests
- 50 E2E tests against real Go server + PostgreSQL + Redis
- Property-based testing via fast-check