@abloatai/ablo 0.25.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 (244) hide show
  1. package/AGENTS.md +5 -3
  2. package/CHANGELOG.md +20 -0
  3. package/README.md +3 -3
  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 +328 -262
  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/examples/existing-python-backend.md +3 -3
  220. package/docs/identity.md +4 -4
  221. package/docs/integration-guide.md +1 -1
  222. package/docs/react.md +1 -1
  223. package/docs/sessions.md +5 -7
  224. package/package.json +15 -13
  225. package/dist/client/index.d.ts +0 -36
  226. package/dist/client/index.js +0 -33
  227. package/dist/config/index.d.ts +0 -10
  228. package/dist/config/index.js +0 -12
  229. package/dist/interfaces/headless.d.ts +0 -95
  230. package/dist/interfaces/headless.js +0 -41
  231. package/dist/query/index.d.ts +0 -6
  232. package/dist/query/index.js +0 -5
  233. package/dist/realtime/index.d.ts +0 -10
  234. package/dist/realtime/index.js +0 -9
  235. package/dist/schema/plane.d.ts +0 -23
  236. package/dist/schema/plane.js +0 -19
  237. package/dist/server/next.d.ts +0 -51
  238. package/dist/server/next.js +0 -47
  239. package/dist/sync/OfflineFlush.d.ts +0 -9
  240. package/dist/sync/OfflineFlush.js +0 -22
  241. package/dist/sync/OfflineTransactionStore.d.ts +0 -37
  242. package/dist/sync/OfflineTransactionStore.js +0 -263
  243. package/dist/transactions/index.d.ts +0 -16
  244. package/dist/transactions/index.js +0 -7
@@ -0,0 +1,262 @@
1
+ /**
2
+ * deltaPipeline — the incoming-delta dedup/batch/flush pipeline.
3
+ *
4
+ * Extracted from BaseSyncedStore.ts as a cohesive leaf: state-signature
5
+ * dedup, per-delta bookkeeping + enqueue, the live-traffic debounce, and
6
+ * the IDB→pool flush. The store keeps thin protected delegates with
7
+ * unchanged signatures, and the leaf routes every call to a protected
8
+ * override point (getStateFields, isCustomEntity, deduplicateDeltas,
9
+ * flushPendingDeltas, the G/S handlers, …) back through the minimal
10
+ * {@link DeltaPipelineContext} so subclass dynamic dispatch is preserved.
11
+ * `applyDeltaFrame` — the authoritative-apply correctness seam — stays in
12
+ * BaseSyncedStore and drives this pipeline via `enqueueDelta` +
13
+ * `flushPendingDeltas`.
14
+ */
15
+ import { runInAction } from 'mobx';
16
+ import { getContext } from '../context.js';
17
+ import { ModelScope } from '../ObjectPool.js';
18
+ /**
19
+ * A 'G'/'S' handler rejected AFTER the applied watermark already advanced —
20
+ * the delta will never be re-delivered, so a failed SECURITY clear (revoked
21
+ * data must not persist) would otherwise be permanently silent. Fall back to
22
+ * the bluntest safe response: drop the whole in-memory pool and force a full
23
+ * re-bootstrap so local state is rebuilt from server truth. Never throws —
24
+ * this runs inside the delta pipeline's fire-and-forget seam.
25
+ */
26
+ export function handleGroupHandlerFailure(ctx, delta, error) {
27
+ getContext().logger.error('Your access changed but cached data could not be cleared — resetting local data.', {
28
+ syncId: delta.id,
29
+ error: error instanceof Error ? error.message : String(error),
30
+ });
31
+ try {
32
+ ctx.objectPool.clear();
33
+ }
34
+ catch {
35
+ // In-memory clear must never mask the re-bootstrap below.
36
+ }
37
+ try {
38
+ ctx.forceFullRebootstrap();
39
+ }
40
+ catch {
41
+ // Best-effort: the reconnect/bootstrap cycle self-heals on next connect.
42
+ }
43
+ }
44
+ /** State signature for delta deduplication */
45
+ function extractStateSignature(ctx, delta) {
46
+ if (!delta.data || typeof delta.data !== 'object')
47
+ return null;
48
+ const data = typeof delta.data === 'string'
49
+ ? JSON.parse(delta.data)
50
+ : (delta.data);
51
+ // Generic state fields — subclasses can override getStateFields() for model-specific fields
52
+ const fieldsToCheck = ctx.getStateFields(delta.modelName);
53
+ const signature = {
54
+ actionType: delta.actionType,
55
+ modelName: delta.modelName,
56
+ };
57
+ for (const field of fieldsToCheck) {
58
+ if (field in data)
59
+ signature[field] = data[field];
60
+ }
61
+ return signature;
62
+ }
63
+ function isSameState(a, b) {
64
+ if (!a || !b)
65
+ return false;
66
+ const keys = Object.keys(a);
67
+ if (keys.length !== Object.keys(b).length)
68
+ return false;
69
+ return keys.every((k) => a[k] === b[k]);
70
+ }
71
+ /** Deduplicate deltas to the same entity — keep meaningful state transitions only */
72
+ export function deduplicateDeltas(ctx, deltas) {
73
+ const byEntity = new Map();
74
+ for (const d of deltas) {
75
+ const key = `${d.modelName}:${d.modelId}`;
76
+ if (!byEntity.has(key))
77
+ byEntity.set(key, []);
78
+ byEntity.get(key).push(d);
79
+ }
80
+ const result = [];
81
+ for (const entityDeltas of byEntity.values()) {
82
+ const sorted = entityDeltas.sort((a, b) => a.id - b.id);
83
+ // DELETE wins — it's the final state
84
+ const del = sorted.find((d) => d.actionType === 'D');
85
+ if (del) {
86
+ result.push(del);
87
+ continue;
88
+ }
89
+ // Keep deltas that represent different states
90
+ const unique = [];
91
+ let prev = null;
92
+ for (const d of sorted) {
93
+ const sig = extractStateSignature(ctx, d);
94
+ if (!isSameState(prev, sig)) {
95
+ unique.push(d);
96
+ prev = sig;
97
+ }
98
+ }
99
+ if (unique.length > 0) {
100
+ result.push(...unique);
101
+ }
102
+ else {
103
+ // `sorted` is never empty (every byEntity bucket gets at least one
104
+ // delta pushed) — the guard only narrows the indexed access.
105
+ const last = sorted.at(-1);
106
+ if (last)
107
+ result.push(last);
108
+ }
109
+ }
110
+ return result.sort((a, b) => a.id - b.id);
111
+ }
112
+ /**
113
+ * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
114
+ * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
115
+ * subsequent flush must drain), `false` when it was skipped (dedup),
116
+ * deferred (bootstrap queue), or handled immediately out-of-band (G/S
117
+ * sync-group mutations). Does NOT schedule a flush — callers decide
118
+ * whether to debounce (live) or flush atomically (catch-up frame).
119
+ */
120
+ export function enqueueDelta(ctx, delta, options = {}) {
121
+ // Dedup guard — skip already-processed deltas. The `applied` watermark is a
122
+ // valid skip threshold ONLY for in-order live traffic; an authoritative
123
+ // catch-up frame bypasses it (see `applyDeltaFrame`) so an out-of-order
124
+ // live delta that advanced the watermark can't cause the frame's lower ids
125
+ // to be silently dropped.
126
+ if (!options.authoritative && delta.id > 0 && delta.id <= ctx.highestProcessedSyncId) {
127
+ return false;
128
+ }
129
+ // Confirm awaiting transactions via sync ID threshold (before batching)
130
+ ctx.onDeltaReceived(delta.id);
131
+ // Queue during active bootstrap
132
+ if (ctx.bootstrapDeltaQueue !== null) {
133
+ ctx.bootstrapDeltaQueue.push(delta);
134
+ return false;
135
+ }
136
+ // Advance watermark
137
+ ctx.advanceApplied(delta.id);
138
+ // Sync group added — handle immediately. Supports both legacy
139
+ // (addedGroups/removedGroups) and incremental (group/userId) payloads.
140
+ // NOT fire-and-forget: the watermark above already advanced, so a rejected
141
+ // handler (a failed security clear) must trigger the fallback, not vanish.
142
+ if (delta.actionType === 'G') {
143
+ void ctx.handleSyncGroupChange(delta).catch((error) => {
144
+ handleGroupHandlerFailure(ctx, delta, error);
145
+ });
146
+ return false;
147
+ }
148
+ // Sync group removed — handle immediately. Clears affected local state
149
+ // and forces re-bootstrap with the updated group list. Same fallback:
150
+ // a failed revocation clear escalates instead of leaving revoked rows.
151
+ if (delta.actionType === 'S') {
152
+ void ctx.handleGroupRemoved(delta).catch((error) => {
153
+ handleGroupHandlerFailure(ctx, delta, error);
154
+ });
155
+ return false;
156
+ }
157
+ // DELETE — fire the cascade cancel immediately (O(1) via FK index;
158
+ // must run BEFORE any subsequent update on the same model lands so
159
+ // pending update transactions for soon-deleted children don't race
160
+ // their parent's delete) but route the IDB+pool write through the
161
+ // same batched path as UPDATEs. The previous immediate-flush path
162
+ // produced N IDB writes + N pool mutations + N `models:changed`
163
+ // events when a peer deleted a chart with N layers; the batched
164
+ // path produces one of each per microtask flush. Dedup in
165
+ // `flushPendingDeltas` handles the U-then-D-on-same-model case
166
+ // correctly via arrival-order replay through `processDeltaBatch`.
167
+ if (delta.actionType === 'D') {
168
+ ctx.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
169
+ }
170
+ ctx.pendingDeltas.push(delta);
171
+ return true;
172
+ }
173
+ /** Debounce a flush for live single-delta traffic. */
174
+ export function scheduleDeltaFlush(ctx) {
175
+ if (ctx.batchTimer)
176
+ clearTimeout(ctx.batchTimer);
177
+ if (ctx.pendingDeltas.length >= ctx.smartSyncOptions.maxBatchSize) {
178
+ void ctx.flushPendingDeltas().catch(ctx.handleFlushError);
179
+ }
180
+ else {
181
+ ctx.batchTimer = setTimeout(() => {
182
+ void ctx.flushPendingDeltas().catch(ctx.handleFlushError);
183
+ }, ctx.smartSyncOptions.batchingDelay);
184
+ }
185
+ }
186
+ /** Flush pending deltas with deduplication and batched ObjectPool mutations */
187
+ /** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
188
+ export async function flushPendingDeltas(ctx) {
189
+ if (ctx.pendingDeltas.length === 0)
190
+ return;
191
+ const deduplicatedDeltas = ctx.deduplicateDeltas(ctx.pendingDeltas);
192
+ // Custom entities → apply directly to ObjectPool (skip IDB)
193
+ const customDeltas = deduplicatedDeltas.filter((d) => ctx.isCustomEntity(d.modelName));
194
+ if (customDeltas.length > 0) {
195
+ runInAction(() => {
196
+ for (const delta of customDeltas) {
197
+ const data = typeof delta.data === 'string'
198
+ ? JSON.parse(delta.data)
199
+ : (delta.data);
200
+ // 'C' (Covering) is treated identically to 'I' here — the client
201
+ // gained permission to see the entity, so we insert it into the
202
+ // pool as if newly created.
203
+ if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
204
+ const existing = ctx.objectPool.get(delta.modelId);
205
+ if (existing) {
206
+ existing.updateFromData(data);
207
+ }
208
+ else {
209
+ const model = ctx.createCustomEntity(delta.modelName, delta.modelId, data);
210
+ if (model) {
211
+ model.markAsPersisted();
212
+ ctx.objectPool.add(model, ModelScope.live);
213
+ }
214
+ }
215
+ }
216
+ else if (delta.actionType === 'D') {
217
+ ctx.objectPool.remove(delta.modelId);
218
+ }
219
+ }
220
+ });
221
+ }
222
+ // Regular deltas → IDB then ObjectPool via SyncClient.
223
+ // 'G' and 'S' deltas are routed upstream (handleSyncGroupChange,
224
+ // handleGroupRemoved) and never reach flushPendingDeltas, but the
225
+ // Database.processDelta signature accepts them defensively.
226
+ const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
227
+ const batch = await ctx.processDeltaBatch(regularDeltas.map((d) => ({
228
+ syncId: d.id,
229
+ actionType: d.actionType,
230
+ modelName: d.modelName,
231
+ modelId: d.modelId,
232
+ data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
233
+ // Thread `transactionId` through so the receive layer can
234
+ // recognize echoes of locally-applied transactions and skip
235
+ // the pool mutation. See `OPTIMISTIC_RECONCILIATION.md`.
236
+ transactionId: d.transactionId,
237
+ })));
238
+ const dbResults = batch.results;
239
+ // Delegate ObjectPool writes to SyncClient (owns pool operations)
240
+ ctx.applyDeltaBatchToPool(dbResults);
241
+ // Acknowledge + advance sync cursor — gated on IDB persistence.
242
+ //
243
+ // We MUST ack `persistedSyncId` (the high-water mark of deltas whose
244
+ // store transaction actually committed), NOT the input batch's last
245
+ // delta id. Acking by input range advances the server's view past
246
+ // deltas that never wrote to IDB; the next catch-up request would
247
+ // then send the advanced cursor and the server replies "you're up
248
+ // to date" — losing the un-persisted delta forever. This is the
249
+ // Replicache "same-transaction" invariant: the cursor and the
250
+ // persisted view must be consistent.
251
+ const persistedSyncId = batch.persistedSyncId;
252
+ if (persistedSyncId > ctx.lastAckedId) {
253
+ ctx.acknowledge(persistedSyncId);
254
+ ctx.advancePersisted(persistedSyncId);
255
+ }
256
+ // Cache invalidation is automatic via SyncClient 'models:changed' event
257
+ ctx.pendingDeltas = [];
258
+ if (ctx.batchTimer) {
259
+ clearTimeout(ctx.batchTimer);
260
+ ctx.batchTimer = null;
261
+ }
262
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * groupChange — sync-group change / shrinkage handling.
3
+ *
4
+ * Extracted from BaseSyncedStore.ts as a cohesive leaf: the 'G'/'S' delta
5
+ * handlers (incremental group-added, legacy group-diff, group-removed), the
6
+ * group-set math, the force-re-bootstrap trigger, and the security-critical
7
+ * shrinkage check. The store keeps thin protected delegates with unchanged
8
+ * signatures — subclass override points stay overridable, and the leaf
9
+ * routes every cross-handler call back through the minimal
10
+ * {@link GroupChangeContext} so dynamic dispatch is preserved.
11
+ */
12
+ import type { SyncDelta, SyncGroupChangePayload, GroupAddedPayload } from './SyncWebSocket.js';
13
+ /**
14
+ * What the group-change handlers need back from their host store —
15
+ * narrow collaborator facades plus the host's own protected hooks (so a
16
+ * subclass override of e.g. `forceFullRebootstrap` still takes effect).
17
+ */
18
+ export interface GroupChangeContext {
19
+ /** Local persistence — SECURITY clears, subscription metadata, and the
20
+ * full-bootstrap flag. Structural subset of `Database`. */
21
+ readonly database: {
22
+ clear(): Promise<void>;
23
+ getWorkspaceMetadata(): Promise<{
24
+ subscribedSyncGroups?: string[];
25
+ } | null>;
26
+ updateWorkspaceMetadata(metadata: {
27
+ subscribedSyncGroups: string[];
28
+ }): Promise<void>;
29
+ markRequiresFullBootstrap(): void;
30
+ };
31
+ /** The in-memory pool — cleared alongside IDB on revocation. */
32
+ readonly objectPool: {
33
+ clear(): void;
34
+ };
35
+ /** Groups the CURRENT socket is subscribed to (`syncWebSocket?.getSyncGroups() ?? []`). */
36
+ getSubscribedSyncGroups(): readonly string[];
37
+ /** The session's authoritative groups via the host's `resolveSyncGroups`;
38
+ * null when no user context has been set yet. */
39
+ getCurrentSyncGroups(): readonly string[] | null;
40
+ /** `userContext?.bootstrapMode` — 'none' participants never re-bootstrap. */
41
+ getBootstrapMode(): 'full' | 'none' | undefined;
42
+ /** Disconnect the live socket (part of the force-re-bootstrap cycle). */
43
+ disconnectWebSocket(): void;
44
+ /** Forward to the host's `onConnectionEvent` lifecycle hook (no-op when unwired). */
45
+ emitConnectionEvent(event: string): void;
46
+ handleGroupAdded(payload: GroupAddedPayload, syncId: number): Promise<void>;
47
+ computeUpdatedSyncGroups(payload: SyncGroupChangePayload): string[];
48
+ forceFullRebootstrap(): void;
49
+ }
50
+ /**
51
+ * Handle an actionType 'G' delta.
52
+ *
53
+ * The server emits 'G' via two distinct pathways, distinguished by payload
54
+ * shape:
55
+ *
56
+ * Incremental (EmitGroupAdded): { group, userId }
57
+ * - The recipient was added to a single sync group.
58
+ * - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
59
+ * - No re-bootstrap — entities arrive via the normal insert path.
60
+ *
61
+ * Legacy (EmitGroupChange): { addedGroups, removedGroups }
62
+ * - Single delta carrying the full group membership diff.
63
+ * - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
64
+ * - Deprecated on the server; kept here for wire-level backward compat.
65
+ */
66
+ export declare function handleSyncGroupChange(ctx: GroupChangeContext, delta: SyncDelta): Promise<void>;
67
+ /**
68
+ * Handle an incremental GroupAdded delta.
69
+ *
70
+ * Adds the new group to the subscription metadata without triggering a
71
+ * re-bootstrap. The server will follow up with 'C' (Covering) deltas for
72
+ * each newly-visible entity, which flow through the normal insert path.
73
+ */
74
+ export declare function handleGroupAdded(ctx: GroupChangeContext, payload: GroupAddedPayload, syncId: number): Promise<void>;
75
+ /**
76
+ * Handle an actionType 'S' (GroupRemoved) delta.
77
+ *
78
+ * Signals that the recipient has lost access to a sync group. Because
79
+ * the client does not track per-entity group membership, we can't
80
+ * selectively purge entities belonging to that group. The safe fallback
81
+ * is the legacy behavior: clear local state and force a re-bootstrap
82
+ * with the updated group list.
83
+ *
84
+ * Future optimization: track group membership in the ObjectPool so 'S'
85
+ * can do a targeted purge instead of a full re-bootstrap.
86
+ */
87
+ export declare function handleGroupRemoved(ctx: GroupChangeContext, delta: SyncDelta): Promise<void>;
88
+ /** Compute new sync groups after applying additions and removals */
89
+ export declare function computeUpdatedSyncGroups(ctx: GroupChangeContext, payload: SyncGroupChangePayload): string[];
90
+ /** Force a full re-bootstrap via connection lifecycle event.
91
+ *
92
+ * No-op for `bootstrapMode: 'none'` participants — they never pull
93
+ * baseline state, so a "force re-bootstrap" trigger (sync-group
94
+ * shrink, scope revocation) instead just flushes the local pool and
95
+ * relies on covering deltas to repopulate the data they actually
96
+ * subscribe to.
97
+ */
98
+ export declare function forceFullRebootstrap(ctx: GroupChangeContext): void;
99
+ /**
100
+ * Single source of truth for the sync-group list this session is
101
+ * subscribed to. Server-issued (`context.syncGroups`) is authoritative.
102
+ * When absent, the SDK subscribes to no explicit groups. Both
103
+ * `checkSyncGroupShrinkage` and `setupWebSocketSync` resolve through
104
+ * here so the WS subscription and the security-critical shrinkage
105
+ * check can never disagree.
106
+ */
107
+ export declare function resolveSyncGroups(context: {
108
+ syncGroups?: readonly string[];
109
+ }): readonly string[];
110
+ /** Check if sync groups shrank since last session — force full bootstrap if so */
111
+ export declare function checkSyncGroupShrinkage(ctx: GroupChangeContext): Promise<void>;
@@ -0,0 +1,246 @@
1
+ /**
2
+ * groupChange — sync-group change / shrinkage handling.
3
+ *
4
+ * Extracted from BaseSyncedStore.ts as a cohesive leaf: the 'G'/'S' delta
5
+ * handlers (incremental group-added, legacy group-diff, group-removed), the
6
+ * group-set math, the force-re-bootstrap trigger, and the security-critical
7
+ * shrinkage check. The store keeps thin protected delegates with unchanged
8
+ * signatures — subclass override points stay overridable, and the leaf
9
+ * routes every cross-handler call back through the minimal
10
+ * {@link GroupChangeContext} so dynamic dispatch is preserved.
11
+ */
12
+ import { getContext } from '../context.js';
13
+ /** Sentinel for a 'G'/'S' payload that could not be parsed (vs a valid
14
+ * `null`/absent payload, which the handlers already tolerate). */
15
+ const MALFORMED_PAYLOAD = Symbol('malformed-group-change-payload');
16
+ /**
17
+ * Parse a group-change delta payload without ever throwing. The server
18
+ * serializes these as JSON strings; a corrupt frame used to escape the
19
+ * whole delta pipeline as an unhandled rejection AFTER the watermark had
20
+ * advanced — the delta is never re-delivered, so the security clear it
21
+ * carried was permanently lost.
22
+ */
23
+ function parseGroupChangePayload(delta) {
24
+ if (typeof delta.data !== 'string')
25
+ return delta.data;
26
+ try {
27
+ return JSON.parse(delta.data);
28
+ }
29
+ catch (error) {
30
+ getContext().logger.debug('[BaseSyncedStore] Malformed group-change payload', {
31
+ syncId: delta.id,
32
+ actionType: delta.actionType,
33
+ error: error instanceof Error ? error.message : String(error),
34
+ });
35
+ return MALFORMED_PAYLOAD;
36
+ }
37
+ }
38
+ /**
39
+ * The legacy-clear fallback for an unparseable group-change delta: we know
40
+ * access changed but not how, so treat it as a revocation — clear cached
41
+ * data (IDB + pool) and force a full re-bootstrap with server truth.
42
+ */
43
+ async function clearForUnknownGroupChange(ctx, delta, kind) {
44
+ getContext().logger.debug(`[BaseSyncedStore] Unreadable ${kind} payload — clearing cached data and re-bootstrapping`, { syncId: delta.id });
45
+ // SECURITY: same rationale as the legacy removedGroups path — revoked data
46
+ // must not persist if the device goes offline before the re-bootstrap.
47
+ await ctx.database.clear();
48
+ ctx.objectPool.clear();
49
+ ctx.forceFullRebootstrap();
50
+ }
51
+ /**
52
+ * Handle an actionType 'G' delta.
53
+ *
54
+ * The server emits 'G' via two distinct pathways, distinguished by payload
55
+ * shape:
56
+ *
57
+ * Incremental (EmitGroupAdded): { group, userId }
58
+ * - The recipient was added to a single sync group.
59
+ * - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
60
+ * - No re-bootstrap — entities arrive via the normal insert path.
61
+ *
62
+ * Legacy (EmitGroupChange): { addedGroups, removedGroups }
63
+ * - Single delta carrying the full group membership diff.
64
+ * - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
65
+ * - Deprecated on the server; kept here for wire-level backward compat.
66
+ */
67
+ export async function handleSyncGroupChange(ctx, delta) {
68
+ const raw = parseGroupChangePayload(delta);
69
+ if (raw === MALFORMED_PAYLOAD) {
70
+ // Malformed payload — we can't know WHICH groups changed, and this
71
+ // delta will never be re-delivered (the watermark already advanced).
72
+ // Degrade to the legacy security path: assume a removal, clear cached
73
+ // data, and rebuild from server truth. Never throw out of the pipeline.
74
+ await clearForUnknownGroupChange(ctx, delta, 'sync-group change');
75
+ return;
76
+ }
77
+ const rawObj = (raw ?? {});
78
+ // Detect incremental payload shape: { group, userId }
79
+ if (typeof rawObj.group === 'string' && typeof rawObj.userId === 'string') {
80
+ const incremental = {
81
+ group: rawObj.group,
82
+ userId: rawObj.userId,
83
+ };
84
+ await ctx.handleGroupAdded(incremental, delta.id);
85
+ return;
86
+ }
87
+ // Legacy payload: { addedGroups, removedGroups }
88
+ const payload = {
89
+ removedGroups: rawObj.removedGroups ?? [],
90
+ addedGroups: rawObj.addedGroups ?? [],
91
+ };
92
+ getContext().logger.info('[BaseSyncedStore] Sync group change received (legacy)', {
93
+ removedGroups: payload.removedGroups,
94
+ addedGroups: payload.addedGroups,
95
+ syncId: delta.id,
96
+ });
97
+ // SECURITY: If groups were removed, clear cached data immediately.
98
+ // This prevents revoked data from persisting if the device goes offline
99
+ // before the full re-bootstrap completes.
100
+ if (payload.removedGroups.length > 0) {
101
+ await ctx.database.clear();
102
+ ctx.objectPool.clear();
103
+ getContext().logger.info('[BaseSyncedStore] Cleared cached data due to revoked sync groups', {
104
+ removedGroups: payload.removedGroups,
105
+ });
106
+ }
107
+ const updatedGroups = ctx.computeUpdatedSyncGroups(payload);
108
+ await ctx.database.updateWorkspaceMetadata({ subscribedSyncGroups: updatedGroups });
109
+ ctx.forceFullRebootstrap();
110
+ }
111
+ /**
112
+ * Handle an incremental GroupAdded delta.
113
+ *
114
+ * Adds the new group to the subscription metadata without triggering a
115
+ * re-bootstrap. The server will follow up with 'C' (Covering) deltas for
116
+ * each newly-visible entity, which flow through the normal insert path.
117
+ */
118
+ export async function handleGroupAdded(ctx, payload, syncId) {
119
+ getContext().logger.info('[BaseSyncedStore] Group added (incremental)', {
120
+ group: payload.group,
121
+ syncId,
122
+ });
123
+ const current = new Set(ctx.getSubscribedSyncGroups());
124
+ current.add(payload.group);
125
+ await ctx.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
126
+ // Note: no forceFullRebootstrap() — covering deltas will bring the entities.
127
+ }
128
+ /**
129
+ * Handle an actionType 'S' (GroupRemoved) delta.
130
+ *
131
+ * Signals that the recipient has lost access to a sync group. Because
132
+ * the client does not track per-entity group membership, we can't
133
+ * selectively purge entities belonging to that group. The safe fallback
134
+ * is the legacy behavior: clear local state and force a re-bootstrap
135
+ * with the updated group list.
136
+ *
137
+ * Future optimization: track group membership in the ObjectPool so 'S'
138
+ * can do a targeted purge instead of a full re-bootstrap.
139
+ */
140
+ export async function handleGroupRemoved(ctx, delta) {
141
+ const raw = parseGroupChangePayload(delta);
142
+ if (raw === MALFORMED_PAYLOAD) {
143
+ // Malformed 'S' payload — access WAS revoked but we can't tell which
144
+ // group. Degrade to the legacy clear (the safe direction for a
145
+ // security delta) instead of throwing out of the pipeline.
146
+ await clearForUnknownGroupChange(ctx, delta, 'group-removed');
147
+ return;
148
+ }
149
+ const rawObj = (raw ?? {});
150
+ const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
151
+ if (!groupKey) {
152
+ getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
153
+ syncId: delta.id,
154
+ });
155
+ return;
156
+ }
157
+ getContext().logger.info('[BaseSyncedStore] Group removed', {
158
+ group: groupKey,
159
+ syncId: delta.id,
160
+ });
161
+ // SECURITY: Clear cached data before re-bootstrap. This prevents
162
+ // revoked-group data from persisting if the device goes offline
163
+ // between receiving 'S' and completing the re-bootstrap.
164
+ await ctx.database.clear();
165
+ ctx.objectPool.clear();
166
+ // Update subscription metadata so the re-bootstrap fetches the
167
+ // correct set of groups.
168
+ const current = new Set(ctx.getSubscribedSyncGroups());
169
+ current.delete(groupKey);
170
+ await ctx.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
171
+ ctx.forceFullRebootstrap();
172
+ }
173
+ /** Compute new sync groups after applying additions and removals */
174
+ export function computeUpdatedSyncGroups(ctx, payload) {
175
+ const current = new Set(ctx.getSubscribedSyncGroups());
176
+ for (const g of payload.removedGroups)
177
+ current.delete(g);
178
+ for (const g of payload.addedGroups)
179
+ current.add(g);
180
+ return Array.from(current);
181
+ }
182
+ /** Force a full re-bootstrap via connection lifecycle event.
183
+ *
184
+ * No-op for `bootstrapMode: 'none'` participants — they never pull
185
+ * baseline state, so a "force re-bootstrap" trigger (sync-group
186
+ * shrink, scope revocation) instead just flushes the local pool and
187
+ * relies on covering deltas to repopulate the data they actually
188
+ * subscribe to.
189
+ */
190
+ export function forceFullRebootstrap(ctx) {
191
+ if (ctx.getBootstrapMode() === 'none') {
192
+ getContext().logger.info('[BaseSyncedStore] forceFullRebootstrap skipped (bootstrapMode=none)');
193
+ return;
194
+ }
195
+ ctx.database.markRequiresFullBootstrap();
196
+ ctx.disconnectWebSocket();
197
+ ctx.emitConnectionEvent('WS_DISCONNECTED');
198
+ }
199
+ /**
200
+ * Single source of truth for the sync-group list this session is
201
+ * subscribed to. Server-issued (`context.syncGroups`) is authoritative.
202
+ * When absent, the SDK subscribes to no explicit groups. Both
203
+ * `checkSyncGroupShrinkage` and `setupWebSocketSync` resolve through
204
+ * here so the WS subscription and the security-critical shrinkage
205
+ * check can never disagree.
206
+ */
207
+ export function resolveSyncGroups(context) {
208
+ if (context.syncGroups && context.syncGroups.length > 0) {
209
+ return context.syncGroups;
210
+ }
211
+ return [];
212
+ }
213
+ /** Check if sync groups shrank since last session — force full bootstrap if so */
214
+ export async function checkSyncGroupShrinkage(ctx) {
215
+ const currentSyncGroups = ctx.getCurrentSyncGroups();
216
+ if (!currentSyncGroups)
217
+ return;
218
+ try {
219
+ const metadata = await ctx.database.getWorkspaceMetadata();
220
+ const stored = metadata?.subscribedSyncGroups ?? [];
221
+ if (stored.length === 0)
222
+ return;
223
+ const currentGroups = new Set(currentSyncGroups);
224
+ const removedGroups = stored.filter((g) => !currentGroups.has(g));
225
+ if (removedGroups.length > 0) {
226
+ getContext().logger.info('[BaseSyncedStore] Sync groups shrank — forcing full bootstrap', {
227
+ removedGroups,
228
+ storedCount: stored.length,
229
+ currentCount: currentGroups.size,
230
+ });
231
+ // SECURITY: Clear cached data before re-bootstrap to prevent
232
+ // revoked-group data from persisting if device goes offline
233
+ await ctx.database.clear();
234
+ ctx.objectPool.clear();
235
+ ctx.database.markRequiresFullBootstrap();
236
+ }
237
+ await ctx.database.updateWorkspaceMetadata({
238
+ subscribedSyncGroups: Array.from(currentGroups),
239
+ });
240
+ }
241
+ catch (error) {
242
+ getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
243
+ error: error instanceof Error ? error.message : String(error),
244
+ });
245
+ }
246
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Application-level heartbeat for the sync WebSocket.
3
+ *
4
+ * The browser WebSocket API hides RFC 6455 protocol-level ping/pong from
5
+ * JavaScript, so the server's `ws.ping()` keepalive can't be observed by
6
+ * client code — meaning the client cannot tell a healthy idle connection
7
+ * apart from a "zombie" socket where TCP silently broke (laptop sleep,
8
+ * NAT timeout, mobile handoff). We send an application-level
9
+ * `{ type: 'ping' }` every 30s and force-close the socket if no inbound
10
+ * traffic arrives within 10s. ANY inbound message counts as
11
+ * proof-of-life — the explicit `pong` is just a guarantee that something
12
+ * will arrive even on an idle stream.
13
+ */
14
+ /**
15
+ * One cadence for both sides: the SDK's application-level ping runs at the
16
+ * same `PING_INTERVAL_MS` as the server's RFC 6455 keepalive (and the claim
17
+ * lease window is derived from it — see `wire/protocol.ts`).
18
+ */
19
+ export declare const HEARTBEAT_INTERVAL_MS = 30000;
20
+ export declare const HEARTBEAT_TIMEOUT_MS = 10000;
21
+ /**
22
+ * The slice of the socket the heartbeat needs — kept minimal so this
23
+ * leaf never depends on the SyncWebSocket class itself.
24
+ */
25
+ export interface HeartbeatTransport {
26
+ /** True only while the underlying socket is `OPEN`. */
27
+ isSocketOpen(): boolean;
28
+ /** Send the `{ type: 'ping' }` frame; throws when the socket is already dead. */
29
+ sendPing(): void;
30
+ /**
31
+ * Force-close the socket from the client side (private 4xxx code) so
32
+ * `onclose` fires and runs the owner's reconnect / handshake-failed
33
+ * dispatch.
34
+ */
35
+ forceClose(reason: string): void;
36
+ }
37
+ /**
38
+ * Every `HEARTBEAT_INTERVAL_MS` while `OPEN`, send `{ type: 'ping' }`
39
+ * and arm a `HEARTBEAT_TIMEOUT_MS` watchdog. Any inbound frame (the
40
+ * owner's `onmessage` calls {@link clearHeartbeatTimeout}) clears the
41
+ * watchdog. If the watchdog fires, we treat the connection as zombie
42
+ * and force-close it — `onclose` then triggers the existing reconnect
43
+ * path.
44
+ *
45
+ * Why both sides need this:
46
+ * - The server sends RFC 6455 protocol pings via `ws.ping()` every
47
+ * 30s. Browsers auto-respond with a pong but DO NOT expose either
48
+ * frame to JavaScript, so the client is blind to its own keepalive.
49
+ * - On a half-open TCP (laptop wake, NAT timeout, mobile handoff)
50
+ * the browser may keep `readyState === OPEN` for minutes before
51
+ * the OS surfaces the broken connection. App-level traffic is
52
+ * the only signal we can observe.
53
+ */
54
+ export declare class HeartbeatController {
55
+ private readonly transport;
56
+ private heartbeatTimer;
57
+ private heartbeatTimeoutTimer;
58
+ constructor(transport: HeartbeatTransport);
59
+ start(): void;
60
+ stop(): void;
61
+ clearHeartbeatTimeout(): void;
62
+ }