@abloatai/ablo 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +62 -23
- package/dist/cli.cjs +115 -19
- package/dist/client/Ablo.js +16 -1
- package/dist/client/ApiClient.js +4 -1
- package/dist/client/auth.d.ts +9 -4
- package/dist/client/auth.js +40 -5
- package/dist/client/createModelProxy.d.ts +25 -0
- package/dist/client/createModelProxy.js +79 -4
- package/dist/errorCodes.js +1 -1
- package/dist/schema/schema.d.ts +3 -3
- package/dist/transactions/TransactionQueue.d.ts +11 -0
- package/dist/transactions/TransactionQueue.js +60 -4
- package/dist/types/global.d.ts +8 -3
- package/dist/types/global.js +8 -3
- package/docs/api.md +3 -3
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +13 -3
- package/docs/data-sources.md +29 -9
- package/docs/migration.md +40 -0
- package/docs/quickstart.md +61 -33
- package/docs/react.md +46 -0
- package/llms-full.txt +25 -8
- package/llms.txt +11 -9
- package/package.json +1 -1
|
@@ -18,6 +18,7 @@ import { autorun } from 'mobx';
|
|
|
18
18
|
import { AbloClaimedError, AbloValidationError, formatClaimedErrorMessage, toAbloError, } from '../errors.js';
|
|
19
19
|
import { descriptionFromMeta } from '../coordination/schema.js';
|
|
20
20
|
import { Model, modelAsRow } from '../Model.js';
|
|
21
|
+
import { toMs } from '../utils/duration.js';
|
|
21
22
|
import { assertWriteOptions } from './writeOptionsSchema.js';
|
|
22
23
|
import { ModelScope } from '../types/index.js';
|
|
23
24
|
const modelClientMeta = new WeakMap();
|
|
@@ -75,7 +76,17 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
75
76
|
// Claims this proxy currently holds, keyed by entity id. Lets the flat
|
|
76
77
|
// `release({ id })` and `update({ id, data })` find the lease + snapshot a `claim({ id })`
|
|
77
78
|
// took — no per-call handle. Released on dispose, explicit release, or TTL.
|
|
79
|
+
//
|
|
80
|
+
// `target` / `action` / `expiresAt` are kept alongside the lease so
|
|
81
|
+
// `claim.state` can synthesize a self-claim: the server excludes a holder's
|
|
82
|
+
// own presence frames, so the local proxy is the ONLY place that knows "I
|
|
83
|
+
// hold this." `expiresAt` is the client's best estimate from the requested
|
|
84
|
+
// TTL (a genuine epoch-ms expiry, not a fabricated watermark), defaulting to
|
|
85
|
+
// the server's keepalive lease window when no TTL was requested.
|
|
78
86
|
const activeClaims = new Map();
|
|
87
|
+
// Server keepalive lease window (Hub `LEASE_RENEW_TTL_MS`). The fallback
|
|
88
|
+
// expiry estimate when a claim is taken without an explicit TTL.
|
|
89
|
+
const DEFAULT_LEASE_TTL_MS = 90_000;
|
|
79
90
|
const isClaimHandle = (value) => typeof value === 'object' &&
|
|
80
91
|
value !== null &&
|
|
81
92
|
value.object === 'claim' &&
|
|
@@ -124,7 +135,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
124
135
|
};
|
|
125
136
|
const takeClaim = async (params) => {
|
|
126
137
|
if (!collaboration) {
|
|
127
|
-
throw new AbloValidationError(`Model "${schemaKey}"
|
|
138
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
|
|
128
139
|
}
|
|
129
140
|
const { id, ...options } = params;
|
|
130
141
|
// Is someone ELSE already on this target? Read the local coordination
|
|
@@ -157,6 +168,14 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
157
168
|
if (!model) {
|
|
158
169
|
throw new AbloValidationError(`Entity not found: ${registeredModelName}/${id}`, { code: 'entity_not_found' });
|
|
159
170
|
}
|
|
171
|
+
// Write-intent: enter the entity scope BEFORE acquiring the lease so the
|
|
172
|
+
// holder's claim presence broadcasts to whoever is in this entity group —
|
|
173
|
+
// including a peer that subscribed just before us. Pinning before the
|
|
174
|
+
// lease (rather than after) closes the subscribe-vs-broadcast race: the
|
|
175
|
+
// server fans `broadcastPresenceChange` out at claim time, so we must be
|
|
176
|
+
// in the group when `createClaim` lands. Awaited because the broadcast
|
|
177
|
+
// ordering depends on it; still soft (the store swallows reconcile errors).
|
|
178
|
+
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
160
179
|
// Acquire the lease. Default (`wait` !== false) goes through the server's
|
|
161
180
|
// fair FIFO queue — `queue: true` resolves only once the lease is genuinely
|
|
162
181
|
// ours, blocking behind any current holder, with no TOCTOU gap (the server
|
|
@@ -194,7 +213,27 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
194
213
|
model = objectPool.get(id) ?? model;
|
|
195
214
|
}
|
|
196
215
|
const snapshot = collaboration.createSnapshot(schemaKey, id);
|
|
197
|
-
|
|
216
|
+
const action = options?.action ?? 'editing';
|
|
217
|
+
// The self-claim's `EntityRef` mirrors what a peer's `claim.state` would
|
|
218
|
+
// report (`observe` maps `held.target.model` → `type`), so a holder and a
|
|
219
|
+
// peer see the SAME target.type for one row — the wire model token.
|
|
220
|
+
const selfTarget = {
|
|
221
|
+
type: wireModel,
|
|
222
|
+
id,
|
|
223
|
+
...(options?.field ? { field: options.field } : {}),
|
|
224
|
+
...(options?.path ? { path: options.path } : {}),
|
|
225
|
+
...(options?.range ? { range: options.range } : {}),
|
|
226
|
+
...(claimMeta(options) ? { meta: claimMeta(options) } : {}),
|
|
227
|
+
};
|
|
228
|
+
const expiresAt = Date.now() +
|
|
229
|
+
(options?.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS);
|
|
230
|
+
activeClaims.set(id, {
|
|
231
|
+
lease,
|
|
232
|
+
snapshot,
|
|
233
|
+
target: selfTarget,
|
|
234
|
+
action,
|
|
235
|
+
expiresAt,
|
|
236
|
+
});
|
|
198
237
|
const target = {
|
|
199
238
|
model: schemaKey,
|
|
200
239
|
id,
|
|
@@ -209,7 +248,7 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
209
248
|
claimId: lease.claimId,
|
|
210
249
|
readAt: snapshot.stamp,
|
|
211
250
|
target,
|
|
212
|
-
action
|
|
251
|
+
action,
|
|
213
252
|
...(options?.description ? { description: options.description } : {}),
|
|
214
253
|
data: modelAsRow(model),
|
|
215
254
|
release,
|
|
@@ -226,6 +265,28 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
226
265
|
// are the same object.
|
|
227
266
|
const claimApi = Object.assign(guard(claim), {
|
|
228
267
|
state(params) {
|
|
268
|
+
// Read-interest: a passive observer subscribing to a row's claim state
|
|
269
|
+
// must enter that row's entity scope, or it sits on `org:`/`user:`
|
|
270
|
+
// groups only and never receives the holder's entity-scoped claim
|
|
271
|
+
// presence. Soft + fire-and-forget — never blocks or rejects the read.
|
|
272
|
+
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
273
|
+
// Self-awareness: the server excludes a holder's OWN presence frames and
|
|
274
|
+
// the client skips them, so `observe` returns null for a row WE hold.
|
|
275
|
+
// Synthesize the active claim for self from the stored lease so the
|
|
276
|
+
// holder sees its own claim (the JSDoc contract on `claim.state`).
|
|
277
|
+
const own = activeClaims.get(params.id);
|
|
278
|
+
if (own) {
|
|
279
|
+
return {
|
|
280
|
+
object: 'claim',
|
|
281
|
+
id: own.lease.claimId,
|
|
282
|
+
status: 'active',
|
|
283
|
+
target: own.target,
|
|
284
|
+
action: own.action,
|
|
285
|
+
heldBy: collaboration?.selfParticipantId ?? '',
|
|
286
|
+
participantKind: collaboration?.selfParticipantKind ?? 'user',
|
|
287
|
+
expiresAt: own.expiresAt,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
229
290
|
return collaboration?.observe({ model: wireModel, id: params.id }) ?? null;
|
|
230
291
|
},
|
|
231
292
|
queue(params) {
|
|
@@ -241,6 +302,11 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
241
302
|
});
|
|
242
303
|
const operations = {
|
|
243
304
|
retrieve: guard(async (params) => {
|
|
305
|
+
// Read-interest enrolment: READ a row → enter its entity scope, so a
|
|
306
|
+
// Node/agent client lands in the same group the holder's claim presence
|
|
307
|
+
// fans out on and `claim.state`/`claim.queue` report peers. Soft +
|
|
308
|
+
// fire-and-forget — never make the read reject or slower.
|
|
309
|
+
void collaboration?.enterScope?.({ [schemaKey]: params.id });
|
|
244
310
|
const rows = await load({
|
|
245
311
|
...params,
|
|
246
312
|
where: [['id', params.id]],
|
|
@@ -248,6 +314,9 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
248
314
|
});
|
|
249
315
|
return rows[0];
|
|
250
316
|
}),
|
|
317
|
+
// NB: no auto scope enrolment on bulk `list`/`getAll` — that would
|
|
318
|
+
// subscribe to an unbounded set of rows' entity groups. Bulk-list scope
|
|
319
|
+
// enrolment is a deliberate follow-up (a bounded, opt-in policy).
|
|
251
320
|
list: guard(load),
|
|
252
321
|
get(id) {
|
|
253
322
|
return objectPool.get(id);
|
|
@@ -295,8 +364,14 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
|
|
|
295
364
|
let autoLease;
|
|
296
365
|
if (claim && !isClaimHandle(claim)) {
|
|
297
366
|
if (!collaboration) {
|
|
298
|
-
throw new AbloValidationError(`Model "${schemaKey}"
|
|
367
|
+
throw new AbloValidationError(`Model "${schemaKey}" was built without the collaboration runtime, so claim() is unavailable here. Claiming needs no per-model config — use the standard Ablo({ schema, apiKey }) client and every model is claimable.`, { code: 'model_claim_not_configured' });
|
|
299
368
|
}
|
|
369
|
+
// Write-intent: enter the new row's entity scope BEFORE acquiring the
|
|
370
|
+
// create-claim so the holder's claim presence broadcasts to whoever is
|
|
371
|
+
// already in this entity group (closing the subscribe-vs-broadcast
|
|
372
|
+
// race — see `takeClaim`). Released with the lease in the `finally`
|
|
373
|
+
// below. Awaited for broadcast ordering; still soft.
|
|
374
|
+
await collaboration.pinScope?.({ [schemaKey]: id });
|
|
300
375
|
autoLease = await collaboration.createClaim({
|
|
301
376
|
target: {
|
|
302
377
|
model: wireModel,
|
package/dist/errorCodes.js
CHANGED
|
@@ -158,7 +158,7 @@ export const ERROR_CODES = {
|
|
|
158
158
|
malformed_subscription: wire('validation', 400, false, 'The update_subscription payload was malformed; expected { syncGroups: string[] }.'),
|
|
159
159
|
model_claimed: wire('claim', 409, false, 'The model instance is claimed by another participant.'),
|
|
160
160
|
model_claimed_timeout: wire('claim', 409, false, 'Timed out waiting for a model claim to clear.'),
|
|
161
|
-
model_claim_not_configured: client('claim', 'Claiming
|
|
161
|
+
model_claim_not_configured: client('claim', 'Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically — there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path).'),
|
|
162
162
|
// ── stale context / idempotency (409) ──────────────────────────────
|
|
163
163
|
stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
|
|
164
164
|
idempotency_conflict: wire('conflict', 409, false, 'The same Idempotency-Key was reused with a different request body.'),
|
package/dist/schema/schema.d.ts
CHANGED
|
@@ -116,13 +116,13 @@ export interface Schema<S extends SchemaRecord = SchemaRecord> {
|
|
|
116
116
|
* ```
|
|
117
117
|
*/
|
|
118
118
|
/** The schema bound via `declare module … interface Register { Schema: … }`
|
|
119
|
-
* (the `ablo.
|
|
119
|
+
* (the `ablo/register.ts` the scaffold writes). `never` when not registered. */
|
|
120
120
|
type RegisteredSchema = import('../types/global.js').Register extends {
|
|
121
121
|
Schema: infer S extends Schema;
|
|
122
122
|
} ? S : never;
|
|
123
123
|
/**
|
|
124
|
-
* THE model type helper. With the scaffold's `ablo.
|
|
125
|
-
* place, one parameter is all it takes:
|
|
124
|
+
* THE model type helper. With the scaffold's `ablo/register.ts` registration
|
|
125
|
+
* in place, one parameter is all it takes:
|
|
126
126
|
*
|
|
127
127
|
* ```ts
|
|
128
128
|
* type Task = Model<'tasks'>;
|
|
@@ -427,6 +427,17 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
427
427
|
private extractUpdateData;
|
|
428
428
|
private buildUpdateInput;
|
|
429
429
|
private extractPreviousData;
|
|
430
|
+
/**
|
|
431
|
+
* Re-baseline `modifiedProperties` for the fields a freshly-staged update just
|
|
432
|
+
* committed. Called right after {@link extractPreviousData} freezes their
|
|
433
|
+
* `.old` into the transaction, so the NEXT update to the same field sees this
|
|
434
|
+
* update's result as its baseline rather than the stale pre-session `.old`
|
|
435
|
+
* preserved by `Model.propertyChanged`'s first-old-wins policy. Only consumes
|
|
436
|
+
* keys present in this update — untouched fields keep their baselines. Safe
|
|
437
|
+
* because the wire payload lives on `transaction.data` and rollback restores
|
|
438
|
+
* from `transaction.previousData`; neither re-reads `modifiedProperties`.
|
|
439
|
+
*/
|
|
440
|
+
private consumeModifiedFields;
|
|
430
441
|
/**
|
|
431
442
|
* Public API
|
|
432
443
|
*/
|
|
@@ -773,6 +773,15 @@ export class TransactionQueue extends EventEmitter {
|
|
|
773
773
|
? this.mapChangesToInput(actualModelName, precomputedChanges)
|
|
774
774
|
: this.extractUpdateData(model);
|
|
775
775
|
const previousData = this.extractPreviousData(model, updateInput);
|
|
776
|
+
// Advance the per-field baseline for the keys we just froze into this
|
|
777
|
+
// transaction. `Model.propertyChanged` is first-old-wins and only cleared on
|
|
778
|
+
// sync-ack, so without this a SECOND update to the same field before the
|
|
779
|
+
// first acks would re-capture the original `.old` (the pre-session value)
|
|
780
|
+
// instead of THIS update's result — corrupting the stream-recorded undo
|
|
781
|
+
// inverse (the second move's "before" would point all the way back). The
|
|
782
|
+
// wire payload is already frozen in `transaction.data`, so dropping the
|
|
783
|
+
// consumed entries is safe. Mirrors `RecordingTransaction.consumeModifiedFields`.
|
|
784
|
+
this.consumeModifiedFields(model, updateInput);
|
|
776
785
|
const modelKey = normalizeModelKey(actualModelName);
|
|
777
786
|
const priorityScore = this.computePriorityScore('update', actualModelName);
|
|
778
787
|
const transaction = {
|
|
@@ -1991,16 +2000,63 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1991
2000
|
// expose a typed `getPreviousData()` accessor on Model and call that.
|
|
1992
2001
|
extractPreviousData(model, updateInput) {
|
|
1993
2002
|
const prev = { id: model.id };
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
2003
|
+
const modified = model.modifiedProperties instanceof Map ? model.modifiedProperties : null;
|
|
2004
|
+
// When the update's written keys are known, capture a before-image for
|
|
2005
|
+
// EXACTLY those keys so the recorded undo inverse can revert them and only
|
|
2006
|
+
// them (a full-row inverse would clobber concurrent edits to unrelated
|
|
2007
|
+
// fields). Resolution order mirrors `RecordingTransaction.snapshotFields`:
|
|
2008
|
+
// 1. `modifiedProperties.old` — first-old-wins pre-session baseline, set
|
|
2009
|
+
// whenever the caller mutated the field in place before committing.
|
|
2010
|
+
// 2. `getOriginalSnapshot()` — the last loaded/acked row, the correct
|
|
2011
|
+
// before-image for a key written WITHOUT a prior in-place mutation
|
|
2012
|
+
// (e.g. a `precomputedChanges` write).
|
|
2013
|
+
// Without (2) such a key yields an empty `previousData`, and `buildUndoOps`
|
|
2014
|
+
// nulls the inverse entirely — making updates silently un-undoable where a
|
|
2015
|
+
// create's `delete(id)` inverse never is. This closes that asymmetry.
|
|
2016
|
+
if (updateInput) {
|
|
2017
|
+
const original = model.getOriginalSnapshot();
|
|
2018
|
+
for (const key of Object.keys(updateInput)) {
|
|
2019
|
+
if (key === 'id')
|
|
1998
2020
|
continue;
|
|
2021
|
+
const mod = modified?.get(key);
|
|
2022
|
+
if (mod) {
|
|
2023
|
+
prev[key] = mod.old;
|
|
2024
|
+
}
|
|
2025
|
+
else if (original && key in original) {
|
|
2026
|
+
prev[key] = original[key];
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
return prev;
|
|
2030
|
+
}
|
|
2031
|
+
if (modified && modified.size > 0) {
|
|
2032
|
+
for (const [key, change] of modified) {
|
|
1999
2033
|
prev[key] = change.old;
|
|
2000
2034
|
}
|
|
2001
2035
|
}
|
|
2002
2036
|
return prev;
|
|
2003
2037
|
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Re-baseline `modifiedProperties` for the fields a freshly-staged update just
|
|
2040
|
+
* committed. Called right after {@link extractPreviousData} freezes their
|
|
2041
|
+
* `.old` into the transaction, so the NEXT update to the same field sees this
|
|
2042
|
+
* update's result as its baseline rather than the stale pre-session `.old`
|
|
2043
|
+
* preserved by `Model.propertyChanged`'s first-old-wins policy. Only consumes
|
|
2044
|
+
* keys present in this update — untouched fields keep their baselines. Safe
|
|
2045
|
+
* because the wire payload lives on `transaction.data` and rollback restores
|
|
2046
|
+
* from `transaction.previousData`; neither re-reads `modifiedProperties`.
|
|
2047
|
+
*/
|
|
2048
|
+
consumeModifiedFields(model, updateInput) {
|
|
2049
|
+
if (!(model.modifiedProperties instanceof Map) || model.modifiedProperties.size === 0) {
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
for (const key of [...model.modifiedProperties.keys()]) {
|
|
2053
|
+
if (key === 'id')
|
|
2054
|
+
continue;
|
|
2055
|
+
if (updateInput && !(key in updateInput))
|
|
2056
|
+
continue;
|
|
2057
|
+
model.modifiedProperties.delete(key);
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2004
2060
|
/**
|
|
2005
2061
|
* Public API
|
|
2006
2062
|
*/
|
package/dist/types/global.d.ts
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* global, not prefixed). It's a language feature, not a library trick: any file
|
|
13
13
|
* in the compilation can augment it and every resolver below picks it up.
|
|
14
14
|
*
|
|
15
|
-
* Consumer example
|
|
15
|
+
* Consumer example (`npx ablo init` scaffolds this as `ablo/register.ts`, a
|
|
16
|
+
* sibling of `ablo/schema.ts`). It's a regular `.ts` module, NOT a hand-authored
|
|
17
|
+
* `.d.ts`: the top-level `import type { schema }` makes the `declare module`
|
|
18
|
+
* block MERGE (augment) this interface rather than collide with it — the same
|
|
19
|
+
* shape TanStack Router uses in `src/router.tsx`. Any `.ts` file in the
|
|
20
|
+
* `tsconfig` `include` works; it never needs to be imported.
|
|
16
21
|
*
|
|
17
22
|
* ```ts
|
|
18
|
-
* //
|
|
19
|
-
* import type { schema } from './
|
|
23
|
+
* // ablo/register.ts
|
|
24
|
+
* import type { schema } from './schema';
|
|
20
25
|
*
|
|
21
26
|
* declare module '@abloatai/ablo' {
|
|
22
27
|
* interface Register {
|
package/dist/types/global.js
CHANGED
|
@@ -12,11 +12,16 @@
|
|
|
12
12
|
* global, not prefixed). It's a language feature, not a library trick: any file
|
|
13
13
|
* in the compilation can augment it and every resolver below picks it up.
|
|
14
14
|
*
|
|
15
|
-
* Consumer example
|
|
15
|
+
* Consumer example (`npx ablo init` scaffolds this as `ablo/register.ts`, a
|
|
16
|
+
* sibling of `ablo/schema.ts`). It's a regular `.ts` module, NOT a hand-authored
|
|
17
|
+
* `.d.ts`: the top-level `import type { schema }` makes the `declare module`
|
|
18
|
+
* block MERGE (augment) this interface rather than collide with it — the same
|
|
19
|
+
* shape TanStack Router uses in `src/router.tsx`. Any `.ts` file in the
|
|
20
|
+
* `tsconfig` `include` works; it never needs to be imported.
|
|
16
21
|
*
|
|
17
22
|
* ```ts
|
|
18
|
-
* //
|
|
19
|
-
* import type { schema } from './
|
|
23
|
+
* // ablo/register.ts
|
|
24
|
+
* import type { schema } from './schema';
|
|
20
25
|
*
|
|
21
26
|
* declare module '@abloatai/ablo' {
|
|
22
27
|
* interface Register {
|
package/docs/api.md
CHANGED
|
@@ -120,18 +120,18 @@ coordination surface is `claim.state({ id })` / `claim.queue({ id })` /
|
|
|
120
120
|
|
|
121
121
|
| Field | Type | Description |
|
|
122
122
|
|---|---|---|
|
|
123
|
-
| `object` | `'
|
|
123
|
+
| `object` | `'claim'` | String representing the object's type. |
|
|
124
124
|
| `id` | string | Unique identifier for the claim. |
|
|
125
125
|
| `status` | `'active' \| 'queued' \| 'committed' \| 'expired' \| 'canceled'` | The whole lifecycle, in one field. `active` is the holder; `queued` is a waiter in the FIFO line behind it. |
|
|
126
126
|
| `target` | `{ type, id, field? }` | What is being coordinated. |
|
|
127
127
|
| `action` | string | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
128
128
|
| `heldBy` | string | Participant id holding the claim. |
|
|
129
|
-
| `participantKind` | `'
|
|
129
|
+
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
130
130
|
| `expiresAt` | string | Ms-epoch at which the server auto-expires it if the holder doesn't finish. |
|
|
131
131
|
|
|
132
132
|
```json
|
|
133
133
|
{
|
|
134
|
-
"object": "
|
|
134
|
+
"object": "claim",
|
|
135
135
|
"id": "claim_3MtwBwLkdIwHu7ix",
|
|
136
136
|
"status": "active",
|
|
137
137
|
"target": { "type": "weatherReports", "id": "report_stockholm", "field": "status" },
|
package/docs/client-behavior.md
CHANGED
|
@@ -29,6 +29,7 @@ Common options:
|
|
|
29
29
|
|---|---|
|
|
30
30
|
| `schema` | Required for typed model clients. |
|
|
31
31
|
| `apiKey` | Bearer credential for trusted server runtimes. Defaults to `ABLO_API_KEY` when available. |
|
|
32
|
+
| `databaseUrl` | Optional, server-only. Registers your Postgres directly (the connection-string path). Pass it explicitly — it is **not** auto-read from the environment. Omit it for a signed Data Source endpoint or the hosted sandbox. The SDK throws if it sees this in a browser. |
|
|
32
33
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
33
34
|
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
34
35
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
@@ -36,9 +37,11 @@ Common options:
|
|
|
36
37
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
|
37
38
|
| `dangerouslyAllowBrowser` | Required before sending an API key from browser code. Prefer a server route instead. |
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
`databaseUrl` is an optional, server-only constructor option. It is **not**
|
|
41
|
+
auto-read from the environment — pass it explicitly to register your Postgres
|
|
42
|
+
directly (the connection-string path). Omit it when you expose a signed
|
|
43
|
+
[Data Source](./data-sources.md) endpoint, or when trying Ablo against the hosted
|
|
44
|
+
sandbox.
|
|
42
45
|
|
|
43
46
|
## Model Methods
|
|
44
47
|
|
package/docs/coordination.md
CHANGED
|
@@ -29,7 +29,7 @@ make:
|
|
|
29
29
|
|
|
30
30
|
| layer | kind | what it does | enforces? |
|
|
31
31
|
|---|---|---|---|
|
|
32
|
-
| **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." | **No.** Advisory only — it never blocks or rejects a write. |
|
|
32
|
+
| **Presence** (`claim.state`, observers) | observation | Broadcasts who is working where, live. Renders cursors / "agent X is editing." Reading or claiming a row auto-enrolls you in its sync group, so `claim.state({ id })` observes co-participants from any client (browser or Node agent) with no manual subscribe step. | **No.** Advisory only — it never blocks or rejects a write. |
|
|
33
33
|
| **Claim** (`claim`/`claim.queue`/`claim.release`) | pessimistic | Reserves a row for one participant. Foreign writers are rejected server-side; contenders join a fair FIFO queue. | **Yes**, between participants — mutual exclusion. |
|
|
34
34
|
| **Stale-context** (`readAt` + `onStale`) | optimistic (LWW) | On commit, rejects a write whose snapshot is older than the row's latest delta. Last-writer-wins detection. | **Yes**, against time — lost-update detection. |
|
|
35
35
|
|
|
@@ -70,7 +70,7 @@ a model row. It's what `claim.state()` returns and what observers render.
|
|
|
70
70
|
| `target` | `EntityRef` | What is being coordinated (`{ model, id, field? }`). |
|
|
71
71
|
| `action` | `string` | Human-readable phase — `'editing'`, `'writing'`, `'reviewing'`. |
|
|
72
72
|
| `heldBy` | `string` | Participant holding (or waiting on) it (e.g. `'agent:forecaster'`). |
|
|
73
|
-
| `participantKind` | `'
|
|
73
|
+
| `participantKind` | `'user' \| 'agent' \| 'system'` | Who's behind it — a human (`user`), an AI (`agent`), or automated infrastructure (`system`). |
|
|
74
74
|
| `position` | `number?` | 0-based place in the FIFO line — present only when `status: 'queued'` (`0` = next behind the holder). |
|
|
75
75
|
| `createdAt` | `string?` | Ms-epoch the holder opened it. Optional — derived shapes may omit it. |
|
|
76
76
|
| `expiresAt` | `string` | Ms-epoch the server reclaims it if the holder goes **silent**. Renewed automatically while the holder's connection stays alive — a crash-cleanup floor, not a duration you size. |
|
|
@@ -167,6 +167,16 @@ ablo.<model>.claim.state({ id })
|
|
|
167
167
|
Read who's currently working on a row, for observers and UI. Synchronous and
|
|
168
168
|
reactive (it reads the local coordination snapshot). Never blocks.
|
|
169
169
|
|
|
170
|
+
**You don't subscribe to anything first.** Reading or claiming a row
|
|
171
|
+
automatically enrolls you in that row's sync group: reading it (including
|
|
172
|
+
`retrieve`/`get`, or `claim.state` itself) gives you **read-interest**, and
|
|
173
|
+
`claim`-ing it gives you a **pinned write-intent**. So `claim.state({ id })`
|
|
174
|
+
observes co-participants on that row from **any** client — a browser, a Server
|
|
175
|
+
Action, or a Node agent — and a holder sees its own claim, with no manual
|
|
176
|
+
subscribe step. There is no `participants.join` to call: the typed
|
|
177
|
+
`ablo.<model>` surface (read / `claim` / `claim.state` / `claim.queue`) is the
|
|
178
|
+
whole coordination API.
|
|
179
|
+
|
|
170
180
|
**Parameters**
|
|
171
181
|
|
|
172
182
|
| name | type | required | description |
|
|
@@ -306,7 +316,7 @@ inspect the `code`.
|
|
|
306
316
|
| `AbloClaimedError` | `claim_conflict` | An `update`/`delete` targets a row another participant holds — the server's pre-commit check rejected it. | — |
|
|
307
317
|
| `AbloClaimedError` | `entity_claimed` | Same conflict, from the commit guard backstop. | — |
|
|
308
318
|
| `AbloStaleContextError` | — | A guarded `update` (under a claim, or any write carrying `readAt`) targets a row that received deltas since the snapshot — your reasoning is stale. | `readAt`, `conflicts[]` |
|
|
309
|
-
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model without collaboration
|
|
319
|
+
| `AbloValidationError` | `model_claim_not_configured` | `claim` called on a model proxy built without the collaboration runtime — an internal/advanced construction path. The standard `Ablo({ schema, apiKey })` client enables claiming for **every** model; there is no per-model claim config to add. | — |
|
|
310
320
|
| `AbloValidationError` | `entity_not_found` | The row id doesn't exist locally or on load. | — |
|
|
311
321
|
|
|
312
322
|
`AbloStaleContextError.conflicts` lists the `(model, id, observedSyncId)` rows
|
package/docs/data-sources.md
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
# Connect Your Database
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
**In production, your database is the system of record.** Every synced model is
|
|
4
|
+
backed by your own Postgres; Ablo is the transaction layer on top of it. There
|
|
5
|
+
are two ways to connect, and they are the same product with the same writes — the
|
|
6
|
+
only difference is where your database credential lives:
|
|
7
7
|
|
|
8
8
|
| | How Ablo reaches your Postgres | Use when |
|
|
9
9
|
|---|---|---|
|
|
10
|
-
| **Connection string** (
|
|
10
|
+
| **Connection string** (primary) | You pass `databaseUrl` to `Ablo(...)` explicitly (it is never auto-read from the environment); Ablo registers the connection and commits each write directly, behind row-level security. | You can hand over a scoped connection string. |
|
|
11
11
|
| **Signed endpoint** | Your app exposes one route built from an ORM adapter; Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
|
|
12
12
|
|
|
13
|
+
> Just trying Ablo? You don't need a database at all to start: the hosted
|
|
14
|
+
> **sandbox** can host rows in Ablo's test plane — pass an `apiKey` only and omit
|
|
15
|
+
> `databaseUrl`, like Stripe test mode. Connect your Postgres (either shape
|
|
16
|
+
> below) when you're ready for it to be the system of record.
|
|
17
|
+
|
|
13
18
|
Either way, you define an Ablo schema with `defineSchema`, `model`, and Zod. The
|
|
14
19
|
Ablo schema describes **only your synced, collaborative models** — the rows Ablo
|
|
15
20
|
coordinates and fans out in realtime. It is *not* your whole-database schema and
|
|
@@ -36,7 +41,7 @@ import { schema } from './ablo/schema';
|
|
|
36
41
|
export const ablo = Ablo({
|
|
37
42
|
schema,
|
|
38
43
|
apiKey: process.env.ABLO_API_KEY,
|
|
39
|
-
databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here
|
|
44
|
+
databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
|
|
40
45
|
});
|
|
41
46
|
```
|
|
42
47
|
|
|
@@ -50,6 +55,20 @@ On first connect the SDK registers the connection — sent once over TLS, stored
|
|
|
50
55
|
sealed, never returned by any API. From then on Ablo commits every confirmed
|
|
51
56
|
write directly to your database and reads canonical rows from it.
|
|
52
57
|
|
|
58
|
+
### A localhost Postgres can't be the system of record
|
|
59
|
+
|
|
60
|
+
This is the connection-string fact people hit first. Ablo's **cloud** registers
|
|
61
|
+
your connection string and connects to your Postgres **over the network**. A
|
|
62
|
+
`localhost` / private-range database (`127.0.0.1`, `192.168.*`, Docker's
|
|
63
|
+
`db:5432`) is unreachable from Ablo's side, so such connection strings are
|
|
64
|
+
**rejected**. Two escape hatches for local development against your own DB:
|
|
65
|
+
|
|
66
|
+
- **Expose a signed Data Source endpoint.** Your app — which *can* reach your
|
|
67
|
+
local DB — proxies Ablo's commits to it. See [Signed Endpoint](#signed-endpoint)
|
|
68
|
+
below. This is the right answer for "my dev DB stays on my machine."
|
|
69
|
+
- **Use the hosted sandbox.** Skip the database entirely: pass an `apiKey` only,
|
|
70
|
+
omit `databaseUrl`, and let Ablo's test plane host the rows while you build.
|
|
71
|
+
|
|
53
72
|
Safety requirements, enforced server-side before the first write:
|
|
54
73
|
|
|
55
74
|
- **Non-superuser role.** The connection must not be a superuser or hold
|
|
@@ -58,11 +77,12 @@ Safety requirements, enforced server-side before the first write:
|
|
|
58
77
|
- **Row-level security on synced tables.** `npx ablo migrate` provisions your
|
|
59
78
|
synced-model tables with `FORCE ROW LEVEL SECURITY` already applied; tables
|
|
60
79
|
you create yourself must do the same.
|
|
61
|
-
- **
|
|
62
|
-
address ranges are rejected.
|
|
80
|
+
- **Network-reachable host.** As above, connection strings resolving to loopback
|
|
81
|
+
or private address ranges are rejected — Ablo connects from its cloud.
|
|
63
82
|
|
|
64
83
|
`databaseUrl` is server-only: the SDK throws if it sees one in a browser-like
|
|
65
|
-
environment, and `dangerouslyAllowBrowser` does not override that.
|
|
84
|
+
environment, and `dangerouslyAllowBrowser` does not override that. It is also
|
|
85
|
+
never auto-read from the environment — pass it explicitly to `Ablo(...)`.
|
|
66
86
|
|
|
67
87
|
## Signed Endpoint
|
|
68
88
|
|
package/docs/migration.md
CHANGED
|
@@ -11,6 +11,7 @@ change when you upgrade.
|
|
|
11
11
|
|
|
12
12
|
| Version | What changed | What to do |
|
|
13
13
|
|---|---|---|
|
|
14
|
+
| **0.11.0** | `intent` → `claim` rename completed across the React hook, type namespace, and wire frames | `useIntent` → `useClaim`; `Register.Intents` → `Register.Claims`; `Ablo.Intent.*` → `Ablo.Claim.*`. Upgrade client **and** server together (wire frames moved `intent_*` → `claim_*`) |
|
|
14
15
|
| **0.10.0** | Environment enum renamed `test`/`live` → `sandbox`/`production` | Update code that branches on the environment (e.g. source `mode`): `'test'`→`'sandbox'`, `'live'`→`'production'`. Key prefixes `sk_test_`/`sk_live_` are unchanged |
|
|
15
16
|
| **0.9.2** | `turn` primitive + agent-work `tasks` resource removed | Coordinate with `claim`; mint a scoped session instead of `agent().run()` |
|
|
16
17
|
| **0.9.2** | `intents` deprecated in favor of `claim` | Use `ablo.<model>.claim`; `ablo.intents` is now `@internal` |
|
|
@@ -24,6 +25,45 @@ change when you upgrade.
|
|
|
24
25
|
|
|
25
26
|
---
|
|
26
27
|
|
|
28
|
+
## 0.11.0 — `intent` → `claim` rename completed
|
|
29
|
+
|
|
30
|
+
The coordination primitive has been `claim` since 0.9.2, but a few `intent`-named
|
|
31
|
+
surfaces lingered. 0.11.0 finishes the rename. There are three edits, all
|
|
32
|
+
mechanical:
|
|
33
|
+
|
|
34
|
+
**1. React hook.** `useIntent` is now `useClaim` (same signature):
|
|
35
|
+
|
|
36
|
+
```diff
|
|
37
|
+
- import { useIntent } from '@abloatai/ablo/react';
|
|
38
|
+
- const claimEditLayer = useIntent('editLayer');
|
|
39
|
+
+ import { useClaim } from '@abloatai/ablo/react';
|
|
40
|
+
+ const claimEditLayer = useClaim('editLayer');
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**2. Type registration.** The `Register` interface key is `Claims`, not `Intents`:
|
|
44
|
+
|
|
45
|
+
```diff
|
|
46
|
+
declare module '@abloatai/ablo' {
|
|
47
|
+
interface Register {
|
|
48
|
+
- Intents: { editLayer: { slideId: string; layerId: string } };
|
|
49
|
+
+ Claims: { editLayer: { slideId: string; layerId: string } };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**3. Type namespace.** The `Ablo.Intent.*` helper types moved to `Ablo.Claim.*`.
|
|
55
|
+
If you referenced them directly, rename the namespace; the shapes are unchanged.
|
|
56
|
+
|
|
57
|
+
> **Coordinated deploy required.** The on-the-wire frames moved from `intent_*`
|
|
58
|
+
> to `claim_*`. A `claim_*`-aware client cannot coordinate with an `intent_*`
|
|
59
|
+
> server (and vice-versa), so ship the client and server together. If you run a
|
|
60
|
+
> self-managed sync server, deploy it first.
|
|
61
|
+
|
|
62
|
+
Two non-breaking improvements ride along: claim-rejection errors now surface the
|
|
63
|
+
contending holders (`AbloClaimedError.claims` and a policy reason folded into the
|
|
64
|
+
message), and `participantKind` is the canonical `'user' | 'agent' | 'system'`
|
|
65
|
+
on presence and claim state.
|
|
66
|
+
|
|
27
67
|
## 0.10.0 — environment enum `sandbox` / `production`; stateless HTTP transport
|
|
28
68
|
|
|
29
69
|
### Environment enum rename (the only breaking change)
|