@abloatai/ablo 0.32.0 → 0.34.0
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 +35 -0
- package/README.md +24 -12
- package/dist/cli.cjs +148 -21
- package/dist/client/Ablo.js +3 -2
- package/dist/client/createModelProxy.d.ts +53 -10
- package/dist/client/createModelProxy.js +24 -5
- package/dist/client/httpTransport.js +1 -0
- package/dist/client/resourceTypes.d.ts +10 -1
- package/dist/client/wsMutationExecutor.d.ts +2 -2
- package/dist/client/wsMutationExecutor.js +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/schema.d.ts +20 -0
- package/dist/coordination/schema.js +22 -0
- package/dist/errorCodes.d.ts +1 -1
- package/dist/errorCodes.js +1 -1
- package/dist/interfaces/index.d.ts +13 -1
- package/dist/react/AbloProvider.d.ts +8 -8
- package/dist/react/AbloProvider.js +6 -6
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +2 -2
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +37 -40
- package/dist/schema/schema.js +24 -30
- package/dist/schema/select.js +1 -1
- package/dist/schema/serialize.d.ts +3 -3
- package/dist/schema/serialize.js +4 -2
- package/dist/server/commit.d.ts +8 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -1
- package/dist/sync/SyncWebSocket.d.ts +3 -3
- package/dist/sync/SyncWebSocket.js +4 -4
- package/dist/sync/commitFrames.d.ts +2 -2
- package/dist/sync/commitFrames.js +5 -1
- package/dist/transactions/TransactionQueue.d.ts +4 -1
- package/dist/transactions/TransactionQueue.js +18 -0
- package/dist/transactions/commitEnvelope.d.ts +8 -0
- package/dist/transactions/commitEnvelope.js +2 -1
- package/dist/transactions/durableWriteStore.d.ts +8 -0
- package/dist/wire/frames.d.ts +17 -1
- package/dist/wire/frames.js +2 -1
- package/docs/client-behavior.md +1 -1
- package/docs/coordination.md +18 -6
- package/docs/groups.md +50 -0
- package/docs/identity.md +2 -2
- package/docs/migration.md +29 -5
- package/docs/react.md +9 -9
- package/llms.txt +1 -1
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ import { EventEmitter } from 'events';
|
|
|
10
10
|
import type { MutationOperation } from '../interfaces/index.js';
|
|
11
11
|
import { type ClientSyncDelta } from '../wire/delta.js';
|
|
12
12
|
import type { BootstrapReason } from '../wire/bootstrapReason.js';
|
|
13
|
-
import type { ClaimRejection, StaleNotification, ReadDependency, WireClaim } from '../coordination/schema.js';
|
|
13
|
+
import type { ClaimRejection, StaleNotification, ReadDependency, TrackDependency, WireClaim } from '../coordination/schema.js';
|
|
14
14
|
import { type CommitAck } from './commitFrames.js';
|
|
15
15
|
export type { CommitAck } from './commitFrames.js';
|
|
16
16
|
import { type AuthTokenGetter } from '../auth/credentialSource.js';
|
|
@@ -441,7 +441,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
441
441
|
* not auto-retry — the caller's transaction queue owns retry and offline
|
|
442
442
|
* replay, and the SDK does not duplicate that logic.
|
|
443
443
|
*/
|
|
444
|
-
sendCommit(operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): Promise<CommitAck>;
|
|
444
|
+
sendCommit(operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null, track?: readonly TrackDependency[] | null): Promise<CommitAck>;
|
|
445
445
|
/**
|
|
446
446
|
* Send a commit frame without waiting for `mutation_result`.
|
|
447
447
|
*
|
|
@@ -450,7 +450,7 @@ export declare class SyncWebSocket<TCollaboration extends EventMap<TCollaboratio
|
|
|
450
450
|
* eventual `mutation_result` frame is intentionally ignored by this
|
|
451
451
|
* instance because no pending resolver is registered.
|
|
452
452
|
*/
|
|
453
|
-
sendCommitQueued(operations: readonly MutationOperation[], clientTxId: string, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): void;
|
|
453
|
+
sendCommitQueued(operations: readonly MutationOperation[], clientTxId: string, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null, track?: readonly TrackDependency[] | null): void;
|
|
454
454
|
/**
|
|
455
455
|
* Activates a participant claim on this connection. One connection can hold
|
|
456
456
|
* several concurrent claims at once, each scoped to a different set of sync
|
|
@@ -689,7 +689,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
689
689
|
* not auto-retry — the caller's transaction queue owns retry and offline
|
|
690
690
|
* replay, and the SDK does not duplicate that logic.
|
|
691
691
|
*/
|
|
692
|
-
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads) {
|
|
692
|
+
sendCommit(operations, clientTxId, timeoutMs = 15_000, causedByTaskId, reads, track) {
|
|
693
693
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
694
694
|
return Promise.reject(this.notConnectedError('commit'));
|
|
695
695
|
}
|
|
@@ -704,7 +704,7 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
704
704
|
// an open turn — keeps the wire shape stable for sessions
|
|
705
705
|
// that don't use turns. Servers that don't know the field
|
|
706
706
|
// ignore it; newer servers stamp it onto every delta.
|
|
707
|
-
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
|
|
707
|
+
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track);
|
|
708
708
|
this.ws.send(JSON.stringify(frame));
|
|
709
709
|
}
|
|
710
710
|
catch (error) {
|
|
@@ -722,11 +722,11 @@ export class SyncWebSocket extends EventEmitter {
|
|
|
722
722
|
* eventual `mutation_result` frame is intentionally ignored by this
|
|
723
723
|
* instance because no pending resolver is registered.
|
|
724
724
|
*/
|
|
725
|
-
sendCommitQueued(operations, clientTxId, causedByTaskId, reads) {
|
|
725
|
+
sendCommitQueued(operations, clientTxId, causedByTaskId, reads, track) {
|
|
726
726
|
if (this.ws?.readyState !== WebSocket.OPEN) {
|
|
727
727
|
throw this.notConnectedError('commit');
|
|
728
728
|
}
|
|
729
|
-
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads);
|
|
729
|
+
const frame = buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track);
|
|
730
730
|
this.ws.send(JSON.stringify(frame));
|
|
731
731
|
}
|
|
732
732
|
/**
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
import type { CommitMessage } from '../wire/index.js';
|
|
8
8
|
import type { CommitAck as CanonicalCommitAck } from '../wire/commit.js';
|
|
9
9
|
import type { MutationOperation, ClaimEvent } from '../interfaces/index.js';
|
|
10
|
-
import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
10
|
+
import type { StaleNotification, ReadDependency, TrackDependency } from '../coordination/schema.js';
|
|
11
11
|
/**
|
|
12
12
|
* The value a commit acknowledgement resolves to. `notifications` is present
|
|
13
13
|
* only when a guarded write (`onStale: 'notify'`) met a concurrent change; it
|
|
@@ -24,7 +24,7 @@ export type CommitAck = CanonicalCommitAck;
|
|
|
24
24
|
* here; the single `as` cast narrows the validated `type` to the wire union and
|
|
25
25
|
* is the only place that loosening happens.
|
|
26
26
|
*/
|
|
27
|
-
export declare function buildCommitFrame(operations: readonly MutationOperation[], clientTxId: string, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null): CommitMessage;
|
|
27
|
+
export declare function buildCommitFrame(operations: readonly MutationOperation[], clientTxId: string, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null, track?: readonly TrackDependency[] | null): CommitMessage;
|
|
28
28
|
/**
|
|
29
29
|
* Defensively validate the optional `notifications` array off a commit ack.
|
|
30
30
|
* Untrusted wire data — a malformed entry is dropped rather than throwing,
|
|
@@ -16,7 +16,7 @@ import { formatClaim } from '../coordination/trace.js';
|
|
|
16
16
|
* here; the single `as` cast narrows the validated `type` to the wire union and
|
|
17
17
|
* is the only place that loosening happens.
|
|
18
18
|
*/
|
|
19
|
-
export function buildCommitFrame(operations, clientTxId, causedByTaskId, reads) {
|
|
19
|
+
export function buildCommitFrame(operations, clientTxId, causedByTaskId, reads, track) {
|
|
20
20
|
const payload = {
|
|
21
21
|
operations: operations.map((op) => ({
|
|
22
22
|
type: op.type,
|
|
@@ -35,6 +35,10 @@ export function buildCommitFrame(operations, clientTxId, causedByTaskId, reads)
|
|
|
35
35
|
// The read set the batch was premised on: the rows or groups the writer read before committing.
|
|
36
36
|
if (reads && reads.length > 0)
|
|
37
37
|
payload.reads = [...reads];
|
|
38
|
+
// Durable read-dependencies the writer is registering: the rows or groups it
|
|
39
|
+
// wants to keep hearing about after this commit, delivered on a future receipt.
|
|
40
|
+
if (track && track.length > 0)
|
|
41
|
+
payload.track = [...track];
|
|
38
42
|
return { type: 'commit', payload };
|
|
39
43
|
}
|
|
40
44
|
/**
|
|
@@ -16,7 +16,7 @@ import type { Database } from '../Database.js';
|
|
|
16
16
|
import { Model } from '../Model.js';
|
|
17
17
|
import { SyncPosition } from '../sync/syncPosition.js';
|
|
18
18
|
import type { WriteOptions } from '../interfaces/index.js';
|
|
19
|
-
import type { StaleNotification, ReadDependency } from '../coordination/schema.js';
|
|
19
|
+
import type { StaleNotification, ReadDependency, TrackDependency } from '../coordination/schema.js';
|
|
20
20
|
import { type MutationInput, type Transaction, type UserContext } from './commitPayload.js';
|
|
21
21
|
import { type DurableCommitEnvelope, type DurableCommitOperation, type CommitOutboxScope } from './commitEnvelope.js';
|
|
22
22
|
import type { DurableWriteStore } from './durableWriteStore.js';
|
|
@@ -45,6 +45,8 @@ interface CommitTransaction {
|
|
|
45
45
|
causedByTaskId?: string | null;
|
|
46
46
|
/** Read dependencies for the whole batch, forwarded to the executor so the server can detect stale-context writes. */
|
|
47
47
|
reads?: ReadDependency[] | null;
|
|
48
|
+
/** Durable read-dependencies (`track`) to register for the batch's participant, forwarded to the executor. */
|
|
49
|
+
track?: TrackDependency[] | null;
|
|
48
50
|
status: 'pending' | 'executing' | 'awaiting_delta' | 'completed' | 'failed';
|
|
49
51
|
createdAt: number;
|
|
50
52
|
attempts: number;
|
|
@@ -410,6 +412,7 @@ export declare class TransactionQueue extends EventEmitter {
|
|
|
410
412
|
enqueueCommit(clientTxId: string, operations: CommitTransaction['operations'], options?: {
|
|
411
413
|
causedByTaskId?: string | null;
|
|
412
414
|
reads?: ReadDependency[] | null;
|
|
415
|
+
track?: TrackDependency[] | null;
|
|
413
416
|
}): Promise<void>;
|
|
414
417
|
/**
|
|
415
418
|
* Drains the pending commit-lane envelopes one at a time. A transient
|
|
@@ -400,6 +400,13 @@ export class TransactionQueue extends EventEmitter {
|
|
|
400
400
|
: [...input.commitOptions.reads],
|
|
401
401
|
}
|
|
402
402
|
: {}),
|
|
403
|
+
...(input.commitOptions?.track !== undefined
|
|
404
|
+
? {
|
|
405
|
+
track: input.commitOptions.track === null
|
|
406
|
+
? null
|
|
407
|
+
: [...input.commitOptions.track],
|
|
408
|
+
}
|
|
409
|
+
: {}),
|
|
403
410
|
},
|
|
404
411
|
...(this.commitOutboxScope ? { scope: this.commitOutboxScope } : {}),
|
|
405
412
|
createdAt: input.createdAt,
|
|
@@ -1829,11 +1836,13 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1829
1836
|
operations: existing.operations,
|
|
1830
1837
|
causedByTaskId: existing.causedByTaskId ?? null,
|
|
1831
1838
|
reads: existing.reads ?? null,
|
|
1839
|
+
track: existing.track ?? null,
|
|
1832
1840
|
});
|
|
1833
1841
|
const incomingIntent = stableStringify({
|
|
1834
1842
|
operations,
|
|
1835
1843
|
causedByTaskId: options.causedByTaskId ?? null,
|
|
1836
1844
|
reads: options.reads ?? null,
|
|
1845
|
+
track: options.track ?? null,
|
|
1837
1846
|
});
|
|
1838
1847
|
if (existingIntent !== incomingIntent) {
|
|
1839
1848
|
throw new AbloIdempotencyError('Idempotency key reused with a different atomic commit request', { code: 'idempotency_conflict' });
|
|
@@ -1864,6 +1873,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1864
1873
|
operations: [...operations],
|
|
1865
1874
|
causedByTaskId: options.causedByTaskId ?? null,
|
|
1866
1875
|
...(options.reads ? { reads: options.reads } : {}),
|
|
1876
|
+
...(options.track ? { track: options.track } : {}),
|
|
1867
1877
|
status: 'pending',
|
|
1868
1878
|
createdAt: Date.now(),
|
|
1869
1879
|
attempts: 0,
|
|
@@ -1878,6 +1888,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1878
1888
|
commitOptions: {
|
|
1879
1889
|
causedByTaskId: tx.causedByTaskId ?? null,
|
|
1880
1890
|
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1891
|
+
...(tx.track ? { track: tx.track } : {}),
|
|
1881
1892
|
},
|
|
1882
1893
|
createdAt: tx.createdAt,
|
|
1883
1894
|
sealedAt: tx.sealedAt,
|
|
@@ -1941,6 +1952,7 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1941
1952
|
commitOptions: {
|
|
1942
1953
|
causedByTaskId: tx.causedByTaskId ?? null,
|
|
1943
1954
|
...(tx.reads ? { reads: tx.reads } : {}),
|
|
1955
|
+
...(tx.track ? { track: tx.track } : {}),
|
|
1944
1956
|
},
|
|
1945
1957
|
createdAt: tx.createdAt,
|
|
1946
1958
|
sealedAt: tx.sealedAt,
|
|
@@ -1955,6 +1967,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
1955
1967
|
...(durableEnvelope.commitOptions.reads
|
|
1956
1968
|
? { reads: durableEnvelope.commitOptions.reads }
|
|
1957
1969
|
: {}),
|
|
1970
|
+
...(durableEnvelope.commitOptions.track
|
|
1971
|
+
? { track: durableEnvelope.commitOptions.track }
|
|
1972
|
+
: {}),
|
|
1958
1973
|
}));
|
|
1959
1974
|
tx.durableEnvelope = await this.persistDurableCommitAcceptance(durableEnvelope, result);
|
|
1960
1975
|
tx.lastSyncId = result.lastSyncId;
|
|
@@ -2446,6 +2461,9 @@ export class TransactionQueue extends EventEmitter {
|
|
|
2446
2461
|
...(envelope.commitOptions.reads
|
|
2447
2462
|
? { reads: [...envelope.commitOptions.reads] }
|
|
2448
2463
|
: {}),
|
|
2464
|
+
...(envelope.commitOptions.track
|
|
2465
|
+
? { track: [...envelope.commitOptions.track] }
|
|
2466
|
+
: {}),
|
|
2449
2467
|
status: 'pending',
|
|
2450
2468
|
createdAt: envelope.createdAt,
|
|
2451
2469
|
sealedAt: envelope.sealedAt,
|
|
@@ -111,6 +111,14 @@ export declare const durableCommitEnvelopeSchema: z.ZodObject<{
|
|
|
111
111
|
notify: "notify";
|
|
112
112
|
}>>;
|
|
113
113
|
}, z.core.$strip>]>>>>;
|
|
114
|
+
track: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
115
|
+
model: z.ZodString;
|
|
116
|
+
id: z.ZodString;
|
|
117
|
+
readAt: z.ZodOptional<z.ZodNumber>;
|
|
118
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
119
|
+
group: z.ZodString;
|
|
120
|
+
readAt: z.ZodOptional<z.ZodNumber>;
|
|
121
|
+
}, z.core.$strip>]>>>>;
|
|
114
122
|
}, z.core.$strict>>;
|
|
115
123
|
scope: z.ZodOptional<z.ZodObject<{
|
|
116
124
|
organizationId: z.ZodString;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* operations, and the source mutations it supersedes.
|
|
8
8
|
*/
|
|
9
9
|
import { z } from 'zod';
|
|
10
|
-
import { readDependencySchema } from '../coordination/schema.js';
|
|
10
|
+
import { readDependencySchema, trackDependencySchema } from '../coordination/schema.js';
|
|
11
11
|
import { commitOperationSchema } from '../wire/frames.js';
|
|
12
12
|
import { correlationIdSchema } from '../wire/commit.js';
|
|
13
13
|
import { idempotencyKeySchema } from './idempotencyKey.js';
|
|
@@ -50,6 +50,7 @@ export const durableCommitOperationSchema = commitOperationSchema
|
|
|
50
50
|
const durableCommitOptionsSchema = z.strictObject({
|
|
51
51
|
causedByTaskId: z.string().min(1).nullable().optional(),
|
|
52
52
|
reads: z.array(readDependencySchema).nullable().optional(),
|
|
53
|
+
track: z.array(trackDependencySchema).nullable().optional(),
|
|
53
54
|
});
|
|
54
55
|
export const commitOutboxScopeSchema = z.strictObject({
|
|
55
56
|
organizationId: z.string().min(1),
|
|
@@ -62,6 +62,14 @@ export declare const pendingWriteSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
62
62
|
notify: "notify";
|
|
63
63
|
}>>;
|
|
64
64
|
}, z.core.$strip>]>>>>;
|
|
65
|
+
track: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
66
|
+
model: z.ZodString;
|
|
67
|
+
id: z.ZodString;
|
|
68
|
+
readAt: z.ZodOptional<z.ZodNumber>;
|
|
69
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
70
|
+
group: z.ZodString;
|
|
71
|
+
readAt: z.ZodOptional<z.ZodNumber>;
|
|
72
|
+
}, z.core.$strip>]>>>>;
|
|
65
73
|
}, z.core.$strict>>;
|
|
66
74
|
scope: z.ZodOptional<z.ZodObject<{
|
|
67
75
|
organizationId: z.ZodString;
|
package/dist/wire/frames.d.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* server to update together.
|
|
14
14
|
*/
|
|
15
15
|
import { z } from 'zod';
|
|
16
|
-
import type { OnStaleMode, ReadDependency } from '../coordination/index.js';
|
|
16
|
+
import type { OnStaleMode, ReadDependency, TrackDependency } from '../coordination/index.js';
|
|
17
17
|
import type { MutationResultMessageWire } from './commit.js';
|
|
18
18
|
/**
|
|
19
19
|
* A single operation within a {@link CommitMessage} batch. Each operation is
|
|
@@ -121,6 +121,14 @@ export interface CommitMessage {
|
|
|
121
121
|
* being written are checked for staleness.
|
|
122
122
|
*/
|
|
123
123
|
reads?: ReadDependency[] | null;
|
|
124
|
+
/**
|
|
125
|
+
* Durable read-dependencies to register for this batch's participant. Each
|
|
126
|
+
* entry — a row (`{ model, id }`) or a sync group (`{ group }`) — is persisted
|
|
127
|
+
* and re-checked against every future delta; a later match surfaces a
|
|
128
|
+
* `StaleNotification` on the participant's next commit. Distinct from `reads`,
|
|
129
|
+
* which is checked once here and discarded.
|
|
130
|
+
*/
|
|
131
|
+
track?: TrackDependency[] | null;
|
|
124
132
|
};
|
|
125
133
|
}
|
|
126
134
|
/**
|
|
@@ -172,6 +180,14 @@ export declare const commitPayloadSchema: z.ZodObject<{
|
|
|
172
180
|
notify: "notify";
|
|
173
181
|
}>>;
|
|
174
182
|
}, z.core.$strip>]>>>>;
|
|
183
|
+
track: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
184
|
+
model: z.ZodString;
|
|
185
|
+
id: z.ZodString;
|
|
186
|
+
readAt: z.ZodOptional<z.ZodNumber>;
|
|
187
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
188
|
+
group: z.ZodString;
|
|
189
|
+
readAt: z.ZodOptional<z.ZodNumber>;
|
|
190
|
+
}, z.core.$strip>]>>>>;
|
|
175
191
|
}, z.core.$strip>;
|
|
176
192
|
/**
|
|
177
193
|
* The server's acknowledgement of a {@link CommitMessage}. Runtime shape and
|
package/dist/wire/frames.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { z } from 'zod';
|
|
16
16
|
// The runtime schema primitives are imported straight from the coordination
|
|
17
17
|
// schema module to keep this file's runtime dependencies limited to Zod.
|
|
18
|
-
import { commitOperationSchema as coordinationCommitOperationSchema, readDependencySchema, } from '../coordination/schema.js';
|
|
18
|
+
import { commitOperationSchema as coordinationCommitOperationSchema, readDependencySchema, trackDependencySchema, } from '../coordination/schema.js';
|
|
19
19
|
/**
|
|
20
20
|
* Runtime validator for {@link CommitOperation}. Both commit transports — the
|
|
21
21
|
* WebSocket `commit` frame and the HTTP `/v1/commits` endpoint — run this check
|
|
@@ -42,6 +42,7 @@ export const commitPayloadSchema = z.object({
|
|
|
42
42
|
clientTxId: z.string(),
|
|
43
43
|
causedByTaskId: z.string().nullish(),
|
|
44
44
|
reads: z.array(readDependencySchema).nullish(),
|
|
45
|
+
track: z.array(trackDependencySchema).nullish(),
|
|
45
46
|
});
|
|
46
47
|
// Pins the schema to the payload type: fails to compile if either side drifts.
|
|
47
48
|
const _commitPayloadContract = true;
|
package/docs/client-behavior.md
CHANGED
|
@@ -32,7 +32,7 @@ Common options:
|
|
|
32
32
|
| `baseURL` | Override the hosted sync endpoint for staging or private deployments. |
|
|
33
33
|
| `persistence` | `memory` by default. Use `indexeddb` for a durable browser cache that survives reloads. |
|
|
34
34
|
| `durableWrites` | Optional crash recovery for unacknowledged agent/worker writes. Independent of the default memory cache; accepts `{ store, namespace? }`. |
|
|
35
|
-
| `transport` | `'websocket'` (default) is the live, stateful client — a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (`get`/`getAll`, `onChange`, `
|
|
35
|
+
| `transport` | `'websocket'` (default) is the live, stateful client — a persistent socket, a local synced pool, and `onChange` subscriptions. `'http'` returns the **stateless** client for server-side actors (agents, workers, serverless): the same `ablo.<model>` read/write/claim surface, but each call is one HTTP round-trip with no socket. Under `'http'` the return type narrows to `AbloHttpClient`, so stateful-only methods (`get`/`getAll`, `onChange`, `join`) are compile errors rather than runtime gaps. |
|
|
36
36
|
| `fetch` | Custom fetch implementation for tests or non-standard runtimes. |
|
|
37
37
|
| `defaultHeaders` | Extra headers attached to every HTTP request. |
|
|
38
38
|
| `defaultQuery` | Extra query parameters attached to every HTTP request. |
|
package/docs/coordination.md
CHANGED
|
@@ -473,6 +473,18 @@ Each beat's answer carries two more things:
|
|
|
473
473
|
the lease. Durable progress belongs in the data itself — write a row, and
|
|
474
474
|
every subscriber already sees it.
|
|
475
475
|
|
|
476
|
+
Cooperative yield has a server-side backstop your deployment can turn on: a
|
|
477
|
+
**cumulative-hold ceiling**. Left unset — the default — a holder that keeps
|
|
478
|
+
beating holds the row as long as it likes, and the line behind it depends on
|
|
479
|
+
that holder reading `queueDepth` and releasing of its own accord. With a ceiling
|
|
480
|
+
configured for a model, a holder that runs past its fair share *while contenders
|
|
481
|
+
are queued* is preempted at the server: its next beat comes back `claim_lost`
|
|
482
|
+
(reason `preempted`) — the same loss you already handle, so abandon or re-claim,
|
|
483
|
+
and any write attempted under the old lease is fenced regardless. A holder with
|
|
484
|
+
no one waiting is never preempted, however long it runs. It is the same idea as
|
|
485
|
+
an SQS message that cannot stay invisible past a hard cap however often its lock
|
|
486
|
+
is refreshed, narrowed here to bite only under real contention.
|
|
487
|
+
|
|
476
488
|
Works identically on both transports: the realtime client sends a
|
|
477
489
|
`claim_heartbeat` frame; the HTTP client posts
|
|
478
490
|
`POST /api/v1/models/{model}/{id}/claim/heartbeat` (`{ ttl?, claimId?, details? }`).
|
|
@@ -530,23 +542,23 @@ snapshot.
|
|
|
530
542
|
| still has a second live connection | survives — release fires only on the last connection |
|
|
531
543
|
| loses the server to a restart | rides the TTL in the coordination store; re-announced on reconnect |
|
|
532
544
|
|
|
533
|
-
### `
|
|
545
|
+
### `join` — presence for a set of rows
|
|
534
546
|
|
|
535
547
|
Reading or claiming a row auto-enrolls you in its sync group, which is enough for
|
|
536
548
|
`claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
|
|
537
549
|
presence on a known set of rows — a deck's slides, a board's cards — and react to
|
|
538
|
-
who joins or leaves, use `
|
|
550
|
+
who joins or leaves, use `join`:
|
|
539
551
|
|
|
540
552
|
```ts
|
|
541
|
-
await using room = await ablo.slides.
|
|
553
|
+
await using room = await ablo.slides.join(slideIds, { ttl: '5m' });
|
|
542
554
|
room.peers; // who else is here, live
|
|
543
555
|
```
|
|
544
556
|
|
|
545
|
-
`
|
|
557
|
+
`join(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
|
|
546
558
|
a participant handle (`.peers`, the scoped claim stream, `.leave()` / `await using`
|
|
547
559
|
disposal). It is the model-scoped successor to the old top-level
|
|
548
560
|
`ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
|
|
549
|
-
socket, so `
|
|
561
|
+
socket, so `join` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
|
|
550
562
|
throws on any non-ws construction.
|
|
551
563
|
|
|
552
564
|
### Writing under a claim
|
|
@@ -603,7 +615,7 @@ inspect the `code`.
|
|
|
603
615
|
|
|
604
616
|
| error | `code` | thrown when | carries |
|
|
605
617
|
|---|---|---|---|
|
|
606
|
-
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, or was
|
|
618
|
+
| `AbloClaimedError` | `claim_lost` | A held/queued claim was taken away — the holder disconnected (reaped on the keepalive cycle), went silent past its TTL, was revoked, or was preempted (a privileged reorder, or a configured cumulative-hold ceiling reached while contenders waited) — while you were holding or waiting. | `claims?` |
|
|
607
619
|
| `AbloClaimedError` | `claim_queued` | **HTTP transport only.** A contended `claim` (default `queue: true`) could not block-wait for the lease (no socket), so it rejected immediately instead of queueing. Retryable — re-attempt the claim. | `claims?` |
|
|
608
620
|
| `AbloClaimedError` | `grant_timeout` | The optional `timeoutMs` elapsed while you were still queued for a grant. | `claims?` |
|
|
609
621
|
| `AbloClaimedError` | `queue_too_deep` | `claim` was passed `maxQueueDepth` and the wait line was already that deep when you tried to join — fail-fast instead of waiting. | `claims?` |
|
package/docs/groups.md
CHANGED
|
@@ -135,6 +135,56 @@ the same row don't collide.
|
|
|
135
135
|
|
|
136
136
|
---
|
|
137
137
|
|
|
138
|
+
## Staying subscribed across commits: `track`
|
|
139
|
+
|
|
140
|
+
A read premise guards a single commit: you state what you read, the engine
|
|
141
|
+
checks it, the premise is gone. That fits an actor that reads and writes in one
|
|
142
|
+
breath. It does not fit a long-running one — an agent that reads a row now,
|
|
143
|
+
works for a few minutes, and writes much later. By the time it commits, the
|
|
144
|
+
premise it would have declared is stale, and there was no commit in between on
|
|
145
|
+
which to hear that the ground had shifted.
|
|
146
|
+
|
|
147
|
+
`track` is the durable half of the same idea. Register what you are watching and
|
|
148
|
+
it persists on the server; the next time you commit anything, a change that
|
|
149
|
+
landed on the tracked target since you registered rides back on your receipt —
|
|
150
|
+
the same `StaleNotification` an `onStale: 'notify'` premise would have handed
|
|
151
|
+
you, arriving on the write you were going to make anyway.
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// Register interest and walk away — no write required.
|
|
155
|
+
await ablo.slides.track({ id: 's-1' });
|
|
156
|
+
|
|
157
|
+
// …minutes of other work later, on your next commit…
|
|
158
|
+
const res = await ablo.layers.update({ id: 'layer-C', data: { text: revised } });
|
|
159
|
+
res.notifications; // populated if s-1 moved under you in the meantime
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The target is a row (`{ id }` on the model verb) or a sync group (as a write
|
|
163
|
+
option, below). A track is an idempotent registration: calling it again refreshes
|
|
164
|
+
the same subscription rather than stacking duplicates, and once a change fires the
|
|
165
|
+
track re-baselines, so the same change notifies once. Your own writes to a target
|
|
166
|
+
you track never notify you — the signal is about what *others* did.
|
|
167
|
+
|
|
168
|
+
You can also register a track as part of a write you are already making, the
|
|
169
|
+
persisted companion to `reads`:
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
await ablo.slides.update({
|
|
173
|
+
id: 's-1',
|
|
174
|
+
data: { title: revised },
|
|
175
|
+
reads: [{ group: 'deck:abc', readAt: N, onStale: 'notify' }], // guards THIS commit
|
|
176
|
+
track: [{ group: 'deck:abc' }], // and keeps watching after it
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
So `reads` is the premise for the commit in hand; `track` is a standing
|
|
181
|
+
subscription that outlives it. Both speak the same notification vocabulary, and
|
|
182
|
+
both leave the resolution to you — the engine reports that the target moved and
|
|
183
|
+
lets the actor decide what that means. Delivery is on your next commit's receipt;
|
|
184
|
+
a track does not yet push out of band between commits.
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
138
188
|
## Sizing groups
|
|
139
189
|
|
|
140
190
|
A group is the unit of both delivery and staleness, so its size is a real
|
package/docs/identity.md
CHANGED
|
@@ -519,8 +519,8 @@ an agent pointed at the entities it's working on. You **never hand-write**
|
|
|
519
519
|
(**read-interest**), and `claim`-ing it pins a **write-intent** subscription.
|
|
520
520
|
So an agent's reachable set **accretes** as it works — no extra subscribe call.
|
|
521
521
|
|
|
522
|
-
3. **Explicitly, for presence — `
|
|
523
|
-
and react to peers, use the WebSocket-only `ablo.<model>.
|
|
522
|
+
3. **Explicitly, for presence — `join`.** To hold presence on a known set of rows
|
|
523
|
+
and react to peers, use the WebSocket-only `ablo.<model>.join(ids, { ttl })`
|
|
524
524
|
(it returns a participant handle with `.peers`). See
|
|
525
525
|
[Coordination](./coordination.md).
|
|
526
526
|
|
package/docs/migration.md
CHANGED
|
@@ -11,8 +11,9 @@ change when you upgrade.
|
|
|
11
11
|
|
|
12
12
|
| Version | What changed | What to do |
|
|
13
13
|
|---|---|---|
|
|
14
|
-
| **0.
|
|
15
|
-
| **0.
|
|
14
|
+
| **0.34.0** | Presence verb renamed `watch` → `join` | `ablo.<model>.watch(ids)` → `ablo.<model>.join(ids)`; `useWatch` → `useJoin`; the `WatchOptions` / `UseWatchOptions` / `UseWatchReturn` types → `JoinOptions` / `UseJoinOptions` / `UseJoinReturn`; error code `model_watch_not_configured` → `model_join_not_configured` |
|
|
15
|
+
| **0.28.0** | Removed React placeholders that had no working runtime | `usePresence` → `usePeers` or `useJoin`; `useClaim` → `ablo.<model>.claim`; `SyncGroupProvider` / `useSyncGroup` → `useJoin({ scope })` |
|
|
16
|
+
| **0.11.0** | Historical `intent` → `claim` rename | The hook renamed in that release was later removed in 0.28.0. Current code uses `ablo.<model>.claim` or `useJoin` |
|
|
16
17
|
| **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 |
|
|
17
18
|
| **0.9.2** | `turn` primitive + agent-work `tasks` resource removed | Coordinate with `claim`; mint a scoped session instead of `agent().run()` |
|
|
18
19
|
| **0.9.2** | `intents` deprecated in favor of `claim` | Use `ablo.<model>.claim`; `ablo.intents` is now `@internal` |
|
|
@@ -26,17 +27,40 @@ change when you upgrade.
|
|
|
26
27
|
|
|
27
28
|
---
|
|
28
29
|
|
|
30
|
+
## 0.34.0 — presence verb renamed `watch` → `join`
|
|
31
|
+
|
|
32
|
+
The model-level presence verb read like a data subscription but delivered
|
|
33
|
+
presence — who else is on a row and what they hold — so it now says what it
|
|
34
|
+
does. `ablo.<model>.join(ids, { ttl })` opens the participant handle
|
|
35
|
+
(`.peers`, `.claims`, `await using` disposal); the returned `status` was
|
|
36
|
+
already `'joined'`, and the layer beneath always called itself `join`, so the
|
|
37
|
+
verb now matches. `onChange` remains the way to hear row *values* change, and
|
|
38
|
+
`track` remains the durable read-dependency for actors.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// before
|
|
42
|
+
await using room = await ablo.slides.watch(slideIds, { ttl: '5m' });
|
|
43
|
+
// after
|
|
44
|
+
await using room = await ablo.slides.join(slideIds, { ttl: '5m' });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The React hook follows: `useWatch({ scope })` → `useJoin({ scope })`. There is
|
|
48
|
+
no compatibility alias — rename the call sites and the `WatchOptions` /
|
|
49
|
+
`UseWatchOptions` / `UseWatchReturn` type imports.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
29
53
|
## 0.28.0 — dead React multiplayer placeholders removed
|
|
30
54
|
|
|
31
55
|
Four React exports looked usable but had no live implementation:
|
|
32
56
|
|
|
33
57
|
- `usePresence` returned no provider-backed presence value. Use `usePeers` for
|
|
34
|
-
read-only presence or `
|
|
58
|
+
read-only presence or `useJoin` to join a scoped participant.
|
|
35
59
|
- `useClaim` depended on a callback the provider never supplied and always
|
|
36
60
|
threw. Use `ablo.<model>.claim({ id, ... })` for row claims or
|
|
37
|
-
`
|
|
61
|
+
`useJoin({ scope, claim: true })` for a scoped participant claim.
|
|
38
62
|
- `SyncGroupProvider` and `useSyncGroup` had no repository consumers. Pass the
|
|
39
|
-
scope directly to `
|
|
63
|
+
scope directly to `useJoin({ scope })`.
|
|
40
64
|
|
|
41
65
|
There is no compatibility alias: the replacement APIs were already the only
|
|
42
66
|
working paths.
|
package/docs/react.md
CHANGED
|
@@ -178,9 +178,9 @@ imperative work after an event or effect.
|
|
|
178
178
|
|
|
179
179
|
See [API reference](/docs/api) for the full options surface.
|
|
180
180
|
|
|
181
|
-
##
|
|
181
|
+
## useJoin — scoped presence + read interest
|
|
182
182
|
|
|
183
|
-
`
|
|
183
|
+
`useJoin` is the React form of `ablo.<model>.join`. It joins multiplayer for a
|
|
184
184
|
scope on the engine's existing socket (one TCP connection, N logical
|
|
185
185
|
sub-syncgroup participants) and returns the reactive participant facade. Use it
|
|
186
186
|
when a mount should both *see* who else is on an entity and, optionally, declare
|
|
@@ -189,10 +189,10 @@ write interest in it.
|
|
|
189
189
|
```tsx
|
|
190
190
|
'use client';
|
|
191
191
|
|
|
192
|
-
import {
|
|
192
|
+
import { useJoin } from '@abloatai/ablo/react';
|
|
193
193
|
|
|
194
194
|
export function DeckPresence({ deckId }: { deckId: string }) {
|
|
195
|
-
const { peers, claims, status } =
|
|
195
|
+
const { peers, claims, status } = useJoin({
|
|
196
196
|
scope: { slideDecks: deckId },
|
|
197
197
|
claim: true, // I intend to write — pin the scope + let peers observe the claim
|
|
198
198
|
hydrate: true, // backfill the deck's current rows if not already loaded
|
|
@@ -203,7 +203,7 @@ export function DeckPresence({ deckId }: { deckId: string }) {
|
|
|
203
203
|
}
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
Options (`
|
|
206
|
+
Options (`UseJoinOptions`):
|
|
207
207
|
|
|
208
208
|
| Option | Default | Effect |
|
|
209
209
|
| --- | --- | --- |
|
|
@@ -213,7 +213,7 @@ Options (`UseWatchOptions`):
|
|
|
213
213
|
| `ttlSeconds` | — | Lease TTL for the scope claim. |
|
|
214
214
|
| `paused` | `false` | Tear down and don't re-join while true. |
|
|
215
215
|
|
|
216
|
-
Returns (`
|
|
216
|
+
Returns (`UseJoinReturn`): `{ participant, peers, claims, status, error }`.
|
|
217
217
|
`peers` is everyone else on the scope's sync groups; `claims` is their active
|
|
218
218
|
write-claims; `status` is the join lifecycle. Auto-cleans up on unmount or when
|
|
219
219
|
`paused` flips true.
|
|
@@ -221,7 +221,7 @@ write-claims; `status` is the join lifecycle. Auto-cleans up on unmount or when
|
|
|
221
221
|
## usePeers — read-only presence
|
|
222
222
|
|
|
223
223
|
`usePeers` is a *pure reader* of the presence stream already flowing on the
|
|
224
|
-
connection. Unlike `
|
|
224
|
+
connection. Unlike `useJoin`, it does **not** enter/leave a scope (no
|
|
225
225
|
`update_subscription`, no warm-TTL churn) — so reading it never changes what the
|
|
226
226
|
connection is subscribed to.
|
|
227
227
|
|
|
@@ -242,9 +242,9 @@ engine's groups. Returns `ReadonlyArray<Peer>`, where each `Peer` carries
|
|
|
242
242
|
`participantKind` (`'user' | 'agent' | 'system'`), `participantId`, optional
|
|
243
243
|
`label`, `syncGroups`, `activity`, `lastActive`, and optional `activeClaims`.
|
|
244
244
|
|
|
245
|
-
Reach for `usePeers` (not a second `
|
|
245
|
+
Reach for `usePeers` (not a second `useJoin`) when some **other** mount already
|
|
246
246
|
owns the scope's read interest — scope `leave` is not reference-counted, so a
|
|
247
|
-
second `
|
|
247
|
+
second `useJoin` on the same scope would warm-drop the owner's subscription on
|
|
248
248
|
unmount.
|
|
249
249
|
|
|
250
250
|
## Next.js
|
package/llms.txt
CHANGED
|
@@ -90,7 +90,7 @@ coordination until the app reports it through Data Source events.
|
|
|
90
90
|
|
|
91
91
|
## Change propagation
|
|
92
92
|
|
|
93
|
-
A change to one row reaches other rows three ways. ROUTING: a write fans out to every sync group the row belongs to, INCLUDING its ancestors' groups (editing a layer routes to `layer:` + `slide:` + `deck:`), so everyone watching the deck sees it — delivery, not recomputation. DELETE CASCADE: deleting a parent emits explicit tombstone deltas for its descendants, so open clients never silently hold rows that are gone. VALUE: derived values are NOT recomputed server-side — Ablo surfaces that the source moved and the actor decides. To keep dependent work fresh, declare what you read as a batch premise: `reads: [{ group: 'deck:abc', readAt: N, onStale: 'notify' }]`. At commit the server checks whether anything in that group moved past `readAt`; `notify` holds the write and returns a `StaleNotification` (re-read the group and regenerate), `reject` aborts. To chain A→B→C, put A+B in one group and B+C in another: A's change reaches B, and C hears it only once B ITSELF writes — the engine wires the edges and signals each hop, the actor walks them. No transitive auto-recompute, no convergence guarantee for cycles.
|
|
93
|
+
A change to one row reaches other rows three ways. ROUTING: a write fans out to every sync group the row belongs to, INCLUDING its ancestors' groups (editing a layer routes to `layer:` + `slide:` + `deck:`), so everyone watching the deck sees it — delivery, not recomputation. DELETE CASCADE: deleting a parent emits explicit tombstone deltas for its descendants, so open clients never silently hold rows that are gone. VALUE: derived values are NOT recomputed server-side — Ablo surfaces that the source moved and the actor decides. To keep dependent work fresh, declare what you read as a batch premise: `reads: [{ group: 'deck:abc', readAt: N, onStale: 'notify' }]`. At commit the server checks whether anything in that group moved past `readAt`; `notify` holds the write and returns a `StaleNotification` (re-read the group and regenerate), `reject` aborts. To chain A→B→C, put A+B in one group and B+C in another: A's change reaches B, and C hears it only once B ITSELF writes — the engine wires the edges and signals each hop, the actor walks them. No transitive auto-recompute, no convergence guarantee for cycles. `reads` guards ONE commit; for a long-running actor that reads now and writes much later, register a DURABLE premise with `track`: `ablo.<model>.track({ id })` for a row, or the `track:` write option (`track: [{ group: 'deck:abc' }]`) alongside a write. A track persists server-side; the next time you commit anything, a change that landed on the tracked target rides back on your receipt's `notifications` (same `StaleNotification` shape as `onStale: 'notify'`). It is an idempotent registration, re-baselines so a change fires once, and never notifies you of your own writes. Delivery is on your next commit — a track does not yet push out of band between commits.
|
|
94
94
|
|
|
95
95
|
## Nouns
|
|
96
96
|
|