@abloatai/humans 0.62.0 → 0.63.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/local/InstanceCache.js +1 -1
  3. package/dist/local/client/createModelOperations.d.ts +21 -0
  4. package/dist/local/client/createModelOperations.js +84 -0
  5. package/dist/local/client/reactiveEngine.js +16 -0
  6. package/dist/local/transactions/mutations/MutationQueue.d.ts +3 -0
  7. package/dist/local/transactions/mutations/MutationQueue.js +36 -2
  8. package/dist/local/transactions/mutations/deltaConfirmation.d.ts +1 -0
  9. package/dist/local/transactions/mutations/deltaConfirmation.js +4 -0
  10. package/dist/local/transactions/mutations/failureHandling.d.ts +1 -0
  11. package/dist/local/transactions/mutations/failureHandling.js +1 -1
  12. package/dist/presence/index.d.ts +7 -2
  13. package/dist/presence/index.js +18 -0
  14. package/dist/presence/readActivity.d.ts +19 -0
  15. package/dist/presence/readActivity.js +122 -0
  16. package/dist/react/AbloProvider.d.ts +2 -18
  17. package/dist/react/AbloProvider.js +11 -27
  18. package/dist/react/createAbloReact.d.ts +4 -0
  19. package/dist/react/createAbloReact.js +12 -2
  20. package/dist/react/useAblo.d.ts +3 -1
  21. package/dist/react/useAblo.js +13 -11
  22. package/dist/react/usePresence.d.ts +19 -0
  23. package/dist/react/usePresence.js +28 -0
  24. package/dist/react/useSyncStatus.js +14 -3
  25. package/dist/react.d.ts +1 -0
  26. package/dist/react.js +1 -0
  27. package/dist/surface.d.ts +1 -1
  28. package/dist/surface.js +1 -0
  29. package/dist/useReactive.js +1 -3
  30. package/package.json +2 -2
  31. package/src/index.ts +1 -0
  32. package/src/local/InstanceCache.ts +1 -1
  33. package/src/local/client/createModelOperations.ts +132 -0
  34. package/src/local/client/reactiveEngine.ts +16 -0
  35. package/src/local/transactions/mutations/MutationQueue.ts +26 -2
  36. package/src/local/transactions/mutations/deltaConfirmation.ts +3 -0
  37. package/src/local/transactions/mutations/failureHandling.ts +2 -1
  38. package/src/presence/index.ts +32 -3
  39. package/src/presence/readActivity.ts +149 -0
  40. package/src/react/AbloProvider.tsx +14 -54
  41. package/src/react/createAbloReact.ts +25 -1
  42. package/src/react/useAblo.ts +18 -13
  43. package/src/react/usePresence.ts +88 -0
  44. package/src/react/useSyncStatus.ts +14 -3
  45. package/src/react.ts +4 -0
  46. package/src/surface.ts +1 -0
  47. package/src/useReactive.ts +1 -3
@@ -44,7 +44,7 @@ export function AbloProvider(props) {
44
44
  const schema = engine.schema;
45
45
  // Account scope isn't a prop — read it from `_store.orgId` once `ready()`
46
46
  // resolves the identity from the client's auth.
47
- const [resolvedAccountScope, setResolvedAccountScope] = useState(null);
47
+ const [resolvedScope, setResolvedScope] = useState(null);
48
48
  // ── Error emitter (provider-instance scoped) ─────────────────────
49
49
  const errorEmitterRef = useRef(null);
50
50
  if (!errorEmitterRef.current) {
@@ -98,7 +98,10 @@ export function AbloProvider(props) {
98
98
  .then(() => {
99
99
  if (stale)
100
100
  return;
101
- setResolvedAccountScope(engine._store.orgId ?? null);
101
+ setResolvedScope({
102
+ engine,
103
+ account: engine._store.orgId ?? null,
104
+ });
102
105
  })
103
106
  .catch((err) => {
104
107
  if (stale)
@@ -133,7 +136,7 @@ export function AbloProvider(props) {
133
136
  // unknown until `ready()` resolves identity — so `syncValue` is null until
134
137
  // then, which drives the initial fallback below.
135
138
  const syncValue = useMemo(() => {
136
- const currentAccountScope = resolvedAccountScope ??
139
+ const currentAccountScope = (resolvedScope?.engine === engine ? resolvedScope.account : null) ??
137
140
  engine._store.orgId;
138
141
  if (!currentAccountScope)
139
142
  return null;
@@ -142,7 +145,7 @@ export function AbloProvider(props) {
142
145
  organizationId: currentAccountScope,
143
146
  schema,
144
147
  };
145
- }, [engine, resolvedAccountScope, schema]);
148
+ }, [engine, resolvedScope, schema]);
146
149
  // ── Internal context (currentUserId + error subscription) ────────
147
150
  const internalValue = useMemo(() => ({
148
151
  currentUserId: userId ?? null,
@@ -152,28 +155,10 @@ export function AbloProvider(props) {
152
155
  }), [userId, errorEmitter, engine]);
153
156
  // ── Render ───────────────────────────────────────────────────────
154
157
  //
155
- // Two-phase gate (see `BootstrapGate` below for the latch logic):
156
- //
157
- // 1. Engine is null on first render (constructed in the effect
158
- // above, not in render). We render `fallback` directly — there
159
- // is no SyncContext to read status from, and by definition the
160
- // engine hasn't started bootstrapping.
161
- // 2. Engine exists. Mount SyncContext. `BootstrapGate` then reads
162
- // `useSyncStatus()` and shows `fallback` only during the very
163
- // first `connecting` transition; children render on every
164
- // subsequent state change, including reconnects and auth
165
- // failures (the app's own UI handles those).
166
- //
167
- // `fallback === 'passthrough'` short-circuits both branches — children
168
- // render immediately without any gate, restoring pre-gate behavior
169
- // for consumers who need debug helpers / error boundaries / analytics
170
- // to mount before the engine is ready.
158
+ // Keep the context tree stable during startup so passthrough children retain
159
+ // their component state when authenticated row scope becomes available.
171
160
  const passthrough = fallback === 'passthrough';
172
- const initialFallback = passthrough ? children : fallback;
173
- if (!syncValue) {
174
- return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: initialFallback }));
175
- }
176
- return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) }) }));
161
+ return (_jsx(AbloInternalContext.Provider, { value: internalValue, children: _jsx(SyncContext.Provider, { value: syncValue, children: passthrough ? (children) : syncValue ? (_jsx(BootstrapGate, { fallback: fallback, children: children }, engineKey)) : fallback }) }));
177
162
  }
178
163
  /**
179
164
  * Internal gate that renders `fallback` only during the very first
@@ -183,8 +168,7 @@ export function AbloProvider(props) {
183
168
  * re-show the fallback, because by then the app has already rendered
184
169
  * once and its own reconnect UI should take over.
185
170
  *
186
- * Re-keyed on `engineState.key` in the parent so engine rotations
187
- * (userId/org/url change) reset the latch — a new engine genuinely IS
171
+ * Re-keyed when the client instance changes so account rotations reset the latch — a new engine genuinely IS
188
172
  * a new "first bootstrap" cycle.
189
173
  */
190
174
  function BootstrapGate({ fallback, children, }) {
@@ -25,6 +25,8 @@ import { type AbloSelector, type ModelClientSelector, type UseAbloHydratedModelR
25
25
  import type { AbloClient as Ablo } from '../client.js';
26
26
  import type { ModelOperations } from '../local/client/createModelOperations.js';
27
27
  import type { Schema, SchemaRecord } from '@abloatai/transaction/schema/schema';
28
+ import { type PresenceModelSelector } from './usePresence.js';
29
+ import type { PresenceSession } from '@abloatai/transaction/presence';
28
30
  /** What a binding returns: the provider and the hook, with `S` fixed. */
29
31
  export interface AbloReactBinding<S extends SchemaRecord> {
30
32
  /** `AbloProvider` with its `client` prop typed `Ablo<S>` — same component,
@@ -40,6 +42,8 @@ export interface AbloReactBinding<S extends SchemaRecord> {
40
42
  }): UseAbloHydratedModelResult<T>;
41
43
  <T, C>(modelClientOrSelect: ModelOperations<T, C> | ModelClientSelector<S, T, C>, id: string, options?: UseAbloModelOptions<T>): UseAbloModelResult<T>;
42
44
  };
45
+ /** Declare and reactively read record presence with the same model clients. */
46
+ usePresence: <T, C>(modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<S, T, C>, recordId: string) => readonly PresenceSession[];
43
47
  }
44
48
  /**
45
49
  * Bind the react surface to one schema. The schema value is taken for
@@ -22,7 +22,8 @@
22
22
  */
23
23
  import { createContext, createElement, useContext } from 'react';
24
24
  import { AbloProvider, } from './AbloProvider.js';
25
- import { useAbloImpl, } from './useAblo.js';
25
+ import { useAbloImpl, useAbloClientImpl, } from './useAblo.js';
26
+ import { usePresenceImpl, } from './usePresence.js';
26
27
  /**
27
28
  * Bind the react surface to one schema. The schema value is taken for
28
29
  * inference — write `createAbloReact(schema)`, never a hand-spelled type
@@ -44,5 +45,14 @@ export function createAbloReact(schema) {
44
45
  const bound = useContext(BoundClientContext);
45
46
  return useAbloImpl(bound, modelOrSelect, id, options);
46
47
  }
47
- return { AbloProvider: BoundAbloProvider, useAblo: useBoundAblo };
48
+ function useBoundPresence(modelOrSelect, recordId) {
49
+ const bound = useContext(BoundClientContext);
50
+ const engine = useAbloClientImpl(bound);
51
+ return usePresenceImpl(engine, modelOrSelect, recordId);
52
+ }
53
+ return {
54
+ AbloProvider: BoundAbloProvider,
55
+ useAblo: useBoundAblo,
56
+ usePresence: useBoundPresence,
57
+ };
48
58
  }
@@ -60,7 +60,7 @@ export type UseAbloHydratedModelResult<T> = Omit<UseAbloModelResult<T>, 'data'>
60
60
  * // are typed as snapshot rows — data fields + computeds, no relation
61
61
  * // accessors — matching what the hook actually returns:
62
62
  * const doc = useAblo((ablo) => ablo.records.local.get(id)) ?? serverDoc;
63
- * const active = useAblo((ablo) => ablo.records.claim.state({ id }));
63
+ * const { claimed } = useAblo((ablo) => ablo.records, id);
64
64
  *
65
65
  * // Without the augmentation, pass the schema as a type argument:
66
66
  * const ablo = useAblo<(typeof schema)['models']>();
@@ -93,4 +93,6 @@ export declare function useAblo<R extends SchemaRecord = DefaultModels, T = Reco
93
93
  * legacy mount migrates.
94
94
  */
95
95
  export declare function useAbloImpl<R extends SchemaRecord, T = Record<string, unknown>, C = unknown>(boundClient: Ablo<R> | null, modelOrSelect?: ModelOperations<T, C> | ModelClientSelector<R, T, C> | AbloSelector<R, T>, id?: string, options?: UseAbloModelOptions<T>): Ablo<R> | null | UseAbloModelResult<T> | T | undefined;
96
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
97
+ export declare function useAbloClientImpl<R extends SchemaRecord>(boundClient: Ablo<R> | null): Ablo<R> | null;
96
98
  export {};
@@ -70,11 +70,7 @@ export function useAblo(modelOrSelect, id, options) {
70
70
  * legacy mount migrates.
71
71
  */
72
72
  export function useAbloImpl(boundClient, modelOrSelect, id, options) {
73
- const ctx = useContext(AbloInternalContext);
74
- // The bound client wins — it is already `Ablo<R>`, no rebinding. The
75
- // fallback is the ONE remaining schema rebind in the SDK; it retires with
76
- // the last legacy provider mount (docs/plans/typed-react-binding.md).
77
- const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
73
+ const engine = useAbloClientImpl(boundClient);
78
74
  const initial = options?.initial;
79
75
  const isSelectorOnly = typeof modelOrSelect === 'function' && id === undefined;
80
76
  const modelClient = typeof modelOrSelect === 'function' && id !== undefined
@@ -86,12 +82,9 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
86
82
  : modelOrSelect;
87
83
  // Claims arrive through an event emitter (engine.claims), not through MobX, so
88
84
  // the useReactive reactions below cannot track them; we bridge changes with a
89
- // setState bump instead. Only the model-row form (`id !== undefined`) reads
90
- // claims, so we subscribe only when `id` is set. The selector-only form never
91
- // reads claims, and subscribing it to the workspace-wide claim stream would
92
- // re-render and recompute it on every claim or presence change anywhere — a
93
- // real storm during AI editing or live collaboration — for a value that cannot
94
- // change.
85
+ // setState bump instead. Subscribe the model-row form (`id !== undefined`)
86
+ // to claims. Selector-only reads track MobX model data; callers displaying
87
+ // ownership use the row form's `claims` / `claimed` result.
95
88
  const [claimVersion, setClaimVersion] = useState(0);
96
89
  useEffect(() => {
97
90
  if (!engine || id === undefined)
@@ -118,3 +111,12 @@ export function useAbloImpl(boundClient, modelOrSelect, id, options) {
118
111
  return modelResult;
119
112
  return engine;
120
113
  }
114
+ /** @internal Resolve the bound or legacy provider client through one rebind seam. */
115
+ export function useAbloClientImpl(boundClient) {
116
+ const ctx = useContext(AbloInternalContext);
117
+ // The bound client wins — it is already `Ablo<R>`, no rebinding. The
118
+ // fallback is the ONE remaining schema rebind in the SDK; it retires with
119
+ // the last legacy provider mount (docs/plans/typed-react-binding.md).
120
+ const engine = boundClient ?? (ctx?.engine ? rebindEngine(ctx.engine) : null);
121
+ return engine;
122
+ }
@@ -0,0 +1,19 @@
1
+ import type { PresenceSession } from '@abloatai/transaction/presence';
2
+ import { type ModelOperations } from '../local/client/createModelOperations.js';
3
+ import type { AbloClient as Ablo } from '../client.js';
4
+ import type { SchemaRecord } from '@abloatai/transaction/schema/schema';
5
+ import type { ResolveSchema } from '@abloatai/transaction/types/global';
6
+ type DefaultModels = ResolveSchema extends {
7
+ models: infer M;
8
+ } ? M extends SchemaRecord ? M : SchemaRecord : SchemaRecord;
9
+ export type PresenceModelSelector<R extends SchemaRecord, T, C> = (ablo: Ablo<R>) => ModelOperations<T, C>;
10
+ /**
11
+ * Declare that this component is reading one row and return every live session
12
+ * reading or otherwise acting on that row. Ablo owns the lease, refresh,
13
+ * reconnect, and cleanup mechanics for the component's lifetime.
14
+ */
15
+ export declare function usePresence<T, C>(modelClient: ModelOperations<T, C>, recordId: string): readonly PresenceSession[];
16
+ export declare function usePresence<R extends SchemaRecord = DefaultModels, T = Record<string, unknown>, C = unknown>(select: PresenceModelSelector<R, T, C>, recordId: string): readonly PresenceSession[];
17
+ /** @internal Shared by the global hook and schema-bound React factory. */
18
+ export declare function usePresenceImpl<R extends SchemaRecord, T, C>(engine: Ablo<R> | null, modelOrSelect: ModelOperations<T, C> | PresenceModelSelector<R, T, C>, recordId: string): readonly PresenceSession[];
19
+ export {};
@@ -0,0 +1,28 @@
1
+ 'use client';
2
+ import { useEffect, useState } from 'react';
3
+ import { AbloValidationError } from '@abloatai/transaction/errors';
4
+ import { getModelClientMeta, } from '../local/client/createModelOperations.js';
5
+ import { useAbloClientImpl } from './useAblo.js';
6
+ export function usePresence(modelOrSelect, recordId) {
7
+ const engine = useAbloClientImpl(null);
8
+ return usePresenceImpl(engine, modelOrSelect, recordId);
9
+ }
10
+ /** @internal Shared by the global hook and schema-bound React factory. */
11
+ export function usePresenceImpl(engine, modelOrSelect, recordId) {
12
+ if (recordId.length === 0) {
13
+ throw new AbloValidationError('usePresence requires a non-empty record id.', { code: 'invalid_request', param: 'recordId' });
14
+ }
15
+ const modelClient = typeof modelOrSelect === 'function'
16
+ ? engine
17
+ ? modelOrSelect(engine)
18
+ : null
19
+ : modelOrSelect;
20
+ const presence = modelClient ? getModelClientMeta(modelClient)?.presence : undefined;
21
+ if (modelClient !== null && presence === undefined) {
22
+ throw new AbloValidationError('usePresence requires a model from the reactive Ablo client.', { code: 'invalid_request', param: 'modelClient' });
23
+ }
24
+ const [, render] = useState(0);
25
+ useEffect(() => presence?.subscribe(() => { render((version) => version + 1); }), [presence]);
26
+ useEffect(() => presence?.read(recordId), [presence, recordId]);
27
+ return presence?.get(recordId) ?? [];
28
+ }
@@ -1,10 +1,21 @@
1
1
  'use client';
2
- import { useCallback } from 'react';
3
- import { useSyncContext, } from './context.js';
2
+ import { useCallback, useContext } from 'react';
3
+ import { SyncContext, } from './context.js';
4
+ import { AbloInternalContext } from './internalContext.js';
5
+ import { AbloValidationError } from '@abloatai/transaction/errors';
4
6
  import { useReactive } from '../useReactive.js';
5
7
  /** Reactively exposes the local store's connection and confirmation status. */
6
8
  export function useSyncStatus() {
7
- const { store } = useSyncContext();
9
+ const provider = useContext(AbloInternalContext);
10
+ const sync = useContext(SyncContext);
11
+ // Status does not require authenticated row scope. The client exists before
12
+ // ready() resolves, including inside passthrough children and custom fallbacks.
13
+ const store = provider?.engine?._store ?? sync?.store;
14
+ if (!store) {
15
+ throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
16
+ code: 'sync_context_missing_provider',
17
+ });
18
+ }
8
19
  const compute = useCallback(() => deriveStatus(store), [store]);
9
20
  return useReactive(compute, sameSnapshot);
10
21
  }
package/dist/react.d.ts CHANGED
@@ -3,6 +3,7 @@ export { useReactive } from './useReactive.js';
3
3
  export { useCurrentUserId } from './react/useCurrentUserId.js';
4
4
  export { useErrorListener } from './react/useErrorListener.js';
5
5
  export { useSyncStatus, type SyncStatusSnapshot } from './react/useSyncStatus.js';
6
+ export { usePresence, type PresenceModelSelector, } from './react/usePresence.js';
6
7
  export { useMutationFailureListener, type MutationFailurePayload, } from './react/useMutationFailureListener.js';
7
8
  export { AbloProvider, usePeers, useSync, useSyncStore, type AbloProviderProps, type GroupScope, } from './react/AbloProvider.js';
8
9
  export { ClientSideSuspense, type ClientSideSuspenseProps, } from './react/ClientSideSuspense.js';
package/dist/react.js CHANGED
@@ -3,6 +3,7 @@ export { useReactive } from './useReactive.js';
3
3
  export { useCurrentUserId } from './react/useCurrentUserId.js';
4
4
  export { useErrorListener } from './react/useErrorListener.js';
5
5
  export { useSyncStatus } from './react/useSyncStatus.js';
6
+ export { usePresence, } from './react/usePresence.js';
6
7
  export { useMutationFailureListener, } from './react/useMutationFailureListener.js';
7
8
  export { AbloProvider, usePeers, useSync, useSyncStore, } from './react/AbloProvider.js';
8
9
  export { ClientSideSuspense, } from './react/ClientSideSuspense.js';
package/dist/surface.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * tuple, so it is the one list of model-verb names a generated summary can
20
20
  * describe.
21
21
  */
22
- export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "presence", "create", "update", "delete", "claim", "onChange"];
22
+ export declare const PUBLIC_MODEL_VERBS: readonly ["get", "read", "list", "listAll", "local", "presence", "events", "create", "update", "delete", "claim", "onChange"];
23
23
  /**
24
24
  * The option keys accepted by `local.list` and `onChange`, matching the
25
25
  * keys of {@link LocalReadOptions}. Note that the lifecycle filter is named
package/dist/surface.js CHANGED
@@ -27,6 +27,7 @@ export const PUBLIC_MODEL_VERBS = [
27
27
  'listAll',
28
28
  'local',
29
29
  'presence',
30
+ 'events',
30
31
  'create',
31
32
  'update',
32
33
  'delete',
@@ -11,7 +11,6 @@ export function useReactive(compute, equals = defaultEquals) {
11
11
  const computeRef = useRef(compute);
12
12
  const equalsRef = useRef(equals);
13
13
  const snapshotRef = useRef(null);
14
- const versionRef = useRef(0);
15
14
  equalsRef.current = equals;
16
15
  if (snapshotRef.current === null) {
17
16
  snapshotRef.current = { value: compute() };
@@ -20,7 +19,6 @@ export function useReactive(compute, equals = defaultEquals) {
20
19
  const next = compute();
21
20
  if (!equals(snapshotRef.current.value, next)) {
22
21
  snapshotRef.current = { value: next };
23
- versionRef.current++;
24
22
  }
25
23
  }
26
24
  computeRef.current = compute;
@@ -30,7 +28,7 @@ export function useReactive(compute, equals = defaultEquals) {
30
28
  snapshotRef.current = { value: next };
31
29
  onChange();
32
30
  }
33
- }), [versionRef.current]);
31
+ }), [compute]);
34
32
  const getSnapshot = useCallback(() => snapshotRef.current.value, []);
35
33
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
36
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/humans",
3
- "version": "0.62.0",
3
+ "version": "0.63.1",
4
4
  "description": "The optional human-facing local-state package for Ablo: presence, live queries, and React bindings.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -84,7 +84,7 @@
84
84
  "directory": "packages/humans"
85
85
  },
86
86
  "dependencies": {
87
- "@abloatai/transaction": "^0.62.0",
87
+ "@abloatai/transaction": "^0.63.1",
88
88
  "mobx": "^6.13.7",
89
89
  "uuid": "^11.1.0",
90
90
  "zod": "^4.4.3"
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export type {
9
9
  } from './Ablo.js';
10
10
  export { humans, type HumansSurface } from './humans.js';
11
11
  export type { AbloClient } from './client.js';
12
+ export type { CollaborationEventContext } from '@abloatai/transaction/collaboration';
12
13
  export type {
13
14
  AbloPlugin,
14
15
  MergedSurface,
@@ -1257,7 +1257,7 @@ export class InstanceCache {
1257
1257
  }
1258
1258
 
1259
1259
  private startGC(): void {
1260
- if (this.gcTimer) return;
1260
+ if (this.gcTimer || this.config.gcInterval <= 0) return;
1261
1261
  this.gcTimer = setInterval(() => this.gc(), this.config.gcInterval);
1262
1262
  // Don't hold a headless Node process open just for pool GC — without
1263
1263
  // this, an agent that never calls disconnect() can never exit. No-op in
@@ -144,6 +144,13 @@ import type {
144
144
  } from '@abloatai/transaction/transport/http';
145
145
  import type { ParticipantKind } from '@abloatai/transaction/types/participant';
146
146
  import type { PresenceSession } from '@abloatai/transaction/presence';
147
+ import type {
148
+ CollaborationEventContext,
149
+ ModelEventEnvelope,
150
+ ModelEventInput,
151
+ ModelEventTarget,
152
+ } from '@abloatai/transaction/collaboration';
153
+ import { modelEventInputSchema } from '@abloatai/transaction/collaboration';
147
154
  import {
148
155
  capturePointRead,
149
156
  prepareReadSet,
@@ -152,10 +159,16 @@ import {
152
159
 
153
160
  const ignoreSeparatelyObservedMutationFailure = (): undefined => undefined;
154
161
  const ignoreBestEffortClaimReleaseFailure = (): undefined => undefined;
162
+ const ignoreBestEffortScopeFailure = (): undefined => undefined;
155
163
 
156
164
  export interface ModelClientMeta {
157
165
  readonly key: string;
158
166
  readonly typename: string;
167
+ readonly presence?: {
168
+ get(recordId: string): readonly PresenceSession[];
169
+ subscribe(listener: () => void): () => void;
170
+ read(recordId: string): () => void;
171
+ };
159
172
  }
160
173
 
161
174
  const modelClientMeta = new WeakMap<object, ModelClientMeta>();
@@ -180,6 +193,13 @@ type EntityHalf = Pick<ModelTarget, 'model' | 'id'>;
180
193
  export interface ModelCollaboration {
181
194
  /** Session projections already held by this client's one presence store. */
182
195
  presence(model: string, recordId?: string): readonly PresenceSession[];
196
+ /** Subscribe once to the connection-owned presence projection. */
197
+ onPresenceChange(listener: () => void): () => void;
198
+ /** Start one session-owned read activity and return its cleanup. */
199
+ startReadPresence(target: EntityHalf): () => void;
200
+ modelEventTarget(recordId: string): ModelEventTarget;
201
+ sendModelEvent(input: ModelEventInput): void;
202
+ onModelEvent(listener: (event: ModelEventEnvelope) => void): () => void;
183
203
  /** Exact point evidence from the HTTP read boundary (stamp captured before data). */
184
204
  readPoint(model: string, id: string): Promise<{ data: unknown; stamp: number }>;
185
205
  /**
@@ -281,6 +301,8 @@ export interface ModelCollaboration {
281
301
  * test doubles can omit it.
282
302
  */
283
303
  enterScope?(scope: Record<string, string>): void | Promise<void>;
304
+ /** Release read interest previously acquired through {@link enterScope}. */
305
+ leaveScope?(scope: Record<string, string>): void | Promise<void>;
284
306
  /**
285
307
  * Pins a scope's sync group(s) — write intent: a row this client holds an
286
308
  * active claim on stays subscribed regardless of navigation. Same
@@ -342,6 +364,9 @@ interface ReactiveModelSurface<T, Fields = T> {
342
364
  /** Sessions currently active on this model, optionally narrowed to one record. */
343
365
  presence(recordId?: string): readonly PresenceSession[];
344
366
 
367
+ /** Lossy, model-record-addressed application events such as cursor or selection. */
368
+ events: ModelEvents;
369
+
345
370
  /**
346
371
  * Claim a row so other writers wait or are rejected until you're done, and
347
372
  * inspect or manage that coordination through the same namespace. Call it to
@@ -377,6 +402,50 @@ interface ReactiveModelSurface<T, Fields = T> {
377
402
  ): () => void;
378
403
  }
379
404
 
405
+ export interface ModelEvents {
406
+ send(
407
+ recordId: string,
408
+ event: string,
409
+ payload: Readonly<Record<string, unknown>>,
410
+ ): void;
411
+ subscribe(
412
+ recordId: string,
413
+ event: string,
414
+ handler: (
415
+ payload: Readonly<Record<string, unknown>>,
416
+ context: CollaborationEventContext,
417
+ ) => void,
418
+ ): () => void;
419
+ }
420
+
421
+ function subscribeInModelScope(
422
+ collaboration: ModelCollaboration,
423
+ scope: Record<string, string>,
424
+ subscribe: () => () => void,
425
+ ): () => void {
426
+ let stopped = false;
427
+ let entered = false;
428
+ let unsubscribe: (() => void) | null = null;
429
+
430
+ void Promise.resolve(collaboration.enterScope?.(scope))
431
+ .then(() => {
432
+ entered = true;
433
+ if (stopped) {
434
+ void collaboration.leaveScope?.(scope);
435
+ return;
436
+ }
437
+ unsubscribe = subscribe();
438
+ })
439
+ .catch(ignoreBestEffortScopeFailure);
440
+
441
+ return () => {
442
+ if (stopped) return;
443
+ stopped = true;
444
+ unsubscribe?.();
445
+ if (entered) void collaboration.leaveScope?.(scope);
446
+ };
447
+ }
448
+
380
449
  /**
381
450
  * Everything reachable as `ablo.<model>` on a reactive client.
382
451
  *
@@ -1411,6 +1480,52 @@ export function createModelOperations<T, C>(
1411
1480
 
1412
1481
  presence: (recordId?: string) => collaboration?.presence(registeredModelName, recordId) ?? [],
1413
1482
 
1483
+ events: {
1484
+ send(recordId, event, payload) {
1485
+ if (!collaboration) return;
1486
+ const target = collaboration.modelEventTarget(recordId);
1487
+ const parsed = modelEventInputSchema.safeParse({ target, event, payload });
1488
+ if (!parsed.success) {
1489
+ throw new AbloValidationError('Invalid model event.', {
1490
+ code: 'invalid_request',
1491
+ param: 'event',
1492
+ cause: parsed.error,
1493
+ });
1494
+ }
1495
+ const scope = { [schemaKey]: recordId };
1496
+ void Promise.resolve(collaboration.enterScope?.(scope))
1497
+ .then(() => { collaboration.sendModelEvent(parsed.data); })
1498
+ .finally(() => { void collaboration.leaveScope?.(scope); });
1499
+ },
1500
+ subscribe(recordId, event, handler) {
1501
+ if (!collaboration) return () => undefined;
1502
+ const target = collaboration.modelEventTarget(recordId);
1503
+ const parsed = modelEventInputSchema.safeParse({ target, event, payload: {} });
1504
+ if (!parsed.success) {
1505
+ throw new AbloValidationError('Invalid model event subscription.', {
1506
+ code: 'invalid_request',
1507
+ param: 'event',
1508
+ cause: parsed.error,
1509
+ });
1510
+ }
1511
+ const scope = { [schemaKey]: recordId };
1512
+ return subscribeInModelScope(collaboration, scope, () =>
1513
+ collaboration.onModelEvent((incoming) => {
1514
+ if (
1515
+ incoming.target.model !== target.model ||
1516
+ incoming.target.id !== target.id ||
1517
+ incoming.target.syncGroup !== target.syncGroup ||
1518
+ incoming.event !== parsed.data.event
1519
+ ) return;
1520
+ handler(incoming.payload, {
1521
+ sender: incoming.sender,
1522
+ sentAt: incoming.sentAt,
1523
+ });
1524
+ }),
1525
+ );
1526
+ },
1527
+ },
1528
+
1414
1529
  get,
1415
1530
  read,
1416
1531
 
@@ -1666,6 +1781,23 @@ export function createModelOperations<T, C>(
1666
1781
  modelClientMeta.set(operations, {
1667
1782
  key: schemaKey,
1668
1783
  typename: registeredModelName,
1784
+ ...(collaboration
1785
+ ? {
1786
+ presence: {
1787
+ get: (recordId: string) => collaboration.presence(registeredModelName, recordId),
1788
+ subscribe: (listener: () => void) => collaboration.onPresenceChange(listener),
1789
+ read: (recordId: string) => {
1790
+ const scope = { [schemaKey]: recordId };
1791
+ return subscribeInModelScope(collaboration, scope, () =>
1792
+ collaboration.startReadPresence({
1793
+ model: registeredModelName,
1794
+ id: recordId,
1795
+ }),
1796
+ );
1797
+ },
1798
+ },
1799
+ }
1800
+ : {}),
1669
1801
  });
1670
1802
 
1671
1803
  return operations;
@@ -78,6 +78,7 @@ import {
78
78
  prepareReadSet,
79
79
  } from '@abloatai/transaction/internal/read-set';
80
80
  import { contextOnChange } from '../sync/contextOnChange.js';
81
+ import { resolveScopeGroups } from '../sync/scopeGroups.js';
81
82
  import type { ReadDependency } from '@abloatai/transaction/coordination';
82
83
  import type { CapturedRow } from '@abloatai/transaction/transport/http';
83
84
 
@@ -575,6 +576,20 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
575
576
  hydration,
576
577
  {
577
578
  presence: (model, recordId) => presenceStream.forModel(model, recordId),
579
+ onPresenceChange: (listener) => presenceStream.onChange(listener),
580
+ startReadPresence: (target) => presenceStream.startRead(target),
581
+ modelEventTarget: (recordId) => {
582
+ const syncGroup = resolveScopeGroups({ [schemaKey]: recordId }, schema)[0];
583
+ if (syncGroup === undefined) {
584
+ throw new AbloValidationError('A model event requires a record scope.', {
585
+ code: 'invalid_request',
586
+ param: 'recordId',
587
+ });
588
+ }
589
+ return { model: registeredModelName, id: recordId, syncGroup };
590
+ },
591
+ sendModelEvent: (input) => { transport.sendModelEvent(input); },
592
+ onModelEvent: (listener) => transport.subscribe('model_event', listener),
578
593
  createClaim: (claimOptions) => publicClaims.create(claimOptions),
579
594
  // Lazily referenced: `commits` is declared below this loop, and this
580
595
  // only runs when someone actually writes a batch.
@@ -617,6 +632,7 @@ export function buildReactiveEngine<const S extends SchemaRecord>(
617
632
  // stay fire-and-forget. It's soft either way — the store swallows
618
633
  // reconcile errors so read interest never makes a read reject or stall.
619
634
  enterScope: (scope) => store.enterScope(scope),
635
+ leaveScope: (scope) => store.leaveScope(scope),
620
636
  pinScope: (scope) => store.pinScope(scope),
621
637
  },
622
638
  readSetContext,