@abloatai/ablo 0.35.0 → 0.36.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 (215) hide show
  1. package/CHANGELOG.md +713 -629
  2. package/README.md +56 -519
  3. package/bin/ablo.cjs +39 -0
  4. package/dist/BaseSyncedStore.d.ts +24 -4
  5. package/dist/BaseSyncedStore.js +53 -37
  6. package/dist/Database.d.ts +8 -20
  7. package/dist/Database.js +61 -59
  8. package/dist/InstanceCache.d.ts +6 -2
  9. package/dist/InstanceCache.js +18 -16
  10. package/dist/Model.d.ts +17 -7
  11. package/dist/Model.js +17 -7
  12. package/dist/ModelRegistry.d.ts +4 -0
  13. package/dist/ModelRegistry.js +17 -15
  14. package/dist/NetworkMonitor.d.ts +3 -1
  15. package/dist/NetworkMonitor.js +7 -5
  16. package/dist/SyncClient.d.ts +5 -15
  17. package/dist/SyncClient.js +60 -57
  18. package/dist/client/Ablo.js +30 -19
  19. package/dist/client/createInternalComponents.d.ts +4 -0
  20. package/dist/client/createInternalComponents.js +8 -2
  21. package/dist/client/createModelProxy.d.ts +21 -1
  22. package/dist/client/createModelProxy.js +123 -57
  23. package/dist/client/humans.d.ts +30 -9
  24. package/dist/client/humans.js +45 -19
  25. package/dist/client/reactiveEngine.d.ts +16 -11
  26. package/dist/client/reactiveEngine.js +113 -335
  27. package/dist/client/storeCluster.d.ts +47 -0
  28. package/dist/client/storeCluster.js +118 -0
  29. package/dist/client/storeLifecycle.d.ts +61 -0
  30. package/dist/client/storeLifecycle.js +231 -0
  31. package/dist/context.d.ts +13 -0
  32. package/dist/context.js +23 -0
  33. package/dist/core/index.d.ts +1 -1
  34. package/dist/docs/catalog.js +6 -3
  35. package/dist/index.d.ts +4 -2
  36. package/dist/index.js +4 -2
  37. package/dist/query/client.d.ts +3 -0
  38. package/dist/query/client.js +6 -5
  39. package/dist/react/AbloProvider.d.ts +13 -1
  40. package/dist/react/AbloProvider.js +5 -2
  41. package/dist/react/context.d.ts +2 -2
  42. package/dist/react/createAbloReact.d.ts +56 -0
  43. package/dist/react/createAbloReact.js +51 -0
  44. package/dist/react/index.d.ts +1 -0
  45. package/dist/react/index.js +3 -0
  46. package/dist/react/useAblo.d.ts +9 -2
  47. package/dist/react/useAblo.js +25 -7
  48. package/dist/schema/coordination.js +5 -1
  49. package/dist/schema/index.d.ts +1 -0
  50. package/dist/schema/index.js +4 -0
  51. package/dist/schema/select.js +3 -0
  52. package/dist/schema/serialize.js +3 -0
  53. package/dist/source/adapter.d.ts +7 -5
  54. package/dist/source/adapter.js +7 -5
  55. package/dist/source/index.d.ts +1 -1
  56. package/dist/source/index.js +1 -1
  57. package/dist/{core/storeContract.d.ts → storeContract.d.ts} +5 -5
  58. package/dist/{core → stores}/DatabaseManager.d.ts +3 -1
  59. package/dist/{core → stores}/DatabaseManager.js +13 -12
  60. package/dist/{core → stores}/StoreManager.d.ts +5 -3
  61. package/dist/{core → stores}/StoreManager.js +24 -22
  62. package/dist/stores/SyncActionStore.d.ts +3 -1
  63. package/dist/stores/SyncActionStore.js +9 -7
  64. package/dist/sync/BootstrapFetcher.d.ts +4 -0
  65. package/dist/sync/BootstrapFetcher.js +28 -26
  66. package/dist/sync/OnDemandLoader.d.ts +3 -0
  67. package/dist/sync/OnDemandLoader.js +1 -0
  68. package/dist/sync/bootstrapApply.d.ts +3 -0
  69. package/dist/sync/bootstrapApply.js +2 -2
  70. package/dist/sync/deltaPipeline.d.ts +13 -12
  71. package/dist/sync/deltaPipeline.js +21 -4
  72. package/dist/sync/groupChange.d.ts +3 -0
  73. package/dist/sync/groupChange.js +16 -14
  74. package/dist/sync/participants.d.ts +19 -2
  75. package/dist/sync/participants.js +3 -1
  76. package/dist/sync/schemas.d.ts +2 -1
  77. package/dist/sync/schemas.js +3 -3
  78. package/dist/syncLog/contract.d.ts +20 -0
  79. package/dist/syncLog/contract.js +19 -0
  80. package/dist/syncLog/index.d.ts +1 -0
  81. package/dist/syncLog/index.js +1 -0
  82. package/dist/transaction/auth/capability.d.ts +35 -0
  83. package/dist/transaction/auth/capability.js +25 -0
  84. package/dist/transaction/coordination/awaitClaimGrant.d.ts +7 -0
  85. package/dist/transaction/coordination/awaitClaimGrant.js +12 -0
  86. package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +34 -0
  87. package/dist/transaction/coordination/claimHeartbeatLoop.js +20 -0
  88. package/dist/transaction/coordination/index.d.ts +4 -4
  89. package/dist/transaction/coordination/index.js +4 -3
  90. package/dist/transaction/coordination/locator.d.ts +23 -2
  91. package/dist/transaction/coordination/locator.js +22 -2
  92. package/dist/transaction/coordination/schema.d.ts +125 -62
  93. package/dist/transaction/coordination/schema.js +228 -64
  94. package/dist/transaction/coordination/targetConflict.js +32 -28
  95. package/dist/transaction/errorCodes.d.ts +2 -2
  96. package/dist/transaction/errorCodes.js +13 -9
  97. package/dist/transaction/plugin.d.ts +95 -2
  98. package/dist/transaction/plugin.js +21 -2
  99. package/dist/transaction/resources/httpResources.d.ts +57 -2
  100. package/dist/transaction/resources/modelOperations.d.ts +148 -40
  101. package/dist/transaction/resources/where.d.ts +16 -0
  102. package/dist/transaction/resources/where.js +45 -0
  103. package/dist/transaction/schema/field.d.ts +12 -18
  104. package/dist/transaction/schema/fieldRef.d.ts +38 -0
  105. package/dist/transaction/schema/fieldRef.js +11 -0
  106. package/dist/transaction/schema/openapi.d.ts +16 -15
  107. package/dist/transaction/schema/openapi.js +186 -25
  108. package/dist/transaction/schema/relation.d.ts +7 -2
  109. package/dist/transaction/schema/schema.d.ts +27 -0
  110. package/dist/transaction/schema/schema.js +20 -0
  111. package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +1 -1
  112. package/dist/transaction/transactions/settlement/pendingWrite.d.ts +1 -1
  113. package/dist/transaction/transport/httpClient.d.ts +9 -1
  114. package/dist/transaction/transport/httpClient.js +1 -0
  115. package/dist/transaction/transport/httpTransport.js +156 -44
  116. package/dist/transaction/transport/wsTransport.d.ts +2 -4
  117. package/dist/transaction/transport/wsTransport.js +16 -10
  118. package/dist/transaction/types/streams.d.ts +10 -0
  119. package/dist/transaction/utils/duration.d.ts +25 -0
  120. package/dist/transaction/utils/duration.js +32 -0
  121. package/dist/transaction/wire/accountResponses.d.ts +69 -0
  122. package/dist/transaction/wire/accountResponses.js +36 -1
  123. package/dist/transaction/wire/auth.d.ts +9 -2
  124. package/dist/transaction/wire/auth.js +7 -1
  125. package/dist/transaction/wire/claims.d.ts +164 -97
  126. package/dist/transaction/wire/claims.js +126 -28
  127. package/dist/transaction/wire/commit.d.ts +1 -1
  128. package/dist/transaction/wire/feedEvent.d.ts +27 -0
  129. package/dist/transaction/wire/feedEvent.js +27 -1
  130. package/dist/transaction/wire/frames.d.ts +2 -2
  131. package/dist/transaction/wire/inboundFrames.d.ts +12 -2
  132. package/dist/transaction/wire/index.d.ts +10 -6
  133. package/dist/transaction/wire/index.js +12 -3
  134. package/dist/transaction/wire/modelMutations.d.ts +31 -0
  135. package/dist/transaction/wire/modelMutations.js +52 -0
  136. package/dist/transaction/wire/modelShape.d.ts +78 -0
  137. package/dist/transaction/wire/modelShape.js +74 -0
  138. package/dist/transactions/mutations/MutationQueue.d.ts +6 -0
  139. package/dist/transactions/mutations/MutationQueue.js +55 -45
  140. package/dist/transactions/mutations/commitPayload.d.ts +3 -2
  141. package/dist/transactions/mutations/commitPayload.js +5 -5
  142. package/dist/transactions/mutations/deltaConfirmation.d.ts +4 -0
  143. package/dist/transactions/mutations/deltaConfirmation.js +10 -8
  144. package/dist/transactions/mutations/replayValidation.d.ts +2 -1
  145. package/dist/transactions/mutations/replayValidation.js +3 -2
  146. package/dist/{core → views}/QueryView.d.ts +1 -1
  147. package/dist/{core → views}/QueryView.js +1 -1
  148. package/dist/{core → views}/ViewRegistry.d.ts +1 -1
  149. package/dist/{core/queryUtils.d.ts → views/incrementalView.d.ts} +6 -6
  150. package/dist/{core/queryUtils.js → views/incrementalView.js} +6 -6
  151. package/docs/agents.md +1 -1
  152. package/docs/api-keys.md +6 -6
  153. package/docs/api.md +5 -43
  154. package/docs/audit.md +4 -3
  155. package/docs/cli.md +11 -11
  156. package/docs/client-behavior.md +4 -4
  157. package/docs/concurrency-convention.md +28 -42
  158. package/docs/coordination.md +235 -83
  159. package/docs/data-sources.md +4 -4
  160. package/docs/debugging.md +34 -12
  161. package/docs/deployment.md +8 -8
  162. package/docs/examples/scoped-agent.md +3 -3
  163. package/docs/groups.md +57 -3
  164. package/docs/guarantees.md +37 -10
  165. package/docs/how-it-works.md +29 -5
  166. package/docs/idempotency.md +6 -6
  167. package/docs/identity.md +22 -23
  168. package/docs/index.md +8 -8
  169. package/docs/integration-guide.md +14 -3
  170. package/docs/mcp.md +7 -7
  171. package/docs/migration.md +34 -15
  172. package/docs/projects.md +1 -1
  173. package/docs/react.md +19 -8
  174. package/docs/sessions.md +1 -1
  175. package/docs/webhooks.md +9 -9
  176. package/llms.txt +4 -4
  177. package/package.json +13 -20
  178. package/dist/cli.cjs +0 -288600
  179. package/dist/testing/fixtures/bootstrap.d.ts +0 -49
  180. package/dist/testing/fixtures/bootstrap.js +0 -59
  181. package/dist/testing/fixtures/deltas.d.ts +0 -83
  182. package/dist/testing/fixtures/deltas.js +0 -136
  183. package/dist/testing/fixtures/httpResponses.d.ts +0 -70
  184. package/dist/testing/fixtures/httpResponses.js +0 -90
  185. package/dist/testing/fixtures/models.d.ts +0 -83
  186. package/dist/testing/fixtures/models.js +0 -272
  187. package/dist/testing/helpers/reactWrapper.d.ts +0 -69
  188. package/dist/testing/helpers/reactWrapper.js +0 -67
  189. package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
  190. package/dist/testing/helpers/syncEngineHarness.js +0 -73
  191. package/dist/testing/helpers/wait.d.ts +0 -30
  192. package/dist/testing/helpers/wait.js +0 -49
  193. package/dist/testing/index.d.ts +0 -23
  194. package/dist/testing/index.js +0 -33
  195. package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
  196. package/dist/testing/mocks/FakeDatabase.js +0 -10
  197. package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
  198. package/dist/testing/mocks/MockMutationExecutor.js +0 -186
  199. package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
  200. package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
  201. package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
  202. package/dist/testing/mocks/MockSyncContext.js +0 -72
  203. package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
  204. package/dist/testing/mocks/MockSyncStore.js +0 -171
  205. package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
  206. package/dist/testing/mocks/MockWebSocket.js +0 -118
  207. package/docs/interaction-model.md +0 -99
  208. /package/dist/{core → query}/QueryProcessor.d.ts +0 -0
  209. /package/dist/{core → query}/QueryProcessor.js +0 -0
  210. /package/dist/{core/storeContract.js → storeContract.js} +0 -0
  211. /package/dist/{core → stores}/openIDBWithTimeout.d.ts +0 -0
  212. /package/dist/{core → stores}/openIDBWithTimeout.js +0 -0
  213. /package/dist/{source → transaction}/footprint.d.ts +0 -0
  214. /package/dist/{source → transaction}/footprint.js +0 -0
  215. /package/dist/{core → views}/ViewRegistry.js +0 -0
@@ -52,6 +52,10 @@ export interface ModelCollaboration {
52
52
  queue?: boolean;
53
53
  /** Reject (don't wait) if the queue is already this deep when we join. */
54
54
  maxQueueDepth?: number;
55
+ /** Cap on the queued wait before rejecting with `grant_timeout`. */
56
+ waitTimeoutMs?: number;
57
+ /** Abort the queued wait — rejects with `claim_wait_aborted`. */
58
+ signal?: AbortSignal;
55
59
  }): Promise<Claim>;
56
60
  createSnapshot(modelKey: string, id: string): Snapshot;
57
61
  /**
@@ -67,6 +71,12 @@ export interface ModelCollaboration {
67
71
  * target because it isn't bound to a single model.
68
72
  */
69
73
  state(target: EntityHalf): Claim | null;
74
+ /**
75
+ * Every active claim on a target, not just one. Sub-row claims on disjoint
76
+ * parts of a row are all granted, so a row can have several holders at once
77
+ * and `state` answers with only the first of them.
78
+ */
79
+ holders(target: EntityHalf): readonly Claim[];
70
80
  /**
71
81
  * The reactive wait queue on a target — the FIFO line of queued claims
72
82
  * behind the holder. Synchronous snapshot off the synced claim stream.
@@ -223,6 +233,16 @@ interface ReactiveModelSurface<T> {
223
233
  * See {@link ReactiveModelSurface}.
224
234
  */
225
235
  export type ModelOperations<T, CreateInput> = Omit<HttpModelClient<T, CreateInput>, 'claim'> & ReactiveModelSurface<T>;
226
- export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: InstanceCache, syncClient: SyncClient, registry: ModelRegistry, hydration: OnDemandLoader, collaboration?: ModelCollaboration,
236
+ export declare function createModelProxy<T, C>(schemaKey: string, registeredModelName: string, objectPool: InstanceCache, syncClient: SyncClient, registry: ModelRegistry,
237
+ /**
238
+ * The one thing this factory asks of the loader: fetch rows for a model.
239
+ *
240
+ * Declared as the slice rather than the whole `OnDemandLoader` because the
241
+ * whole is a class, and a parameter typed as a class can only ever be
242
+ * satisfied by an instance of it — so every caller that has a narrower
243
+ * collaborator, a test most of all, is pushed into a cast through `unknown`
244
+ * to supply the one method that is actually read.
245
+ */
246
+ hydration: Pick<OnDemandLoader, 'fetch'>, collaboration?: ModelCollaboration,
227
247
  /** The client-wide `wait` default; a per-call `wait` still wins over it. */
228
248
  defaultWait?: 'queued' | 'confirmed'): ModelOperations<T, C>;
@@ -17,9 +17,14 @@ import { claimDescription, } from '../transaction/coordination/schema.js';
17
17
  import { Model, modelAsRow } from '../Model.js';
18
18
  import { toMs } from '../transaction/utils/duration.js';
19
19
  import { LEASE_TTL_MS } from '../transaction/wire/protocol.js';
20
- import { heartbeatCadenceMs, resolveHeartbeatOptions, startClaimHeartbeatLoop, } from '../transaction/coordination/claimHeartbeatLoop.js';
20
+ import { heartbeatCadenceMs, resolveHeartbeatOptions, resolveHeartbeatPlan, startClaimHeartbeatLoop, } from '../transaction/coordination/claimHeartbeatLoop.js';
21
21
  import { assertWriteOptions } from '../transaction/resources/writeOptionsSchema.js';
22
22
  import { modelTarget, subTarget } from '../transaction/coordination/index.js';
23
+ // A named claim-meta crossing (see `claim-meta-crossings-are-enumerated` in
24
+ // .dependency-cruiser.cjs): the reactive proxy's self-claim targets are
25
+ // decodes that build a public claim, so their `meta` converts wire→declared
26
+ // here like the other enumerated crossings.
27
+ import { declaredMeta } from '../transaction/coordination/claimMeta.js';
23
28
  import { ModelScope } from '../transaction/types/index.js';
24
29
  const modelClientMeta = new WeakMap();
25
30
  export function getModelClientMeta(modelClient) {
@@ -27,7 +32,17 @@ export function getModelClientMeta(modelClient) {
27
32
  return undefined;
28
33
  return modelClientMeta.get(modelClient);
29
34
  }
30
- export function createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, registry, hydration, collaboration,
35
+ export function createModelProxy(schemaKey, registeredModelName, objectPool, syncClient, registry,
36
+ /**
37
+ * The one thing this factory asks of the loader: fetch rows for a model.
38
+ *
39
+ * Declared as the slice rather than the whole `OnDemandLoader` because the
40
+ * whole is a class, and a parameter typed as a class can only ever be
41
+ * satisfied by an instance of it — so every caller that has a narrower
42
+ * collaborator, a test most of all, is pushed into a cast through `unknown`
43
+ * to supply the one method that is actually read.
44
+ */
45
+ hydration, collaboration,
31
46
  /** The client-wide `wait` default; a per-call `wait` still wins over it. */
32
47
  defaultWait) {
33
48
  /**
@@ -165,20 +180,15 @@ defaultWait) {
165
180
  const held = collaboration.state({ model: wireModel, id });
166
181
  const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
167
182
  const failFast = options.queue === false;
168
- // Fail-fast (`queue: false`): if another participant already holds it,
169
- // reject now instead of queuing. Best-effort at the client (a racing
170
- // claim not yet synced into our snapshot slips through here) — the
171
- // commit-time claim guard is the authoritative backstop that rejects
172
- // the loser's first write. For work-distribution dedup that's exactly
173
- // right: don't wait (that would double-process), skip.
183
+ // The try-claim (`queue: false`): a held target is an expected outcome,
184
+ // not an error, so it resolves `null` the caller reads `if (!claim)`
185
+ // and moves on; who holds it stays readable via `claim.state`. Best-effort
186
+ // at the client (a racing claim not yet synced into our snapshot slips
187
+ // through here) the commit-time claim guard is the authoritative
188
+ // backstop that rejects the loser's first write. For work-distribution
189
+ // dedup that's exactly right: don't wait (that would double-process), skip.
174
190
  if (failFast && contended) {
175
- const claim = claimContextFromClaim(held);
176
- throw new AbloClaimedError(formatClaimedErrorMessage({
177
- targetLabel: `${registeredModelName}/${id}`,
178
- heldBy: held.heldBy,
179
- claim,
180
- fallback: `${registeredModelName}/${id} is held by ${held.heldBy ?? 'another participant'}.`,
181
- }), { code: 'entity_claimed', claims: [claim] });
191
+ return null;
182
192
  }
183
193
  // Ensure the row exists locally before claiming.
184
194
  let model = ownRowOrThrow(id);
@@ -215,6 +225,10 @@ defaultWait) {
215
225
  ttl: options.ttl,
216
226
  queue: !failFast,
217
227
  maxQueueDepth: options.maxQueueDepth,
228
+ // The one wait cap, declared once on ClaimTargetOptions — the socket
229
+ // wait and the HTTP poll-wait both honor it as `grant_timeout`.
230
+ waitTimeoutMs: options.waitTimeoutMs,
231
+ signal: options.signal,
218
232
  });
219
233
  // Only when the claim actually waited behind another holder can the row have
220
234
  // changed underneath us — re-read so the claimed snapshot reflects what that
@@ -238,10 +252,15 @@ defaultWait) {
238
252
  // The self-claim's `ClaimTarget` mirrors what a peer's `claim.state` would
239
253
  // report (`state` maps `held.target.model` to `type`), so a holder and a
240
254
  // peer see the same `target.type` for one row — the wire model token.
255
+ // Its `meta` is the DECLARED shape (the handle is a public claim), so the
256
+ // wire-shaped projection converts back through `declaredMeta` — the same
257
+ // crossing the HTTP handle assembly makes.
258
+ const { meta: selfMeta, ...selfNarrowed } = subTarget(options);
241
259
  const selfTarget = {
242
260
  type: wireModel,
243
261
  id,
244
- ...subTarget(options),
262
+ ...selfNarrowed,
263
+ ...(selfMeta !== undefined ? { meta: declaredMeta(selfMeta) } : {}),
245
264
  };
246
265
  const ttlMs = options.ttl !== undefined ? toMs(options.ttl) : DEFAULT_LEASE_TTL_MS;
247
266
  const expiresAt = Date.now() + ttlMs;
@@ -252,14 +271,20 @@ defaultWait) {
252
271
  description,
253
272
  expiresAt,
254
273
  });
274
+ const { meta: targetMeta, ...targetNarrowed } = subTarget(options);
255
275
  const target = {
256
276
  type: schemaKey,
257
277
  id,
258
- ...subTarget(options),
278
+ ...targetNarrowed,
279
+ ...(targetMeta !== undefined ? { meta: declaredMeta(targetMeta) } : {}),
259
280
  };
281
+ // One reading of the heartbeat options — cadence and callbacks from
282
+ // whichever spelling the caller used (plan object, shorthand, or the
283
+ // deprecated flat callbacks).
284
+ const plan = resolveHeartbeatPlan(options);
260
285
  // A beat resolves with the server's extended expiry; keep the local
261
286
  // self-claim estimate in step so `claim.state` renders the real window,
262
- // and surface every answer through `onHeartbeat` (pressure signal).
287
+ // and surface every answer through the plan's `onBeat` (pressure signal).
263
288
  const heartbeat = async (beatOptions) => {
264
289
  if (!lease.heartbeat) {
265
290
  throw new AbloValidationError('This claim handle has no heartbeat wiring, which the standard Ablo({ schema, apiKey }) client provides on every claim. This appears only when a claim is minted through an internal path that predates heartbeats.', { code: 'claim_not_wired' });
@@ -272,18 +297,16 @@ defaultWait) {
272
297
  const held = activeClaims.get(id);
273
298
  if (held)
274
299
  held.expiresAt = beat.expiresAt;
275
- options.onHeartbeat?.(beat);
300
+ plan.onBeat?.(beat);
276
301
  return beat;
277
302
  };
278
303
  // Opt-in auto-heartbeat: the loop beats until release, and a definitive
279
- // loss stops it and surfaces through `onHeartbeatLost`.
280
- const stopHeartbeatLoop = options.heartbeat
304
+ // loss stops it and surfaces through the plan's `onLost`.
305
+ const stopHeartbeatLoop = plan.loop
281
306
  ? startClaimHeartbeatLoop({
282
307
  beat: () => heartbeat(),
283
- intervalMs: heartbeatCadenceMs(ttlMs, options.heartbeat),
284
- ...(options.onHeartbeatLost
285
- ? { onLost: options.onHeartbeatLost }
286
- : {}),
308
+ intervalMs: heartbeatCadenceMs(ttlMs, plan.cadence),
309
+ ...(plan.onLost ? { onLost: plan.onLost } : {}),
287
310
  })
288
311
  : undefined;
289
312
  const release = () => {
@@ -325,19 +348,14 @@ defaultWait) {
325
348
  const held = collaboration.state({ model: wireModel, id });
326
349
  const contended = !!held && held.heldBy !== collaboration.selfParticipantId;
327
350
  const failFast = options.queue === false;
328
- // Fail-fast (`queue: false`): reject now if a holder is already visible.
329
- // Best-effort at the client — a row this participant never synced usually
330
- // carries no local claim state either, so a peer gets the deterministic
331
- // rejection only once it has observed the holder (entered the row's entity
332
- // scope). The server's queue is the backstop for the queuing path.
351
+ // The try-claim (`queue: false`): resolve `null` if a holder is already
352
+ // visible — an expected outcome, not an error. Best-effort at the client —
353
+ // a row this participant never synced usually carries no local claim state
354
+ // either, so a peer gets the deterministic `null` only once it has
355
+ // observed the holder (entered the row's entity scope). The server's
356
+ // queue is the backstop for the queuing path.
333
357
  if (failFast && contended) {
334
- const claim = claimContextFromClaim(held);
335
- throw new AbloClaimedError(formatClaimedErrorMessage({
336
- targetLabel: `${registeredModelName}/${id}`,
337
- heldBy: held.heldBy,
338
- claim,
339
- fallback: `${registeredModelName}/${id} is held by ${held.heldBy ?? 'another participant'}.`,
340
- }), { code: 'entity_claimed', claims: [claim] });
358
+ return null;
341
359
  }
342
360
  // Enter the entity scope before acquiring the lease so the holder's claim
343
361
  // presence broadcasts to everyone in this entity group — the same ordering
@@ -358,6 +376,10 @@ defaultWait) {
358
376
  ttl: options.ttl,
359
377
  queue: !failFast,
360
378
  maxQueueDepth: options.maxQueueDepth,
379
+ // The one wait cap, declared once on ClaimTargetOptions — the socket
380
+ // wait and the HTTP poll-wait both honor it as `grant_timeout`.
381
+ waitTimeoutMs: options.waitTimeoutMs,
382
+ signal: options.signal,
361
383
  });
362
384
  // A watermark-only snapshot: `createSnapshot` still reads the engine's
363
385
  // current `lastSyncId` even though the pool holds no row (the bucket is
@@ -384,6 +406,9 @@ defaultWait) {
384
406
  id,
385
407
  ...subTarget(options),
386
408
  };
409
+ // One reading of the heartbeat options — cadence and callbacks from
410
+ // whichever spelling the caller used.
411
+ const plan = resolveHeartbeatPlan(options);
387
412
  // A beat resolves with the server's extended expiry; keep the local
388
413
  // self-claim estimate in step so `claim.state` renders the real window.
389
414
  const heartbeat = async (beatOptions) => {
@@ -398,16 +423,14 @@ defaultWait) {
398
423
  const held = activeClaims.get(id);
399
424
  if (held)
400
425
  held.expiresAt = beat.expiresAt;
401
- options.onHeartbeat?.(beat);
426
+ plan.onBeat?.(beat);
402
427
  return beat;
403
428
  };
404
- const stopHeartbeatLoop = options.heartbeat
429
+ const stopHeartbeatLoop = plan.loop
405
430
  ? startClaimHeartbeatLoop({
406
431
  beat: () => heartbeat(),
407
- intervalMs: heartbeatCadenceMs(ttlMs, options.heartbeat),
408
- ...(options.onHeartbeatLost
409
- ? { onLost: options.onHeartbeatLost }
410
- : {}),
432
+ intervalMs: heartbeatCadenceMs(ttlMs, plan.cadence),
433
+ ...(plan.onLost ? { onLost: plan.onLost } : {}),
411
434
  })
412
435
  : undefined;
413
436
  const release = () => {
@@ -455,6 +478,33 @@ defaultWait) {
455
478
  // JSON into an interface. These read as `Claim` here, and the one assertion
456
479
  // that applies the caller's parameter is on the assignment below, in one
457
480
  // place rather than at every call site.
481
+ /**
482
+ * This client's own claim on a row, as a claim-state object.
483
+ *
484
+ * The server excludes a holder's own presence frames and the client skips
485
+ * them, so a row this client holds is absent from every peer-derived read.
486
+ * Both `state` and `list` therefore synthesize it from the stored lease, and
487
+ * they do it through here so the two answers cannot describe the same
488
+ * holding differently.
489
+ */
490
+ const ownClaimState = (id) => {
491
+ const own = activeClaims.get(id);
492
+ if (!own)
493
+ return null;
494
+ return {
495
+ object: 'claim',
496
+ id: own.lease.id,
497
+ status: 'active',
498
+ target: own.target,
499
+ description: own.description,
500
+ heldBy: collaboration?.selfParticipantId ?? '',
501
+ participantKind: collaboration?.selfParticipantKind ?? 'user',
502
+ expiresAt: own.expiresAt,
503
+ // Symmetric with the peer projection: a holder reading its own claim
504
+ // sees the same `meta` an observer does.
505
+ ...(own.target.meta !== undefined ? { meta: own.target.meta } : {}),
506
+ };
507
+ };
458
508
  const claimReaders = {
459
509
  state(params) {
460
510
  // Read interest: a passive observer of a row's claim state must enter that
@@ -466,20 +516,27 @@ defaultWait) {
466
516
  // the client skips them, so `state` would return null for a row this client
467
517
  // holds. Synthesize the active claim from the stored lease so the holder
468
518
  // sees its own claim, honoring the documented contract on `claim.state`.
469
- const own = activeClaims.get(params.id);
470
- if (own) {
471
- return {
472
- object: 'claim',
473
- id: own.lease.id,
474
- status: 'active',
475
- target: own.target,
476
- description: own.description,
477
- heldBy: collaboration?.selfParticipantId ?? '',
478
- participantKind: collaboration?.selfParticipantKind ?? 'user',
479
- expiresAt: own.expiresAt,
480
- };
481
- }
482
- return collaboration?.state({ model: wireModel, id: params.id }) ?? null;
519
+ return (ownClaimState(params.id) ??
520
+ collaboration?.state({ model: wireModel, id: params.id }) ??
521
+ null);
522
+ },
523
+ /**
524
+ * Every claim on the row, holders first. Sub-row claims on disjoint parts
525
+ * are all granted, so a row can have several holders at once and
526
+ * {@link state} answers with one of them — this is the read that renders
527
+ * all of them. Same list envelope as {@link queue}, reactive on the same
528
+ * snapshot, so a render reads it inline.
529
+ */
530
+ list(params) {
531
+ void collaboration?.enterScope?.({ [schemaKey]: params.id });
532
+ const own = ownClaimState(params.id);
533
+ const peers = collaboration?.holders({ model: wireModel, id: params.id }) ?? [];
534
+ return {
535
+ object: 'list',
536
+ // Own claim first: the server excludes a holder's own presence frames,
537
+ // so it is never among `peers` and the two never duplicate.
538
+ data: own ? [own, ...peers] : [...peers],
539
+ };
483
540
  },
484
541
  queue(params) {
485
542
  return {
@@ -669,6 +726,11 @@ defaultWait) {
669
726
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
670
727
  if (autoClaim) {
671
728
  const handle = await takeClaim({ ...autoClaim, id: params.id });
729
+ // A declined try-claim is `null` only on the standalone verb; a
730
+ // write that could not take its claim is a failed write.
731
+ if (!handle) {
732
+ throw new AbloClaimedError(`${registeredModelName}/${params.id} is held by another participant, so this update's claim could not be taken.`, { code: 'entity_claimed' });
733
+ }
672
734
  try {
673
735
  return await operations.update({ ...params, claim: handle });
674
736
  }
@@ -728,6 +790,10 @@ defaultWait) {
728
790
  const autoClaim = params.claim && !isClaimHandle(params.claim) ? params.claim : null;
729
791
  if (autoClaim) {
730
792
  const handle = await takeClaim({ ...autoClaim, id: params.id });
793
+ // Same rule as update: a write that could not take its claim fails.
794
+ if (!handle) {
795
+ throw new AbloClaimedError(`${registeredModelName}/${params.id} is held by another participant, so this delete's claim could not be taken.`, { code: 'entity_claimed' });
796
+ }
731
797
  try {
732
798
  await operations.delete({ ...params, claim: handle });
733
799
  }
@@ -10,19 +10,29 @@
10
10
  * Transitional shape: the contract fields do real configuration work
11
11
  * (`requires.duplex` rejects it on a request-response client at construction
12
12
  * time; `materialises` is the five-second membership test as a checkable
13
- * property), and `init` constructs what the widened context already carries
14
- * enough to build — the presence stream, attached to the connection the
15
- * context carries (the host built it before resolving the plugin list). The
16
- * rest of the materialiser (store, bootstrap, delta pipeline, framework
17
- * bindings) still lives in the composition root and migrates here as the
18
- * context grows to carry what it needs.
13
+ * property), and `init` constructs what the widened context carries enough
14
+ * to build — the presence stream, attached to the connection the context
15
+ * carries, and the store cluster (this client's runtime, the component
16
+ * graph, the registered models, and the `BaseSyncedStore`), now that the
17
+ * context carries the resolved url and the credential source. What still
18
+ * lives in the composition root the credential lifecycle, `ready()`, the
19
+ * model proxies, the resources, the framework bindings — migrates here as
20
+ * the context grows to carry what it needs.
19
21
  */
20
- import type { PluginContext } from '../transaction/plugin.js';
22
+ import type { PluginContext, AppliedChange } from '../transaction/plugin.js';
23
+ import type { SchemaRecord } from '../transaction/schema/schema.js';
21
24
  import { type AttachablePresenceStream } from '../sync/createPresenceStream.js';
25
+ import { kStoreCluster, type StoreCluster } from './storeCluster.js';
26
+ import type { InternalAbloOptions } from './options.js';
22
27
  /**
23
28
  * What `humans()` contributes to the client — exactly the members that merge
24
29
  * onto it, nothing else. Whether a plugin materialises is declared on the
25
30
  * plugin itself, never restated on its surface.
31
+ *
32
+ * The one exception is deliberate and invisible to merging:
33
+ * {@link kStoreCluster} is symbol-keyed, and surface members merge onto the
34
+ * client by string key alone — so the cluster reaches the host without ever
35
+ * becoming client API.
26
36
  */
27
37
  export interface HumansSurface {
28
38
  /**
@@ -31,6 +41,13 @@ export interface HumansSurface {
31
41
  * the wire send, and the stream re-announces on every connect.
32
42
  */
33
43
  readonly presence: AttachablePresenceStream;
44
+ /**
45
+ * The constructed store cluster, when the context carried enough to build
46
+ * it (the connection, the resolved url, the credential source, and a
47
+ * schema). Absent on a thinner context — the presence stream's own
48
+ * tolerance, applied to the store.
49
+ */
50
+ readonly [kStoreCluster]?: StoreCluster;
34
51
  }
35
52
  /**
36
53
  * The reactive materialiser. Installed by default when `Ablo({ ... })` is
@@ -43,6 +60,10 @@ export declare function humans(): {
43
60
  readonly duplex: true;
44
61
  };
45
62
  readonly materialises: true;
46
- readonly stage: "apply";
47
- readonly init: (context: PluginContext) => HumansSurface;
63
+ readonly stages: {
64
+ readonly apply: ({ changes }: {
65
+ readonly changes: readonly AppliedChange[];
66
+ }) => void;
67
+ };
68
+ readonly init: (context: PluginContext<InternalAbloOptions<SchemaRecord>>) => HumansSurface;
48
69
  };
@@ -10,20 +10,28 @@
10
10
  * Transitional shape: the contract fields do real configuration work
11
11
  * (`requires.duplex` rejects it on a request-response client at construction
12
12
  * time; `materialises` is the five-second membership test as a checkable
13
- * property), and `init` constructs what the widened context already carries
14
- * enough to build — the presence stream, attached to the connection the
15
- * context carries (the host built it before resolving the plugin list). The
16
- * rest of the materialiser (store, bootstrap, delta pipeline, framework
17
- * bindings) still lives in the composition root and migrates here as the
18
- * context grows to carry what it needs.
13
+ * property), and `init` constructs what the widened context carries enough
14
+ * to build — the presence stream, attached to the connection the context
15
+ * carries, and the store cluster (this client's runtime, the component
16
+ * graph, the registered models, and the `BaseSyncedStore`), now that the
17
+ * context carries the resolved url and the credential source. What still
18
+ * lives in the composition root the credential lifecycle, `ready()`, the
19
+ * model proxies, the resources, the framework bindings — migrates here as
20
+ * the context grows to carry what it needs.
19
21
  */
22
+ import { AbloValidationError } from '../transaction/errors.js';
20
23
  import { createPresenceStream, } from '../sync/createPresenceStream.js';
24
+ import { buildStoreCluster, kStoreCluster, } from './storeCluster.js';
21
25
  /**
22
26
  * The reactive materialiser. Installed by default when `Ablo({ ... })` is
23
27
  * constructed over the socket with no `plugins` list; listed explicitly, it
24
28
  * reads as what it is — this client renders for people.
25
29
  */
26
30
  export function humans() {
31
+ // The apply handler's target. `init` binds it to the store it constructs;
32
+ // until then the handler is a no-op, and nothing dispatches before then —
33
+ // the store itself is the pipeline that dispatches.
34
+ let applyChanges = null;
27
35
  return {
28
36
  id: 'humans',
29
37
  // Live queries, presence, and claim push are server-initiated frames, so
@@ -33,19 +41,37 @@ export function humans() {
33
41
  // The point of the plugin: it keeps a local copy of rows. A stateless
34
42
  // caller can assert its own list is uniformly false.
35
43
  materialises: true,
36
- // Deltas land in the materialised in-memory graph at `apply`.
37
- stage: 'apply',
38
- init: (context) => ({
39
- presence: createPresenceStream({
40
- participantId: context.participant?.id ?? '',
41
- syncGroups: [...(context.syncGroups ?? [])],
42
- // Peers' kinds are server-stamped; only the local `self` entry is ours.
43
- isAgent: context.participant?.kind === 'agent',
44
- },
45
- // The host built the connection before resolving the plugin list, so
46
- // the stream attaches now and starts the moment the feed opens.
47
- context.transport ?? null),
48
- }),
44
+ stages: {
45
+ // Deltas land in the materialised in-memory graph at `apply` — the
46
+ // declaration and the handler are one field, so this cannot name a
47
+ // stage and forget the work.
48
+ apply: ({ changes }) => { applyChanges?.(changes); },
49
+ },
50
+ init: (context) => {
51
+ const cluster = buildStoreCluster(context);
52
+ if (cluster) {
53
+ // One client per humans() instance: the apply handler routes into
54
+ // the store this init built, so a shared instance would silently
55
+ // apply one client's deltas into another client's store.
56
+ if (applyChanges) {
57
+ throw new AbloValidationError('This humans() instance is already installed on a client. ' +
58
+ 'Construct a fresh humans() for each Ablo({ ... }) call.', { code: 'invalid_options', param: 'plugins' });
59
+ }
60
+ applyChanges = (changes) => { cluster.store.applyChangesToPool(changes); };
61
+ }
62
+ return {
63
+ presence: createPresenceStream({
64
+ participantId: context.participant?.id ?? '',
65
+ syncGroups: [...(context.syncGroups ?? [])],
66
+ // Peers' kinds are server-stamped; only the local `self` entry is ours.
67
+ isAgent: context.participant?.kind === 'agent',
68
+ },
69
+ // The host built the connection before resolving the plugin list, so
70
+ // the stream attaches now and starts the moment the feed opens.
71
+ context.transport ?? null),
72
+ ...(cluster ? { [kStoreCluster]: cluster } : {}),
73
+ };
74
+ },
49
75
  // `satisfies` rather than a return annotation, so `id` keeps its literal
50
76
  // type and a plugin list's surface can be keyed by id at the type level.
51
77
  };
@@ -1,18 +1,18 @@
1
1
  /**
2
- * The reactive engine build everything `humans()` means at construction
3
- * time (ADR 0016). `Ablo({ ... })` resolves auth and capabilities, then hands
4
- * this module the prelude; from here on it is all materialisation: the
5
- * runtime context, the internal components, the store and its credential
6
- * lifecycle, presence and claim streams, the typed model proxies, and the
7
- * commit/claim/session resources — assembled into the reactive client.
2
+ * The reactive engine assembly (ADR 0016). `Ablo({ ... })` resolves auth and
3
+ * capabilities; `humans().init` constructs the store cluster; the lifecycle
4
+ * first mint, identity, ready() lives in `./storeLifecycle.ts`. What
5
+ * remains here is assembly around those parts: the claim stream and
6
+ * participant manager, options validation, the typed model proxies, and the
7
+ * commit/claim/session resources — composed into the reactive client.
8
8
  *
9
9
  * Extracted from the factory so the composition root stays a root: resolve,
10
- * dispatch, return. The construction moves behind `humans().init` proper when
11
- * the plugin context can carry these inputsuntil then the factory calls
12
- * this directly for the humans-installed path.
10
+ * dispatch, return. The remaining assembly converts to decoration of a
11
+ * host-built core client with the per-model surface split — the cut's own
12
+ * design step (docs/plans/package-split.md).
13
13
  */
14
14
  import type { SchemaRecord } from '../transaction/schema/schema.js';
15
- import type { MutationExecutor } from '../interfaces/index.js';
15
+ import type { StoreCluster } from './storeCluster.js';
16
16
  import type { SyncWebSocket } from '../sync/SyncWebSocket.js';
17
17
  import type { AttachablePresenceStream } from '../sync/createPresenceStream.js';
18
18
  import type { AbloOptions } from './options.js';
@@ -28,7 +28,6 @@ import type { Ablo } from './Ablo.js';
28
28
  */
29
29
  export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrelude<S> {
30
30
  options: AbloOptions<S>;
31
- executor: MutationExecutor;
32
31
  /**
33
32
  * The connection, constructed by the factory before the plugin list
34
33
  * resolved — the same instance `PluginContext.transport` carries. The
@@ -38,6 +37,12 @@ export interface ReactiveEngineInputs<S extends SchemaRecord> extends ClientPrel
38
37
  /** The humans() plugin's contribution — built by its `init`, already
39
38
  * attached to the connection the context carried. */
40
39
  presence: AttachablePresenceStream;
40
+ /**
41
+ * The store cluster `humans().init` constructed from the widened context:
42
+ * this client's runtime, the component graph, and the store. The engine
43
+ * assembles around it and constructs none of it.
44
+ */
45
+ cluster: StoreCluster;
41
46
  /**
42
47
  * Constructs a sibling client (`ablo.agents.create(...)` mints a scoped key
43
48
  * and builds a second engine with it). Injected by the factory — a direct