@abloatai/ablo 0.11.1 → 0.12.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 (85) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -2
  3. package/dist/Model.d.ts +39 -0
  4. package/dist/Model.js +68 -0
  5. package/dist/ai-sdk/claim-broadcast.d.ts +4 -3
  6. package/dist/ai-sdk/claim-broadcast.js +2 -2
  7. package/dist/ai-sdk/wrap.d.ts +5 -4
  8. package/dist/ai-sdk/wrap.js +3 -3
  9. package/dist/auth/credentialPolicy.d.ts +145 -0
  10. package/dist/auth/credentialPolicy.js +130 -0
  11. package/dist/cli.cjs +42 -7
  12. package/dist/client/Ablo.d.ts +64 -91
  13. package/dist/client/Ablo.js +43 -103
  14. package/dist/client/ApiClient.d.ts +10 -1
  15. package/dist/client/ApiClient.js +45 -22
  16. package/dist/client/auth.d.ts +12 -5
  17. package/dist/client/auth.js +2 -1
  18. package/dist/client/createModelProxy.d.ts +64 -17
  19. package/dist/client/createModelProxy.js +18 -12
  20. package/dist/client/httpClient.d.ts +17 -3
  21. package/dist/client/httpClient.js +1 -0
  22. package/dist/client/identity.js +134 -122
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/sessionMint.d.ts +15 -0
  25. package/dist/client/sessionMint.js +86 -0
  26. package/dist/coordination/schema.d.ts +1 -1
  27. package/dist/coordination/schema.js +3 -1
  28. package/dist/errorCodes.d.ts +2 -0
  29. package/dist/errorCodes.js +2 -0
  30. package/dist/errors.d.ts +6 -3
  31. package/dist/errors.js +9 -3
  32. package/dist/index.d.ts +4 -4
  33. package/dist/index.js +4 -7
  34. package/dist/mutators/RecordingTransaction.js +14 -42
  35. package/dist/react/AbloProvider.d.ts +12 -13
  36. package/dist/react/AbloProvider.js +10 -10
  37. package/dist/react/context.d.ts +10 -45
  38. package/dist/react/context.js +12 -17
  39. package/dist/react/index.d.ts +8 -10
  40. package/dist/react/index.js +8 -11
  41. package/dist/react/useMutators.js +3 -2
  42. package/dist/react/useSyncStatus.d.ts +1 -1
  43. package/dist/react/useUndoScope.js +3 -2
  44. package/dist/realtime/index.d.ts +1 -1
  45. package/dist/schema/generate.js +1 -2
  46. package/dist/schema/model.d.ts +10 -3
  47. package/dist/schema/schema.d.ts +13 -2
  48. package/dist/schema/schema.js +26 -0
  49. package/dist/surface.d.ts +29 -0
  50. package/dist/surface.js +60 -0
  51. package/dist/sync/ConnectionManager.d.ts +16 -5
  52. package/dist/sync/ConnectionManager.js +42 -7
  53. package/dist/sync/createClaimStream.js +5 -4
  54. package/dist/sync/participants.js +1 -1
  55. package/dist/transactions/TransactionQueue.d.ts +0 -11
  56. package/dist/transactions/TransactionQueue.js +12 -56
  57. package/dist/types/global.d.ts +3 -0
  58. package/dist/types/streams.d.ts +17 -29
  59. package/dist/utils/mobx-setup.js +1 -0
  60. package/docs/api-keys.md +49 -0
  61. package/docs/api.md +3 -2
  62. package/docs/client-behavior.md +1 -0
  63. package/docs/coordination.md +75 -21
  64. package/docs/examples/existing-python-backend.md +9 -5
  65. package/docs/examples/scoped-agent.md +1 -1
  66. package/docs/guarantees.md +4 -3
  67. package/docs/identity.md +89 -82
  68. package/docs/integration-guide.md +19 -10
  69. package/docs/migration.md +11 -3
  70. package/docs/quickstart.md +6 -2
  71. package/docs/react.md +3 -3
  72. package/docs/schema-contract.md +23 -5
  73. package/llms-full.txt +18 -16
  74. package/llms.txt +6 -6
  75. package/package.json +1 -1
  76. package/dist/api/index.d.ts +0 -10
  77. package/dist/api/index.js +0 -9
  78. package/dist/principal.d.ts +0 -44
  79. package/dist/principal.js +0 -49
  80. package/dist/react/SyncGroupProvider.d.ts +0 -19
  81. package/dist/react/SyncGroupProvider.js +0 -44
  82. package/dist/react/useClaim.d.ts +0 -29
  83. package/dist/react/useClaim.js +0 -42
  84. package/dist/react/usePresence.d.ts +0 -32
  85. package/dist/react/usePresence.js +0 -41
@@ -0,0 +1,15 @@
1
+ import type { SchemaRecord } from '../schema/schema.js';
2
+ import type { AbloSession, CreateSessionParams } from './Ablo.js';
3
+ /** The resolved control-plane context a mint needs. `fetch` is optional — the
4
+ * auth helpers fall back to the runtime global when omitted. */
5
+ export interface MintSessionContext {
6
+ readonly apiKey: string;
7
+ readonly baseUrl: string;
8
+ readonly fetch?: typeof fetch;
9
+ }
10
+ /**
11
+ * Mint a session token from an already-resolved `sk_` credential + base URL.
12
+ * Discriminates the `{ user }` / `{ agent }` union onto the server's two mint
13
+ * doors and reshapes each flat response into the `AbloSession` resource.
14
+ */
15
+ export declare function mintSession<S extends SchemaRecord>(params: CreateSessionParams<S>, ctx: MintSessionContext): Promise<AbloSession>;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `mintSession` — the ONE implementation behind `sessions.create`, shared by the
3
+ * stateful `Ablo` client and the stateless protocol / HTTP client so the two can
4
+ * never drift on HOW a token is minted.
5
+ *
6
+ * Minting is a pure control-plane HTTP call (no socket, no synced pool): a backend
7
+ * holding a secret `sk_` exchanges it for a short-lived scoped token — `ek_` for an
8
+ * `{ user }` session (full end-user authority) or `rk_` for an `{ agent }` session
9
+ * (scoped to exactly the operations named in `can`). The two arms map to the
10
+ * server's two mint doors:
11
+ *
12
+ * `{ user }` → POST /auth/ephemeral-keys → `ek_`. The user-session door;
13
+ * routing this arm through /auth/capability is structurally
14
+ * impossible — that route rejects participantKind 'user' outright
15
+ * (`invalid_participant_kind`, the 2026-06-11 Pulse cascade where
16
+ * the SDK's own blessed pattern 403'd and integrators fell back to
17
+ * minting humans as agents).
18
+ * `{ agent }` → POST /auth/capability → scoped `rk_`. `can: { tasks: ['update'] }`
19
+ * serializes to the wire allowlist (`tasks.update`); the Hub matches
20
+ * it against every registered alias of the model.
21
+ *
22
+ * The caller supplies the resolved control-plane credential + base URL in `ctx`;
23
+ * WHICH key to use (the original `sk_`, never a derived `rk_` the startup exchange
24
+ * may have installed) is the caller's concern — see the two call sites.
25
+ *
26
+ * Type-only imports of `CreateSessionParams` / `AbloSession` keep this module a
27
+ * leaf (no runtime cycle back to `Ablo.ts`): at runtime it depends on `auth` +
28
+ * `schema` only.
29
+ */
30
+ import { exchangeApiKey, mintUserSessionKey } from '../auth/index.js';
31
+ /**
32
+ * Mint a session token from an already-resolved `sk_` credential + base URL.
33
+ * Discriminates the `{ user }` / `{ agent }` union onto the server's two mint
34
+ * doors and reshapes each flat response into the `AbloSession` resource.
35
+ */
36
+ export async function mintSession(params, ctx) {
37
+ const { apiKey, baseUrl } = ctx;
38
+ if (params.user) {
39
+ const res = await mintUserSessionKey({
40
+ apiKey,
41
+ baseUrl,
42
+ userId: params.user.id,
43
+ ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
44
+ ttlSeconds: params.ttlSeconds ?? 900,
45
+ ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
46
+ });
47
+ return {
48
+ object: 'session',
49
+ id: res.id,
50
+ token: res.token,
51
+ expiresAt: res.expiresAt,
52
+ organizationId: res.organizationId,
53
+ // The ephemeral mint stores scope on the key row; reshape its flat
54
+ // response into the session resource's scope block.
55
+ scope: {
56
+ organizationId: res.organizationId,
57
+ syncGroups: res.syncGroups,
58
+ operations: [],
59
+ participantKind: 'user',
60
+ participantId: res.participantId,
61
+ },
62
+ userMeta: params.userMeta ?? { id: res.participantId },
63
+ };
64
+ }
65
+ const operations = Object.entries(params.can).flatMap(([model, ops]) => (ops ?? []).map((op) => `${model.toLowerCase()}.${op}`));
66
+ const res = await exchangeApiKey({
67
+ apiKey,
68
+ baseUrl,
69
+ participantKind: 'agent',
70
+ participantId: params.agent.id,
71
+ ...(params.syncGroups ? { syncGroups: [...params.syncGroups] } : {}),
72
+ operations,
73
+ ttlSeconds: params.ttlSeconds ?? 900,
74
+ ...(params.userMeta ? { userMeta: params.userMeta } : {}),
75
+ ...(ctx.fetch ? { fetch: ctx.fetch } : {}),
76
+ });
77
+ return {
78
+ object: 'session',
79
+ id: res.capabilityId,
80
+ token: res.token,
81
+ expiresAt: res.expiresAt,
82
+ organizationId: res.organizationId,
83
+ scope: res.scope,
84
+ userMeta: res.userMeta,
85
+ };
86
+ }
@@ -281,7 +281,7 @@ export declare const modelClaimSchema: z.ZodReadonly<z.ZodObject<{
281
281
  agent: "agent";
282
282
  system: "system";
283
283
  }>>;
284
- action: z.ZodString;
284
+ reason: z.ZodString;
285
285
  description: z.ZodOptional<z.ZodString>;
286
286
  field: z.ZodOptional<z.ZodString>;
287
287
  status: z.ZodOptional<z.ZodEnum<{
@@ -203,7 +203,9 @@ export const modelClaimSchema = z
203
203
  id: z.string(),
204
204
  actor: z.string(),
205
205
  participantKind: wireParticipantKindSchema,
206
- action: z.string(),
206
+ /** Human-readable phase (`'editing'`). The public SDK field; the WS/HTTP
207
+ * wire carries the same value as `action` (healed on read). */
208
+ reason: z.string(),
207
209
  description: z.string().optional(),
208
210
  field: z.string().optional(),
209
211
  status: z.enum(['active', 'queued']).optional(),
@@ -156,6 +156,7 @@ export declare const ERROR_CODES: {
156
156
  readonly model_claimed: ErrorCodeSpec;
157
157
  readonly model_claimed_timeout: ErrorCodeSpec;
158
158
  readonly model_claim_not_configured: ErrorCodeSpec;
159
+ readonly model_watch_not_configured: ErrorCodeSpec;
159
160
  readonly stale_context: ErrorCodeSpec;
160
161
  readonly idempotency_conflict: ErrorCodeSpec;
161
162
  readonly idempotency_key_too_long: ErrorCodeSpec;
@@ -195,6 +196,7 @@ export declare const ERROR_CODES: {
195
196
  readonly schema_scope_kind_invalid: ErrorCodeSpec;
196
197
  readonly schema_field_not_camelcase: ErrorCodeSpec;
197
198
  readonly schema_field_consecutive_caps: ErrorCodeSpec;
199
+ readonly schema_reserved_field: ErrorCodeSpec;
198
200
  readonly schema_grants_shape_invalid: ErrorCodeSpec;
199
201
  readonly schema_grants_identifier_unsafe: ErrorCodeSpec;
200
202
  readonly schema_grants_relation_kind: ErrorCodeSpec;
@@ -159,6 +159,7 @@ export const ERROR_CODES = {
159
159
  model_claimed: wire('claim', 409, false, 'The model instance is claimed by another participant.'),
160
160
  model_claimed_timeout: wire('claim', 409, false, 'Timed out waiting for a model claim to clear.'),
161
161
  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).'),
162
+ 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).'),
162
163
  // ── stale context / idempotency (409) ──────────────────────────────
163
164
  stale_context: wire('conflict', 409, true, 'The write carried a readAt watermark that is now stale; re-read and retry.'),
164
165
  idempotency_conflict: wire('conflict', 409, false, 'The same Idempotency-Key was reused with a different request body.'),
@@ -210,6 +211,7 @@ export const ERROR_CODES = {
210
211
  schema_scope_kind_invalid: wire('schema', 400, false, 'A scope kind in the schema is invalid.'),
211
212
  schema_field_not_camelcase: wire('schema', 400, false, 'A schema field name is not camelCase.'),
212
213
  schema_field_consecutive_caps: wire('schema', 400, false, 'A schema field name has consecutive capital letters.'),
214
+ schema_reserved_field: client('schema', 'A model redeclared a reserved base field (id, createdAt, updatedAt, organizationId, createdBy) that the SDK provides automatically.'),
213
215
  schema_grants_shape_invalid: wire('schema', 400, false, 'A grants declaration has an invalid shape.'),
214
216
  schema_grants_identifier_unsafe: wire('schema', 400, false, 'A grants declaration referenced an unsafe identifier.'),
215
217
  schema_grants_relation_kind: wire('schema', 400, false, 'A grants relation referenced an invalid kind.'),
package/dist/errors.d.ts CHANGED
@@ -161,7 +161,9 @@ export interface ClaimContext {
161
161
  readonly claimId?: string;
162
162
  readonly actor?: string;
163
163
  readonly participantKind?: ParticipantKind;
164
- readonly action?: string;
164
+ /** Human-readable phase the holder is in (`'editing'`). Matches the public
165
+ * claim surface; the wire summary carries the same value as `action`. */
166
+ readonly reason?: string;
165
167
  readonly description?: string;
166
168
  readonly field?: string;
167
169
  readonly status?: string;
@@ -186,8 +188,9 @@ export declare function formatClaimedErrorMessage(args: {
186
188
  * The target entity is currently claimed by another participant and the caller
187
189
  * asked the SDK not to read/write through that claim.
188
190
  *
189
- * Use `ifClaimed: 'wait'` to wait for the claim to clear, or
190
- * `ifClaimed: 'return'` to inspect active claims yourself.
191
+ * Pass `ifClaimed: 'return'` to inspect active claims yourself instead of
192
+ * throwing; to wait for the claim to clear, take `ablo.<model>.claim({ id })`
193
+ * (it queues fairly) rather than blocking the read.
191
194
  */
192
195
  export declare class AbloClaimedError extends AbloError {
193
196
  readonly type: "AbloClaimedError";
package/dist/errors.js CHANGED
@@ -162,7 +162,12 @@ export class AbloStaleContextError extends AbloError {
162
162
  }
163
163
  }
164
164
  function claimAction(claim) {
165
- return claim?.action;
165
+ if (!claim)
166
+ return undefined;
167
+ // The public `ClaimContext` exposes the phase as `reason`; the wire
168
+ // `WireClaimSummary` projection still carries it under `action`. Read both.
169
+ const c = claim;
170
+ return c.reason ?? c.action;
166
171
  }
167
172
  function claimDescription(claim) {
168
173
  if (!claim)
@@ -208,8 +213,9 @@ export function formatClaimedErrorMessage(args) {
208
213
  * The target entity is currently claimed by another participant and the caller
209
214
  * asked the SDK not to read/write through that claim.
210
215
  *
211
- * Use `ifClaimed: 'wait'` to wait for the claim to clear, or
212
- * `ifClaimed: 'return'` to inspect active claims yourself.
216
+ * Pass `ifClaimed: 'return'` to inspect active claims yourself instead of
217
+ * throwing; to wait for the claim to clear, take `ablo.<model>.claim({ id })`
218
+ * (it queues fairly) rather than blocking the read.
213
219
  */
214
220
  export class AbloClaimedError extends AbloError {
215
221
  type = 'AbloClaimedError';
package/dist/index.d.ts CHANGED
@@ -43,7 +43,6 @@
43
43
  * Advanced — opt-in, most apps never import these (each is tagged
44
44
  * "Advanced —" at its export below, with the one situation it's for):
45
45
  * • `dataSource` / `abloSource` — only if your own DB stays canonical
46
- * • `session` / `agent` — only for delegated agent principals
47
46
  * • `defaultPolicy` — only to customize conflict resolution
48
47
  * • `defineMutators` / `createTransaction` — only for custom mutators
49
48
  * If you don't recognize one, you don't need it — the default path covers you.
@@ -51,11 +50,10 @@
51
50
  export { Ablo } from './client/Ablo.js';
52
51
  export type { MutationExecutor } from './interfaces/index.js';
53
52
  export type { HttpClaimApi, InternalAbloOptions } from './client/Ablo.js';
54
- export { createAbloHttpClient, type AbloHttpClientOptions, type AbloHttpClient, type HttpModelClient, } from './client/httpClient.js';
53
+ export { type AbloHttpClientOptions, type AbloHttpClient, type HttpModelClient, } from './client/httpClient.js';
55
54
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
56
- export type { AbloOptions, ModelCountOptions, ModelListOptions, ModelListScope, ModelLoadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './client/Ablo.js';
55
+ export type { AbloOptions, LocalCountOptions, LocalReadOptions, ModelListScope, ServerReadOptions, ModelRetrieveParams, ModelCreateParams, ModelUpdateParams, ModelDeleteParams, ClaimOptions, ClaimParams, ClaimLookupParams, ClaimReorderParams, ClaimHandle, ModelOperations, } from './client/Ablo.js';
57
56
  export type { AbloPersistence } from './client/persistence.js';
58
- export { session, agent } from './principal.js';
59
57
  import { Ablo } from './client/Ablo.js';
60
58
  export default Ablo;
61
59
  export { dataSource, abloSource, sourceEventForOperation, signAbloSourceRequest, verifyAbloSourceRequest, } from './source/index.js';
@@ -70,6 +68,8 @@ export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './cl
70
68
  export type { WriteOptionsInput } from './client/writeOptionsSchema.js';
71
69
  export type { WriteOptions, MutationOptions } from './interfaces/index.js';
72
70
  export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
71
+ export { PUBLIC_MODEL_VERBS, PUBLIC_LIST_OPTION_KEYS, PUBLIC_ABLO_OPTION_KEYS, } from './surface.js';
72
+ export type { ModelVerb, ListOptionKey, AbloOptionKey } from './surface.js';
73
73
  export type { Register, DefaultSyncShape } from './types/global.js';
74
74
  export { defineMutators } from './mutators/defineMutators.js';
75
75
  export { createTransaction, type Transaction } from './mutators/Transaction.js';
package/dist/index.js CHANGED
@@ -43,7 +43,6 @@
43
43
  * Advanced — opt-in, most apps never import these (each is tagged
44
44
  * "Advanced —" at its export below, with the one situation it's for):
45
45
  * • `dataSource` / `abloSource` — only if your own DB stays canonical
46
- * • `session` / `agent` — only for delegated agent principals
47
46
  * • `defaultPolicy` — only to customize conflict resolution
48
47
  * • `defineMutators` / `createTransaction` — only for custom mutators
49
48
  * If you don't recognize one, you don't need it — the default path covers you.
@@ -56,17 +55,11 @@
56
55
  // `import Ablo from '@abloatai/ablo'` works; named export so
57
56
  // `import { Ablo }` also compiles.
58
57
  export { Ablo } from './client/Ablo.js';
59
- export { createAbloHttpClient, } from './client/httpClient.js';
60
58
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
61
59
  // Participant types live under `Ablo.Participant.*` —
62
60
  // `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
63
61
  // `Ablo.Participant.JoinOptions`, etc. Same dot-access shape as
64
62
  // `Ablo.Peer`, `Ablo.Claim`. No flat re-exports.
65
- // Advanced — most apps never import this. Principal constructors for
66
- // delegated agent paths (`Ablo({ kind: 'agent', as: session({...}) })`).
67
- // The default `Ablo({ schema, apiKey })` resolves identity from the key;
68
- // reach for these only when minting a delegated agent principal.
69
- export { session, agent } from './principal.js';
70
63
  import { Ablo } from './client/Ablo.js';
71
64
  export default Ablo;
72
65
  // Advanced — most apps never import this. Customer-owned storage adapter
@@ -100,6 +93,10 @@ export { writeOptionsSchema, onStaleModeSchema, assertWriteOptions, } from './cl
100
93
  // Storage-wedge detection — lets app shells render a recovery screen when the
101
94
  // IndexedDB backing store is stuck (see core/openIDBWithTimeout.ts).
102
95
  export { IDBOpenTimeoutError, isStorageOpenTimeout } from './core/openIDBWithTimeout.js';
96
+ // Machine-checked surface manifest — the SDK's own description of its public
97
+ // verb/option names, compile-time-bound to the real types (see surface.ts).
98
+ // The MCP `get_api_surface` imports these so docs can't name a phantom verb.
99
+ export { PUBLIC_MODEL_VERBS, PUBLIC_LIST_OPTION_KEYS, PUBLIC_ABLO_OPTION_KEYS, } from './surface.js';
103
100
  // Advanced — most apps never import this. Custom (Zero-style) mutators:
104
101
  // `ablo.<model>.create/update/delete` already covers normal writes. Reach
105
102
  // for `defineMutators` only when you need a named, multi-step mutation with
@@ -59,55 +59,27 @@ function wrapMutateForKey(modelKey, mutate, store, inverses, forwards) {
59
59
  // wider shape is exactly right.
60
60
  return model.toJSON();
61
61
  };
62
+ // Before-image for the undo inverse. Delegates to `Model.capturePreviousValues`
63
+ // — the SINGLE shared implementation (the stream path's
64
+ // `TransactionQueue.extractPreviousData` calls the same method). `fallbackToLive`
65
+ // is ON here: the manual-record path wants the live value as a last resort for
66
+ // a field that was neither pre-mutated nor in the original snapshot. (The
67
+ // stream path passes `false` so it can omit-and-drop instead — that flag is
68
+ // the one intentional difference between the two callers.)
62
69
  const snapshotFields = (id, fieldNames) => {
63
70
  const model = store.pool.get(id);
64
71
  if (!model)
65
72
  return null;
66
- const out = {};
67
- // `modifiedProperties` is populated by M1's `observe()` listener the
68
- // moment the caller mutates an observable field directly. Thanks to
69
- // `Model.propertyChanged`'s first-old-wins policy, `.old` holds the TRUE
70
- // pre-session baseline even after many in-place mutations (e.g. a drag
71
- // frame loop). That makes it the authoritative source for the undo
72
- // inverse when the caller pre-mutates before invoking the mutator.
73
- //
74
- // Fallback chain for models/fields that weren't pre-mutated (so no
75
- // `modifiedProperties` entry exists yet): `getOriginalSnapshot()`
76
- // (populated on load/`markAsPersisted`/sync-ack), then the live
77
- // observable. The live read is correct only when the caller didn't
78
- // touch the field first.
79
- const original = model.getOriginalSnapshot();
80
- for (const f of fieldNames) {
81
- if (f === 'id')
82
- continue;
83
- const mod = model.modifiedProperties.get(f);
84
- if (mod) {
85
- out[f] = mod.old;
86
- }
87
- else if (original && f in original) {
88
- out[f] = original[f];
89
- }
90
- else {
91
- out[f] = Reflect.get(model, f);
92
- }
93
- }
94
- return out;
73
+ return model.capturePreviousValues(fieldNames, { fallbackToLive: true });
95
74
  };
96
75
  // After a mutator's `base.update` succeeds, drop the `modifiedProperties`
97
- // entries we snapshotted from. The next mutator call should see THIS
98
- // update's result as its baseline, not the pre-session old value. The
99
- // transaction queue already captured its frozen copy synchronously inside
100
- // `store.save` (via `captureModelChanges`/`extractPreviousData`), so this
101
- // clear is safe for server rollback.
76
+ // entries we snapshotted from so the next mutator call sees THIS update's
77
+ // result as its baseline, not the pre-session old value. The transaction
78
+ // queue already captured its frozen copy synchronously inside `store.save`,
79
+ // so this clear is safe for server rollback. Shared with the stream path via
80
+ // `Model.consumeModifiedFields`.
102
81
  const consumeModifiedFields = (id, fieldNames) => {
103
- const model = store.pool.get(id);
104
- if (!model)
105
- return;
106
- for (const f of fieldNames) {
107
- if (f === 'id')
108
- continue;
109
- model.modifiedProperties.delete(f);
110
- }
82
+ store.pool.get(id)?.consumeModifiedFields(fieldNames);
111
83
  };
112
84
  return {
113
85
  // Overloaded — single row or array. The recorder dispatches the
@@ -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 `useParticipant()` / `useAblo()` are always
18
+ * multiplayer UI, so `useWatch()` / `useAblo()` are always
19
19
  * available. No opt-in prop.
20
20
  * - **Declarative props for app glue.** `preventUnsavedChanges`,
21
21
  * `onSessionExpired`, `postBootstrap`, `resolveUsers` — each
@@ -36,7 +36,7 @@ import { type SyncStoreContract } from './context.js';
36
36
  * // Build once at module scope — a new instance per render tears down the socket.
37
37
  * const ablo = Ablo({
38
38
  * schema,
39
- * getToken: () =>
39
+ * apiKey: () =>
40
40
  * fetch('/api/ablo-session', { method: 'POST' })
41
41
  * .then((r) => r.json())
42
42
  * .then((d) => d.token),
@@ -114,18 +114,13 @@ export interface AbloProviderProps<R extends SchemaRecord = SchemaRecord> {
114
114
  export declare function AbloProvider<R extends SchemaRecord = SchemaRecord>(props: AbloProviderProps<R>): React.ReactElement;
115
115
  export type { EngineParticipant, ParticipantScope, ParticipantStatus };
116
116
  /**
117
- * Options for `useParticipant`. The hook reuses the engine's single
117
+ * Options for `useWatch`. The hook reuses the engine's single
118
118
  * WebSocket and opens a scoped claim on it when `scope` is provided:
119
119
  * one TCP connection, N logical sub-syncgroup participants.
120
120
  */
121
- export interface UseParticipantOptions {
121
+ export interface UseWatchOptions {
122
122
  readonly scope?: ParticipantScope;
123
- readonly label?: string;
124
- readonly as?: unknown;
125
123
  readonly ttlSeconds?: number | string | null;
126
- readonly agent?: unknown;
127
- readonly idempotencyKey?: string | null;
128
- readonly autoRefreshThresholdSeconds?: number | null;
129
124
  /** Tear down + don't re-join while true. */
130
125
  readonly paused?: boolean;
131
126
  /**
@@ -154,7 +149,7 @@ export interface UseParticipantOptions {
154
149
  }
155
150
  /** @deprecated Use `ParticipantStatus`. */
156
151
  export type MeshParticipantStatus = ParticipantStatus;
157
- export interface UseParticipantReturn {
152
+ export interface UseWatchReturn {
158
153
  readonly participant: EngineParticipant | null;
159
154
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
160
155
  readonly peers: ReadonlyArray<Peer>;
@@ -168,15 +163,19 @@ export interface UseParticipantReturn {
168
163
  * lifecycle status. Auto-cleans up on unmount or when `paused`
169
164
  * flips to true.
170
165
  *
166
+ * `useWatch` is the React form of `ablo.<model>.watch` — scope-level
167
+ * read-interest + presence; returns the reactive participant facade
168
+ * (peers/claims/status).
169
+ *
171
170
  * The returned `participant` is an `EngineParticipant` — `.presence`
172
171
  * + `.claims` only — backed by the engine's existing socket. For
173
172
  * headless-bot patterns (a separate identity in the same browser
174
173
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
175
174
  */
176
- export declare function useParticipant(opts: UseParticipantOptions): UseParticipantReturn;
175
+ export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
177
176
  /**
178
177
  * Read-only presence: the OTHER participants currently visible to this
179
- * connection, bridged to React. Unlike {@link useParticipant}, this does
178
+ * connection, bridged to React. Unlike {@link useWatch}, this does
180
179
  * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
181
180
  * it is a pure reader of the engine's already-flowing presence stream.
182
181
  *
@@ -189,7 +188,7 @@ export declare function useParticipant(opts: UseParticipantOptions): UseParticip
189
188
  * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
190
189
  * broadcasts while alone — when some OTHER mount already owns the scope's
191
190
  * read interest (scope `leave` is not reference-counted, so a second
192
- * `useParticipant` on the same scope would warm-drop the owner's
191
+ * `useWatch` on the same scope would warm-drop the owner's
193
192
  * subscription on unmount).
194
193
  *
195
194
  * ```ts
@@ -36,7 +36,7 @@ export function AbloProvider(props) {
36
36
  // REACTIVE binding over it (context + bootstrap gate + error/session
37
37
  // forwarding); it does NOT construct, configure, or own the connection. The
38
38
  // client owns auth, the credential lifecycle (first mint, refresh, and
39
- // wake/online/focus re-mint — see `Ablo({ getToken })`), transport, and
39
+ // wake/online/focus re-mint — see `Ablo({ apiKey })`), transport, and
40
40
  // `dispose()`. The CONSUMER built the client, so the consumer owns teardown;
41
41
  // the provider never disposes it.
42
42
  const engine = client;
@@ -204,12 +204,16 @@ const EMPTY_INTENTS = Object.freeze([]);
204
204
  * lifecycle status. Auto-cleans up on unmount or when `paused`
205
205
  * flips to true.
206
206
  *
207
+ * `useWatch` is the React form of `ablo.<model>.watch` — scope-level
208
+ * read-interest + presence; returns the reactive participant facade
209
+ * (peers/claims/status).
210
+ *
207
211
  * The returned `participant` is an `EngineParticipant` — `.presence`
208
212
  * + `.claims` only — backed by the engine's existing socket. For
209
213
  * headless-bot patterns (a separate identity in the same browser
210
214
  * tab), construct a second `Ablo({ kind: 'agent', ... })` directly.
211
215
  */
212
- export function useParticipant(opts) {
216
+ export function useWatch(opts) {
213
217
  const ctx = useContext(AbloInternalContext);
214
218
  const engine = ctx?.engine ?? null;
215
219
  const { paused = false } = opts;
@@ -338,15 +342,11 @@ export function useParticipant(opts) {
338
342
  unsubClaims();
339
343
  };
340
344
  }, [participant, paused]);
341
- // `opts.as`, `opts.agent`, `opts.idempotencyKey`, and
342
- // `opts.autoRefreshThresholdSeconds` remain migration placeholders
343
- // for future capability-mint/attenuation wiring. `scope` is already
344
- // active: it opens a multiplexed claim on the engine WebSocket.
345
345
  return { participant, peers, claims, status, error };
346
346
  }
347
347
  /**
348
348
  * Read-only presence: the OTHER participants currently visible to this
349
- * connection, bridged to React. Unlike {@link useParticipant}, this does
349
+ * connection, bridged to React. Unlike {@link useWatch}, this does
350
350
  * NOT enter/leave a scope (no `update_subscription`, no warm-TTL churn) —
351
351
  * it is a pure reader of the engine's already-flowing presence stream.
352
352
  *
@@ -359,7 +359,7 @@ export function useParticipant(opts) {
359
359
  * Use this to answer "is anyone else here?" — e.g. suppressing live-cursor
360
360
  * broadcasts while alone — when some OTHER mount already owns the scope's
361
361
  * read interest (scope `leave` is not reference-counted, so a second
362
- * `useParticipant` on the same scope would warm-drop the owner's
362
+ * `useWatch` on the same scope would warm-drop the owner's
363
363
  * subscription on unmount).
364
364
  *
365
365
  * ```ts
@@ -370,7 +370,7 @@ export function useParticipant(opts) {
370
370
  export function usePeers(scope) {
371
371
  const ctx = useContext(AbloInternalContext);
372
372
  const engine = ctx?.engine ?? null;
373
- // Resolve scope → groups through the schema (same idiom as useParticipant).
373
+ // Resolve scope → groups through the schema (same idiom as useWatch).
374
374
  // The stringified, sorted key is the stable effect dependency.
375
375
  const scopeKey = JSON.stringify(resolveParticipantSyncGroups(scope, engine?.schema).sort());
376
376
  const groups = useMemo(() => JSON.parse(scopeKey), [scopeKey]);
@@ -387,7 +387,7 @@ export function usePeers(scope) {
387
387
  // Plain useState + onChange — presence changes on join/leave/activity
388
388
  // only (never on cursor traffic, a separate channel), so this fires
389
389
  // rarely; a frame of stale presence is harmless (same rationale as
390
- // useParticipant's peers bridge).
390
+ // useWatch's peers bridge).
391
391
  setPeers(compute());
392
392
  return presence.onChange(() => setPeers(compute()));
393
393
  }, [engine, scopeKey]);
@@ -137,29 +137,12 @@ export interface SyncReactContext {
137
137
  * augmentation — see `src/types/global.ts`.
138
138
  */
139
139
  schema?: Schema;
140
- /**
141
- * Optional presence source. When set, `usePresence()` returns this
142
- * value cast to the consumer's `ResolvePresence` type (declared via
143
- * `interface Register { Presence: ... }`). The SDK doesn't own a
144
- * presence wire format — consumers plug whatever backs their cursors,
145
- * status, or activity state (a MobX store, a Zustand slice, a custom
146
- * subscription). The typed-global gives it a call-site-ergonomic
147
- * type without the SDK dictating the transport.
148
- */
149
- presence?: unknown;
150
- /**
151
- * Optional claim initiator. Same pattern as presence — consumers
152
- * plug a function that turns an claim claim into a handle they
153
- * control (WebSocket send, optimistic local update, whatever).
154
- * `useClaim(name)` returns a typed invoker for the named claim
155
- * from `interface Register { Claims: ... }`.
156
- */
157
- beginClaim?: (claimName: string, claim: unknown) => unknown;
158
140
  }
159
141
  export declare const SyncContext: import("react").Context<SyncReactContext | null>;
160
142
  /**
161
- * Access the sync store from React components.
162
- * Must be used within a SyncProvider.
143
+ * Access the sync store from React components. The context is provided by
144
+ * `<AbloProvider>` (which renders the internal {@link SyncProvider}); public
145
+ * consumers wire `<AbloProvider client={ablo}>`, never this directly.
163
146
  */
164
147
  export declare function useSyncContext(): SyncReactContext;
165
148
  /**
@@ -177,33 +160,15 @@ export interface SyncProviderProps {
177
160
  * their legacy `(schema, modelKey, …)` signatures.
178
161
  */
179
162
  schema?: Schema;
180
- /**
181
- * Optional presence source for `usePresence()`. See
182
- * {@link SyncReactContext.presence} — the consumer plugs whatever
183
- * backs their presence state; the hook returns it with
184
- * `ResolvePresence` typing.
185
- */
186
- presence?: unknown;
187
- /**
188
- * Optional claim initiator for `useClaim()`. See
189
- * {@link SyncReactContext.beginClaim}.
190
- */
191
- beginClaim?: (claimName: string, claim: unknown) => unknown;
192
163
  children?: ReactNode;
193
164
  }
194
165
  /**
195
- * SyncProvider wires the sync store into React so SDK hooks
196
- * (useModel, useModels, useMutations) can access it.
197
- *
198
- * @example
199
- * import { SyncProvider } from '@abloatai/ablo/react';
166
+ * SyncProvider the INTERNAL low-level provider that wires a built sync store
167
+ * into React so SDK hooks (useModel, useModels, useMutations) can reach it.
200
168
  *
201
- * function App() {
202
- * return (
203
- * <SyncProvider store={syncStore} organizationId={orgId}>
204
- * <YourApp />
205
- * </SyncProvider>
206
- * );
207
- * }
169
+ * Public consumers do NOT use this directly (it is not exported from
170
+ * `@abloatai/ablo/react`). `<AbloProvider client={ablo}>` constructs the
171
+ * store from your `Ablo({ schema, apiKey })` client and renders this provider
172
+ * underneath — reach for `<AbloProvider>`.
208
173
  */
209
- export declare function SyncProvider({ store, organizationId, schema, presence, beginClaim, children, }: SyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SyncReactContext | null>>;
174
+ export declare function SyncProvider({ store, organizationId, schema, children, }: SyncProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SyncReactContext | null>>;
@@ -3,33 +3,28 @@ import { createContext, createElement, useContext } from 'react';
3
3
  import { AbloValidationError } from '../errors.js';
4
4
  export const SyncContext = createContext(null);
5
5
  /**
6
- * Access the sync store from React components.
7
- * Must be used within a SyncProvider.
6
+ * Access the sync store from React components. The context is provided by
7
+ * `<AbloProvider>` (which renders the internal {@link SyncProvider}); public
8
+ * consumers wire `<AbloProvider client={ablo}>`, never this directly.
8
9
  */
9
10
  export function useSyncContext() {
10
11
  const ctx = useContext(SyncContext);
11
12
  if (!ctx) {
12
- throw new AbloValidationError('useSyncContext must be used within a SyncProvider', {
13
+ throw new AbloValidationError('Sync hooks must be used within an <AbloProvider>.', {
13
14
  code: 'sync_context_missing_provider',
14
15
  });
15
16
  }
16
17
  return ctx;
17
18
  }
18
19
  /**
19
- * SyncProvider wires the sync store into React so SDK hooks
20
- * (useModel, useModels, useMutations) can access it.
20
+ * SyncProvider the INTERNAL low-level provider that wires a built sync store
21
+ * into React so SDK hooks (useModel, useModels, useMutations) can reach it.
21
22
  *
22
- * @example
23
- * import { SyncProvider } from '@abloatai/ablo/react';
24
- *
25
- * function App() {
26
- * return (
27
- * <SyncProvider store={syncStore} organizationId={orgId}>
28
- * <YourApp />
29
- * </SyncProvider>
30
- * );
31
- * }
23
+ * Public consumers do NOT use this directly (it is not exported from
24
+ * `@abloatai/ablo/react`). `<AbloProvider client={ablo}>` constructs the
25
+ * store from your `Ablo({ schema, apiKey })` client and renders this provider
26
+ * underneath reach for `<AbloProvider>`.
32
27
  */
33
- export function SyncProvider({ store, organizationId, schema, presence, beginClaim, children, }) {
34
- return createElement(SyncContext.Provider, { value: { store, organizationId, schema, presence, beginClaim } }, children);
28
+ export function SyncProvider({ store, organizationId, schema, children, }) {
29
+ return createElement(SyncContext.Provider, { value: { store, organizationId, schema } }, children);
35
30
  }