@abloatai/ablo 0.33.0 → 0.34.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.
Files changed (45) hide show
  1. package/AGENTS.md +2 -0
  2. package/CHANGELOG.md +22 -0
  3. package/dist/cli.cjs +500 -383
  4. package/dist/client/Ablo.js +3 -2
  5. package/dist/client/createModelProxy.d.ts +53 -10
  6. package/dist/client/createModelProxy.js +24 -5
  7. package/dist/client/httpTransport.js +1 -0
  8. package/dist/client/resourceTypes.d.ts +10 -1
  9. package/dist/client/wsMutationExecutor.d.ts +2 -2
  10. package/dist/client/wsMutationExecutor.js +1 -1
  11. package/dist/coordination/index.d.ts +2 -2
  12. package/dist/coordination/index.js +1 -1
  13. package/dist/coordination/schema.d.ts +20 -0
  14. package/dist/coordination/schema.js +22 -0
  15. package/dist/errorCodes.d.ts +1 -1
  16. package/dist/errorCodes.js +1 -1
  17. package/dist/interfaces/index.d.ts +13 -1
  18. package/dist/react/AbloProvider.d.ts +8 -8
  19. package/dist/react/AbloProvider.js +6 -6
  20. package/dist/react/index.d.ts +2 -2
  21. package/dist/react/index.js +2 -2
  22. package/dist/server/commit.d.ts +8 -1
  23. package/dist/surface.d.ts +1 -1
  24. package/dist/surface.js +2 -1
  25. package/dist/sync/SyncWebSocket.d.ts +3 -3
  26. package/dist/sync/SyncWebSocket.js +4 -4
  27. package/dist/sync/commitFrames.d.ts +2 -2
  28. package/dist/sync/commitFrames.js +5 -1
  29. package/dist/transactions/TransactionQueue.d.ts +4 -1
  30. package/dist/transactions/TransactionQueue.js +18 -0
  31. package/dist/transactions/commitEnvelope.d.ts +8 -0
  32. package/dist/transactions/commitEnvelope.js +2 -1
  33. package/dist/transactions/durableWriteStore.d.ts +8 -0
  34. package/dist/wire/frames.d.ts +17 -1
  35. package/dist/wire/frames.js +2 -1
  36. package/docs/client-behavior.md +1 -1
  37. package/docs/coordination.md +5 -5
  38. package/docs/groups.md +50 -0
  39. package/docs/identity.md +2 -2
  40. package/docs/index.md +2 -0
  41. package/docs/migration.md +29 -5
  42. package/docs/operating-on-your-database.md +109 -0
  43. package/docs/react.md +9 -9
  44. package/llms.txt +1 -1
  45. package/package.json +1 -1
@@ -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/index.md CHANGED
@@ -50,6 +50,7 @@ Three things stay true no matter how you use Ablo:
50
50
  - [Client Behavior](./client-behavior.md) — Options, errors, retries, timeouts, and imports.
51
51
  - [Debugging & Logs](./debugging.md) — Turn on the `[Ablo]` coordination trace (`debug` / `logLevel`) to watch claims, queueing, and grants while you build — or read the same activity in code via `ClaimLog` to render an activity feed.
52
52
  - [Connect Your Database](./data-sources.md) — Keep canonical rows in your app database without giving Ablo database credentials.
53
+ - [Operating on Your Database](./operating-on-your-database.md) — Which actions run freely, which to verify first, and which belong to a human — and how to tell.
53
54
  - [React](./react.md) — Provider, hooks, and reactive reads for React apps.
54
55
  - [API Keys](./api-keys.md) — Bearer tokens for the public API.
55
56
 
@@ -77,6 +78,7 @@ Three things stay true no matter how you use Ablo:
77
78
  - [Guarantees](./guarantees.md) — Confirmed writes, optimistic state, stale-write protection, and agent lifecycle.
78
79
  - [Coordination](./coordination.md) — `claim`, `claim.state`, and `claim.queue` for active work.
79
80
  - [Connect Your Database](./data-sources.md) — Where data lands when your app database is canonical.
81
+ - [Operating on Your Database](./operating-on-your-database.md) — The safety model for working on a live database: reversible model writes vs. human-gated DDL, and the read-only checks that resolve the doubt.
80
82
  - [Receipt](./api.md#receipt) — Confirm what landed.
81
83
  - [Usage](./api.md#usage) — Metering and audit dimensions.
82
84
  - [Audit Log](./audit.md) — Trace any confirmed write back to the human behind it.
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.
@@ -0,0 +1,109 @@
1
+ # Operating on Your Database
2
+
3
+ Ablo sits over your database as a coordination layer, not an owner. It reads
4
+ your Postgres replication stream and routes each write through a claim-checked
5
+ commit that lands in your own tables. It never runs DDL, never migrates, never
6
+ drops. That single boundary is why almost everything you do through Ablo is
7
+ either read-only or reversible — and why the few actions that aren't are easy to
8
+ name. This page is how to tell them apart, so you can work on a real database
9
+ without guessing which move is the dangerous one.
10
+
11
+ The habit that makes it easy is to look before you act. One command shows you
12
+ the real shape of your database measured against your schema, and changes
13
+ nothing:
14
+
15
+ ```bash
16
+ npx ablo check # read-only — reports which columns fit your models and which don't
17
+ ```
18
+
19
+ When a question is about the live database — does this column exist, is it
20
+ nullable, will this write fit — you can usually answer it by observing rather
21
+ than reasoning in the dark.
22
+
23
+ ## The floor: what Ablo never does
24
+
25
+ These hold on every database Ablo connects to, and they are what bound the blast
26
+ radius of anything you do through the model API:
27
+
28
+ - It **never runs DDL or migrations** on your database, and never drops a table
29
+ or column. Schema changes to your own tables are always your application's
30
+ action, run with your admin credential — never Ablo's.
31
+ - It **never owns your rows.** Canonical data stays in your tables; Ablo hosts
32
+ only the transaction log and the coordination state.
33
+ - Every model write is **claim-checked and recorded.** A write based on a row
34
+ that moved under you is rejected rather than applied, and the prior value is
35
+ retained in the log, so a confirmed change is attributable and
36
+ reconstructable.
37
+
38
+ So a normal `ablo.<model>.update(...)` cannot silently corrupt your database:
39
+ the worst case is a clean rejection and a re-read, not a lost row.
40
+
41
+ ## Three kinds of action
42
+
43
+ Sort any action you're about to take into one of these, and the right move
44
+ follows.
45
+
46
+ **Run freely — read-only or reversible.**
47
+ Reads (`retrieve`, `list`, `get`), `ablo check`, and `ablo pull` observe and
48
+ never change anything. Previews — `--show-sql`, `--dry-run` — print the exact
49
+ SQL a command would run without executing it. Model writes through
50
+ `ablo.<model>.create` / `update` are claim-checked, optimistic, rolled back if
51
+ the server rejects them, and recorded in the log with the prior value. All of
52
+ these are safe to run on your own initiative.
53
+
54
+ **Verify first — needs one look at the live database.**
55
+ Routing an existing table's writes through a model requires the model to match
56
+ the table's real columns. Run `ablo check`: it names the columns that fit and
57
+ the ones that don't, so a `NOT NULL` column your model doesn't set shows up as a
58
+ line in the report rather than a surprise at commit time. Decide the model shape
59
+ from what `check` tells you, then proceed. Nothing here is risky — it just reads
60
+ better after you've seen the ground truth.
61
+
62
+ **Hand to a human — irreversible, outside the log's protection.**
63
+ Raw DDL on the live database — `ALTER TABLE … OWNER TO`, adding or dropping a
64
+ column, changing a constraint — changes the database itself and is not covered
65
+ by the reversible log, so it belongs to a person with their hand on it. So does
66
+ a `connect` cutover run with its confirmation skipped (`--yes`): the prompt
67
+ exists because the step provisions real roles and reconciles publication on a
68
+ live database, and on a shared or production database that confirmation is the
69
+ human's to give. Removing a model from your schema belongs here too — for the
70
+ reason below.
71
+
72
+ ## The one action that isn't what it looks like
73
+
74
+ Deleting a model from `ablo/schema.ts` reads like a code cleanup, but it is a
75
+ schema change. Your schema is a desired-state declaration: `ablo push` diffs it
76
+ against the server's copy, and a model that has vanished from the schema can be
77
+ read as a table that should no longer exist. "Nothing imports it" answers a
78
+ code question. The question that governs safety is *does removing this drop a
79
+ real table on the next push* — and that one is answered against the database,
80
+ not the codebase. Before removing a model that maps a live table, confirm the
81
+ table is gone or empty and that your push path is additive; otherwise keep the
82
+ model until the data is dealt with. A `load: 'manual'` mapping is often present
83
+ precisely to hold a table in place, so treat its comment as load-bearing.
84
+
85
+ ## The verification loop
86
+
87
+ Most of the uncertainty in working on a live database dissolves into a few
88
+ read-only checks:
89
+
90
+ - `ablo check` — does the live database match the schema? Reports the exact
91
+ column-by-column fit. Read-only.
92
+ - `ablo pull` — what is actually in the database, expressed as a schema.
93
+ Read-only, like `prisma db pull`.
94
+ - `--show-sql` / `--dry-run` on `connect` and `migrate` — the exact statements,
95
+ printed and unexecuted, so you approve the SQL before it runs.
96
+ - Read the row and its claim state before you write — `retrieve` / `list`, and
97
+ `ablo.<model>.claim.state({ id })` for who is already working on it.
98
+
99
+ The pattern underneath all of it is steady: reads and model writes flow freely
100
+ because the boundary and the log make them safe, DDL and cutovers pause for a
101
+ human because they change the database itself, and the space between the two is
102
+ one `ablo check` away from certain.
103
+
104
+ ## See also
105
+
106
+ - [Connect Your Database](./data-sources.md) — the one path a database joins Ablo.
107
+ - [Guarantees](./guarantees.md) — what a confirmed write, a stale check, and a claim promise.
108
+ - [CLI & Migrations](./cli.md) — `check`, `pull`, `migrate`, and their read-only / preview flags.
109
+ - [Schema Contract](./schema-contract.md) — how the schema drives push, and why it is a desired-state declaration.
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.1",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",