@abloatai/ablo 0.24.0 → 0.26.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 (246) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +45 -36
  4. package/dist/BaseSyncedStore.d.ts +72 -195
  5. package/dist/BaseSyncedStore.js +240 -625
  6. package/dist/Database.d.ts +8 -9
  7. package/dist/Database.js +9 -14
  8. package/dist/LazyReferenceCollection.js +5 -0
  9. package/dist/Model.js +6 -8
  10. package/dist/ModelRegistry.d.ts +25 -6
  11. package/dist/ModelRegistry.js +12 -6
  12. package/dist/NetworkMonitor.js +1 -1
  13. package/dist/ObjectPool.js +9 -3
  14. package/dist/SyncClient.d.ts +15 -5
  15. package/dist/SyncClient.js +152 -66
  16. package/dist/SyncEngineContext.d.ts +1 -3
  17. package/dist/SyncEngineContext.js +1 -2
  18. package/dist/agent/Agent.d.ts +21 -8
  19. package/dist/agent/Agent.js +26 -13
  20. package/dist/ai-sdk/coordination-context.js +9 -3
  21. package/dist/ai-sdk/wrap.d.ts +1 -1
  22. package/dist/ai-sdk/wrap.js +1 -1
  23. package/dist/auth/credentialPolicy.d.ts +10 -2
  24. package/dist/auth/credentialSource.d.ts +1 -13
  25. package/dist/auth/credentialSource.js +6 -13
  26. package/dist/auth/index.js +3 -3
  27. package/dist/cli.cjs +583 -287
  28. package/dist/client/Ablo.d.ts +35 -764
  29. package/dist/client/Ablo.js +64 -688
  30. package/dist/client/ApiClient.d.ts +14 -1
  31. package/dist/client/ApiClient.js +63 -9
  32. package/dist/client/auth.d.ts +8 -17
  33. package/dist/client/auth.js +42 -6
  34. package/dist/client/consoleLogger.d.ts +36 -0
  35. package/dist/client/consoleLogger.js +45 -0
  36. package/dist/client/createInternalComponents.js +1 -1
  37. package/dist/client/createModelProxy.js +10 -6
  38. package/dist/client/credentialEndpoint.d.ts +63 -0
  39. package/dist/client/credentialEndpoint.js +87 -0
  40. package/dist/client/hostedEndpoints.d.ts +24 -0
  41. package/dist/client/hostedEndpoints.js +24 -0
  42. package/dist/client/httpClient.d.ts +1 -1
  43. package/dist/client/identity.js +2 -1
  44. package/dist/client/modelRegistration.d.ts +14 -0
  45. package/dist/client/modelRegistration.js +321 -0
  46. package/dist/client/options.d.ts +396 -0
  47. package/dist/client/options.js +10 -0
  48. package/dist/client/resourceTypes.d.ts +344 -0
  49. package/dist/client/resourceTypes.js +13 -0
  50. package/dist/client/schemaConfig.d.ts +56 -0
  51. package/dist/client/schemaConfig.js +188 -0
  52. package/dist/client/sessionMint.d.ts +1 -1
  53. package/dist/client/validateAbloOptions.js +1 -1
  54. package/dist/client/wsMutationExecutor.d.ts +33 -0
  55. package/dist/client/wsMutationExecutor.js +77 -0
  56. package/dist/context.js +0 -3
  57. package/dist/coordination/index.d.ts +8 -1
  58. package/dist/coordination/index.js +24 -1
  59. package/dist/coordination/trace.d.ts +0 -1
  60. package/dist/coordination/trace.js +0 -1
  61. package/dist/core/DatabaseManager.d.ts +0 -1
  62. package/dist/core/DatabaseManager.js +26 -24
  63. package/dist/core/QueryProcessor.js +7 -8
  64. package/dist/core/QueryView.d.ts +14 -2
  65. package/dist/core/QueryView.js +4 -5
  66. package/dist/core/StoreManager.d.ts +2 -2
  67. package/dist/core/StoreManager.js +5 -1
  68. package/dist/core/index.d.ts +1 -1
  69. package/dist/core/openIDBWithTimeout.js +15 -11
  70. package/dist/core/query-utils.js +1 -1
  71. package/dist/core/storeContract.d.ts +143 -0
  72. package/dist/core/storeContract.js +16 -0
  73. package/dist/errorCodes.d.ts +11 -2
  74. package/dist/errorCodes.js +155 -137
  75. package/dist/errors.d.ts +18 -7
  76. package/dist/errors.js +14 -1
  77. package/dist/index.js +5 -0
  78. package/dist/interfaces/index.d.ts +6 -20
  79. package/dist/keys/index.js +18 -9
  80. package/dist/mutators/UndoManager.js +6 -1
  81. package/dist/mutators/defineMutators.d.ts +1 -3
  82. package/dist/policy/types.d.ts +1 -1
  83. package/dist/query/client.d.ts +11 -0
  84. package/dist/query/client.js +81 -52
  85. package/dist/react/AbloProvider.d.ts +6 -10
  86. package/dist/react/AbloProvider.js +22 -15
  87. package/dist/react/context.d.ts +2 -118
  88. package/dist/react/useAblo.js +1 -1
  89. package/dist/react/useErrorListener.js +1 -1
  90. package/dist/react/useMutationFailureListener.js +1 -1
  91. package/dist/react/useMutators.js +7 -1
  92. package/dist/react/useReactive.js +0 -1
  93. package/dist/react/useUndoScope.js +2 -2
  94. package/dist/schema/ddlLock.d.ts +39 -0
  95. package/dist/schema/ddlLock.js +51 -0
  96. package/dist/schema/field.js +1 -1
  97. package/dist/schema/index.d.ts +4 -3
  98. package/dist/schema/index.js +15 -5
  99. package/dist/schema/model.d.ts +3 -3
  100. package/dist/schema/model.js +2 -2
  101. package/dist/schema/openapi.js +5 -1
  102. package/dist/schema/queries.d.ts +1 -1
  103. package/dist/schema/queries.js +1 -0
  104. package/dist/schema/residency.d.ts +35 -0
  105. package/dist/schema/residency.js +27 -0
  106. package/dist/schema/roles.d.ts +25 -0
  107. package/dist/schema/roles.js +37 -0
  108. package/dist/schema/schema.d.ts +3 -3
  109. package/dist/schema/schema.js +11 -7
  110. package/dist/schema/serialize.d.ts +8 -8
  111. package/dist/schema/sync-delta-row.d.ts +2 -2
  112. package/dist/schema/sync-delta-row.js +2 -2
  113. package/dist/server/storage-mode.d.ts +8 -1
  114. package/dist/server/storage-mode.js +14 -4
  115. package/dist/source/adapter.d.ts +1 -1
  116. package/dist/source/adapters/drizzle.js +4 -3
  117. package/dist/source/adapters/kysely.js +4 -3
  118. package/dist/source/adapters/memory.js +1 -1
  119. package/dist/source/adapters/prisma.js +5 -4
  120. package/dist/source/conformance.js +10 -8
  121. package/dist/source/connector.js +4 -2
  122. package/dist/source/factory.d.ts +106 -0
  123. package/dist/source/factory.js +273 -0
  124. package/dist/source/index.d.ts +11 -461
  125. package/dist/source/index.js +14 -416
  126. package/dist/source/next.d.ts +1 -1
  127. package/dist/source/next.js +1 -1
  128. package/dist/source/pushQueue.d.ts +1 -1
  129. package/dist/source/pushQueue.js +6 -3
  130. package/dist/source/signing.d.ts +59 -0
  131. package/dist/source/signing.js +142 -0
  132. package/dist/source/types.d.ts +334 -0
  133. package/dist/source/types.js +44 -0
  134. package/dist/stores/ObjectStore.d.ts +1 -1
  135. package/dist/stores/ObjectStore.js +23 -23
  136. package/dist/stores/SyncActionStore.d.ts +1 -1
  137. package/dist/stores/SyncActionStore.js +70 -35
  138. package/dist/surface.d.ts +1 -1
  139. package/dist/surface.js +1 -0
  140. package/dist/sync/AreaOfInterestManager.d.ts +2 -2
  141. package/dist/sync/AreaOfInterestManager.js +1 -1
  142. package/dist/sync/BootstrapHelper.d.ts +1 -3
  143. package/dist/sync/BootstrapHelper.js +29 -8
  144. package/dist/sync/ConnectionManager.d.ts +8 -0
  145. package/dist/sync/ConnectionManager.js +19 -4
  146. package/dist/sync/HydrationCoordinator.d.ts +12 -0
  147. package/dist/sync/HydrationCoordinator.js +25 -5
  148. package/dist/sync/NetworkProbe.d.ts +3 -2
  149. package/dist/sync/NetworkProbe.js +7 -6
  150. package/dist/sync/SyncWebSocket.d.ts +71 -120
  151. package/dist/sync/SyncWebSocket.js +324 -630
  152. package/dist/sync/awaitClaimGrant.js +27 -19
  153. package/dist/sync/bootstrapApply.d.ts +60 -0
  154. package/dist/sync/bootstrapApply.js +65 -0
  155. package/dist/sync/commitFrames.d.ts +43 -0
  156. package/dist/sync/commitFrames.js +94 -0
  157. package/dist/sync/createClaimStream.js +3 -2
  158. package/dist/sync/createSnapshot.d.ts +1 -3
  159. package/dist/sync/credentialLifecycle.d.ts +175 -0
  160. package/dist/sync/credentialLifecycle.js +329 -0
  161. package/dist/sync/deltaPipeline.d.ts +111 -0
  162. package/dist/sync/deltaPipeline.js +262 -0
  163. package/dist/sync/groupChange.d.ts +111 -0
  164. package/dist/sync/groupChange.js +246 -0
  165. package/dist/sync/heartbeat.d.ts +62 -0
  166. package/dist/sync/heartbeat.js +91 -0
  167. package/dist/sync/participants.d.ts +8 -8
  168. package/dist/sync/syncCursor.d.ts +44 -0
  169. package/dist/sync/syncCursor.js +59 -0
  170. package/dist/sync/syncPlan.d.ts +62 -0
  171. package/dist/sync/syncPlan.js +51 -0
  172. package/dist/sync/wsFrameHandlers.d.ts +117 -0
  173. package/dist/sync/wsFrameHandlers.js +378 -0
  174. package/dist/testing/fixtures/bootstrap.d.ts +9 -11
  175. package/dist/testing/fixtures/deltas.d.ts +1 -1
  176. package/dist/testing/fixtures/models.js +1 -0
  177. package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
  178. package/dist/testing/helpers/sync-engine-harness.js +2 -1
  179. package/dist/testing/index.d.ts +1 -1
  180. package/dist/testing/index.js +1 -1
  181. package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
  182. package/dist/testing/mocks/MockSyncContext.js +1 -32
  183. package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
  184. package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
  185. package/dist/transactions/TransactionQueue.d.ts +20 -56
  186. package/dist/transactions/TransactionQueue.js +101 -487
  187. package/dist/transactions/TransactionStore.d.ts +18 -0
  188. package/dist/transactions/TransactionStore.js +51 -0
  189. package/dist/transactions/coalesceRules.d.ts +34 -0
  190. package/dist/transactions/coalesceRules.js +117 -0
  191. package/dist/transactions/commitPayload.d.ts +134 -0
  192. package/dist/transactions/commitPayload.js +152 -0
  193. package/dist/transactions/deltaConfirmation.d.ts +60 -0
  194. package/dist/transactions/deltaConfirmation.js +223 -0
  195. package/dist/transactions/optimistic.d.ts +24 -0
  196. package/dist/transactions/optimistic.js +45 -0
  197. package/dist/transactions/persistedReplay.d.ts +93 -0
  198. package/dist/transactions/persistedReplay.js +105 -0
  199. package/dist/types/index.d.ts +3 -3
  200. package/dist/types/index.js +0 -5
  201. package/dist/types/modelData.d.ts +12 -0
  202. package/dist/types/modelData.js +11 -0
  203. package/dist/types/participant.d.ts +21 -0
  204. package/dist/types/participant.js +12 -0
  205. package/dist/types/streams.d.ts +8 -14
  206. package/dist/utils/mobx-setup.d.ts +1 -1
  207. package/dist/utils/mobx-setup.js +2 -2
  208. package/dist/wire/errorEnvelope.d.ts +11 -2
  209. package/dist/wire/errorEnvelope.js +14 -2
  210. package/dist/wire/frames.d.ts +90 -0
  211. package/dist/wire/frames.js +55 -1
  212. package/dist/wire/index.d.ts +4 -1
  213. package/dist/wire/index.js +20 -1
  214. package/dist/wire/protocol.d.ts +45 -0
  215. package/dist/wire/protocol.js +45 -0
  216. package/dist/wire/protocolVersion.d.ts +56 -0
  217. package/dist/wire/protocolVersion.js +63 -0
  218. package/docs/api-keys.md +4 -3
  219. package/docs/data-sources.md +12 -5
  220. package/docs/examples/existing-python-backend.md +3 -3
  221. package/docs/identity.md +4 -4
  222. package/docs/integration-guide.md +1 -1
  223. package/docs/react.md +1 -1
  224. package/docs/sessions.md +5 -7
  225. package/llms.txt +4 -2
  226. package/package.json +15 -13
  227. package/dist/client/index.d.ts +0 -36
  228. package/dist/client/index.js +0 -33
  229. package/dist/config/index.d.ts +0 -10
  230. package/dist/config/index.js +0 -12
  231. package/dist/interfaces/headless.d.ts +0 -95
  232. package/dist/interfaces/headless.js +0 -41
  233. package/dist/query/index.d.ts +0 -6
  234. package/dist/query/index.js +0 -5
  235. package/dist/realtime/index.d.ts +0 -10
  236. package/dist/realtime/index.js +0 -9
  237. package/dist/schema/plane.d.ts +0 -23
  238. package/dist/schema/plane.js +0 -19
  239. package/dist/server/next.d.ts +0 -51
  240. package/dist/server/next.js +0 -47
  241. package/dist/sync/OfflineFlush.d.ts +0 -9
  242. package/dist/sync/OfflineFlush.js +0 -22
  243. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  244. package/dist/sync/OfflineTransactionStore.js +0 -263
  245. package/dist/transactions/index.d.ts +0 -16
  246. package/dist/transactions/index.js +0 -7
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Inbound frame dispatch for the sync WebSocket.
3
+ *
4
+ * Replaces the monolithic frame `switch` that used to live inside
5
+ * `SyncWebSocket.setupEventHandlers` with a frame-type → handler table of
6
+ * functions over a minimal {@link WsSession} interface — only the members
7
+ * the handlers actually touch, never the transport class itself (no
8
+ * import cycle). The host's `onmessage` stays responsible for JSON
9
+ * parsing, heartbeat proof-of-life, and the outer try/catch; everything
10
+ * after that funnels through {@link dispatchWsFrame}.
11
+ */
12
+ import { getContext } from '../context.js';
13
+ import { CapabilityError, errorFromWire, } from '../errors.js';
14
+ import { subscriptionAckPayloadSchema } from '../coordination/schema.js';
15
+ import { formatConflict } from '../coordination/trace.js';
16
+ import { parseNotifications, recordClaim } from './commitFrames.js';
17
+ /** Narrow arbitrary wire data to a plain string-keyed record. */
18
+ export function isRecord(value) {
19
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
20
+ }
21
+ /**
22
+ * Envelope guard for the raw `JSON.parse` result of an inbound WS message.
23
+ * A frame is any plain object whose `type`, when present, is a string —
24
+ * payload-level validation stays with each handler (deltas go through the
25
+ * canonical `clientSyncDeltaSchema` at the `normalizeWireDelta` seam).
26
+ */
27
+ export function isWsInboundFrame(value) {
28
+ if (!isRecord(value))
29
+ return false;
30
+ const type = value.type;
31
+ return type === undefined || typeof type === 'string';
32
+ }
33
+ /**
34
+ * Ack for a prior `commit` we sent. Canonical shape is
35
+ * `MutationResultMessage` in `@abloatai/ablo/wire`. This stays a
36
+ * DEFENSIVE parse (not a typed cast) because the payload is
37
+ * untrusted wire data that may be malformed or from an older server.
38
+ */
39
+ const handleMutationResult = (session, message) => {
40
+ const p = (message.payload ?? message);
41
+ const { clientTxId, success, lastSyncId, error } = p ?? {};
42
+ // Defensive: validate notifications against the canonical schema —
43
+ // untrusted wire data from a possibly-older/newer server.
44
+ const notifications = parseNotifications(p?.notifications);
45
+ const pending = typeof clientTxId === 'string'
46
+ ? session.pendingMutations.get(clientTxId)
47
+ : undefined;
48
+ if (!pending)
49
+ return;
50
+ clearTimeout(pending.timeout);
51
+ // `pending` exists ⇒ clientTxId was a string key (the guard above).
52
+ session.pendingMutations.delete(clientTxId);
53
+ if (success) {
54
+ // Coerce defensively — bigint columns serialize as strings
55
+ // from older servers (see normalizeWireDelta).
56
+ const ackedSyncId = Number(lastSyncId);
57
+ // Notify-instead-of-abort: a guarded write's premise moved. Emit
58
+ // the advisory signal so an agent loop can self-heal, AND resolve
59
+ // the receipt with it (the commit still succeeded).
60
+ if (notifications && notifications.length > 0) {
61
+ const txId = typeof clientTxId === 'string' ? clientTxId : '';
62
+ const event = {
63
+ clientTxId: txId,
64
+ rows: notifications.map((n) => ({
65
+ model: n.model,
66
+ id: n.id,
67
+ fields: n.conflictingFields,
68
+ writtenBy: n.writtenBy?.kind,
69
+ })),
70
+ };
71
+ const message = formatConflict(event);
72
+ const ctx = getContext();
73
+ ctx.logger.warn(message);
74
+ ctx.observability.breadcrumb(message, 'sync.coordination', 'warning');
75
+ ctx.observability.captureConflict(event);
76
+ session.emit('conflict:notified', {
77
+ clientTxId: txId,
78
+ notifications,
79
+ });
80
+ }
81
+ pending.resolve({
82
+ lastSyncId: Number.isFinite(ackedSyncId) ? ackedSyncId : 0,
83
+ ...(notifications && notifications.length > 0
84
+ ? { notifications }
85
+ : {}),
86
+ });
87
+ }
88
+ else {
89
+ // Capture the FULL server error so the user can see what
90
+ // actually rejected the mutation. Without this, every
91
+ // rejection becomes the generic "mutation failed on
92
+ // server" — useless when debugging chart batches that
93
+ // tank 40+ ops at once. We stringify object errors so
94
+ // structured server payloads (e.g., Zod issues, schema
95
+ // violations) survive the trip through `new Error(...)`.
96
+ let errorMessage;
97
+ let errorCode;
98
+ let requiredCapability;
99
+ if (typeof error === 'string') {
100
+ errorMessage = error;
101
+ }
102
+ else if (error != null && typeof error === 'object') {
103
+ const obj = error;
104
+ if (typeof obj.code === 'string')
105
+ errorCode = obj.code;
106
+ if (typeof obj.message === 'string') {
107
+ errorMessage = obj.message;
108
+ }
109
+ else {
110
+ try {
111
+ errorMessage = JSON.stringify(error);
112
+ }
113
+ catch {
114
+ errorMessage = String(error);
115
+ }
116
+ }
117
+ if (obj.requiredCapability != null &&
118
+ typeof obj.requiredCapability === 'object' &&
119
+ typeof obj.requiredCapability.scope === 'string') {
120
+ requiredCapability = obj.requiredCapability;
121
+ }
122
+ }
123
+ else {
124
+ errorMessage = 'mutation failed on server';
125
+ }
126
+ // Coordination collision: a stale-context rejection (the write's
127
+ // readAt premise moved underneath) or a foreign-claim conflict is
128
+ // exactly the collision ClaimLog exists to surface. The notify
129
+ // path (success + notifications) emits captureConflict above; a
130
+ // HARD rejection must too — otherwise observability.collisions()
131
+ // silently misses every rejected write. The conflicted rows ride
132
+ // along on the typed error's `conflicts` detail (see
133
+ // AbloStaleContextError.toJSON / errorEnvelope).
134
+ if (errorCode === 'stale_context' ||
135
+ errorCode === 'claim_conflict' ||
136
+ errorCode === 'entity_claimed' ||
137
+ errorCode?.startsWith('policy:') === true) {
138
+ const rawConflicts = error != null &&
139
+ typeof error === 'object' &&
140
+ Array.isArray(error.conflicts)
141
+ ? error
142
+ .conflicts
143
+ : [];
144
+ const conflictEvent = {
145
+ clientTxId: typeof clientTxId === 'string' ? clientTxId : '',
146
+ rows: rawConflicts.map((r) => ({
147
+ model: typeof r.model === 'string' ? r.model : 'unknown',
148
+ id: typeof r.id === 'string' ? r.id : 'unknown',
149
+ fields: [],
150
+ })),
151
+ };
152
+ const ctx = getContext();
153
+ ctx.observability.breadcrumb(formatConflict(conflictEvent), 'sync.coordination', 'warning');
154
+ ctx.observability.captureConflict(conflictEvent);
155
+ }
156
+ // Build the proper typed AbloError from the wire code via the
157
+ // shared factory — the same code→class mapping the HTTP commit
158
+ // path uses (`translateHttpError`). This keeps rejected commits
159
+ // inside the typed hierarchy (capability denials →
160
+ // CapabilityError with `.requiredCapability`; foreign-claim
161
+ // conflicts → AbloClaimedError; everything else → the subclass
162
+ // its registry `httpStatus` implies) instead of a hand-rolled
163
+ // `new Error`, so callers can `instanceof`/`e.type` it and
164
+ // downstream retry logic can read the contract's retryability.
165
+ pending.reject(errorFromWire(errorMessage, {
166
+ code: errorCode,
167
+ requiredCapability,
168
+ }));
169
+ }
170
+ };
171
+ /**
172
+ * Ack for a prior `claim` we sent. Wire format mirrors
173
+ * apps/sync-server/src/hub/types.ts ClaimAckMessage:
174
+ * { type: 'claim_ack',
175
+ * payload: { claimId, success, syncGroups?,
176
+ * ttlSeconds?, error? } }
177
+ */
178
+ const handleClaimAck = (session, message) => {
179
+ const p = (message.payload ?? {});
180
+ const { claimId, success, syncGroups, ttlSeconds, error } = p;
181
+ const pending = typeof claimId === 'string'
182
+ ? session.pendingClaims.get(claimId)
183
+ : undefined;
184
+ if (!pending)
185
+ return;
186
+ clearTimeout(pending.timeout);
187
+ // `pending` exists ⇒ claimId was a string key (the guard above).
188
+ session.pendingClaims.delete(claimId);
189
+ if (success) {
190
+ pending.resolve({
191
+ syncGroups: Array.isArray(syncGroups) ? syncGroups : [],
192
+ ttlSeconds: typeof ttlSeconds === 'number' ? ttlSeconds : undefined,
193
+ });
194
+ }
195
+ else {
196
+ const err = error;
197
+ const code = err?.code && typeof err.code === 'string'
198
+ ? err.code
199
+ : 'claim_rejected';
200
+ const msg = err?.message && typeof err.message === 'string'
201
+ ? err.message
202
+ : 'claim rejected by server';
203
+ // Capability denials get the typed CapabilityError so
204
+ // callers can read `.requiredCapability` and attenuate-
205
+ // and-retry the claim with a narrower token.
206
+ if (code === 'capability_scope_denied' ||
207
+ code === 'capability_invalid') {
208
+ const rc = error
209
+ ?.requiredCapability;
210
+ const requiredCapability = rc != null &&
211
+ typeof rc === 'object' &&
212
+ typeof rc.scope === 'string'
213
+ ? rc
214
+ : undefined;
215
+ pending.reject(new CapabilityError(code, msg, requiredCapability));
216
+ }
217
+ else {
218
+ // Route through the shared factory so a failed claim_ack is a
219
+ // typed AbloError (registry code → right subclass), symmetric
220
+ // with the commit `mutation_result` path — never a bare Error.
221
+ pending.reject(errorFromWire(msg, { code }));
222
+ }
223
+ }
224
+ };
225
+ /**
226
+ * Ack for a prior `update_subscription`. The wire carries no
227
+ * correlation id, so FIFO-match against the oldest pending
228
+ * request — the server applies and acks subscription updates
229
+ * in receive order. Validated through the canonical zod schema
230
+ * (mirrors how the Hub validates inbound frames).
231
+ */
232
+ const handleSubscriptionAck = (session, message) => {
233
+ const pending = session.shiftPendingSubscription();
234
+ if (!pending)
235
+ return;
236
+ clearTimeout(pending.timeout);
237
+ const parsed = subscriptionAckPayloadSchema.safeParse(message.payload);
238
+ if (!parsed.success) {
239
+ // Unreadable ack — resolve the pending request as a failure
240
+ // rather than hang it until timeout.
241
+ pending.reject(errorFromWire('malformed subscription_ack from server', {
242
+ code: 'malformed_subscription',
243
+ }));
244
+ return;
245
+ }
246
+ const ack = parsed.data;
247
+ if (ack.success) {
248
+ // Keep the reconnect URL aligned with current interest: a
249
+ // reconnect re-subscribes from `this.options.syncGroups`.
250
+ session.options.syncGroups = ack.syncGroups;
251
+ pending.resolve({ syncGroups: ack.syncGroups });
252
+ }
253
+ else {
254
+ pending.reject(errorFromWire(ack.error?.message ?? 'update_subscription rejected by server', { code: ack.error?.code ?? 'malformed_subscription' }));
255
+ }
256
+ };
257
+ /**
258
+ * `delta` frames carry either a single delta or a `{ deltas: [...] }` batch.
259
+ * Only DISCRIMINATES the two shapes here — each delta is validated exactly
260
+ * once downstream (the host's `normalizeWireDelta` seam), so batch elements
261
+ * are handed over raw rather than pre-parsed.
262
+ */
263
+ const handleDeltaFrame = (session, message) => {
264
+ const p = message.payload;
265
+ if (!isRecord(p))
266
+ return;
267
+ if (p.actionType || p.modelName) {
268
+ session.handleDelta(p);
269
+ }
270
+ else if (Array.isArray(p.deltas)) {
271
+ for (const d of p.deltas) {
272
+ session.handleDelta(d);
273
+ }
274
+ // `p.newVersions` from pre-cutover servers is ignored — the version
275
+ // vector was removed in W4a (sync_id is the causality token).
276
+ }
277
+ };
278
+ /**
279
+ * Frame-type → handler table. Every named server frame the SDK
280
+ * understands dispatches through here; anything else falls to the
281
+ * collaboration-event / unknown-type path in {@link dispatchWsFrame}.
282
+ */
283
+ export const wsFrameHandlers = {
284
+ sync_response: (session, message) => { session.handleSyncResponse(message.payload); },
285
+ bootstrap_response: (session, message) => { session.handleBootstrapResponse(message.payload); },
286
+ presence_update: (session, message) => { session.handlePresenceUpdate(message); },
287
+ mutation_result: handleMutationResult,
288
+ claim_ack: handleClaimAck,
289
+ subscription_ack: handleSubscriptionAck,
290
+ claim_expired: (session, message) => {
291
+ // Server-initiated expiry notification. Emit as a typed
292
+ // event so consumers can react (re-claim with a fresh
293
+ // capability, or accept the drop). The claim is already
294
+ // inactive server-side by the time this arrives.
295
+ const p = (message.payload ?? {});
296
+ if (typeof p.claimId === 'string') {
297
+ recordClaim('expired', p);
298
+ session.emit('claim_expired', { claimId: p.claimId });
299
+ }
300
+ },
301
+ claim_rejected: (session, message) => {
302
+ // Server denied an `claim_begin` because the target is
303
+ // already claimed by another participant. Forward the
304
+ // payload as-is — the ClaimStream consumer interprets
305
+ // the conflict shape (peerId, target, etc.).
306
+ recordClaim('rejected', (message.payload ?? {}));
307
+ session.emit('claim_rejected', message.payload ?? {});
308
+ },
309
+ claim_acquired: (session, message) => {
310
+ // Opt-in fair queue: the target was free, so the lease is ours
311
+ // immediately (no waiting). Payload carries { claimId, target }.
312
+ recordClaim('acquired', (message.payload ?? {}));
313
+ session.emit('claim_acquired', message.payload ?? {});
314
+ },
315
+ claim_queue: (session, message) => {
316
+ // Per-entity wait-queue snapshot for reactive `queue(id)`. Not a
317
+ // single claim's state change, so it isn't logged — the per-claim
318
+ // `queued`/`granted` events already tell that story.
319
+ session.emit('claim_queue', message.payload ?? {});
320
+ },
321
+ claim_queued: (session, message) => {
322
+ // Opt-in fair queue: our claim is waiting in line. Payload
323
+ // carries { claimId, target, position }.
324
+ recordClaim('queued', (message.payload ?? {}));
325
+ session.emit('claim_queued', message.payload ?? {});
326
+ },
327
+ claim_granted: (session, message) => {
328
+ // Our queued claim reached the head — the lease is now ours.
329
+ recordClaim('granted', (message.payload ?? {}));
330
+ session.emit('claim_granted', message.payload ?? {});
331
+ },
332
+ claim_lost: (session, message) => {
333
+ // A held/granted claim was taken from us (TTL lapse, revoke).
334
+ recordClaim('lost', (message.payload ?? {}));
335
+ session.emit('claim_lost', message.payload ?? {});
336
+ },
337
+ delta: handleDeltaFrame,
338
+ };
339
+ /**
340
+ * Route one parsed inbound frame to its handler. Mirrors the original
341
+ * inline switch exactly: keepalives are ignored, a missing `type` is
342
+ * the legacy bare-delta form, unknown types fall through to the
343
+ * collaboration-event map (underscore wire format → colon event key).
344
+ */
345
+ export function dispatchWsFrame(session, message) {
346
+ if (message.type === 'pong' || message.type === 'ping') {
347
+ // Ignore keepalive messages
348
+ getContext().logger.debug('Received keepalive', { type: message.type });
349
+ return;
350
+ }
351
+ if (message.type === undefined) {
352
+ // Legacy support: bare delta (validated at the host's
353
+ // normalizeWireDelta seam like every other delta).
354
+ if (message.actionType || message.modelName) {
355
+ session.handleDelta(message);
356
+ }
357
+ return;
358
+ }
359
+ // Own-property lookup so wire types like 'toString' can never hit
360
+ // Object.prototype members — those fall through to the unknown-type
361
+ // path exactly as the switch's `default` did.
362
+ const handler = Object.prototype.hasOwnProperty.call(wsFrameHandlers, message.type)
363
+ ? wsFrameHandlers[message.type]
364
+ : undefined;
365
+ if (handler) {
366
+ handler(session, message);
367
+ return;
368
+ }
369
+ // Collaboration events use underscore wire format (e.g., 'sheet_selection')
370
+ // Convert to colon format for the event map (e.g., 'sheet:selection')
371
+ const eventKey = message.type?.replace(/_/g, ':');
372
+ if (eventKey && session.collaborationEventTypes.has(eventKey)) {
373
+ session.emit(eventKey, message.payload);
374
+ }
375
+ else {
376
+ getContext().logger.debug('Received unknown message type', { message });
377
+ }
378
+ }
@@ -4,20 +4,18 @@
4
4
  * Creates well-formed bootstrap responses matching the server API.
5
5
  */
6
6
  import type { BootstrapType } from '../../types/index.js';
7
- export interface BootstrapModelData {
8
- [modelName: string]: Array<Record<string, unknown>>;
9
- }
7
+ export type BootstrapModelData = Record<string, Record<string, unknown>[]>;
10
8
  export interface BootstrapResponse {
11
9
  type: BootstrapType;
12
10
  lastSyncId: number;
13
11
  models?: BootstrapModelData;
14
- deltas?: Array<{
12
+ deltas?: {
15
13
  id: number;
16
14
  modelName: string;
17
15
  modelId: string;
18
16
  action: string;
19
17
  data: Record<string, unknown>;
20
- }>;
18
+ }[];
21
19
  deltaCount?: number;
22
20
  failedModels?: string[];
23
21
  timestamp: number;
@@ -34,12 +32,12 @@ export declare function createPartialBootstrapResponse(deltas: BootstrapResponse
34
32
  * Create a full bootstrap response with test model data pre-populated.
35
33
  */
36
34
  export declare function createTestBootstrapResponse(options?: {
37
- tasks?: Array<Record<string, unknown>>;
38
- projects?: Array<Record<string, unknown>>;
39
- slideDecks?: Array<Record<string, unknown>>;
40
- slides?: Array<Record<string, unknown>>;
41
- slideLayers?: Array<Record<string, unknown>>;
42
- comments?: Array<Record<string, unknown>>;
35
+ tasks?: Record<string, unknown>[];
36
+ projects?: Record<string, unknown>[];
37
+ slideDecks?: Record<string, unknown>[];
38
+ slides?: Record<string, unknown>[];
39
+ slideLayers?: Record<string, unknown>[];
40
+ comments?: Record<string, unknown>[];
43
41
  lastSyncId?: number;
44
42
  failedModels?: string[];
45
43
  }): BootstrapResponse;
@@ -78,7 +78,7 @@ export declare function createGroupRemovedDelta(userId: string, group: string, s
78
78
  /**
79
79
  * Create a batch of deltas with sequential sync IDs.
80
80
  */
81
- export declare function createDeltaBatch(deltas: Array<Omit<CreateDeltaOptions, 'id'>>, startingSyncId?: number): SyncAction[];
81
+ export declare function createDeltaBatch(deltas: Omit<CreateDeltaOptions, 'id'>[], startingSyncId?: number): SyncAction[];
82
82
  /**
83
83
  * Create a confirmation delta — used to confirm that a mutation
84
84
  * was persisted by the server (TransactionQueue watches for this).
@@ -7,6 +7,7 @@
7
7
  * TestSlideDeck (10) → TestSlide (15, FK→SlideDeck) → TestSlideLayer (20, FK→Slide)
8
8
  */
9
9
  import { Model } from '../../Model.js';
10
+ import { ModelRegistry } from '../../ModelRegistry.js';
10
11
  import { PropertyType, LoadStrategy } from '../../types/index.js';
11
12
  // ─────────────────────────────────────────────
12
13
  // Test Model Classes
@@ -9,7 +9,6 @@ import { ObjectPool } from '../../ObjectPool.js';
9
9
  import { MockMutationExecutor } from '../mocks/MockMutationExecutor.js';
10
10
  import { MockNetworkMonitor } from '../mocks/MockNetworkMonitor.js';
11
11
  import { MockWebSocket } from '../mocks/MockWebSocket.js';
12
- import { MockMutationDispatcher } from '../mocks/MockSyncContext.js';
13
12
  import type { TestContextResult } from '../mocks/MockSyncContext.js';
14
13
  export interface TestHarness {
15
14
  /** Pre-registered ModelRegistry with test models */
@@ -24,8 +23,6 @@ export interface TestHarness {
24
23
  mutationExecutor: MockMutationExecutor;
25
24
  /** Shorthand: mock network monitor */
26
25
  networkMonitor: MockNetworkMonitor;
27
- /** Shorthand: mock mutation dispatcher */
28
- mutationDispatcher: MockMutationDispatcher;
29
26
  /** Cleanup everything */
30
27
  cleanup: () => void;
31
28
  }
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { ModelRegistry, setActiveRegistry } from '../../ModelRegistry.js';
8
8
  import { ObjectPool } from '../../ObjectPool.js';
9
+ import { MockMutationExecutor } from '../mocks/MockMutationExecutor.js';
10
+ import { MockNetworkMonitor } from '../mocks/MockNetworkMonitor.js';
9
11
  import { MockWebSocket } from '../mocks/MockWebSocket.js';
10
12
  import { createTestContext } from '../mocks/MockSyncContext.js';
11
13
  import { registerTestModels, createTestConfig, resetFixtureCounter, } from '../fixtures/models.js';
@@ -58,7 +60,6 @@ export function createTestHarness(options = {}) {
58
60
  context,
59
61
  mutationExecutor: context.mocks.mutationExecutor,
60
62
  networkMonitor: context.mocks.networkMonitor,
61
- mutationDispatcher: context.mocks.mutationDispatcher,
62
63
  cleanup: () => {
63
64
  pool.clear();
64
65
  webSocket.reset();
@@ -9,7 +9,7 @@ export type { CapturedMutation, MockMutationExecutorOptions } from './mocks/Mock
9
9
  export { MockNetworkMonitor } from './mocks/MockNetworkMonitor.js';
10
10
  export { MockWebSocket } from './mocks/MockWebSocket.js';
11
11
  export type { MockDelta, MockBootstrapHint } from './mocks/MockWebSocket.js';
12
- export { createTestContext, MockMutationDispatcher, } from './mocks/MockSyncContext.js';
12
+ export { createTestContext, } from './mocks/MockSyncContext.js';
13
13
  export type { TestContextOptions, TestContextResult } from './mocks/MockSyncContext.js';
14
14
  export { TestProject, TestTask, TestComment, TestSlideDeck, TestSlide, TestSlideLayer, TEST_MODEL_PRIORITIES, registerTestModels, createTestConfig, resetFixtureCounter, createProjectFixture, createTaskFixture, createCommentFixture, createSlideDeckFixture, createSlideFixture, createSlideLayerFixture, } from './fixtures/models.js';
15
15
  export { createDelta, createInsertDelta, createUpdateDelta, createDeleteDelta, createArchiveDelta, createUnarchiveDelta, createCoveringDelta, createGroupAddedDelta, createLegacyGroupChangeDelta, createGroupRemovedDelta, createDeltaBatch, createConfirmationDelta, resetDeltaCounter, } from './fixtures/deltas.js';
@@ -10,7 +10,7 @@
10
10
  export { MockMutationExecutor } from './mocks/MockMutationExecutor.js';
11
11
  export { MockNetworkMonitor } from './mocks/MockNetworkMonitor.js';
12
12
  export { MockWebSocket } from './mocks/MockWebSocket.js';
13
- export { createTestContext, MockMutationDispatcher, } from './mocks/MockSyncContext.js';
13
+ export { createTestContext, } from './mocks/MockSyncContext.js';
14
14
  // ─────────────────────────────────────────────
15
15
  // Fixtures: Models
16
16
  // ─────────────────────────────────────────────
@@ -6,7 +6,7 @@
6
6
  * returns handles to all mocks for test assertions.
7
7
  */
8
8
  import type { SyncEngineContext } from '../../SyncEngineContext.js';
9
- import type { SyncLogger, SyncObservabilityProvider, SessionErrorDetector, MutationDispatcher, SyncEngineConfig } from '../../interfaces/index.js';
9
+ import type { SyncLogger, SyncObservabilityProvider, SessionErrorDetector, SyncEngineConfig } from '../../interfaces/index.js';
10
10
  import { MockMutationExecutor } from './MockMutationExecutor.js';
11
11
  import { MockNetworkMonitor } from './MockNetworkMonitor.js';
12
12
  export interface TestContextOptions {
@@ -29,27 +29,11 @@ export interface TestContextResult {
29
29
  /** Mock handles for test assertions */
30
30
  mocks: {
31
31
  mutationExecutor: MockMutationExecutor;
32
- mutationDispatcher: MockMutationDispatcher;
33
32
  networkMonitor: MockNetworkMonitor;
34
33
  };
35
34
  /** Cleanup: calls resetSyncEngine() */
36
35
  cleanup: () => void;
37
36
  }
38
- /**
39
- * Simple mock mutation dispatcher that records dispatch calls.
40
- */
41
- export declare class MockMutationDispatcher implements MutationDispatcher {
42
- readonly dispatched: Array<{
43
- operationName: string;
44
- variables: Record<string, unknown>;
45
- }>;
46
- private _shouldSucceed;
47
- private _error?;
48
- dispatch(operationName: string, variables: Record<string, unknown>): Promise<void>;
49
- failAll(error?: Error): void;
50
- succeedAll(): void;
51
- reset(): void;
52
- }
53
37
  /**
54
38
  * Create a test SyncEngineContext with all mocks pre-wired.
55
39
  * Calls initSyncEngine() so the global context is set.
@@ -7,37 +7,10 @@
7
7
  */
8
8
  import { noopLogger, noopObservability, noopAnalytics, defaultSessionErrorDetector, emptyConfig, } from '../../SyncEngineContext.js';
9
9
  import { initSyncEngine, resetSyncEngine } from '../../context.js';
10
- import { ModelRegistry, setActiveRegistry, hasActiveRegistry, } from '../../ModelRegistry.js';
10
+ import { ModelRegistry, setActiveRegistry, hasActiveRegistry, clearActiveRegistry, } from '../../ModelRegistry.js';
11
11
  import { registerTestModels } from '../fixtures/models.js';
12
12
  import { MockMutationExecutor } from './MockMutationExecutor.js';
13
13
  import { MockNetworkMonitor } from './MockNetworkMonitor.js';
14
- /**
15
- * Simple mock mutation dispatcher that records dispatch calls.
16
- */
17
- export class MockMutationDispatcher {
18
- dispatched = [];
19
- _shouldSucceed = true;
20
- _error;
21
- async dispatch(operationName, variables) {
22
- this.dispatched.push({ operationName, variables });
23
- if (!this._shouldSucceed) {
24
- throw this._error ?? new Error(`Mock dispatch failed: ${operationName}`);
25
- }
26
- }
27
- failAll(error) {
28
- this._shouldSucceed = false;
29
- this._error = error;
30
- }
31
- succeedAll() {
32
- this._shouldSucceed = true;
33
- this._error = undefined;
34
- }
35
- reset() {
36
- this.dispatched.length = 0;
37
- this._shouldSucceed = true;
38
- this._error = undefined;
39
- }
40
- }
41
14
  /**
42
15
  * Create a test SyncEngineContext with all mocks pre-wired.
43
16
  * Calls initSyncEngine() so the global context is set.
@@ -51,7 +24,6 @@ export class MockMutationDispatcher {
51
24
  */
52
25
  export function createTestContext(options = {}) {
53
26
  const mutationExecutor = new MockMutationExecutor(options.mutationExecutorOptions);
54
- const mutationDispatcher = new MockMutationDispatcher();
55
27
  const networkMonitor = new MockNetworkMonitor(!options.startOffline);
56
28
  const config = {
57
29
  ...emptyConfig,
@@ -66,7 +38,6 @@ export function createTestContext(options = {}) {
66
38
  sessionErrorDetector: options.sessionErrorDetector ?? defaultSessionErrorDetector,
67
39
  onlineStatus: networkMonitor,
68
40
  mutationExecutor,
69
- mutationDispatcher,
70
41
  config,
71
42
  };
72
43
  initSyncEngine(context);
@@ -82,7 +53,6 @@ export function createTestContext(options = {}) {
82
53
  context,
83
54
  mocks: {
84
55
  mutationExecutor,
85
- mutationDispatcher,
86
56
  networkMonitor,
87
57
  },
88
58
  cleanup: () => {
@@ -93,7 +63,6 @@ export function createTestContext(options = {}) {
93
63
  // registry in place keeps those calls valid; the next createTestContext
94
64
  // with hasActiveRegistry()===true simply reuses it.
95
65
  mutationExecutor.reset();
96
- mutationDispatcher.reset();
97
66
  networkMonitor.reset();
98
67
  },
99
68
  };
@@ -27,14 +27,14 @@ type ModelCtor<T extends Model> = abstract new (...args: never[]) => T;
27
27
  export declare class MockSyncStore implements SyncStoreContract {
28
28
  private byClass;
29
29
  calls: {
30
- retrieve: Array<{
30
+ retrieve: {
31
31
  modelClass: ModelCtor<Model>;
32
32
  id: string;
33
- }>;
34
- query: Array<{
33
+ }[];
34
+ query: {
35
35
  modelClass: ModelCtor<Model>;
36
36
  options?: QueryOptions<Model>;
37
- }>;
37
+ }[];
38
38
  save: Model[];
39
39
  delete: Model[];
40
40
  archive: Model[];
@@ -30,10 +30,10 @@ export declare class MockWebSocket {
30
30
  private _sessionError;
31
31
  private _listeners;
32
32
  /** Track all emitted events for assertions */
33
- readonly emittedEvents: Array<{
33
+ readonly emittedEvents: {
34
34
  type: string;
35
35
  data: unknown;
36
- }>;
36
+ }[];
37
37
  get connected(): boolean;
38
38
  get sessionError(): boolean;
39
39
  on(event: string, handler: EventHandler): () => void;