@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.
- package/CHANGELOG.md +12 -0
- package/dist/cli.cjs +58 -3
- package/dist/client/Ablo.js +3 -2
- package/dist/client/createModelProxy.d.ts +53 -10
- package/dist/client/createModelProxy.js +24 -5
- package/dist/client/httpTransport.js +1 -0
- package/dist/client/resourceTypes.d.ts +10 -1
- package/dist/client/wsMutationExecutor.d.ts +2 -2
- package/dist/client/wsMutationExecutor.js +1 -1
- package/dist/coordination/index.d.ts +2 -2
- package/dist/coordination/index.js +1 -1
- package/dist/coordination/schema.d.ts +20 -0
- package/dist/coordination/schema.js +22 -0
- package/dist/errorCodes.d.ts +1 -1
- package/dist/errorCodes.js +1 -1
- package/dist/interfaces/index.d.ts +13 -1
- package/dist/react/AbloProvider.d.ts +8 -8
- package/dist/react/AbloProvider.js +6 -6
- package/dist/react/index.d.ts +2 -2
- package/dist/react/index.js +2 -2
- package/dist/server/commit.d.ts +8 -1
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +2 -1
- package/dist/sync/SyncWebSocket.d.ts +3 -3
- package/dist/sync/SyncWebSocket.js +4 -4
- package/dist/sync/commitFrames.d.ts +2 -2
- package/dist/sync/commitFrames.js +5 -1
- package/dist/transactions/TransactionQueue.d.ts +4 -1
- package/dist/transactions/TransactionQueue.js +18 -0
- package/dist/transactions/commitEnvelope.d.ts +8 -0
- package/dist/transactions/commitEnvelope.js +2 -1
- package/dist/transactions/durableWriteStore.d.ts +8 -0
- package/dist/wire/frames.d.ts +17 -1
- package/dist/wire/frames.js +2 -1
- package/docs/client-behavior.md +1 -1
- package/docs/coordination.md +5 -5
- package/docs/groups.md +50 -0
- package/docs/identity.md +2 -2
- package/docs/migration.md +29 -5
- package/docs/react.md +9 -9
- package/llms.txt +1 -1
- package/package.json +1 -1
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
|
-
|
|
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);
|
package/dist/client/Ablo.js
CHANGED
|
@@ -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>.
|
|
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
|
-
|
|
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`), `
|
|
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>.
|
|
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
|
-
|
|
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>.
|
|
373
|
-
export interface
|
|
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
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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.
|
|
515
|
+
* await using participant = await ablo.slides.join(slideIds, { ttl: '5m' });
|
|
473
516
|
* participant.peers; // who else is here
|
|
474
517
|
* ```
|
|
475
518
|
*/
|
|
476
|
-
|
|
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`), `
|
|
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
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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.
|
|
776
|
+
return collaboration.createJoin(schemaKey, ids, options);
|
|
758
777
|
}),
|
|
759
778
|
onChange(callback, options) {
|
|
760
779
|
return autorun(() => {
|
|
@@ -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
|
// ─────────────────────────────────────────────────────────────────────────
|
package/dist/errorCodes.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/errorCodes.js
CHANGED
|
@@ -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
|
-
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
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
|
-
* `
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
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
|
|
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
|
-
//
|
|
397
|
+
// useJoin's peers bridge).
|
|
398
398
|
setPeers(compute());
|
|
399
399
|
return presence.onChange(() => { setPeers(compute()); });
|
|
400
400
|
}, [engine, scopeKey]);
|