@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
@@ -8,7 +8,8 @@
8
8
  * - Handle conflict resolution for local changes
9
9
  */
10
10
  import { runInAction } from 'mobx';
11
- import { ModelScope } from './ObjectPool.js';
11
+ import { ObjectPool, ModelScope } from './ObjectPool.js';
12
+ import { Model } from './Model.js';
12
13
  // ModelRegistry instance accessed via this.objectPool.registry
13
14
  import { LoadStrategy } from './types/index.js';
14
15
  import { getContext } from './context.js';
@@ -16,6 +17,7 @@ import { AbloAuthenticationError, AbloError, AbloValidationError } from './error
16
17
  import { EventEmitter } from 'events';
17
18
  import { NetworkMonitor } from './NetworkMonitor.js';
18
19
  import { TransactionQueue } from './transactions/TransactionQueue.js';
20
+ import { persistedMutationSchema } from './transactions/persistedReplay.js';
19
21
  import { OptimisticEchoTracker, } from './transactions/OptimisticEchoTracker.js';
20
22
  import { SyncPosition } from './sync/syncPosition.js';
21
23
  /**
@@ -43,6 +45,22 @@ function rawRecordIsNewer(data, existing) {
43
45
  return true;
44
46
  return inMs > exMs;
45
47
  }
48
+ /**
49
+ * Narrow an untyped server `updatedAt` (ISO string / epoch / Date, off a
50
+ * `Record<string, unknown>` row) to epoch ms for LWW comparison. Falsy /
51
+ * non-date values → 0, matching the conflict resolver's historical
52
+ * "no timestamp = epoch" behavior.
53
+ */
54
+ function toEpochMs(value) {
55
+ if (!value)
56
+ return 0;
57
+ if (value instanceof Date)
58
+ return value.getTime();
59
+ if (typeof value === 'string' || typeof value === 'number') {
60
+ return new Date(value).getTime();
61
+ }
62
+ return 0;
63
+ }
46
64
  export class SyncClient extends EventEmitter {
47
65
  objectPool;
48
66
  database;
@@ -122,8 +140,25 @@ export class SyncClient extends EventEmitter {
122
140
  * Setup network monitoring handlers
123
141
  */
124
142
  setupNetworkMonitoring() {
125
- this.networkMonitor.on('online', () => this.handleReconnection());
126
- this.networkMonitor.on('offline', () => this.handleDisconnection());
143
+ // Both handlers emit to external listeners (which can throw) before/around
144
+ // their own try/catch — route rejections into observability rather than
145
+ // losing a failed reconnect flush silently.
146
+ this.networkMonitor.on('online', () => {
147
+ void this.handleReconnection().catch((error) => {
148
+ getContext().observability.captureTransactionFailure({
149
+ context: 'network-online-reconnection',
150
+ error: error instanceof Error ? error : new Error(String(error)),
151
+ });
152
+ });
153
+ });
154
+ this.networkMonitor.on('offline', () => {
155
+ void this.handleDisconnection().catch((error) => {
156
+ getContext().observability.captureTransactionFailure({
157
+ context: 'network-offline-handler',
158
+ error: error instanceof Error ? error : new Error(String(error)),
159
+ });
160
+ });
161
+ });
127
162
  }
128
163
  /**
129
164
  * Handle transaction rollback. Two distinct shapes flow through this
@@ -281,46 +316,14 @@ export class SyncClient extends EventEmitter {
281
316
  * will deliver the missing deltas and naturally confirm the transaction.
282
317
  */
283
318
  setupAwaitingTransactionPersistence() {
284
- this.transactionQueue.on('transaction:persist_awaiting', async (event) => {
285
- if (!this.database)
286
- return;
287
- try {
288
- await this.database.saveTransaction({
289
- id: `awaiting_${event.txId}`,
290
- type: 'awaiting_delta',
291
- timestamp: Date.now(),
292
- awaitingDelta: {
293
- syncIdNeeded: event.syncIdNeeded ?? 0,
294
- modelName: event.model,
295
- modelId: event.modelId,
296
- operationType: event.operationType,
297
- },
298
- });
299
- getContext().observability.breadcrumb('Persisted unconfirmed transaction to IDB', 'sync.transaction', 'info', {
300
- txId: event.txId,
301
- model: event.model,
302
- modelId: event.modelId,
303
- });
304
- }
305
- catch (error) {
306
- getContext().observability.captureTransactionFailure({
307
- context: 'persist-awaiting-transaction',
308
- modelName: event.model,
309
- modelId: event.modelId,
310
- error: error instanceof Error ? error : new Error(String(error)),
311
- });
312
- }
319
+ this.transactionQueue.on('transaction:persist_awaiting', (event) => {
320
+ // void is safe: the handler's body is fully try/catch'd.
321
+ void this.persistAwaitingTransaction(event);
313
322
  });
314
323
  // Clean up persisted awaiting transactions when they're finally confirmed
315
- this.transactionQueue.on('transaction:completed', async (tx) => {
316
- if (!this.database)
317
- return;
318
- try {
319
- await this.database.removeTransaction(`awaiting_${tx.id}`);
320
- }
321
- catch {
322
- // Ignore — might not have been persisted
323
- }
324
+ this.transactionQueue.on('transaction:completed', (tx) => {
325
+ // void is safe: the handler's body is fully try/catch'd.
326
+ void this.removeAwaitingTransaction(tx.id);
324
327
  });
325
328
  // Echo detection bridge. When the queue stages a transaction, the
326
329
  // client has already optimistically applied the change to the
@@ -338,6 +341,48 @@ export class SyncClient extends EventEmitter {
338
341
  this.echoTracker.drainOnRollback(event.transaction.id);
339
342
  });
340
343
  }
344
+ /** Persist an unconfirmed transaction to IDB (never rejects — failures are captured). */
345
+ async persistAwaitingTransaction(event) {
346
+ if (!this.database)
347
+ return;
348
+ try {
349
+ await this.database.saveTransaction({
350
+ id: `awaiting_${event.txId}`,
351
+ type: 'awaiting_delta',
352
+ timestamp: Date.now(),
353
+ awaitingDelta: {
354
+ syncIdNeeded: event.syncIdNeeded ?? 0,
355
+ modelName: event.model,
356
+ modelId: event.modelId,
357
+ operationType: event.operationType,
358
+ },
359
+ });
360
+ getContext().observability.breadcrumb('Persisted unconfirmed transaction to IDB', 'sync.transaction', 'info', {
361
+ txId: event.txId,
362
+ model: event.model,
363
+ modelId: event.modelId,
364
+ });
365
+ }
366
+ catch (error) {
367
+ getContext().observability.captureTransactionFailure({
368
+ context: 'persist-awaiting-transaction',
369
+ modelName: event.model,
370
+ modelId: event.modelId,
371
+ error: error instanceof Error ? error : new Error(String(error)),
372
+ });
373
+ }
374
+ }
375
+ /** Drop the persisted awaiting-row once confirmed (never rejects). */
376
+ async removeAwaitingTransaction(txId) {
377
+ if (!this.database)
378
+ return;
379
+ try {
380
+ await this.database.removeTransaction(`awaiting_${txId}`);
381
+ }
382
+ catch {
383
+ // Ignore — might not have been persisted
384
+ }
385
+ }
341
386
  /**
342
387
  * Initialize sync client with authentication
343
388
  */
@@ -483,8 +528,9 @@ export class SyncClient extends EventEmitter {
483
528
  // Persist healed records back to IndexedDB (fire-and-forget, non-blocking)
484
529
  if (recordsToHeal.length > 0 && this.database) {
485
530
  getContext().logger.info(`[SyncClient.hydrate] Persisting ${recordsToHeal.length} healed ${modelType} records to IndexedDB`);
486
- // Use fire-and-forget to not block hydration
487
- Promise.resolve().then(async () => {
531
+ // Use fire-and-forget to not block hydration.
532
+ // void is safe: the handler's body is fully try/catch'd.
533
+ void Promise.resolve().then(async () => {
488
534
  try {
489
535
  for (const { id, data } of recordsToHeal) {
490
536
  await this.database.putRecord(modelType, id, data);
@@ -501,13 +547,6 @@ export class SyncClient extends EventEmitter {
501
547
  });
502
548
  }
503
549
  const typeEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
504
- // Dev-only hydration summary
505
- if (modelType === 'InboxItem' && process.env.NODE_ENV !== 'production') {
506
- getContext().logger.debug('[SyncClient] InboxItem hydration summary', {
507
- fetched: rawData.length,
508
- added: modelsForType.length,
509
- });
510
- }
511
550
  perTypePerfLogs.push({
512
551
  type: modelType,
513
552
  fetched: rawData.length,
@@ -600,7 +639,8 @@ export class SyncClient extends EventEmitter {
600
639
  if (this.database) {
601
640
  const id = healResult.data.id;
602
641
  const healedData = healResult.data;
603
- Promise.resolve().then(async () => {
642
+ // void is safe: the handler's body is fully try/catch'd.
643
+ void Promise.resolve().then(async () => {
604
644
  try {
605
645
  await this.database.putRecord(modelType, id, healedData);
606
646
  }
@@ -755,11 +795,11 @@ export class SyncClient extends EventEmitter {
755
795
  }
756
796
  /** Add new model (CREATE) - works offline */
757
797
  add(model, options) {
758
- this.mutate('create', model, () => this.objectPool.add(model, ModelScope.live), options);
798
+ this.mutate('create', model, () => { this.objectPool.add(model, ModelScope.live); }, options);
759
799
  }
760
800
  /** Update existing model (UPDATE) - works offline */
761
801
  update(model, options) {
762
- this.mutate('update', model, () => this.objectPool.upsert(model, ModelScope.live), options);
802
+ this.mutate('update', model, () => { this.objectPool.upsert(model, ModelScope.live); }, options);
763
803
  }
764
804
  /**
765
805
  * Update existing model with pre-computed changes.
@@ -912,7 +952,7 @@ export class SyncClient extends EventEmitter {
912
952
  }
913
953
  /** Archive model (ARCHIVE) - works offline */
914
954
  archive(model) {
915
- this.mutate('archive', model, () => this.objectPool.updateScope(model.id, ModelScope.archived));
955
+ this.mutate('archive', model, () => { this.objectPool.updateScope(model.id, ModelScope.archived); });
916
956
  }
917
957
  /**
918
958
  * Append a mutation and schedule its sync work.
@@ -969,10 +1009,21 @@ export class SyncClient extends EventEmitter {
969
1009
  timestamp: Date.now(),
970
1010
  });
971
1011
  }
972
- catch (error) { }
1012
+ catch (error) {
1013
+ // Best-effort persistence — the in-memory queue still processes; only
1014
+ // a tab close before reconnect loses these. Forensic → debug.
1015
+ getContext().logger.debug('[SyncClient] Failed to persist offline mutation queue', {
1016
+ error: error instanceof Error ? error.message : String(error),
1017
+ });
1018
+ }
973
1019
  }
974
1020
  /**
975
- * Restore mutation queue from IndexedDB
1021
+ * Restore mutation queue from IndexedDB.
1022
+ *
1023
+ * The persisted record was written by a PREVIOUS session (possibly an older
1024
+ * SDK build), so each entry is validated at this replay boundary (T1.8):
1025
+ * corrupt entries are dropped + logged at debug, and a failure never
1026
+ * vanishes into an empty catch — offline write survival must be observable.
976
1027
  */
977
1028
  async restoreMutationQueue() {
978
1029
  if (!this.database || !this.userId)
@@ -982,19 +1033,39 @@ export class SyncClient extends EventEmitter {
982
1033
  const queue = stored.find((t) => t.id === 'mutation-queue');
983
1034
  if (queue?.mutations) {
984
1035
  for (const mutation of queue.mutations) {
985
- const model = this.objectPool.createFromData(mutation.modelData);
1036
+ const parsed = persistedMutationSchema.safeParse(mutation);
1037
+ if (!parsed.success) {
1038
+ getContext().logger.debug('[SyncClient] Dropping malformed persisted mutation', {
1039
+ issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
1040
+ });
1041
+ continue;
1042
+ }
1043
+ const model = this.objectPool.createFromData(parsed.data.modelData);
986
1044
  if (model) {
987
1045
  this.pendingMutations.push({
988
- type: mutation.type,
1046
+ type: parsed.data.type,
989
1047
  model,
990
- timestamp: new Date(mutation.timestamp),
991
- writeOptions: mutation.writeOptions,
1048
+ timestamp: new Date(parsed.data.timestamp),
1049
+ ...(parsed.data.writeOptions !== undefined
1050
+ ? { writeOptions: parsed.data.writeOptions }
1051
+ : {}),
992
1052
  });
993
1053
  }
994
1054
  }
995
1055
  }
996
1056
  }
997
- catch (error) { }
1057
+ catch (error) {
1058
+ // A restore failure means queued offline writes did NOT rehydrate.
1059
+ // Self-healing is impossible here (the record may be unreadable), but
1060
+ // the failure must be visible for diagnosis instead of silent loss.
1061
+ getContext().logger.debug('[SyncClient] Failed to restore offline mutation queue', {
1062
+ error: error instanceof Error ? error.message : String(error),
1063
+ });
1064
+ getContext().observability.captureTransactionFailure({
1065
+ context: 'restore-mutation-queue',
1066
+ error: error instanceof Error ? error : String(error),
1067
+ });
1068
+ }
998
1069
  }
999
1070
  /**
1000
1071
  * Process pending mutations - can be called by SyncedStore when online
@@ -1055,12 +1126,26 @@ export class SyncClient extends EventEmitter {
1055
1126
  if (!this.userId || !this.organizationId)
1056
1127
  return;
1057
1128
  const ctx = { userId: this.userId, organizationId: this.organizationId };
1129
+ // Settlement is delivered via transaction.confirmation, not this promise —
1130
+ // it only rejects when staging itself throws (change extraction, optimistic
1131
+ // apply, store add). That means the write never entered the queue, so
1132
+ // capture it instead of dropping it silently.
1133
+ const captureStagingFailure = (error) => {
1134
+ getContext().observability.captureTransactionFailure({
1135
+ context: `stage-mutation-${mutation.type}`,
1136
+ modelName: mutation.model.getModelName(),
1137
+ modelId: mutation.model.id,
1138
+ error: error instanceof Error ? error : new Error(String(error)),
1139
+ });
1140
+ };
1058
1141
  if (mutation.type === 'update') {
1059
- this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions);
1142
+ this.transactionQueue
1143
+ .update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions)
1144
+ .catch(captureStagingFailure);
1060
1145
  }
1061
1146
  else {
1062
1147
  const handler = this.transactionQueue[mutation.type].bind(this.transactionQueue);
1063
- handler(mutation.model, ctx, mutation.writeOptions);
1148
+ handler(mutation.model, ctx, mutation.writeOptions).catch(captureStagingFailure);
1064
1149
  }
1065
1150
  }
1066
1151
  /**
@@ -1078,7 +1163,7 @@ export class SyncClient extends EventEmitter {
1078
1163
  ? localModel.updatedAt.getTime()
1079
1164
  : new Date(localModel.updatedAt).getTime()
1080
1165
  : 0;
1081
- const serverUpdatedAt = serverData?.updatedAt ? new Date(serverData.updatedAt).getTime() : 0;
1166
+ const serverUpdatedAt = toEpochMs(serverData.updatedAt);
1082
1167
  getContext().logger.debug('Conflict resolution', {
1083
1168
  modelId: localModel.id,
1084
1169
  modelType: localModel.getModelName(),
@@ -1114,7 +1199,7 @@ export class SyncClient extends EventEmitter {
1114
1199
  // Merge: server baseline + local dirty fields win
1115
1200
  const merged = { ...serverData, ...(localChanges || {}) };
1116
1201
  // Preserve the most recent updatedAt without clearing dirty flags
1117
- if (serverData?.updatedAt || localModel.updatedAt) {
1202
+ if (serverData.updatedAt || localModel.updatedAt) {
1118
1203
  const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
1119
1204
  // updateFromData accepts Date or ISO string for dates
1120
1205
  merged.updatedAt = mergedUpdatedAt;
@@ -1134,7 +1219,8 @@ export class SyncClient extends EventEmitter {
1134
1219
  }
1135
1220
  /**
1136
1221
  * Extract critical state fields from server data
1137
- * These are states that must always be respected, even with local changes
1222
+ * These are states that must always be respected, even with local changes.
1223
+ * The conflict brain reads exactly these known fields — nothing else.
1138
1224
  */
1139
1225
  extractCriticalState(serverData) {
1140
1226
  const critical = {};
@@ -4,7 +4,7 @@
4
4
  * All SDK classes receive this context at construction time.
5
5
  * It bundles every injectable dependency so constructors stay clean.
6
6
  */
7
- import type { SyncLogger, SyncObservabilityProvider, SyncAnalytics, SessionErrorDetector, OnlineStatusProvider, ModelDebugLoggerContract, MutationExecutor, MutationDispatcher, SyncEngineConfig } from './interfaces/index.js';
7
+ import type { SyncLogger, SyncObservabilityProvider, SyncAnalytics, SessionErrorDetector, OnlineStatusProvider, ModelDebugLoggerContract, MutationExecutor, SyncEngineConfig } from './interfaces/index.js';
8
8
  export interface SyncEngineContext {
9
9
  /** Structured logger */
10
10
  logger: SyncLogger;
@@ -20,8 +20,6 @@ export interface SyncEngineContext {
20
20
  modelDebugLogger?: ModelDebugLoggerContract;
21
21
  /** Backend mutation transport (GraphQL, REST, etc.) */
22
22
  mutationExecutor: MutationExecutor;
23
- /** Offline mutation replay dispatcher */
24
- mutationDispatcher: MutationDispatcher;
25
23
  /** Application-specific sync configuration */
26
24
  config: SyncEngineConfig;
27
25
  }
@@ -26,7 +26,6 @@ export const noopObservability = {
26
26
  captureReconciliation() { },
27
27
  captureDeltaRetryExhausted() { },
28
28
  captureWebSocketError() { },
29
- captureOfflineFlushFailure() { },
30
29
  captureSelfHealing() { },
31
30
  captureClaim() { },
32
31
  captureConflict() { },
@@ -57,7 +56,7 @@ export const browserOnlineStatus = {
57
56
  export const defaultSessionErrorDetector = {
58
57
  isSessionError(error) {
59
58
  if (error && typeof error === 'object' && 'isSessionError' in error) {
60
- return error.isSessionError === true;
59
+ return error.isSessionError;
61
60
  }
62
61
  return false;
63
62
  },
@@ -141,14 +141,14 @@ export interface AgentMessage {
141
141
  /** Subset of AI SDK's prepareStep context. */
142
142
  export interface PrepareStepContext<M extends AgentMessage = AgentMessage> {
143
143
  stepNumber: number;
144
- steps: ReadonlyArray<{
145
- toolCalls?: ReadonlyArray<{
144
+ steps: readonly {
145
+ toolCalls?: readonly {
146
146
  toolName: string;
147
147
  input?: unknown;
148
148
  args?: unknown;
149
- }>;
150
- toolResults?: ReadonlyArray<unknown>;
151
- }>;
149
+ }[];
150
+ toolResults?: readonly unknown[];
151
+ }[];
152
152
  messages: M[];
153
153
  model?: unknown;
154
154
  }
@@ -164,12 +164,12 @@ export interface StepFinishContext {
164
164
  stepType?: 'initial' | 'continue' | 'tool-result';
165
165
  finishReason?: string;
166
166
  text?: string;
167
- toolCalls?: ReadonlyArray<{
167
+ toolCalls?: readonly {
168
168
  toolName: string;
169
169
  input?: unknown;
170
170
  args?: unknown;
171
- }>;
172
- toolResults?: ReadonlyArray<unknown>;
171
+ }[];
172
+ toolResults?: readonly unknown[];
173
173
  usage?: {
174
174
  inputTokens?: number;
175
175
  outputTokens?: number;
@@ -230,6 +230,19 @@ export interface WrapToolOptions<TArgs> {
230
230
  */
231
231
  announceOnExecute?: boolean;
232
232
  }
233
+ /**
234
+ * Console-backed default logger — the SAME gated factory the `Ablo()` client
235
+ * uses (`createConsoleLogger`, threshold from `ABLO_LOG_LEVEL`, default
236
+ * `warn`), tagged `[agent]` so agent-runtime lines are distinguishable. The
237
+ * previous hand-rolled shim ignored the level gate entirely and printed
238
+ * `[perception]` engine internals at debug/info by default. Level is resolved
239
+ * per construction (not at module load) so `ABLO_LOG_LEVEL` set by the host
240
+ * process before building an Agent is honored.
241
+ *
242
+ * Exported for the unit test that pins the gating; consumers pass their own
243
+ * `logger` option instead.
244
+ */
245
+ export declare function defaultAgentLogger(): SyncLogger;
233
246
  export declare class Agent implements PresenceAnnouncer {
234
247
  private readonly opts;
235
248
  constructor(options: AgentOptions);
@@ -40,17 +40,30 @@
40
40
  * for custom integrations outside the AI SDK.
41
41
  */
42
42
  import { createAgentSession } from './session.js';
43
+ import { createConsoleLogger, resolveLogLevel } from '../client/consoleLogger.js';
43
44
  import { AbloValidationError } from '../errors.js';
44
45
  // ── Agent ───────────────────────────────────────────────────────
45
- // Console-backed default logger. Local to this module so the agent
46
- // SDK doesn't take a transitive dependency on `getContext()` (which
47
- // belongs to the web-app context, not standalone agent workers).
48
- const consoleLogger = {
49
- debug: (msg, ...args) => console.debug('[agent]', msg, ...args),
50
- info: (msg, ...args) => console.info('[agent]', msg, ...args),
51
- warn: (msg, ...args) => console.warn('[agent]', msg, ...args),
52
- error: (msg, ...args) => console.error('[agent]', msg, ...args),
53
- };
46
+ /**
47
+ * Console-backed default logger the SAME gated factory the `Ablo()` client
48
+ * uses (`createConsoleLogger`, threshold from `ABLO_LOG_LEVEL`, default
49
+ * `warn`), tagged `[agent]` so agent-runtime lines are distinguishable. The
50
+ * previous hand-rolled shim ignored the level gate entirely and printed
51
+ * `[perception]` engine internals at debug/info by default. Level is resolved
52
+ * per construction (not at module load) so `ABLO_LOG_LEVEL` set by the host
53
+ * process before building an Agent is honored.
54
+ *
55
+ * Exported for the unit test that pins the gating; consumers pass their own
56
+ * `logger` option instead.
57
+ */
58
+ export function defaultAgentLogger() {
59
+ const gated = createConsoleLogger(resolveLogLevel());
60
+ return {
61
+ debug: (msg, ...args) => { gated.debug('[agent]', msg, ...args); },
62
+ info: (msg, ...args) => { gated.info('[agent]', msg, ...args); },
63
+ warn: (msg, ...args) => { gated.warn('[agent]', msg, ...args); },
64
+ error: (msg, ...args) => { gated.error('[agent]', msg, ...args); },
65
+ };
66
+ }
54
67
  export class Agent {
55
68
  opts;
56
69
  constructor(options) {
@@ -63,7 +76,7 @@ export class Agent {
63
76
  syncGroups: options.syncGroups,
64
77
  fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
65
78
  timeoutMs: options.timeoutMs ?? 5_000,
66
- logger: options.logger ?? consoleLogger,
79
+ logger: options.logger ?? defaultAgentLogger(),
67
80
  };
68
81
  }
69
82
  /**
@@ -222,9 +235,10 @@ export class Agent {
222
235
  pendingClaims,
223
236
  };
224
237
  }
225
- const body = (await res.json());
238
+ const body = (await (res).json());
226
239
  const rows = body.results?.[0];
227
- if (!rows || rows.length === 0) {
240
+ const entity = rows?.[0];
241
+ if (!entity) {
228
242
  return {
229
243
  stale: true,
230
244
  reason: 'not_found',
@@ -232,7 +246,6 @@ export class Agent {
232
246
  pendingClaims,
233
247
  };
234
248
  }
235
- const entity = rows[0];
236
249
  const updatedAtRaw = entity.updated_at ?? entity.updatedAt;
237
250
  const lastModifiedBy = entity.updated_by ??
238
251
  entity.updatedBy ??
@@ -38,7 +38,13 @@ export function coordinationContextMiddleware(options) {
38
38
  return params;
39
39
  // Read peer claims on the same target. Synchronous lookup
40
40
  // against the engine's reactive claims.others array — no I/O.
41
- const peerClaims = agent.claims.others.filter((claim) => claim.target.type === target.type &&
41
+ // Type compares case-insensitively: observed claims carry the WIRE
42
+ // dialect (lowercased typename, `slidedeck`), while callers naturally
43
+ // write the schema typename (`SlideDeck`) — the same normalization the
44
+ // commit plane applies (`modelMap` lookups lowercase, and
45
+ // `targetsOverlap` below already lowercases field/path).
46
+ const wantedType = target.type.toLowerCase();
47
+ const peerClaims = agent.claims.others.filter((claim) => claim.target.type.toLowerCase() === wantedType &&
42
48
  claim.target.id === target.id &&
43
49
  targetsOverlap(claim.target, target) &&
44
50
  !excludeClaimIds.has(claim.id));
@@ -78,8 +84,8 @@ function targetsOverlap(claimTarget, target) {
78
84
  */
79
85
  function formatCoordinationNote(claims, target) {
80
86
  const entityLabel = target.type.toLowerCase();
81
- if (claims.length === 1) {
82
- const c = claims[0];
87
+ const c = claims.length === 1 ? claims[0] : undefined;
88
+ if (c) {
83
89
  const details = c.description ? `Declared work: ${c.description}. ` : '';
84
90
  return (`<multiplayer_context>\n` +
85
91
  `Another participant is currently editing this ${entityLabel}. ` +
@@ -13,7 +13,7 @@
13
13
  * const wrapped = wrapWithMultiplayer({
14
14
  * model: anthropic('claude-opus-4-7'),
15
15
  * agent,
16
- * target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
16
+ * target: { type: 'SlideDeck', id: 'deck-abc' },
17
17
  * });
18
18
  *
19
19
  * const result = streamText({
@@ -13,7 +13,7 @@
13
13
  * const wrapped = wrapWithMultiplayer({
14
14
  * model: anthropic('claude-opus-4-7'),
15
15
  * agent,
16
- * target: { entityType: 'SlideDeck', entityId: 'deck-abc' },
16
+ * target: { type: 'SlideDeck', id: 'deck-abc' },
17
17
  * });
18
18
  *
19
19
  * const result = streamText({
@@ -22,7 +22,14 @@
22
22
  * prefix lookup so it can ship in the client bundle.
23
23
  */
24
24
  import type { exchangeApiKey, mintUserSessionKey, resolveIdentity } from './index.js';
25
- import type { resolveApiKeyValue } from '../client/auth.js';
25
+ /**
26
+ * Structural signature of `client/auth.ts`'s `resolveApiKeyValue` — declared
27
+ * locally (not `typeof`-imported) because `client/auth.ts` runtime-imports
28
+ * {@link classifyCredentialKind} from THIS module; a back-edge here was the
29
+ * SDK's last auth-layer import cycle. The setter shape matches `ApiKeySetter`.
30
+ * `identity.ts` passes the real function; structural typing keeps them bound.
31
+ */
32
+ type ResolveApiKeyValueFn = (apiKey: string | (() => Promise<string | null>) | null) => Promise<string | null>;
26
33
  /**
27
34
  * The four Ablo API-key kinds (Stripe-style). Prefix contract — kept in lockstep
28
35
  * with `keys/index.ts` `API_KEY_KINDS` / `KIND_BY_PREFIX`, but declared locally
@@ -52,7 +59,7 @@ export interface CredentialPrimitives {
52
59
  readonly exchangeApiKey: typeof exchangeApiKey;
53
60
  readonly mintUserSessionKey: typeof mintUserSessionKey;
54
61
  readonly resolveIdentity: typeof resolveIdentity;
55
- readonly resolveApiKeyValue: typeof resolveApiKeyValue;
62
+ readonly resolveApiKeyValue: ResolveApiKeyValueFn;
56
63
  }
57
64
  export interface ResolveCredentialContext {
58
65
  readonly primitives: CredentialPrimitives;
@@ -143,3 +150,4 @@ export type ResolvedCredential =
143
150
  * 3. otherwise (identity known) → `explicit` (legacy self-hosted, no round-trip).
144
151
  */
145
152
  export declare function resolveCredential(input: ResolveCredentialInput, ctx: ResolveCredentialContext): Promise<ResolvedCredential>;
153
+ export {};
@@ -6,19 +6,7 @@
6
6
  * auth. Token refresh writes here once; consumers observe the new value
7
7
  * through their getter without being manually patched one by one.
8
8
  */
9
- /**
10
- * WebSocket subprotocols used to carry the bearer credential OUT of the URL.
11
- *
12
- * Browsers cannot set an `Authorization` header on a WebSocket, so the SDK
13
- * offers the token as a `Sec-WebSocket-Protocol` value — `ablo.bearer.<token>` —
14
- * alongside the real `ablo.sync.v1` protocol the server selects. This keeps the
15
- * credential out of the query string, which ALB access logs, proxies, and
16
- * browser history capture. The server reads the token from the subprotocol and
17
- * echoes back ONLY `ablo.sync.v1`, never the token-bearing value. Shared with
18
- * the sync-server so client and server can never drift on the wire format.
19
- */
20
- export declare const WS_BEARER_SUBPROTOCOL_PREFIX = "ablo.bearer.";
21
- export declare const WS_SYNC_SUBPROTOCOL = "ablo.sync.v1";
9
+ export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from '../wire/protocol.js';
22
10
  export interface AuthCredentialSource {
23
11
  getAuthToken(): string | null;
24
12
  setAuthToken(token: string | null | undefined): void;
@@ -6,19 +6,12 @@
6
6
  * auth. Token refresh writes here once; consumers observe the new value
7
7
  * through their getter without being manually patched one by one.
8
8
  */
9
- /**
10
- * WebSocket subprotocols used to carry the bearer credential OUT of the URL.
11
- *
12
- * Browsers cannot set an `Authorization` header on a WebSocket, so the SDK
13
- * offers the token as a `Sec-WebSocket-Protocol` value — `ablo.bearer.<token>` —
14
- * alongside the real `ablo.sync.v1` protocol the server selects. This keeps the
15
- * credential out of the query string, which ALB access logs, proxies, and
16
- * browser history capture. The server reads the token from the subprotocol and
17
- * echoes back ONLY `ablo.sync.v1`, never the token-bearing value. Shared with
18
- * the sync-server so client and server can never drift on the wire format.
19
- */
20
- export const WS_BEARER_SUBPROTOCOL_PREFIX = 'ablo.bearer.';
21
- export const WS_SYNC_SUBPROTOCOL = 'ablo.sync.v1';
9
+ // The WS bearer-subprotocol constants moved to `../wire/protocol.js` — they are
10
+ // the wire contract shared with the sync-server (which imports them via
11
+ // `@abloatai/ablo/wire`, never the root barrel). Re-exported here so
12
+ // existing SDK-side importers (`SyncWebSocket`, `source/connector-protocol`)
13
+ // keep their import path.
14
+ export { WS_BEARER_SUBPROTOCOL_PREFIX, WS_SYNC_SUBPROTOCOL } from '../wire/protocol.js';
22
15
  export function createAuthCredentialSource(initialToken) {
23
16
  let authToken = normalizeToken(initialToken);
24
17
  return {