@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
@@ -37,10 +37,28 @@
37
37
  * lowercased them.
38
38
  */
39
39
  function fieldSet(target) {
40
- const names = [
40
+ const named = [
41
41
  ...(target?.field !== undefined ? [target.field] : []),
42
42
  ...(target?.fields ?? []),
43
43
  ];
44
+ // A path names a part too: `/content/3` addresses a position inside the
45
+ // `content` field, so a claim on it covers that field and nothing else.
46
+ //
47
+ // Deriving it here is what stops a path from reading as EVERY field. A
48
+ // target that named no field left this set empty, and an empty set means
49
+ // "all of them" — so a claim on one paragraph blocked a write to `status`
50
+ // on the same row. It also makes the exclusion honest in the other
51
+ // direction: a write of the whole `content` field does conflict with a
52
+ // claim on part of it, because nothing can write only that part.
53
+ //
54
+ // Only when no field was named outright — an explicit `field`/`fields`
55
+ // beside a path is the caller being specific, and is not second-guessed.
56
+ // A bare `range` derives nothing: it names a span without saying of what,
57
+ // so it stays the conservative whole-row answer.
58
+ const rootOfPath = named.length === 0 && target?.path !== undefined
59
+ ? target.path.split('/').filter(Boolean)[0]
60
+ : undefined;
61
+ const names = rootOfPath !== undefined ? [...named, rootOfPath] : named;
44
62
  return new Set(names.map((n) => n.toLowerCase()));
45
63
  }
46
64
  /** Whether a claim narrows below the whole entity at all. */
@@ -50,30 +68,6 @@ function hasSubtarget(target) {
50
68
  (target?.fields && target.fields.length > 0) ||
51
69
  target?.range);
52
70
  }
53
- function lower(value) {
54
- return value?.toLowerCase();
55
- }
56
- /**
57
- * Whether two paths address overlapping parts of a document.
58
- *
59
- * Paths are hierarchical, so equality is the wrong test: `/content` contains
60
- * `/content/3`, and a writer holding the parent is holding the child too.
61
- * Comparing them as opaque strings reported no conflict and granted both
62
- * leases — the parent's writer and the child's writer would then both write,
63
- * and one update would vanish with nothing raised anywhere.
64
- *
65
- * Containment is checked on a separator boundary so `/content` contains
66
- * `/content/3` but not `/contentious`, which is the same trap as any
67
- * prefix match over structured names.
68
- */
69
- function pathsOverlap(a, b) {
70
- const x = lower(a) ?? '';
71
- const y = lower(b) ?? '';
72
- if (x === y)
73
- return true;
74
- const contains = (parent, child) => child.startsWith(parent.endsWith('/') ? parent : `${parent}/`);
75
- return contains(x, y) || contains(y, x);
76
- }
77
71
  function rangesOverlap(a, b) {
78
72
  // Line-level overlap is the stable default. Columns can be layered
79
73
  // in by policy later, but line ranges are what code/editor agents
@@ -85,9 +79,19 @@ export function targetsConflict(held, incoming) {
85
79
  // narrower path/field/range claim under the same entity.
86
80
  if (!hasSubtarget(held) || !hasSubtarget(incoming))
87
81
  return true;
88
- if (held.path && incoming?.path && !pathsOverlap(held.path, incoming.path)) {
89
- return false;
90
- }
82
+ // A claim is only as fine as the smallest thing the write path can address,
83
+ // and today that is a field: nothing writes part of a value. So two paths
84
+ // into the SAME field contend, and this rule used to grant them both — by
85
+ // comparing paths for containment and answering "disjoint" for `/content/3`
86
+ // against `/content/7`. Both holders were then blocked at their first write,
87
+ // each by the other, which is a wait dressed as concurrency and resolves
88
+ // only when someone releases.
89
+ //
90
+ // Both are answered below instead, through the field a path names: same
91
+ // field, conflict; different fields, disjoint. Path containment returns when
92
+ // a field can declare a smaller write unit than its whole value — an
93
+ // operation rather than a value — because that is what makes two positions
94
+ // inside one field separately writable and the disjointness real.
91
95
  // A claim that names no field covers every field, so it conflicts with any
92
96
  // claim under the same path. Two that both name fields conflict only where
93
97
  // their sets intersect.
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * error documentation and returned on the `Ablo-Version` response header, so a
38
38
  * consumer can detect when its expected contract has drifted from the server's.
39
39
  */
40
- export declare const ERROR_CONTRACT_VERSION = "2026-07-21";
40
+ export declare const ERROR_CONTRACT_VERSION = "2026-07-23";
41
41
  /** A coarse grouping of error codes, used to organize metrics and documentation. */
42
42
  export type ErrorCategory = 'auth' | 'permission' | 'capability' | 'claim' | 'conflict' | 'validation' | 'not_found' | 'tenant' | 'schema' | 'bootstrap' | 'transport' | 'rate_limit' | 'server' | 'client';
43
43
  /**
@@ -227,7 +227,6 @@ export declare const ERROR_CODES: {
227
227
  readonly claim_not_wired: ErrorCodeSpec;
228
228
  readonly claim_queued: ErrorCodeSpec;
229
229
  readonly claim_wait_aborted: ErrorCodeSpec;
230
- readonly claim_wait_poll_interval_required: ErrorCodeSpec;
231
230
  readonly grant_timeout: ErrorCodeSpec;
232
231
  readonly bootstrap_fetch_timeout: ErrorCodeSpec;
233
232
  readonly bootstrap_cancelled: ErrorCodeSpec;
@@ -360,6 +359,7 @@ export declare const ERROR_CODES: {
360
359
  readonly request_too_large: ErrorCodeSpec;
361
360
  readonly invalid_schema: ErrorCodeSpec;
362
361
  readonly incompatible_change: ErrorCodeSpec;
362
+ readonly replication_reset_required: ErrorCodeSpec;
363
363
  };
364
364
  /**
365
365
  * The type of a valid error code: any key registered in {@link ERROR_CODES},
@@ -37,7 +37,7 @@ import { z } from 'zod';
37
37
  * error documentation and returned on the `Ablo-Version` response header, so a
38
38
  * consumer can detect when its expected contract has drifted from the server's.
39
39
  */
40
- export const ERROR_CONTRACT_VERSION = '2026-07-21';
40
+ export const ERROR_CONTRACT_VERSION = '2026-07-23';
41
41
  /**
42
42
  * A closed classification of how a failure can be recovered from — a level above
43
43
  * the raw {@link ErrorCode}. Where a code says what went wrong, a recovery class
@@ -150,15 +150,19 @@ export const ERROR_CODES = {
150
150
  // only loop. Recovery belongs to the caller: take a claim, which queues fairly
151
151
  // behind the holder (`ablo.<model>.claim`), or re-read and rebase.
152
152
  claim_conflict: wire('claim', 409, false, 'Another participant holds a claim on this row, so the write was rejected. Take a claim with `ablo.<model>.claim` to queue fairly behind the holder, or re-read and rebase.'),
153
- claim_lost: wire('claim', 409, false, 'The claim held on this row was lost before the write could apply. Re-acquire the claim and retry.'),
153
+ claim_lost: wire('claim', 409, false, 'The claim held on this row was lost before the write could apply. Re-acquire with `ablo.<model>.claim` and retry from its fresh snapshot.'),
154
154
  fence_token_stale: wire('claim', 409, false, 'This write carried a fencing token below the row’s current high-water: a later holder claimed the row, wrote, and moved on while this claim was lapsed, so applying the write would silently overwrite their work. The claim is gone — re-claim the row and retry from the current state.'),
155
155
  entity_claimed: wire('claim', 409, false, 'This row is currently claimed by another participant, so the write was blocked. Queue behind the holder with `ablo.<model>.claim`, or wait for the claim to clear.'),
156
156
  // A claim payload that cannot be parsed is a malformed request, not
157
157
  // contention — it is filed with `malformed_subscription` below rather than
158
158
  // with the 409s above, so the claim category stays purely about a target
159
159
  // being held. This is the code for a claim that fails to name its target,
160
- // over either transport.
161
- malformed_claim: wire('validation', 400, false, 'The claim payload could not be parsed. A claim must name the model and the entity it targets; check the payload shape and resend.'),
160
+ // over either transport — and for the participant claim behind `join`, which
161
+ // names scopes rather than a row but fails the same way and to the same
162
+ // caller. The copy covers both because the caller sees one word, `claim`, and
163
+ // needs to be told which shape was expected without being handed a lecture on
164
+ // the two frames.
165
+ malformed_claim: wire('validation', 400, false, 'The claim payload could not be parsed. A claim on a row must name the model and the entity it targets; a claim on a scope, which is what `join` opens, must name sync groups spelled `kind:id` or `default`. Check the payload shape and resend.'),
162
166
  malformed_subscription: wire('validation', 400, false, 'The `update_subscription` payload was malformed; expected `{ syncGroups: string[] }`.'),
163
167
  // The counterpart to the two above, pointing the other way: those are a
164
168
  // client sending the server something it cannot read, this is the server
@@ -177,7 +181,7 @@ export const ERROR_CODES = {
177
181
  // `readAt`, so resending the identical payload can never succeed. Recovery
178
182
  // is a caller-level re-read that produces a NEW request with a fresh
179
183
  // watermark — the same shape as `claim_conflict`.
180
- stale_context: wire('conflict', 409, false, "The row changed after you read it — the write's `readAt` watermark is older than the current row version. Re-read the row and retry."),
184
+ stale_context: wire('conflict', 409, false, "The row changed after you read it — the write's `readAt` watermark is older than the current row version. Pass a function to `update(id, current => next)` and the SDK re-reads and retries for you; or re-read and retry by hand."),
181
185
  // Raised by the functional `update(id, current => next)` form once its
182
186
  // internal reconcile budget is exhausted, because the row stayed continuously
183
187
  // contended. The SDK has already retried; the caller decides whether to back
@@ -261,10 +265,9 @@ export const ERROR_CODES = {
261
265
  // ── claim / lease (409 / transport) ───────────────────────────────
262
266
  claim_lease_unavailable: wire('claim', 503, true, 'The claim-lease coordination subsystem is temporarily unavailable, so the claim could not be processed. Retry shortly.'),
263
267
  claim_not_wired: client('claim', 'Claims were used, but this runtime has no claim support wired in. The standard `Ablo({ schema, apiKey })` client wires it up automatically.'),
264
- claim_queued: wire('claim', 409, true, 'The claim was queued behind the current lease holder and will be granted in turn. Wait, or read `claim.queue` to see your position.'),
268
+ claim_queued: wire('claim', 409, true, 'The claim was queued behind the current lease holder and will be granted in turn. Poll `claims.retrieve({ claimId })` for the grant — the id rides on the error — or read `claim.queue` to see the line.'),
265
269
  claim_wait_aborted: wire('claim', 409, true, 'The wait for this claim lease was aborted before the lease was granted.'),
266
- claim_wait_poll_interval_required: client('claim', 'Waiting on a claim requires a poll interval, and none was provided.'),
267
- grant_timeout: wire('claim', 504, true, 'The wait for a capability grant timed out before one arrived. Retry the request.'),
270
+ grant_timeout: wire('claim', 504, true, 'The wait for the claim grant timed out before your turn arrived (`waitTimeoutMs`). Claim again to rejoin the line, raise the cap, or re-read and proceed without the claim.'),
268
271
  // ── bootstrap (transport) ──────────────────────────────────────────
269
272
  bootstrap_fetch_timeout: wire('bootstrap', 504, true, 'The initial bootstrap fetch timed out before the server responded. Retry shortly.'),
270
273
  // Deliberate cancellation, never a failure of the network. It is what the
@@ -289,7 +292,7 @@ export const ERROR_CODES = {
289
292
  commit_failed: wire('transport', 500, true, 'The commit reached the server but failed to apply. Retrying may succeed.'),
290
293
  replication_lag_timeout: wire('transport', 504, true, "The data source accepted the write, but its correlated authoritative source delta did not arrive before the confirmation deadline. The write may still materialize; retry with the same idempotency key or wait for source ingestion to recover."),
291
294
  commit_offline_grace_expired: wire('transport', 503, false, 'The offline grace window expired before this commit could be sent, so it was not applied. Re-apply the change once the connection returns.'),
292
- queue_too_deep: wire('transport', 503, true, 'The transaction queue is over its depth limit, so new writes are being rejected until it drains. Retry shortly.'),
295
+ queue_too_deep: wire('transport', 503, true, 'The line is already past its depth limit. For a claim, more participants were waiting than your `maxQueueDepth` allows — claim again without the cap to wait anyway, or work elsewhere and retry later. For a write, the transaction queue is draining — retry shortly.'),
293
296
  flush_timeout: wire('transport', 504, true, 'Flushing the transaction queue timed out before every pending write was sent. Retry once connectivity stabilizes.'),
294
297
  wait_for_timeout: wire('transport', 504, true, 'A wait-for condition timed out before it was satisfied. Retry, or extend the timeout.'),
295
298
  instance_at_capacity: wire('transport', 503, true, 'The server is at connection capacity. Retry shortly — transient and not specific to your credentials.'),
@@ -421,6 +424,7 @@ export const ERROR_CODES = {
421
424
  request_too_large: wire('validation', 413, false, 'The request body exceeds the maximum size.'),
422
425
  invalid_schema: wire('validation', 400, false, 'The submitted schema could not be parsed.'),
423
426
  incompatible_change: wire('conflict', 409, false, 'The schema change is incompatible with the schema currently deployed and cannot be applied as-is.'),
427
+ replication_reset_required: wire('conflict', 409, false, 'A connected log plane has live rows for a mapped model that disappeared. Declare a rename or an explicit drop/reset before advancing the schema.'),
424
428
  };
425
429
  /** Looks up the {@link ErrorCodeSpec} for a code. Returns `undefined` for the
426
430
  * dynamic `policy:*` family and for any newer code this client does not yet
@@ -18,6 +18,9 @@ import type { ErrorCodeSpec } from './errorCodes.js';
18
18
  import type { Logger } from './logger.js';
19
19
  import type { CoordinationObservability } from './observability.js';
20
20
  import type { WsTransport } from './transport/wsTransport.js';
21
+ import type { AuthCredentialSource } from './auth/credentialSource.js';
22
+ import type { SyncDeltaWireCore } from './wire/delta.js';
23
+ import type { ModelData } from './types/modelData.js';
21
24
  /**
22
25
  * A string type that keeps literal inference alive: `id: 'humans'` stays the
23
26
  * literal `'humans'` through object-literal inference instead of widening to
@@ -51,6 +54,59 @@ export type PipelineStage =
51
54
  | 'notify';
52
55
  /** Canonical order. A plugin runner must run stages in this sequence. */
53
56
  export declare const PIPELINE_STAGES: readonly PipelineStage[];
57
+ /**
58
+ * A delta as stage handlers receive it — the identity slice of the wire
59
+ * shape, projected rather than restated so the wire schema stays the one
60
+ * definition.
61
+ */
62
+ export type StageDelta = Pick<SyncDeltaWireCore, 'id' | 'actionType' | 'modelName' | 'modelId'>;
63
+ /**
64
+ * One persisted change: what the durable write reported and the apply stage
65
+ * lands in the in-memory graph. `data` is absent for actions that carry
66
+ * none (a remove reports identity only).
67
+ */
68
+ export interface AppliedChange {
69
+ action: 'add' | 'update' | 'remove' | 'archive' | 'verify';
70
+ modelName: string;
71
+ modelId: string;
72
+ data?: ModelData | null;
73
+ /**
74
+ * Server-stamped transaction id, echoing the client's own commit
75
+ * operation id — how a client recognizes the confirmation of a change it
76
+ * already applied locally. Absent for system-emitted changes, which have
77
+ * no client transaction behind them.
78
+ */
79
+ transactionId?: string;
80
+ }
81
+ /**
82
+ * What each stage hands its handlers. Read off the delta pipeline these
83
+ * stages were read off, not invented: `receive` sees the delta being
84
+ * queued, `dedupe` and `persist` see the batch at those boundaries,
85
+ * `apply` and `notify` see the persisted changes, and `acknowledge` sees
86
+ * the persistence-gated cursor value.
87
+ */
88
+ export interface StagePayloads {
89
+ receive: {
90
+ readonly delta: StageDelta;
91
+ };
92
+ dedupe: {
93
+ readonly deltas: readonly StageDelta[];
94
+ };
95
+ persist: {
96
+ readonly deltas: readonly StageDelta[];
97
+ };
98
+ apply: {
99
+ readonly changes: readonly AppliedChange[];
100
+ };
101
+ acknowledge: {
102
+ readonly syncId: number;
103
+ };
104
+ notify: {
105
+ readonly changes: readonly AppliedChange[];
106
+ };
107
+ }
108
+ /** The handler a plugin attaches at one stage. The payload derives from the key. */
109
+ export type StageHandler<K extends PipelineStage> = (payload: StagePayloads[K]) => void;
54
110
  /**
55
111
  * What a capability needs from whatever transport was selected.
56
112
  *
@@ -97,6 +153,28 @@ export interface PluginContext<Options = unknown> {
97
153
  };
98
154
  /** The connection's initial read scope (sync groups). */
99
155
  readonly syncGroups?: readonly string[];
156
+ /** The resolved server base URL, settled by the host before construction. */
157
+ readonly url?: string;
158
+ /**
159
+ * The client's single bearer-credential source, shared with every
160
+ * auth-aware transport the host built. A plugin that constructs its own
161
+ * fetching component hands this on rather than re-deriving a credential.
162
+ */
163
+ readonly auth?: AuthCredentialSource;
164
+ /**
165
+ * Whether a plugin with the given id is installed on this client. Supplied
166
+ * by {@link resolvePlugins} from the list it is resolving — the one place
167
+ * that truthfully knows the assembly — so a plugin interrogates what is
168
+ * installed instead of the host special-casing plugins by name.
169
+ */
170
+ readonly hasPlugin?: (id: string) => boolean;
171
+ /**
172
+ * The resolved plugin list itself, supplied by {@link resolvePlugins}
173
+ * alongside {@link hasPlugin} (which derives from it). A component a
174
+ * plugin constructs — the store and its delta pipeline — holds this list
175
+ * to dispatch the declared stage handlers through {@link runStage}.
176
+ */
177
+ readonly plugins?: readonly AbloPlugin[];
100
178
  }
101
179
  /**
102
180
  * One installed capability.
@@ -130,8 +208,16 @@ export interface AbloPlugin<Surface = unknown> {
130
208
  * hole in the published reference.
131
209
  */
132
210
  readonly errorCodes?: Readonly<Record<string, ErrorCodeSpec>>;
133
- /** Which pipeline stage this plugin attaches to, if it observes deltas. */
134
- readonly stage?: PipelineStage;
211
+ /**
212
+ * The pipeline stages this plugin attaches to: each key names a stage and
213
+ * holds the handler the runner invokes there. One field on purpose — a
214
+ * declared stage cannot exist without its handler, nor a handler without
215
+ * its stage, so the declaration can never point at nothing. The payload
216
+ * type derives from the key ({@link StagePayloads}).
217
+ */
218
+ readonly stages?: {
219
+ readonly [K in PipelineStage]?: StageHandler<K>;
220
+ };
135
221
  /** Build the plugin's contribution to the client surface. */
136
222
  init(context: PluginContext): Surface;
137
223
  }
@@ -189,4 +275,11 @@ export declare function layerPluginSurface<T extends object>(base: T, installed:
189
275
  * to choose and carries no correctness weight.
190
276
  */
191
277
  export declare function pluginsForStage(plugins: readonly AbloPlugin[], stage: PipelineStage): readonly AbloPlugin[];
278
+ /**
279
+ * Invokes every handler declared for one stage, in declaration order, with
280
+ * that stage's payload. No handlers declared is a plain no-op, so a pipeline
281
+ * dispatches unconditionally at each boundary and pays nothing when the
282
+ * stage is unclaimed.
283
+ */
284
+ export declare function runStage<K extends PipelineStage>(plugins: readonly AbloPlugin[], stage: K, payload: StagePayloads[K]): void;
192
285
  export {};
@@ -33,6 +33,14 @@ export const PIPELINE_STAGES = [
33
33
  export function resolvePlugins(plugins, transport, context) {
34
34
  const surface = {};
35
35
  const seen = new Set();
36
+ // The assembly, injected from the list being resolved. Always this list's
37
+ // own answer — a host-supplied `plugins` or `hasPlugin` could disagree
38
+ // with the assembly it describes, so both are replaced, not deferred to.
39
+ const initContext = {
40
+ ...context,
41
+ plugins,
42
+ hasPlugin: (id) => plugins.some((plugin) => plugin.id === id),
43
+ };
36
44
  for (const plugin of plugins) {
37
45
  if (seen.has(plugin.id)) {
38
46
  throw new AbloValidationError(`The ${plugin.id} plugin is listed twice. Remove the duplicate; a plugin is installed once per client.`, { code: 'invalid_options', param: 'plugins' });
@@ -41,7 +49,7 @@ export function resolvePlugins(plugins, transport, context) {
41
49
  if (plugin.requires?.duplex && !transport.duplex) {
42
50
  throw new AbloValidationError(`The ${plugin.id} plugin needs a connection the server can send on, and this client is set up for request-response only. Switch the transport to 'websocket', or drop the plugin.`, { code: 'invalid_options', param: 'plugins' });
43
51
  }
44
- surface[plugin.id] = plugin.init(context);
52
+ surface[plugin.id] = plugin.init(initContext);
45
53
  }
46
54
  return surface;
47
55
  }
@@ -83,5 +91,16 @@ export function layerPluginSurface(base, installed) {
83
91
  * to choose and carries no correctness weight.
84
92
  */
85
93
  export function pluginsForStage(plugins, stage) {
86
- return plugins.filter((plugin) => plugin.stage === stage);
94
+ return plugins.filter((plugin) => plugin.stages?.[stage] !== undefined);
95
+ }
96
+ /**
97
+ * Invokes every handler declared for one stage, in declaration order, with
98
+ * that stage's payload. No handlers declared is a plain no-op, so a pipeline
99
+ * dispatches unconditionally at each boundary and pays nothing when the
100
+ * stage is unclaimed.
101
+ */
102
+ export function runStage(plugins, stage, payload) {
103
+ for (const plugin of plugins) {
104
+ plugin.stages?.[stage]?.(payload);
105
+ }
87
106
  }
@@ -4,7 +4,8 @@
4
4
  * {@link HttpClaimApi} derivation. This module holds only types and has no runtime
5
5
  * imports.
6
6
  */
7
- import type { OnStaleMode, ReadDependency, TrackDependency } from '../coordination/schema.js';
7
+ import type { ClaimHeartbeatAckPayload, OnStaleMode, ReadDependency, TrackDependency } from '../coordination/schema.js';
8
+ import type { ClaimHeartbeatReply, ClaimState } from '../wire/claims.js';
8
9
  import type { ClientCommitReceipt, CommitWait } from '../wire/commit.js';
9
10
  export type { CommitWait };
10
11
  import type { ModelTarget, ModelClaim } from '../coordination/schema.js';
@@ -68,6 +69,9 @@ export interface ClaimCreateOptions {
68
69
  readonly queue?: boolean;
69
70
  /** Cap on how long to wait for a queued grant before rejecting. */
70
71
  readonly waitTimeoutMs?: number;
72
+ /** Abort a pending wait from outside — rejects with `claim_wait_aborted`.
73
+ * Ignored once the grant has arrived. */
74
+ readonly signal?: AbortSignal;
71
75
  /**
72
76
  * Backpressure: reject with `AbloClaimedError('queue_too_deep')` instead of
73
77
  * waiting if the queue is already `>= maxQueueDepth` when we join.
@@ -131,6 +135,55 @@ export interface ClaimResource extends ClaimStream {
131
135
  list(target?: Partial<ModelTarget>): readonly ModelClaim[];
132
136
  waitFor(target: Partial<ModelTarget>, options?: ClaimWaitOptions): Promise<void>;
133
137
  }
138
+ /**
139
+ * The claim-ticket surface of the stateless HTTP client — the operations a
140
+ * caller performs holding only a `claimId`, which is what a queued acquire
141
+ * leaves in its hand (`AbloClaimedError('claim_queued')` carries it on
142
+ * `error.claims`). The WebSocket client never needs these: it awaits the grant
143
+ * on its socket.
144
+ *
145
+ * All three shapes are the wire's own — `claimStateSchema`,
146
+ * `claimHeartbeatReplySchema`, and the batch ack — so this surface cannot
147
+ * describe a response the server does not send.
148
+ */
149
+ export interface HttpClaimsResource {
150
+ /**
151
+ * The claim's current state, by its id — `GET /v1/claims/{claimId}`. The
152
+ * poll half of the queued-grant handover: a queued caller polls until
153
+ * `status` is `'active'`, at which point `fenceToken` is present and the
154
+ * work can begin. `position` is advisory (a privileged caller can reorder
155
+ * the line, so it may go up); only `status` is authoritative.
156
+ */
157
+ retrieve(params: {
158
+ readonly claimId: string;
159
+ }): Promise<ClaimState>;
160
+ /**
161
+ * One beat on the named lease — `POST /v1/claims/{claimId}/heartbeat`. On a
162
+ * held lease it extends the TTL; on a queued ticket it refreshes the
163
+ * waiter's slot in the line and reports `{ status: 'queued', position }`.
164
+ */
165
+ heartbeat(params: {
166
+ readonly claimId: string;
167
+ readonly ttl?: string | number;
168
+ }): Promise<ClaimHeartbeatReply>;
169
+ /**
170
+ * One beat for every lease this identity holds — `POST /v1/claims/heartbeat`,
171
+ * one ack per extended lease. The socketless twin of the realtime
172
+ * keepalive, for a stateless worker holding many rows.
173
+ */
174
+ heartbeatAll(options?: {
175
+ readonly ttl?: string | number;
176
+ }): Promise<readonly ClaimHeartbeatAckPayload[]>;
177
+ /**
178
+ * Give the ticket back — `DELETE /v1/claims/{claimId}`. On a held lease this
179
+ * releases it and promotes the head of the queue; on a queued ticket it
180
+ * leaves the line, so the waiters behind move up instead of waiting out
181
+ * your slot's TTL. Idempotent: releasing an already-ended claim is a no-op.
182
+ */
183
+ release(params: {
184
+ readonly claimId: string;
185
+ }): Promise<void>;
186
+ }
134
187
  export interface ModelMutationOptions extends ClaimedOptions {
135
188
  readonly claimRef?: string | {
136
189
  readonly id: string;
@@ -159,7 +212,9 @@ export interface ModelMutationOptions extends ClaimedOptions {
159
212
  * statelessness forces. `claim({ id })` is identical on both (already async);
160
213
  * `state`, `queue`, `reorder`, and `release` are the awaited form.
161
214
  */
162
- export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T>) => Promise<HeldClaim<T>>) & {
215
+ export type HttpClaimApi<T = Record<string, unknown>> = ((params: ClaimParams<T> & {
216
+ queue: false;
217
+ }) => Promise<HeldClaim<T> | null>) & ((params: ClaimParams<T>) => Promise<HeldClaim<T>>) & {
163
218
  [K in keyof ClaimReadApi<T>]: AwaitedClaimMethod<ClaimReadApi<T>[K]>;
164
219
  };
165
220
  /** A single data operation a scoped **agent** session may perform on a model.