@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ A long-running actor has a stale-context problem the per-commit read gate never reaches. The `reads` guard is a premise for the commit in hand: you declare what you looked at, the server checks it at commit, and the premise is gone. That fits an actor that reads and writes in one breath, not one that reads a row, works for minutes — an LLM call, a fetch, a human's turn — and only then writes. By the time it commits, the premise it would have declared is already old, and there was no commit in between on which to hear that the ground had shifted. This release adds `track`, the durable half of the same idea. `ablo.<model>.track({ id })` registers a read-dependency that persists on the server; the next time you commit anything, a change that landed on the tracked row since you registered rides back on the receipt's `notifications` — the same `StaleNotification` an `onStale: 'notify'` premise hands you, arriving on the write you were going to make anyway. You can also register one as part of a write, `track: [{ group: 'deck:abc' }]` alongside the batch, the standing-subscription companion to the single-commit `reads`. A track is idempotent — registering the same target again refreshes the one subscription rather than stacking duplicates — it re-baselines after it fires so a given change notifies once, and it never notifies you of your own writes, since the signal is about what others did. Delivery is on your next commit's receipt; a track does not yet push out of band between commits, so it sharpens the write-time freshness check rather than replacing a live subscription.
8
+
9
+ The model-level presence verb is renamed from `watch` to `join`. It read like a data subscription but delivered presence — who else is on a set of rows and what they hold — so it now says what it does. `ablo.<model>.join(ids, { ttl })` opens the participant handle, with `.peers`, the scoped claim stream, and `await using` disposal unchanged; the handle's `status` was already `'joined'` and the layer beneath always called itself join, so the verb now matches the thing it returns. `onChange` remains the way to hear a row's *values* change, and `track` is the durable read-dependency for actors — three distinct jobs that the one overloaded `watch` used to blur. The React hook follows: `useWatch` becomes `useJoin`, the `WatchOptions` / `UseWatchOptions` / `UseWatchReturn` types become `JoinOptions` / `UseJoinOptions` / `UseJoinReturn`, and the error code `model_watch_not_configured` is now `model_join_not_configured`. There is no compatibility alias — rename the call sites and the type imports. The migration guide carries the mechanical diff.
10
+
11
+ ### Patch Changes
12
+
13
+ `ablo connect --apply` now checks, before it runs any grants, that you can actually grant on the tables you're publishing — and stops with the one-line fix if you can't, instead of failing partway through. The setup grants the writer role access to each published table, an operation Postgres reserves for the table's owner, so a table left owned by an earlier integration's role used to abort the run midway with a bare `must be owner of table …` and no guidance. Apply now names the offending tables and their owner up front and prints the exact `ALTER TABLE … OWNER TO` to reassign them to your admin — metadata only, your rows and row-level-security policies untouched. The check understands inherited role membership, so an admin that inherits the owning role — the ordinary managed-Postgres case — is left to proceed rather than stopped needlessly; only a membership that can't act as the owner is flagged.
14
+
3
15
  ## 0.33.0
4
16
 
5
17
  ### Minor Changes
package/dist/cli.cjs CHANGED
@@ -909,7 +909,7 @@ var init_errorCodes = __esm({
909
909
  model_claimed: wire("claim", 409, false, "Another participant holds a claim on this row. Read `claim.state` to see who holds it, or queue behind them with a claim of your own."),
910
910
  model_claimed_timeout: wire("claim", 409, false, "Another participant held a claim on this row and did not release it in time. Retry, or read `claim.state` to see who holds it."),
911
911
  model_claim_not_configured: client("claim", "Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically \u2014 there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path)."),
912
- model_watch_not_configured: client("claim", "watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport)."),
912
+ model_join_not_configured: client("claim", "join() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport)."),
913
913
  // ── stale context / idempotency (409) ──────────────────────────────
914
914
  // Not retryable at the transport: the rejected request carries its frozen
915
915
  // `readAt`, so resending the identical payload can never succeed. Recovery
@@ -1199,7 +1199,7 @@ var init_roles = __esm({
1199
1199
  });
1200
1200
 
1201
1201
  // src/coordination/schema.ts
1202
- var import_zod3, targetRangeSchema, participantKindSchema, wireParticipantKindSchema, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, claimStatusSchema, grantStampFields, wireClaimBaseSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, claimLostSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatFieldsSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema;
1202
+ var import_zod3, targetRangeSchema, participantKindSchema, wireParticipantKindSchema, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, trackDependencySchema, claimStatusSchema, grantStampFields, wireClaimBaseSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, claimLostSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatFieldsSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema;
1203
1203
  var init_schema = __esm({
1204
1204
  "src/coordination/schema.ts"() {
1205
1205
  "use strict";
@@ -1284,6 +1284,17 @@ var init_schema = __esm({
1284
1284
  onStale: onStaleModeSchema.optional()
1285
1285
  })
1286
1286
  ]);
1287
+ trackDependencySchema = import_zod3.z.union([
1288
+ import_zod3.z.object({
1289
+ model: import_zod3.z.string(),
1290
+ id: import_zod3.z.string(),
1291
+ readAt: import_zod3.z.number().optional()
1292
+ }),
1293
+ import_zod3.z.object({
1294
+ group: import_zod3.z.string(),
1295
+ readAt: import_zod3.z.number().optional()
1296
+ })
1297
+ ]);
1287
1298
  claimStatusSchema = import_zod3.z.enum([
1288
1299
  "active",
1289
1300
  "committed",
@@ -5838,7 +5849,8 @@ __export(connectApply_exports, {
5838
5849
  logicalReplicationGuidance: () => logicalReplicationGuidance,
5839
5850
  passwordClause: () => passwordClause,
5840
5851
  postRegistrationOutcome: () => postRegistrationOutcome,
5841
- runConnectApply: () => runConnectApply
5852
+ runConnectApply: () => runConnectApply,
5853
+ tableOwnershipBlockers: () => tableOwnershipBlockers
5842
5854
  });
5843
5855
  function postRegistrationOutcome(input) {
5844
5856
  if (input.registered) return { exitCode: 0, notice: null };
@@ -5971,6 +5983,27 @@ async function unmanageableLedgerOwner(sql) {
5971
5983
  );
5972
5984
  return ledgerBlockedBy(rows[0]);
5973
5985
  }
5986
+ function tableOwnershipBlockers(rows) {
5987
+ return rows.filter((row) => !row.can_manage && !row.is_superuser).map((row) => ({ relation: row.relation, owner: row.owner }));
5988
+ }
5989
+ async function unmanageablePublishedTableOwners(sql, tables) {
5990
+ const scoped = tables.length > 0;
5991
+ const rows = await sql.unsafe(
5992
+ `SELECT format('%I.%I', n.nspname, c.relname) AS relation,
5993
+ pg_get_userbyid(c.relowner) AS owner,
5994
+ pg_has_role(current_user, c.relowner, 'USAGE') AS can_manage,
5995
+ r.rolsuper AS is_superuser
5996
+ FROM pg_class c
5997
+ JOIN pg_namespace n ON n.oid = c.relnamespace
5998
+ JOIN pg_roles r ON r.rolname = current_user
5999
+ WHERE c.relkind = 'r'
6000
+ AND n.nspname = 'public'
6001
+ AND c.relname <> 'ablo_idempotency'
6002
+ ${scoped ? "AND c.relname = ANY($1)" : ""}`,
6003
+ scoped ? [tables] : []
6004
+ );
6005
+ return tableOwnershipBlockers(rows);
6006
+ }
5974
6007
  function detectProvider(hostOrTarget) {
5975
6008
  const host = hostOrTarget.toLowerCase();
5976
6009
  if (host.includes("neon.tech")) return "neon";
@@ -6096,6 +6129,28 @@ async function runConnectApply(args) {
6096
6129
  or drop the existing ledger so Ablo recreates it under this admin \u2014 it holds only
6097
6130
  idempotency replay records, safe to drop when no commit is in flight:
6098
6131
  ${import_picocolors8.default.cyan("DROP TABLE ablo_idempotency;")}
6132
+ `
6133
+ );
6134
+ process.exit(1);
6135
+ }
6136
+ const foreignTables = await unmanageablePublishedTableOwners(admin, args.tables).catch(() => []);
6137
+ if (foreignTables.length > 0) {
6138
+ await admin.end({ timeout: 2 });
6139
+ const list = foreignTables.map((t) => `${t.relation} (owned by ${t.owner})`).join("\n ");
6140
+ const alters = foreignTables.map((t) => `ALTER TABLE ${t.relation} OWNER TO ${quoteIdent(capability.rolname)};`).join(" ");
6141
+ const plural = foreignTables.length === 1;
6142
+ console.error(
6143
+ import_picocolors8.default.red(
6144
+ `
6145
+ ${import_picocolors8.default.bold(String(foreignTables.length))} published table${plural ? "" : "s"} ${plural ? "is" : "are"} owned by another role, but you connected as ${import_picocolors8.default.bold(capability.rolname)}:`
6146
+ ) + `
6147
+ ${list}
6148
+
6149
+ Ablo grants the writer role access to your published tables, and Postgres reserves that
6150
+ for the table's owner. Reassign them to your admin \u2014 metadata only, your rows and RLS
6151
+ policies are untouched, and it works when your admin is a member of the owning role:
6152
+ ${import_picocolors8.default.cyan(alters)}
6153
+ Or re-run pointing ${import_picocolors8.default.bold("--url")} at the owning role's connection.
6099
6154
  `
6100
6155
  );
6101
6156
  process.exit(1);
@@ -715,10 +715,10 @@ export function Ablo(options) {
715
715
  // reconcile errors so read interest never makes a read reject or stall.
716
716
  enterScope: (scope) => store.enterScope(scope),
717
717
  pinScope: (scope) => store.pinScope(scope),
718
- // `ablo.<model>.watch(ids, { ttl })` performs a scoped participant join
718
+ // `ablo.<model>.join(ids, { ttl })` performs a scoped participant join
719
719
  // on this model's sync group(s). WebSocket only — `join` throws
720
720
  // `AbloConnectionError` if the socket isn't ready.
721
- createWatch: (modelKey, ids, options) => participantManager.join({
721
+ createJoin: (modelKey, ids, options) => participantManager.join({
722
722
  scope: { [modelKey]: ids },
723
723
  ...(options?.ttl !== undefined ? { ttlSeconds: options.ttl } : {}),
724
724
  }),
@@ -758,6 +758,7 @@ export function Ablo(options) {
758
758
  const queue = syncClient.getTransactionQueue();
759
759
  await queue.enqueueCommit(clientTxId, operations, {
760
760
  ...(commitOptions.reads ? { reads: [...commitOptions.reads] } : {}),
761
+ ...(commitOptions.track ? { track: [...commitOptions.track] } : {}),
761
762
  });
762
763
  if (wait === 'queued') {
763
764
  return { id: clientTxId, status: 'queued' };
@@ -6,13 +6,14 @@
6
6
  * `retrieve` and `list`, the synchronous local-graph snapshots `get`, `getAll`,
7
7
  * and `getCount`, the writes `create`, `update`, and `delete`, the coordination
8
8
  * namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
9
- * `claim.queue`, `claim.release`, and `claim.reorder`), `watch`, and `onChange`.
9
+ * `claim.queue`, `claim.release`, and `claim.reorder`), `join`, and `onChange`.
10
10
  * The factory returns a plain object; the client assembles the `ablo.<model>`
11
11
  * lookup table from one of these per model.
12
12
  */
13
13
  import { AbloClaimedError } from '../errors.js';
14
14
  import { type ModelUpdater, type ContentionOptions } from './functionalUpdate.js';
15
15
  import type { MutationOptions } from '../interfaces/index.js';
16
+ import type { StaleNotification } from '../coordination/schema.js';
16
17
  import type { ModelRegistry } from '../ModelRegistry.js';
17
18
  import type { InstanceCache } from '../InstanceCache.js';
18
19
  import type { SyncClient } from '../SyncClient.js';
@@ -27,6 +28,27 @@ export interface ModelClientMeta {
27
28
  }
28
29
  export declare function getModelClientMeta(modelClient: unknown): ModelClientMeta | undefined;
29
30
  export type ModelListScope = ModelScope | 'live' | 'archived' | 'all';
31
+ /** Options for `track({ id })` — register a durable read-dependency on a row. */
32
+ export interface ModelTrackParams {
33
+ /** The row to keep hearing about, by id. */
34
+ id: string;
35
+ /**
36
+ * The sync watermark this track is premised on. Omit to baseline at the
37
+ * current head — "tell me about anything from here on". Pass a known
38
+ * `lastSyncId` (e.g. the one you read the row at) to also catch a change that
39
+ * already landed between that read and this call.
40
+ */
41
+ readAt?: number;
42
+ }
43
+ /** The result of `track({ id })`. */
44
+ export interface ModelTrackResult {
45
+ /**
46
+ * Tracks that had ALREADY fired at registration time — a change matching an
47
+ * open track that landed before this call. Present only when something was
48
+ * already stale; the ongoing signal arrives on the receipts of later commits.
49
+ */
50
+ notifications?: StaleNotification[];
51
+ }
30
52
  /** Options for the synchronous local-pool reads `get`, `getAll`, and
31
53
  * `onChange` — a JavaScript `filter`, an equality `where`, and a lifecycle
32
54
  * `state`. This is the local, reactive axis; contrast {@link ServerReadOptions},
@@ -172,11 +194,11 @@ export interface ModelCollaboration<T> {
172
194
  pinScope?(scope: Record<string, string>): void | Promise<void>;
173
195
  /**
174
196
  * Opens a presence and claim subscription on this model's sync group(s) and
175
- * returns the live participant handle. Backs `ablo.<model>.watch(ids)`.
197
+ * returns the live participant handle. Backs `ablo.<model>.join(ids)`.
176
198
  * WebSocket only, since presence needs a live socket; it is absent on other
177
199
  * client constructions, where the proxy throws a clear error.
178
200
  */
179
- createWatch?(modelKey: string, ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
201
+ createJoin?(modelKey: string, ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
180
202
  }
181
203
  export interface ClaimTargetOptions<T = Record<string, unknown>> {
182
204
  /** Peer-visible description of the work being performed — the sentence a
@@ -369,8 +391,8 @@ export interface ModelDeleteParams<T> extends MutationOptions {
369
391
  readonly id: string;
370
392
  readonly claim?: Claim<T> | ClaimTargetOptions<T> | null;
371
393
  }
372
- /** Options for the WebSocket-only `ablo.<model>.watch(ids, options?)`. */
373
- export interface WatchOptions {
394
+ /** Options for the WebSocket-only `ablo.<model>.join(ids, options?)`. */
395
+ export interface JoinOptions {
374
396
  /**
375
397
  * Lease TTL for the underlying presence claim — the participant
376
398
  * auto-releases after this if the holder dies. Compact duration string
@@ -461,19 +483,40 @@ export interface ModelOperations<T, CreateInput> {
461
483
  */
462
484
  claim: ClaimApi<T>;
463
485
  /**
464
- * Subscribes this client to the sync group(s) for one or more rows of this
465
- * model and returns a live participant handle presence (`.peers`), the
466
- * scoped claim stream (`.claims`), and `.leave()` / `await using` disposal.
486
+ * Register a durable read-dependency on a row of this model keep hearing
487
+ * about it after this call returns. Where the per-write `reads` gate lives for
488
+ * exactly one commit, a track persists on the server: any change that lands on
489
+ * the tracked row rides back on the `notifications` of your next commit, so a
490
+ * long-running actor learns its context went stale without re-reading. A track
491
+ * you already have is refreshed, not duplicated (it is an idempotent upsert).
492
+ *
493
+ * ```ts
494
+ * await ablo.tasks.track({ id: 'task_42' });
495
+ * // …minutes of other work later, on your next write…
496
+ * const res = await ablo.tasks.update({ id: 'task_42', data: { done: true } });
497
+ * res.notifications; // populated if task_42 changed under you in the meantime
498
+ * ```
499
+ *
500
+ * The returned `notifications` are only the tracks that had ALREADY fired at
501
+ * registration time; the ongoing signal arrives on later receipts.
502
+ */
503
+ track(params: ModelTrackParams): Promise<ModelTrackResult>;
504
+ /**
505
+ * Joins the sync group(s) for one or more rows of this model and returns a
506
+ * live participant handle — presence (`.peers`), the scoped claim stream
507
+ * (`.claims`), and `.leave()` / `await using` disposal. This is a presence
508
+ * subscription: it reports who else is here and what they hold, not row
509
+ * values changing — for the latter, use `onChange`.
467
510
  *
468
511
  * WebSocket only: presence needs a live socket, so this is absent on HTTP
469
512
  * clients and throws on any non-WebSocket construction.
470
513
  *
471
514
  * ```ts
472
- * await using participant = await ablo.slides.watch(slideIds, { ttl: '5m' });
515
+ * await using participant = await ablo.slides.join(slideIds, { ttl: '5m' });
473
516
  * participant.peers; // who else is here
474
517
  * ```
475
518
  */
476
- watch(ids: string | readonly string[], options?: WatchOptions): Promise<JoinedParticipant>;
519
+ join(ids: string | readonly string[], options?: JoinOptions): Promise<JoinedParticipant>;
477
520
  /** Subscribe to changes; the callback runs on every change. */
478
521
  onChange(callback: (entities: T[]) => void, options?: LocalReadOptions<T>): () => void;
479
522
  }
@@ -6,7 +6,7 @@
6
6
  * `retrieve` and `list`, the synchronous local-graph snapshots `get`, `getAll`,
7
7
  * and `getCount`, the writes `create`, `update`, and `delete`, the coordination
8
8
  * namespace `claim` (callable as `claim({ id })`, plus `claim.state`,
9
- * `claim.queue`, `claim.release`, and `claim.reorder`), `watch`, and `onChange`.
9
+ * `claim.queue`, `claim.release`, and `claim.reorder`), `join`, and `onChange`.
10
10
  * The factory returns a plain object; the client assembles the `ablo.<model>`
11
11
  * lookup table from one of these per model.
12
12
  */
@@ -750,11 +750,30 @@ export function createModelProxy(schemaKey, registeredModelName, objectPool, syn
750
750
  // `claim` is a callable namespace (take a claim) carrying the coordination
751
751
  // readers (`claim.state` / `claim.queue` / `claim.release` / `claim.reorder`).
752
752
  claim: claimApi,
753
- watch: guard((ids, options) => {
754
- if (!collaboration?.createWatch) {
755
- throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so watch() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_watch_not_configured' });
753
+ track: guard(async (params) => {
754
+ const dep = {
755
+ model: wireModel,
756
+ id: params.id,
757
+ ...(params.readAt !== undefined ? { readAt: params.readAt } : {}),
758
+ };
759
+ // A track carries no write, so it rides the commit lane as a zero-operation
760
+ // commit: the queue tolerates disconnects and de-dupes replays, and the
761
+ // server's track-only path registers the dependency and reports anything
762
+ // that already fired. Reuse the same lane the batch `commits.create` door
763
+ // uses rather than opening a bespoke transport.
764
+ const clientTxId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
765
+ ? crypto.randomUUID()
766
+ : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
767
+ const queue = syncClient.getTransactionQueue();
768
+ await queue.enqueueCommit(clientTxId, [], { track: [dep] });
769
+ const { notifications } = await queue.waitForCommitReceipt(clientTxId);
770
+ return notifications && notifications.length > 0 ? { notifications } : {};
771
+ }),
772
+ join: guard((ids, options) => {
773
+ if (!collaboration?.createJoin) {
774
+ throw new AbloValidationError(`Model "${schemaKey}" was built without a WebSocket runtime, so join() is unavailable here. Presence needs a live socket — use the standard Ablo({ schema, apiKey }) client (not the HTTP transport).`, { code: 'model_join_not_configured' });
756
775
  }
757
- return collaboration.createWatch(schemaKey, ids, options);
776
+ return collaboration.createJoin(schemaKey, ids, options);
758
777
  }),
759
778
  onChange(callback, options) {
760
779
  return autorun(() => {
@@ -738,6 +738,7 @@ export function createHttpTransport(options) {
738
738
  const requestBody = {
739
739
  operations,
740
740
  reads: commitOptions.reads,
741
+ track: commitOptions.track,
741
742
  };
742
743
  const wait = commitOptions.wait ?? 'confirmed';
743
744
  let body;
@@ -4,7 +4,7 @@
4
4
  * {@link HttpClaimApi} derivation. This module holds only types and has no runtime
5
5
  * imports.
6
6
  */
7
- import type { ReadDependency } from '../coordination/schema.js';
7
+ import type { ReadDependency, TrackDependency } from '../coordination/schema.js';
8
8
  import type { ClientCommitReceipt, CommitStatus } from '../wire/commit.js';
9
9
  import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
10
10
  export type { ModelTarget, ModelClaim };
@@ -96,6 +96,15 @@ export interface CommitCreateOptions {
96
96
  * you read, not what you write.
97
97
  */
98
98
  readonly reads?: readonly ReadDependency[] | null;
99
+ /**
100
+ * Durable read-dependencies to register as part of this batch — the persisted
101
+ * sibling of `reads`. Where `reads` guards only this commit, a `track` entry
102
+ * (`{ model, id, readAt? }` for a row or `{ group, readAt? }` for a sync group)
103
+ * lives on past it: a later matching change rides back on a future receipt's
104
+ * `notifications`. A track-only batch (just `track`, an empty `operations`) is
105
+ * the batch form of `ablo.<model>.track()`.
106
+ */
107
+ readonly track?: readonly TrackDependency[] | null;
99
108
  }
100
109
  /** Public projection inferred from the canonical runtime schema. */
101
110
  export type CommitReceipt = ClientCommitReceipt;
@@ -4,7 +4,7 @@
4
4
  * commit time because it does not exist yet when the executor is created. A
5
5
  * client wires this up automatically unless you supply your own executor.
6
6
  */
7
- import type { ReadDependency } from '../coordination/schema.js';
7
+ import type { ReadDependency, TrackDependency } from '../coordination/schema.js';
8
8
  import type { MutationExecutor, MutationOperation } from '../interfaces/index.js';
9
9
  import type { CommitAck } from '../sync/commitFrames.js';
10
10
  /**
@@ -23,5 +23,5 @@ import type { CommitAck } from '../sync/commitFrames.js';
23
23
  * generation below exists only for direct, one-shot executor consumers.
24
24
  */
25
25
  export declare function createDefaultMutationExecutor(getWs: () => {
26
- sendCommit?: (operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null) => Promise<CommitAck>;
26
+ sendCommit?: (operations: readonly MutationOperation[], clientTxId: string, timeoutMs?: number, causedByTaskId?: string | null, reads?: readonly ReadDependency[] | null, track?: readonly TrackDependency[] | null) => Promise<CommitAck>;
27
27
  } | null): MutationExecutor;
@@ -34,7 +34,7 @@ export function createDefaultMutationExecutor(getWs) {
34
34
  : `tx_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`);
35
35
  try {
36
36
  return await ws.sendCommit(operations, clientTxId, undefined, // use sendCommit's built-in 15s default; no per-call override
37
- options?.causedByTaskId, options?.reads);
37
+ options?.causedByTaskId, options?.reads, options?.track);
38
38
  }
39
39
  catch (err) {
40
40
  // Wrap transport-level failures as connection errors so the transaction
@@ -9,7 +9,7 @@
9
9
  * Exports are listed by name rather than re-exported wholesale, so every symbol
10
10
  * that becomes part of this package's public API is a deliberate choice.
11
11
  */
12
- export { targetRangeSchema, participantKindSchema, wireParticipantKindSchema, participantKindFromWire, descriptionFromMeta, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, claimStatusSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema, } from './schema.js';
12
+ export { targetRangeSchema, participantKindSchema, wireParticipantKindSchema, participantKindFromWire, descriptionFromMeta, targetRefSchema, onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, trackDependencySchema, claimStatusSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema, updateSubscriptionPayloadSchema, subscriptionAckPayloadSchema, commitOperationTypeSchema, commitOperationSchema, presenceKindSchema, presenceActivitySchema, presenceUpdateFrameSchema, } from './schema.js';
13
13
  export { defaultPolicy, capabilityPreemptPolicy, interpretConflictAxis } from '../policy/types.js';
14
14
  export type { Conflict, ConflictAxis, ConflictDecision, ConflictKind, ConflictOperation, ConflictPolicy, StaleContextConflict, ClaimHeldConflict, } from '../policy/types.js';
15
- export type { TargetRange, ParticipantKind, TargetRef, OnStaleMode, WriteGuard, StaleNotification, ReadDependency, ClaimStatus, WireClaimSummary, ClaimError, WireClaim, ClaimRejection, ModelTarget, ModelClaim, ClaimBeginPayload, ClaimAbandonPayload, ClaimReorderPayload, ClaimHeartbeatPayload, ClaimHeartbeatAckPayload, ClaimHeartbeatBatchPayload, ClaimHeartbeatBatchAckPayload, UpdateSubscriptionPayload, SubscriptionAckPayload, CommitOperationType, CommitOperation, AnyCommitOperation, PresenceKind, PresenceActivity, PresenceUpdateFrame, } from './schema.js';
15
+ export type { TargetRange, ParticipantKind, TargetRef, OnStaleMode, WriteGuard, StaleNotification, ReadDependency, TrackDependency, ClaimStatus, WireClaimSummary, ClaimError, WireClaim, ClaimRejection, ModelTarget, ModelClaim, ClaimBeginPayload, ClaimAbandonPayload, ClaimReorderPayload, ClaimHeartbeatPayload, ClaimHeartbeatAckPayload, ClaimHeartbeatBatchPayload, ClaimHeartbeatBatchAckPayload, UpdateSubscriptionPayload, SubscriptionAckPayload, CommitOperationType, CommitOperation, AnyCommitOperation, PresenceKind, PresenceActivity, PresenceUpdateFrame, } from './schema.js';
@@ -14,7 +14,7 @@ export {
14
14
  // Shared primitives
15
15
  targetRangeSchema, participantKindSchema, wireParticipantKindSchema, participantKindFromWire, descriptionFromMeta, targetRefSchema,
16
16
  // Layer 3 — optimistic stale-context
17
- onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema,
17
+ onStaleModeSchema, writeGuardSchema, staleNotificationSchema, readDependencySchema, trackDependencySchema,
18
18
  // Layer 2 — pessimistic claim / claim-lease
19
19
  claimStatusSchema, wireClaimSummarySchema, claimErrorSchema, wireClaimSchema, claimRejectionSchema, modelTargetSchema, modelClaimSchema, claimBeginPayloadSchema, claimAbandonPayloadSchema, claimReorderPayloadSchema, claimHeartbeatPayloadSchema, claimHeartbeatAckPayloadSchema, claimHeartbeatBatchPayloadSchema, claimHeartbeatBatchAckPayloadSchema,
20
20
  // Read interest — area-of-interest navigation
@@ -181,6 +181,26 @@ export declare const readDependencySchema: z.ZodUnion<readonly [z.ZodObject<{
181
181
  }>>;
182
182
  }, z.core.$strip>]>;
183
183
  export type ReadDependency = z.infer<typeof readDependencySchema>;
184
+ /**
185
+ * A durable read-dependency — what a participant is watching so that a later
186
+ * change to it opens a {@link StaleNotification}. It is the persisted sibling of
187
+ * a {@link ReadDependency}: the same reference shape, minus the disposition (a
188
+ * track always notifies — that is what tracking is), with an optional `readAt`
189
+ * that defaults to the watermark of the commit that registered it. The row form
190
+ * watches one object; the group form watches a whole sync group ("anything in
191
+ * `deck:abc`"). Where a `ReadDependency` is checked once at commit and discarded,
192
+ * a `TrackDependency` is kept and re-checked against every future delta. See
193
+ * `packages/sync-engine/docs/groups.md` for how it drives change propagation.
194
+ */
195
+ export declare const trackDependencySchema: z.ZodUnion<readonly [z.ZodObject<{
196
+ model: z.ZodString;
197
+ id: z.ZodString;
198
+ readAt: z.ZodOptional<z.ZodNumber>;
199
+ }, z.core.$strip>, z.ZodObject<{
200
+ group: z.ZodString;
201
+ readAt: z.ZodOptional<z.ZodNumber>;
202
+ }, z.core.$strip>]>;
203
+ export type TrackDependency = z.infer<typeof trackDependencySchema>;
184
204
  /**
185
205
  * The lifecycle of a claim. When absent on the wire it means `'active'` (an
186
206
  * additive back-compat default). The server stamps `'active'` on `claim_begin`
@@ -183,6 +183,28 @@ export const readDependencySchema = z.union([
183
183
  onStale: onStaleModeSchema.optional(),
184
184
  }),
185
185
  ]);
186
+ /**
187
+ * A durable read-dependency — what a participant is watching so that a later
188
+ * change to it opens a {@link StaleNotification}. It is the persisted sibling of
189
+ * a {@link ReadDependency}: the same reference shape, minus the disposition (a
190
+ * track always notifies — that is what tracking is), with an optional `readAt`
191
+ * that defaults to the watermark of the commit that registered it. The row form
192
+ * watches one object; the group form watches a whole sync group ("anything in
193
+ * `deck:abc`"). Where a `ReadDependency` is checked once at commit and discarded,
194
+ * a `TrackDependency` is kept and re-checked against every future delta. See
195
+ * `packages/sync-engine/docs/groups.md` for how it drives change propagation.
196
+ */
197
+ export const trackDependencySchema = z.union([
198
+ z.object({
199
+ model: z.string(),
200
+ id: z.string(),
201
+ readAt: z.number().optional(),
202
+ }),
203
+ z.object({
204
+ group: z.string(),
205
+ readAt: z.number().optional(),
206
+ }),
207
+ ]);
186
208
  // ─────────────────────────────────────────────────────────────────────────
187
209
  // Layer 2 — pessimistic claims and leases
188
210
  // ─────────────────────────────────────────────────────────────────────────
@@ -161,7 +161,7 @@ export declare const ERROR_CODES: {
161
161
  readonly model_claimed: ErrorCodeSpec;
162
162
  readonly model_claimed_timeout: ErrorCodeSpec;
163
163
  readonly model_claim_not_configured: ErrorCodeSpec;
164
- readonly model_watch_not_configured: ErrorCodeSpec;
164
+ readonly model_join_not_configured: ErrorCodeSpec;
165
165
  readonly stale_context: ErrorCodeSpec;
166
166
  readonly contention_exhausted: ErrorCodeSpec;
167
167
  readonly update_aborted: ErrorCodeSpec;
@@ -157,7 +157,7 @@ export const ERROR_CODES = {
157
157
  model_claimed: wire('claim', 409, false, 'Another participant holds a claim on this row. Read `claim.state` to see who holds it, or queue behind them with a claim of your own.'),
158
158
  model_claimed_timeout: wire('claim', 409, false, 'Another participant held a claim on this row and did not release it in time. Retry, or read `claim.state` to see who holds it.'),
159
159
  model_claim_not_configured: client('claim', 'Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically — there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path).'),
160
- model_watch_not_configured: client('claim', 'watch() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
160
+ model_join_not_configured: client('claim', 'join() opens a presence/claim subscription and needs a live WebSocket, so it is unavailable on the HTTP transport and on model proxies built without a socket. Use the standard Ablo({ schema, apiKey }) client (default WebSocket transport).'),
161
161
  // ── stale context / idempotency (409) ──────────────────────────────
162
162
  // Not retryable at the transport: the rejected request carries its frozen
163
163
  // `readAt`, so resending the identical payload can never succeed. Recovery
@@ -6,7 +6,7 @@
6
6
  * session-error detection, online-status checks, and the transport that carries
7
7
  * mutations to your backend. The SDK ships sensible no-op defaults where it can.
8
8
  */
9
- import type { ReadDependency, ParticipantKind } from '../coordination/schema.js';
9
+ import type { ReadDependency, TrackDependency, ParticipantKind } from '../coordination/schema.js';
10
10
  import type { CommitStatus, MutationCommitResultInput } from '../wire/commit.js';
11
11
  export interface SyncLogger {
12
12
  debug(message: string, ...args: unknown[]): void;
@@ -232,6 +232,18 @@ export interface MutationOptions {
232
232
  * footprints, §4 the read-set) for the governing convention.
233
233
  */
234
234
  reads?: ReadDependency[] | null;
235
+ /**
236
+ * Durable read-dependencies — what this write (or the record it produces) should
237
+ * keep watching. Unlike `reads`, which is checked once at commit and discarded,
238
+ * each `track` entry is persisted and re-checked against every future delta; a
239
+ * later matching change opens a `StaleNotification` for the tracking participant,
240
+ * delivered at their next commit or live to a held claim. Each entry is a row
241
+ * (`{ model, id }`) or a sync group (`{ group }`), optionally pinned to a `readAt`
242
+ * baseline (defaults to this commit's watermark).
243
+ *
244
+ * See `packages/sync-engine/docs/groups.md` for how `track` drives propagation.
245
+ */
246
+ track?: TrackDependency[] | null;
235
247
  }
236
248
  /**
237
249
  * The subset of {@link MutationOptions} that travels with each write as it is
@@ -15,7 +15,7 @@ import { type SyncStoreContract } from './context.js';
15
15
  * - **One component, one import.** Consumers write the provider
16
16
  * once at the root; nothing else needs to plumb the engine.
17
17
  * - **Multiplayer is default.** React consumers are always browsers doing
18
- * multiplayer UI, so `useWatch()` / `useAblo()` are always
18
+ * multiplayer UI, so `useJoin()` / `useAblo()` are always
19
19
  * available. No opt-in prop.
20
20
  * - **Declarative props for app glue.** `preventUnsavedChanges`,
21
21
  * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
@@ -110,11 +110,11 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
110
110
  export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
111
111
  export type { EngineParticipant, ParticipantScope, ParticipantStatus };
112
112
  /**
113
- * Options for `useWatch`. The hook reuses the engine's single
113
+ * Options for `useJoin`. The hook reuses the engine's single
114
114
  * WebSocket and opens a scoped claim on it when `scope` is provided:
115
115
  * one TCP connection, N logical sub-syncgroup participants.
116
116
  */
117
- export interface UseWatchOptions {
117
+ export interface UseJoinOptions {
118
118
  readonly scope?: ParticipantScope;
119
119
  readonly ttlSeconds?: number | string | null;
120
120
  /** Tear down + don't re-join while true. */
@@ -145,7 +145,7 @@ export interface UseWatchOptions {
145
145
  }
146
146
  /** @deprecated Use `ParticipantStatus`. */
147
147
  export type MeshParticipantStatus = ParticipantStatus;
148
- export interface UseWatchReturn {
148
+ export interface UseJoinReturn {
149
149
  readonly participant: EngineParticipant | null;
150
150
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
151
151
  readonly peers: readonly Peer[];
@@ -159,7 +159,7 @@ export interface UseWatchReturn {
159
159
  * lifecycle status. Auto-cleans up on unmount or when `paused`
160
160
  * flips to true.
161
161
  *
162
- * `useWatch` is the React form of `ablo.<model>.watch` — scope-level
162
+ * `useJoin` is the React form of `ablo.<model>.join` — scope-level
163
163
  * read-interest + presence; returns the reactive participant facade
164
164
  * (peers/claims/status).
165
165
  *
@@ -168,10 +168,10 @@ export interface UseWatchReturn {
168
168
  * headless-bot patterns (a separate identity in the same browser
169
169
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
170
170
  */
171
- export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
171
+ export declare function useJoin(opts: UseJoinOptions): UseJoinReturn;
172
172
  /**
173
173
  * Read-only presence: the OTHER participants currently visible to this
174
- * connection, bridged to React. Unlike {@link useWatch}, this does
174
+ * connection, bridged to React. Unlike {@link useJoin}, this does
175
175
  * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
176
176
  * it is a pure reader of the engine's already-flowing presence stream.
177
177
  *
@@ -184,7 +184,7 @@ export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
184
184
  * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
185
185
  * broadcasts while alone — when some OTHER mount already owns the scope's
186
186
  * read interest (scope `leave` is not reference-counted, so a second
187
- * `useWatch` on the same scope would warm-drop the owner's
187
+ * `useJoin` on the same scope would warm-drop the owner's
188
188
  * subscription on unmount).
189
189
  *
190
190
  * ```ts
@@ -211,7 +211,7 @@ const EMPTY_INTENTS = Object.freeze([]);
211
211
  * lifecycle status. Auto-cleans up on unmount or when `paused`
212
212
  * flips to true.
213
213
  *
214
- * `useWatch` is the React form of `ablo.<model>.watch` — scope-level
214
+ * `useJoin` is the React form of `ablo.<model>.join` — scope-level
215
215
  * read-interest + presence; returns the reactive participant facade
216
216
  * (peers/claims/status).
217
217
  *
@@ -220,7 +220,7 @@ const EMPTY_INTENTS = Object.freeze([]);
220
220
  * headless-bot patterns (a separate identity in the same browser
221
221
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
222
222
  */
223
- export function useWatch(opts) {
223
+ export function useJoin(opts) {
224
224
  const ctx = useContext(AbloInternalContext);
225
225
  const engine = ctx?.engine ?? null;
226
226
  const { paused = false } = opts;
@@ -353,7 +353,7 @@ export function useWatch(opts) {
353
353
  }
354
354
  /**
355
355
  * Read-only presence: the OTHER participants currently visible to this
356
- * connection, bridged to React. Unlike {@link useWatch}, this does
356
+ * connection, bridged to React. Unlike {@link useJoin}, this does
357
357
  * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
358
358
  * it is a pure reader of the engine's already-flowing presence stream.
359
359
  *
@@ -366,7 +366,7 @@ export function useWatch(opts) {
366
366
  * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
367
367
  * broadcasts while alone — when some OTHER mount already owns the scope's
368
368
  * read interest (scope `leave` is not reference-counted, so a second
369
- * `useWatch` on the same scope would warm-drop the owner's
369
+ * `useJoin` on the same scope would warm-drop the owner's
370
370
  * subscription on unmount).
371
371
  *
372
372
  * ```ts
@@ -377,7 +377,7 @@ export function useWatch(opts) {
377
377
  export function usePeers(scope) {
378
378
  const ctx = useContext(AbloInternalContext);
379
379
  const engine = ctx?.engine ?? null;
380
- // Resolve scope → groups through the schema (same idiom as useWatch).
380
+ // Resolve scope → groups through the schema (same idiom as useJoin).
381
381
  // The stringified, sorted key is the stable effect dependency.
382
382
  const scopeKey = JSON.stringify(resolveParticipantSyncGroups(scope, engine?.schema).sort());
383
383
  const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
@@ -394,7 +394,7 @@ export function usePeers(scope) {
394
394
  // Plain useState + onChange — presence changes on join/leave/activity
395
395
  // only (never on cursor traffic, a separate channel), so this fires
396
396
  // rarely; a frame of stale presence is harmless (same rationale as
397
- // useWatch's peers bridge).
397
+ // useJoin's peers bridge).
398
398
  setPeers(compute());
399
399
  return presence.onChange(() => { setPeers(compute()); });
400
400
  }, [engine, scopeKey]);