@abloatai/ablo 0.33.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.
Files changed (42) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli.cjs +58 -3
  3. package/dist/client/Ablo.js +3 -2
  4. package/dist/client/createModelProxy.d.ts +53 -10
  5. package/dist/client/createModelProxy.js +24 -5
  6. package/dist/client/httpTransport.js +1 -0
  7. package/dist/client/resourceTypes.d.ts +10 -1
  8. package/dist/client/wsMutationExecutor.d.ts +2 -2
  9. package/dist/client/wsMutationExecutor.js +1 -1
  10. package/dist/coordination/index.d.ts +2 -2
  11. package/dist/coordination/index.js +1 -1
  12. package/dist/coordination/schema.d.ts +20 -0
  13. package/dist/coordination/schema.js +22 -0
  14. package/dist/errorCodes.d.ts +1 -1
  15. package/dist/errorCodes.js +1 -1
  16. package/dist/interfaces/index.d.ts +13 -1
  17. package/dist/react/AbloProvider.d.ts +8 -8
  18. package/dist/react/AbloProvider.js +6 -6
  19. package/dist/react/index.d.ts +2 -2
  20. package/dist/react/index.js +2 -2
  21. package/dist/server/commit.d.ts +8 -1
  22. package/dist/surface.d.ts +1 -1
  23. package/dist/surface.js +2 -1
  24. package/dist/sync/SyncWebSocket.d.ts +3 -3
  25. package/dist/sync/SyncWebSocket.js +4 -4
  26. package/dist/sync/commitFrames.d.ts +2 -2
  27. package/dist/sync/commitFrames.js +5 -1
  28. package/dist/transactions/TransactionQueue.d.ts +4 -1
  29. package/dist/transactions/TransactionQueue.js +18 -0
  30. package/dist/transactions/commitEnvelope.d.ts +8 -0
  31. package/dist/transactions/commitEnvelope.js +2 -1
  32. package/dist/transactions/durableWriteStore.d.ts +8 -0
  33. package/dist/wire/frames.d.ts +17 -1
  34. package/dist/wire/frames.js +2 -1
  35. package/docs/client-behavior.md +1 -1
  36. package/docs/coordination.md +5 -5
  37. package/docs/groups.md +50 -0
  38. package/docs/identity.md +2 -2
  39. package/docs/migration.md +29 -5
  40. package/docs/react.md +9 -9
  41. package/llms.txt +1 -1
  42. package/package.json +1 -1
@@ -41,10 +41,10 @@
41
41
  * client:
42
42
  *
43
43
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
44
- * useWatch({ scope }) — join a scope to get its peers and claims
44
+ * useJoin({ scope }) — join a scope to get its peers and claims
45
45
  */
46
46
  export type { DefaultSyncShape, ResolveSchema, ResolveUserMeta, ResolveModelKey, } from '../types/global.js';
47
- export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseWatchOptions, type UseWatchReturn, type MeshParticipantStatus, } from './AbloProvider.js';
47
+ export { AbloProvider, useJoin, usePeers, useSync, useSyncStore, type AbloProviderProps, type ParticipantScope, type ParticipantStatus, type UseJoinOptions, type UseJoinReturn, type MeshParticipantStatus, } from './AbloProvider.js';
48
48
  export { ClientSideSuspense, type ClientSideSuspenseProps, } from './ClientSideSuspense.js';
49
49
  export { DefaultFallback } from './DefaultFallback.js';
50
50
  export type { SyncStoreContract } from './context.js';
@@ -41,10 +41,10 @@
41
41
  * client:
42
42
  *
43
43
  * useAblo((ablo) => ablo.<model>.claim.state(...)) — reactive coordination reads
44
- * useWatch({ scope }) — join a scope to get its peers and claims
44
+ * useJoin({ scope }) — join a scope to get its peers and claims
45
45
  */
46
46
  // ── Umbrella provider + lifecycle hooks ────────────────────────────
47
- export { AbloProvider, useWatch, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
47
+ export { AbloProvider, useJoin, usePeers, useSync, useSyncStore, } from './AbloProvider.js';
48
48
  export { ClientSideSuspense, } from './ClientSideSuspense.js';
49
49
  export { DefaultFallback } from './DefaultFallback.js';
50
50
  // ── Status + errors + identity ─────────────────────────────────────
@@ -17,7 +17,7 @@ import type { ParticipantKind, ConfirmationState } from '../schema/syncDeltaRow.
17
17
  import type { ParticipantRef } from '../wire/delta.js';
18
18
  import type { CommitExecutionResultInput } from '../wire/commit.js';
19
19
  import type { Environment } from '../environment.js';
20
- import type { ReadDependency } from '../coordination/schema.js';
20
+ import type { ReadDependency, TrackDependency } from '../coordination/schema.js';
21
21
  export interface CommitContext {
22
22
  participantId: string;
23
23
  /**
@@ -97,6 +97,13 @@ export interface CommitContext {
97
97
  * validates only the rows being written. Omit it to check the write targets alone.
98
98
  */
99
99
  reads?: ReadDependency[] | null;
100
+ /**
101
+ * Durable read-dependencies to persist for this commit's participant. Each entry
102
+ * is kept in `track_dependencies` and re-checked against every future delta; a
103
+ * later match opens a `StaleNotification` delivered out of band. Distinct from
104
+ * `reads`, which is checked once here and discarded.
105
+ */
106
+ track?: TrackDependency[] | null;
100
107
  }
101
108
  /**
102
109
  * The server execution receipt persisted in `mutation_log`. Its runtime schema
package/dist/surface.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * tuple, so it is the one list of model-verb names a generated summary can
20
20
  * describe.
21
21
  */
22
- export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "getAll", "getCount", "create", "update", "delete", "claim", "watch", "onChange"];
22
+ export declare const PUBLIC_MODEL_VERBS: readonly ["retrieve", "list", "get", "getAll", "getCount", "create", "update", "delete", "claim", "track", "join", "onChange"];
23
23
  /**
24
24
  * The option keys accepted by `list`, `getAll`, and `onChange`, matching the
25
25
  * keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
package/dist/surface.js CHANGED
@@ -30,7 +30,8 @@ export const PUBLIC_MODEL_VERBS = [
30
30
  'update',
31
31
  'delete',
32
32
  'claim',
33
- 'watch',
33
+ 'track',
34
+ 'join',
34
35
  'onChange',
35
36
  ];
36
37
  // ── the read/list query option surface ─────────────────────────────────────
@@ -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;
@@ -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
@@ -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;
@@ -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`, `watch`) are compile errors rather than runtime gaps. |
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. |
@@ -542,23 +542,23 @@ snapshot.
542
542
  | still has a second live connection | survives — release fires only on the last connection |
543
543
  | loses the server to a restart | rides the TTL in the coordination store; re-announced on reconnect |
544
544
 
545
- ### `watch` — presence for a set of rows
545
+ ### `join` — presence for a set of rows
546
546
 
547
547
  Reading or claiming a row auto-enrolls you in its sync group, which is enough for
548
548
  `claim.state`/`claim.queue` to observe co-participants. When you want to *hold*
549
549
  presence on a known set of rows — a deck's slides, a board's cards — and react to
550
- who joins or leaves, use `watch`:
550
+ who joins or leaves, use `join`:
551
551
 
552
552
  ```ts
553
- await using room = await ablo.slides.watch(slideIds, { ttl: '5m' });
553
+ await using room = await ablo.slides.join(slideIds, { ttl: '5m' });
554
554
  room.peers; // who else is here, live
555
555
  ```
556
556
 
557
- `watch(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
557
+ `join(ids, { ttl? })` opens a model-scoped presence/claim subscription and returns
558
558
  a participant handle (`.peers`, the scoped claim stream, `.leave()` / `await using`
559
559
  disposal). It is the model-scoped successor to the old top-level
560
560
  `ablo.participants.join({ scope })`. **WebSocket only** — presence needs a live
561
- socket, so `watch` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
561
+ socket, so `join` is absent on the HTTP client (`Ablo({ transport: 'http' })`) and
562
562
  throws on any non-ws construction.
563
563
 
564
564
  ### Writing under a claim
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 — `watch`.** To hold presence on a known set of rows
523
- and react to peers, use the WebSocket-only `ablo.<model>.watch(ids, { ttl })`
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.28.0** | Removed React placeholders that had no working runtime | `usePresence` → `usePeers` or `useWatch`; `useClaim` `ablo.<model>.claim`; `SyncGroupProvider` / `useSyncGroup` `useWatch({ scope })` |
15
- | **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 `useWatch` |
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 `useWatch` to join a scoped participant.
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
- `useWatch({ scope, claim: true })` for a scoped participant claim.
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 `useWatch({ scope })`.
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
- ## useWatch — scoped presence + read interest
181
+ ## useJoin — scoped presence + read interest
182
182
 
183
- `useWatch` is the React form of `ablo.<model>.watch`. It joins multiplayer for a
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 { useWatch } from '@abloatai/ablo/react';
192
+ import { useJoin } from '@abloatai/ablo/react';
193
193
 
194
194
  export function DeckPresence({ deckId }: { deckId: string }) {
195
- const { peers, claims, status } = useWatch({
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 (`UseWatchOptions`):
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 (`UseWatchReturn`): `{ participant, peers, claims, status, error }`.
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 `useWatch`, it does **not** enter/leave a scope (no
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 `useWatch`) when some **other** mount already
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 `useWatch` on the same scope would warm-drop the owner's subscription on
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",