@abloatai/ablo 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -12
  3. package/dist/cli.cjs +148 -21
  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/schema/index.d.ts +1 -1
  23. package/dist/schema/index.js +1 -1
  24. package/dist/schema/schema.d.ts +37 -40
  25. package/dist/schema/schema.js +24 -30
  26. package/dist/schema/select.js +1 -1
  27. package/dist/schema/serialize.d.ts +3 -3
  28. package/dist/schema/serialize.js +4 -2
  29. package/dist/server/commit.d.ts +8 -1
  30. package/dist/surface.d.ts +1 -1
  31. package/dist/surface.js +2 -1
  32. package/dist/sync/SyncWebSocket.d.ts +3 -3
  33. package/dist/sync/SyncWebSocket.js +4 -4
  34. package/dist/sync/commitFrames.d.ts +2 -2
  35. package/dist/sync/commitFrames.js +5 -1
  36. package/dist/transactions/TransactionQueue.d.ts +4 -1
  37. package/dist/transactions/TransactionQueue.js +18 -0
  38. package/dist/transactions/commitEnvelope.d.ts +8 -0
  39. package/dist/transactions/commitEnvelope.js +2 -1
  40. package/dist/transactions/durableWriteStore.d.ts +8 -0
  41. package/dist/wire/frames.d.ts +17 -1
  42. package/dist/wire/frames.js +2 -1
  43. package/docs/client-behavior.md +1 -1
  44. package/docs/coordination.md +18 -6
  45. package/docs/groups.md +50 -0
  46. package/docs/identity.md +2 -2
  47. package/docs/migration.md +29 -5
  48. package/docs/react.md +9 -9
  49. package/llms.txt +1 -1
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
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
+
15
+ ## 0.33.0
16
+
17
+ ### Minor Changes
18
+
19
+ The declarative seam introduced in 0.32.0 for carrying tenant identity into your row-level-security policies has a clearer name and a simpler shape. `tenantContext` is now `sessionSettings`, and instead of a list of `{ guc, from }` pairs it is a plain map from the Postgres session setting your policies read to the Ablo identity that fills it:
20
+
21
+ ```ts
22
+ defineSchema({
23
+ models: { document: { /* … */ } },
24
+ sessionSettings: { 'app.current_org': 'orgId' },
25
+ })
26
+ ```
27
+
28
+ The setting name is the key, so a setting takes exactly one source and a duplicate is unrepresentable rather than something to validate away. If you adopted `tenantContext` in 0.32.0, rename it to `sessionSettings` and turn each `{ guc: 'app.current_org', from: 'orgId' }` into `'app.current_org': 'orgId'`; the exported names followed the rename — `TenantContextMapping` and `TenantContextSource` became `SessionSettings` and `SessionSettingSource`, and `RESERVED_TENANT_CONTEXT_GUCS` became `RESERVED_SESSION_SETTINGS`. The meaning is unchanged: Ablo fills only settings it resolves from your authenticated identity, never from client-supplied data, so a mapping can forward the tenant Ablo already trusts but can never widen a writer's scope, and settings the engine reserves for itself — `row_security`, the timeouts — are still refused at definition time. Schemas pushed before the rename keep parsing.
29
+
30
+ Claims gain a fairness backstop, off by default. Claims coordinate long work by waiting rather than locking: when another participant holds a row, your `claim` joins a fair FIFO line behind it, and the holder keeps its lease alive for as long as the work runs by beating on a cadence. Nothing bounded a hold, so a holder that kept beating — and never read the `queueDepth` pressure signal each beat returns, the cue to checkpoint and release when others are waiting — could keep a contended row indefinitely, and the line behind it had no recourse but that holder's goodwill. A deployment can now configure, per model, how long a single holding may last while contenders are actually queued; a holder that runs past that fair share is preempted at the server on its next beat, which arrives as the `claim_lost` you already handle — abandon or re-claim, exactly as when a lease lapses. The guarantee is deliberately narrow: a holder with no one waiting is never preempted however long it runs, so open-ended solo work is untouched, and preemption is a fairness decision, never a correctness one — a preempted holder's next write is still checked at commit against the row's version and fencing token, so the worst it can cause is a clean re-read, never a lost update.
31
+
32
+ ### Patch Changes
33
+
34
+ Connecting a database a second time now behaves the way you'd expect. `ablo connect --apply` publishes exactly the tables you name with `--tables`, and until now it assumed that publication didn't exist yet — so on a database that had already been connected, the step to publish your tables quietly did nothing, and the writer role ended up granted on one set of tables while Ablo was still reading a different, older set. Registration then refused the writer as "not ready," which was correct but baffling: the two halves disagreed and nothing said so. Apply now reconciles the publication to match your `--tables` on every run — adding what's newly named, dropping what's no longer there, the same declarative model a CDC tool like Debezium uses — and prints what it's about to add or drop before it touches anything, so narrowing the set is a decision you see rather than a surprise.
35
+
36
+ When a readiness check does fail, the reason is finally visible. The direct-write preflight has always returned a precise checklist — which privilege is missing, on which table, and the exact grant to fix it — but the CLI was reading it from the wrong place in the error and rendered a blank message that sent you in circles. The checklist now prints under the failure, one line per unmet requirement, with the fix.
37
+
3
38
  ## 0.32.0
4
39
 
5
40
  ### Minor Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>Let people and AI agents work on the same data without overwriting each other.</strong>
6
+ <strong>The coordination infrastructure for fleets of AI agents.</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -23,17 +23,28 @@
23
23
 
24
24
  ---
25
25
 
26
- When an agent and a person change the same thing at once, work gets lost: one
27
- edit silently clobbers another, or the agent acts on data that already moved.
28
- Ablo gives them one shared, typed write path so people, server actions, and
29
- agents can all work on the same rows without working blind.
26
+ Development stopped being the bottleneck; coordination is. The moment a *fleet*
27
+ of agents not one, and not just people edits the same rows, work gets lost:
28
+ one agent clobbers another, or acts on data that already moved. Ablo is the
29
+ infrastructure that lets the fleet work as one the load-bearing coordination
30
+ layer the way operational-transform and presence sit invisibly under a shared
31
+ document. You build the agents; Ablo is the substrate that lets them run together
32
+ on shared state without stepping on each other, with the people and server
33
+ actions alongside them on the exact same path.
30
34
 
31
35
  The core idea is a **claim**. An agent's work is rarely one instant write; it
32
- reads something, thinks, calls an LLM or tool, then writes back. While that is
33
- happening, the row can change underneath it. So before slow work starts, the
34
- agent claims the row. If someone else is already working on it, `claim` waits,
35
- re-reads the fresh row, then hands it over. No stale overwrite, no separate
36
- agent mutation path.
36
+ reads something, thinks, calls an LLM or a tool, then writes back and in that
37
+ gap the row can move under it. So before the slow work starts, the agent claims
38
+ the row. If another agent is already on it, `claim` waits its turn in a fair
39
+ line, re-reads the fresh row, then hands it over. No stale overwrite, no separate
40
+ agent mutation path. People are exempt by policy: a human edit is never made to
41
+ queue behind an agent, and by declared conflict rules always wins.
42
+
43
+ When the row moves anyway — say a record an agent generated against gets bumped
44
+ the moment before it commits — the commit is rejected and the agent is handed
45
+ back exactly the records that changed, so it re-reads only those, not the world.
46
+ The pattern we kept measuring: with real agents contending on shared state, the
47
+ win comes from the coordination layer, not a bigger model. Org beats intelligence.
37
48
 
38
49
  Under the hood, you define your data once with a Zod schema and get the same
39
50
  typed model client for every actor — people, server actions, and agents:
@@ -63,8 +74,9 @@ yet? A **sandbox** `sk_test` key holds throwaway **test data** — like Stripe t
63
74
  mode — so you can explore before pointing it at your Postgres. Test-mode only; in
64
75
  production every row lives in your database.)
65
76
 
66
- **Built for** collaborative editors, AI agent workflows, background workers on
67
- your own infrastructure, and internal tools anywhere people and agents change
77
+ **Built for** fleets of agents working a shared backlog, AI agent workflows on
78
+ your own infrastructure, collaborative editors where agents and people co-edit,
79
+ and internal tools — anywhere agents (and the people alongside them) change
68
80
  shared state and everyone has to see it live.
69
81
 
70
82
  ## Set up
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",
@@ -5431,6 +5442,55 @@ BEGIN
5431
5442
  END $$;`
5432
5443
  ];
5433
5444
  }
5445
+ function reconcilePublicationPlan(current, desiredTables) {
5446
+ const pub = quoteIdent(ABLO_PUBLICATION);
5447
+ const desiredAll = desiredTables.length === 0;
5448
+ const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(quoteIdent).join(", ")}`;
5449
+ if (!current.exists) {
5450
+ return {
5451
+ sql: [`CREATE PUBLICATION ${pub} ${target};`],
5452
+ added: desiredAll ? [] : [...desiredTables],
5453
+ removed: [],
5454
+ recreated: false
5455
+ };
5456
+ }
5457
+ if (current.allTables !== desiredAll) {
5458
+ return {
5459
+ sql: [`DROP PUBLICATION IF EXISTS ${pub};`, `CREATE PUBLICATION ${pub} ${target};`],
5460
+ added: desiredAll ? [] : desiredTables.filter((t) => !current.tables.includes(t)),
5461
+ removed: current.allTables ? [] : current.tables.filter((t) => !desiredTables.includes(t)),
5462
+ recreated: true
5463
+ };
5464
+ }
5465
+ if (desiredAll) {
5466
+ return { sql: [], added: [], removed: [], recreated: false };
5467
+ }
5468
+ const added = desiredTables.filter((t) => !current.tables.includes(t));
5469
+ const removed = current.tables.filter((t) => !desiredTables.includes(t));
5470
+ if (added.length === 0 && removed.length === 0) {
5471
+ return { sql: [], added: [], removed: [], recreated: false };
5472
+ }
5473
+ return {
5474
+ sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(quoteIdent).join(", ")};`],
5475
+ added,
5476
+ removed,
5477
+ recreated: false
5478
+ };
5479
+ }
5480
+ async function readPublicationState(sql) {
5481
+ const pubRows = await sql.unsafe(
5482
+ `SELECT puballtables FROM pg_publication WHERE pubname = $1`,
5483
+ [ABLO_PUBLICATION]
5484
+ );
5485
+ const pubRow = pubRows[0];
5486
+ if (!pubRow) return { exists: false, allTables: false, tables: [] };
5487
+ if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
5488
+ const tableRows = await sql.unsafe(
5489
+ `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = 'public' ORDER BY tablename`,
5490
+ [ABLO_PUBLICATION]
5491
+ );
5492
+ return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
5493
+ }
5434
5494
  async function probeReadiness(sql, opts = {}) {
5435
5495
  const publication = opts.publication ?? ABLO_PUBLICATION;
5436
5496
  const items = [];
@@ -5568,7 +5628,7 @@ async function registerDirectDataSource(opts) {
5568
5628
  )
5569
5629
  );
5570
5630
  } else if (code === "database_not_replication_ready" || code === "data_source_blocked") {
5571
- for (const f of body.details?.failures ?? []) {
5631
+ for (const f of body.failures ?? body.details?.failures ?? []) {
5572
5632
  console.error(
5573
5633
  ` ${import_picocolors6.default.red("\u2717")} ${import_picocolors6.default.bold(f.item ?? "item")}${f.actual ? import_picocolors6.default.dim(` (${f.actual})`) : ""}`
5574
5634
  );
@@ -5580,7 +5640,8 @@ async function registerDirectDataSource(opts) {
5580
5640
  Apply the fixes, verify with ${import_picocolors6.default.bold("ablo connect check")}, then re-run.`)
5581
5641
  );
5582
5642
  } else if (code === "database_unreachable" || code === "source_unreachable") {
5583
- if (body.details?.reason) console.error(import_picocolors6.default.dim(` ${body.details.reason}`));
5643
+ const reason = body.reason ?? body.details?.reason;
5644
+ if (reason) console.error(import_picocolors6.default.dim(` ${reason}`));
5584
5645
  console.error(
5585
5646
  import_picocolors6.default.dim(
5586
5647
  ` Ablo's servers must be able to reach this database \u2014 a localhost or private-network
@@ -5592,7 +5653,7 @@ async function registerDirectDataSource(opts) {
5592
5653
  console.error();
5593
5654
  return false;
5594
5655
  }
5595
- var import_picocolors6, import_zod6, ABLO_PUBLICATION, ABLO_REPLICATION_ROLE, ABLO_WRITE_ROLE, DIRECT_DATA_SOURCE_ROUTES, DataSourceRegisterSuccess, DataSourceRegisterError;
5656
+ var import_picocolors6, import_zod6, ABLO_PUBLICATION, ABLO_REPLICATION_ROLE, ABLO_WRITE_ROLE, DIRECT_DATA_SOURCE_ROUTES, DataSourceRegisterSuccess, ReadinessFailure, DataSourceRegisterError;
5596
5657
  var init_connectSetup = __esm({
5597
5658
  "src/cli/connectSetup.ts"() {
5598
5659
  "use strict";
@@ -5614,17 +5675,20 @@ var init_connectSetup = __esm({
5614
5675
  host: import_zod6.z.string().optional(),
5615
5676
  status: import_zod6.z.string().optional()
5616
5677
  });
5678
+ ReadinessFailure = import_zod6.z.object({
5679
+ item: import_zod6.z.string().optional(),
5680
+ actual: import_zod6.z.string().optional(),
5681
+ fix: import_zod6.z.string().optional()
5682
+ });
5617
5683
  DataSourceRegisterError = import_zod6.z.object({
5618
5684
  code: import_zod6.z.string().optional(),
5619
5685
  message: import_zod6.z.string().optional(),
5686
+ // Canonical: top-level, spread from the engine's `details`.
5687
+ failures: import_zod6.z.array(ReadinessFailure).optional(),
5688
+ reason: import_zod6.z.string().optional(),
5689
+ // Fallback: a wrapping proxy or older engine that nested the same payload.
5620
5690
  details: import_zod6.z.object({
5621
- failures: import_zod6.z.array(
5622
- import_zod6.z.object({
5623
- item: import_zod6.z.string().optional(),
5624
- actual: import_zod6.z.string().optional(),
5625
- fix: import_zod6.z.string().optional()
5626
- })
5627
- ).optional(),
5691
+ failures: import_zod6.z.array(ReadinessFailure).optional(),
5628
5692
  reason: import_zod6.z.string().optional()
5629
5693
  }).optional(),
5630
5694
  error: import_zod6.z.object({ code: import_zod6.z.string().optional(), message: import_zod6.z.string().optional() }).optional()
@@ -5785,7 +5849,8 @@ __export(connectApply_exports, {
5785
5849
  logicalReplicationGuidance: () => logicalReplicationGuidance,
5786
5850
  passwordClause: () => passwordClause,
5787
5851
  postRegistrationOutcome: () => postRegistrationOutcome,
5788
- runConnectApply: () => runConnectApply
5852
+ runConnectApply: () => runConnectApply,
5853
+ tableOwnershipBlockers: () => tableOwnershipBlockers
5789
5854
  });
5790
5855
  function postRegistrationOutcome(input) {
5791
5856
  if (input.registered) return { exitCode: 0, notice: null };
@@ -5829,18 +5894,21 @@ function connectApplyPlan(input) {
5829
5894
  sql: []
5830
5895
  }
5831
5896
  ];
5897
+ const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
5898
+ const publicationSql = reconcile2 ? reconcile2.sql : [
5899
+ `DO $$ BEGIN
5900
+ CREATE PUBLICATION ${quoteIdent(ABLO_PUBLICATION)} ${publicationTarget};
5901
+ EXCEPTION WHEN duplicate_object THEN NULL;
5902
+ END $$;`
5903
+ ];
5904
+ const publicationDetail = reconcile2?.sql.length === 0 ? "already publishing exactly these tables \u2014 nothing to change" : tables.length > 0 ? `a read stream of the ${tables.length} table${tables.length === 1 ? "" : "s"} you chose` : "a read stream of your tables";
5832
5905
  return [
5833
5906
  ...walStep,
5834
5907
  {
5835
5908
  key: "publication",
5836
5909
  title: "Publish your tables to Ablo",
5837
- detail: tables.length > 0 ? `a read stream of the ${tables.length} table${tables.length === 1 ? "" : "s"} you chose` : "a read stream of your tables",
5838
- sql: [
5839
- `DO $$ BEGIN
5840
- CREATE PUBLICATION ${quoteIdent(ABLO_PUBLICATION)} ${publicationTarget};
5841
- EXCEPTION WHEN duplicate_object THEN NULL;
5842
- END $$;`
5843
- ]
5910
+ detail: publicationDetail,
5911
+ sql: publicationSql
5844
5912
  },
5845
5913
  {
5846
5914
  key: "replication-role",
@@ -5915,6 +5983,27 @@ async function unmanageableLedgerOwner(sql) {
5915
5983
  );
5916
5984
  return ledgerBlockedBy(rows[0]);
5917
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
+ }
5918
6007
  function detectProvider(hostOrTarget) {
5919
6008
  const host = hostOrTarget.toLowerCase();
5920
6009
  if (host.includes("neon.tech")) return "neon";
@@ -6040,12 +6129,38 @@ async function runConnectApply(args) {
6040
6129
  or drop the existing ledger so Ablo recreates it under this admin \u2014 it holds only
6041
6130
  idempotency replay records, safe to drop when no commit is in flight:
6042
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.
6043
6154
  `
6044
6155
  );
6045
6156
  process.exit(1);
6046
6157
  }
6047
6158
  const provider = detectProvider(target);
6048
6159
  const walReady = await currentWalLevel(admin) === "logical";
6160
+ const existingPublication = await readPublicationState(admin).catch(
6161
+ () => ({ exists: false, allTables: false, tables: [] })
6162
+ );
6163
+ const pubReconcile = reconcilePublicationPlan(existingPublication, args.tables);
6049
6164
  const replicationPassword = generateRolePassword();
6050
6165
  const writePassword = generateRolePassword();
6051
6166
  const buildPlan = (mode2) => connectApplyPlan({
@@ -6057,9 +6172,21 @@ async function runConnectApply(args) {
6057
6172
  writeClause: passwordClause(writePassword, mode2)
6058
6173
  },
6059
6174
  walAlreadyLogical: walReady,
6060
- provider
6175
+ provider,
6176
+ existingPublication
6061
6177
  });
6062
6178
  const steps = buildPlan("scram-verifier");
6179
+ if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
6180
+ console.log(
6181
+ ` ${import_picocolors8.default.yellow("!")} ${import_picocolors8.default.bold(ABLO_PUBLICATION)} already publishes a different set; reconciling to your ${import_picocolors8.default.bold("--tables")}:`
6182
+ );
6183
+ for (const t of pubReconcile.added) console.log(` ${import_picocolors8.default.green("+")} ${t}`);
6184
+ for (const t of pubReconcile.removed)
6185
+ console.log(` ${import_picocolors8.default.red("-")} ${t} ${import_picocolors8.default.dim("(stops replicating to Ablo)")}`);
6186
+ if (pubReconcile.recreated && existingPublication.allTables)
6187
+ console.log(` ${import_picocolors8.default.red("-")} ${import_picocolors8.default.dim("every other table (was FOR ALL TABLES)")}`);
6188
+ console.log();
6189
+ }
6063
6190
  printPlan2(steps, args.showSql);
6064
6191
  if (!args.yes) {
6065
6192
  if (!process.stdout.isTTY) {
@@ -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