@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
package/dist/errors.d.ts CHANGED
@@ -162,22 +162,22 @@ export declare class AbloStaleContextError extends AbloError {
162
162
  /** Sync id at the caller's `readAt` when the write was attempted. */
163
163
  readonly readAt?: number;
164
164
  /** Entities that received deltas between `readAt` and the write. */
165
- readonly conflicts?: ReadonlyArray<{
165
+ readonly conflicts?: readonly {
166
166
  readonly model: string;
167
167
  readonly id: string;
168
168
  readonly observedSyncId: number;
169
- }>;
169
+ }[];
170
170
  constructor(message: string, options?: {
171
171
  code?: ErrorCode;
172
172
  httpStatus?: number;
173
173
  requestId?: string;
174
174
  cause?: unknown;
175
175
  readAt?: number;
176
- conflicts?: ReadonlyArray<{
176
+ conflicts?: readonly {
177
177
  readonly model: string;
178
178
  readonly id: string;
179
179
  readonly observedSyncId: number;
180
- }>;
180
+ }[];
181
181
  });
182
182
  }
183
183
  /**
@@ -240,13 +240,13 @@ export declare function formatClaimedErrorMessage(args: {
240
240
  */
241
241
  export declare class AbloClaimedError extends AbloError {
242
242
  readonly type: "AbloClaimedError";
243
- readonly claims?: ReadonlyArray<ClaimErrorClaim>;
243
+ readonly claims?: readonly ClaimErrorClaim[];
244
244
  constructor(message: string, options?: {
245
245
  code?: ErrorCode;
246
246
  httpStatus?: number;
247
247
  requestId?: string;
248
248
  cause?: unknown;
249
- claims?: ReadonlyArray<ClaimErrorClaim>;
249
+ claims?: readonly ClaimErrorClaim[];
250
250
  });
251
251
  }
252
252
  /**
@@ -373,6 +373,17 @@ export declare class SyncSessionError extends AbloAuthenticationError {
373
373
  */
374
374
  static isSessionErrorResponse(status: number, body?: string): boolean;
375
375
  }
376
+ /**
377
+ * WS-close analog of {@link SyncSessionError.isSessionErrorResponse}'s
378
+ * access-vs-session split: `true` for close reasons that mean the SHORT-LIVED
379
+ * access credential (`ek_`/`rk_`) passed its expiry — the hub's keepalive
380
+ * reaper closes such sockets with `4001 'credential_expired'`. Re-mintable
381
+ * from the still-valid login, so the connection layer silently re-mints and
382
+ * reconnects; never a sign-out, never a local-data clear. Every OTHER session
383
+ * close reason (key revocation, genuine login loss) stays terminal — a
384
+ * revoked credential must not be silently re-minted around.
385
+ */
386
+ export declare function isAccessCredentialExpiryCloseReason(reason: string): boolean;
376
387
  /**
377
388
  * Coerce ANY thrown value into an {@link AbloError} — the last-line guarantee
378
389
  * that an SDK consumer never catches an untagged error. An already-typed
@@ -405,7 +416,7 @@ export declare function errorFromWire(message: string, opts?: {
405
416
  httpStatus?: number;
406
417
  requestId?: string;
407
418
  requiredCapability?: RequiredCapability;
408
- claims?: ReadonlyArray<ClaimErrorClaim>;
419
+ claims?: readonly ClaimErrorClaim[];
409
420
  }): AbloError;
410
421
  /**
411
422
  * Translate an HTTP response into the appropriate typed error.
package/dist/errors.js CHANGED
@@ -368,7 +368,7 @@ export class SyncSessionError extends AbloAuthenticationError {
368
368
  return true;
369
369
  }
370
370
  if (error && typeof error === 'object' && 'isSessionError' in error) {
371
- return error.isSessionError === true;
371
+ return error.isSessionError;
372
372
  }
373
373
  return false;
374
374
  }
@@ -399,6 +399,19 @@ export class SyncSessionError extends AbloAuthenticationError {
399
399
  return status === 401;
400
400
  }
401
401
  }
402
+ /**
403
+ * WS-close analog of {@link SyncSessionError.isSessionErrorResponse}'s
404
+ * access-vs-session split: `true` for close reasons that mean the SHORT-LIVED
405
+ * access credential (`ek_`/`rk_`) passed its expiry — the hub's keepalive
406
+ * reaper closes such sockets with `4001 'credential_expired'`. Re-mintable
407
+ * from the still-valid login, so the connection layer silently re-mints and
408
+ * reconnects; never a sign-out, never a local-data clear. Every OTHER session
409
+ * close reason (key revocation, genuine login loss) stays terminal — a
410
+ * revoked credential must not be silently re-minted around.
411
+ */
412
+ export function isAccessCredentialExpiryCloseReason(reason) {
413
+ return reason === 'credential_expired' || classifyRecovery(reason) === 'access_credential_expiry';
414
+ }
402
415
  // ── HTTP → class mapping ──────────────────────────────────────────────
403
416
  const OptionalWireStringSchema = z.preprocess((value) => (typeof value === 'string' ? value : undefined), z.string().optional());
404
417
  const RequiredCapabilityWireSchema = z
package/dist/index.js CHANGED
@@ -56,6 +56,11 @@
56
56
  // `import { Ablo }` also compiles.
57
57
  export { Ablo } from './client/Ablo.js';
58
58
  export { DEFAULT_CONTENTION_RETRIES } from './client/functionalUpdate.js';
59
+ // The stateless HTTP client is constructed ONLY via `Ablo({ transport: 'http' })`
60
+ // (one factory, explicit transport). The `createAbloHttpClient` function stays
61
+ // internal — the factory calls it — but is NOT a public export. Consumers still
62
+ // annotate with the `AbloHttpClient` type (the narrowed return of `transport:'http'`).
63
+ export {} from './client/httpClient.js';
59
64
  export { ABLO_DEFAULT_BASE_URL, ABLO_HOSTED_API_DOMAIN, ABLO_HOSTED_HTTP_BASE_URL, normalizeAbloHostedBaseUrl, } from './client/auth.js';
60
65
  // Participant types live under `Ablo.Participant.*` —
61
66
  // `Ablo.Participant.Joined`, `Ablo.Participant.Manager`,
@@ -68,9 +68,6 @@ export interface CommitZeroSyncIdDetails {
68
68
  operationCount: number;
69
69
  operations: string[];
70
70
  }
71
- export interface OfflineFlushFailureDetails {
72
- error: string;
73
- }
74
71
  /**
75
72
  * One thing that happened to a claim. `phase` is the past-tense state it just
76
73
  * entered — the trail you follow to see WHY two participants collided on a row:
@@ -103,17 +100,15 @@ export interface ConflictEvent {
103
100
  /** The client idempotency key whose write was notified. */
104
101
  clientTxId: string;
105
102
  /** The conflicted rows + the fields that collided. */
106
- rows: ReadonlyArray<{
103
+ rows: readonly {
107
104
  model: string;
108
105
  id: string;
109
106
  fields: readonly string[];
110
107
  writtenBy?: ParticipantKind;
111
- }>;
108
+ }[];
112
109
  }
113
110
  /** Span attributes for performance monitoring */
114
- export interface SpanAttributes {
115
- [key: string]: string | number | boolean | undefined;
116
- }
111
+ export type SpanAttributes = Record<string, string | number | boolean | undefined>;
117
112
  /**
118
113
  * Observability provider — replaces direct Sentry dependency.
119
114
  * SDK ships a no-op default; consumers provide their own (e.g., Sentry, Datadog, OpenTelemetry).
@@ -137,8 +132,6 @@ export interface SyncObservabilityProvider {
137
132
  captureDeltaRetryExhausted(details: DeltaRetryExhaustedDetails): void;
138
133
  /** Capture WebSocket error */
139
134
  captureWebSocketError(details: WebSocketErrorDetails): void;
140
- /** Capture offline flush failure */
141
- captureOfflineFlushFailure(details: OfflineFlushFailureDetails): void;
142
135
  /** Capture self-healing event */
143
136
  captureSelfHealing(details: SelfHealingDetails): void;
144
137
  /** Capture a claim state change (acquired / queued / granted / lost / rejected / expired) */
@@ -313,13 +306,13 @@ export interface MutationExecutor {
313
306
  url: string;
314
307
  }>;
315
308
  /** Batch upload attachments (optional) */
316
- batchUploadAttachments?(items: Array<{
309
+ batchUploadAttachments?(items: {
317
310
  id: string;
318
311
  input: Record<string, unknown>;
319
- }>): Promise<Array<{
312
+ }[]): Promise<{
320
313
  id: string;
321
314
  url: string;
322
- }>>;
315
+ }[]>;
323
316
  /** Delete a subscription entity */
324
317
  deleteSubscription?(entityType: string, entityId: string, txId: string): Promise<void>;
325
318
  /** Delete a favorite entity */
@@ -327,13 +320,6 @@ export interface MutationExecutor {
327
320
  /** Register a callback for session expiry detection */
328
321
  onSessionExpired?(callback: () => void): void;
329
322
  }
330
- /**
331
- * Dispatches queued offline mutations on reconnect.
332
- * Replaces the massive switch statement in OfflineFlush.ts.
333
- */
334
- export interface MutationDispatcher {
335
- dispatch(operationName: string, variables: Record<string, unknown>): Promise<void>;
336
- }
337
323
  /**
338
324
  * Application-specific configuration for the sync engine.
339
325
  * Replaces the 6 hardcoded config maps that were previously
@@ -69,7 +69,8 @@ const CRC32_TABLE = (() => {
69
69
  function crc32(s) {
70
70
  let c = 0xffffffff;
71
71
  for (let i = 0; i < s.length; i++) {
72
- c = (CRC32_TABLE[(c ^ s.charCodeAt(i)) & 0xff] ^ (c >>> 8)) >>> 0;
72
+ // `& 0xff` bounds the index to the 256-entry table the ?? 0 is unreachable.
73
+ c = ((CRC32_TABLE[(c ^ s.charCodeAt(i)) & 0xff] ?? 0) ^ (c >>> 8)) >>> 0;
73
74
  }
74
75
  return (c ^ 0xffffffff) >>> 0;
75
76
  }
@@ -78,7 +79,7 @@ function checksum6(payload) {
78
79
  let n = crc32(payload);
79
80
  let out = '';
80
81
  for (let i = 0; i < CHECKSUM_LEN; i++) {
81
- out = BASE62[n % 62] + out;
82
+ out = BASE62.charAt(n % 62) + out;
82
83
  n = Math.floor(n / 62);
83
84
  }
84
85
  return out;
@@ -89,7 +90,7 @@ function randomBase62(len) {
89
90
  while (out.length < len) {
90
91
  for (const b of randomBytes(len * 2)) {
91
92
  if (b < 248) {
92
- out += BASE62[b % 62];
93
+ out += BASE62.charAt(b % 62);
93
94
  if (out.length === len)
94
95
  break;
95
96
  }
@@ -113,6 +114,14 @@ export const apiKeySchema = z.string().transform((raw, ctx) => {
113
114
  return z.NEVER;
114
115
  }
115
116
  const [, prefix, env, body] = m;
117
+ const kind = prefix === undefined ? undefined : KIND_BY_PREFIX[prefix];
118
+ // Unreachable on a KEY_RE match (all three groups are non-optional and the
119
+ // prefix alternation is exactly the KIND_BY_PREFIX key set) — narrows the
120
+ // regex-group lookups for the checks below.
121
+ if (kind === undefined || env === undefined || body === undefined) {
122
+ ctx.addIssue({ code: 'custom', message: 'not a valid Ablo API key format' });
123
+ return z.NEVER;
124
+ }
116
125
  const checksummed = bodyIsChecksummed(body);
117
126
  if (checksummed && checksum6(raw.slice(0, -CHECKSUM_LEN)) !== body.slice(KEY_BODY_LEN)) {
118
127
  ctx.addIssue({ code: 'custom', message: 'API key checksum mismatch' });
@@ -120,7 +129,7 @@ export const apiKeySchema = z.string().transform((raw, ctx) => {
120
129
  }
121
130
  return {
122
131
  raw,
123
- kind: KIND_BY_PREFIX[prefix],
132
+ kind,
124
133
  env: environmentFromKeyPrefix(env),
125
134
  body,
126
135
  checksummed,
@@ -134,15 +143,15 @@ export function parseApiKey(raw) {
134
143
  }
135
144
  /** True when the key uses the new checksummed format (regardless of validity). */
136
145
  export function isChecksummedKey(raw) {
137
- const m = KEY_RE.exec(raw);
138
- return m !== null && bodyIsChecksummed(m[3]);
146
+ const body = KEY_RE.exec(raw)?.[3];
147
+ return body !== undefined && bodyIsChecksummed(body);
139
148
  }
140
149
  /** Verify the embedded checksum. Meaningful only for checksummed-format keys. */
141
150
  export function keyChecksumMatches(raw) {
142
- const m = KEY_RE.exec(raw);
143
- if (!m || !bodyIsChecksummed(m[3]))
151
+ const body = KEY_RE.exec(raw)?.[3];
152
+ if (body === undefined || !bodyIsChecksummed(body))
144
153
  return false;
145
- return checksum6(raw.slice(0, -CHECKSUM_LEN)) === m[3].slice(KEY_BODY_LEN);
154
+ return checksum6(raw.slice(0, -CHECKSUM_LEN)) === body.slice(KEY_BODY_LEN);
146
155
  }
147
156
  // ── Mint + hash (node:crypto) ───────────────────────────────────────────
148
157
  /**
@@ -146,7 +146,7 @@ export class UndoScope {
146
146
  // doubles can omit it (undo then records nothing).
147
147
  this.unsubscribe =
148
148
  options.recordFromStream && this.store.subscribeLocalMutations
149
- ? this.store.subscribeLocalMutations((m) => this.onLocalMutation(m))
149
+ ? this.store.subscribeLocalMutations((m) => { this.onLocalMutation(m); })
150
150
  : () => { };
151
151
  }
152
152
  /**
@@ -608,6 +608,11 @@ async function applyOps(tx, ops) {
608
608
  const mutateAny = tx.mutations;
609
609
  for (const op of ops) {
610
610
  const m = mutateAny[op.modelKey];
611
+ if (!m) {
612
+ // A persisted inverse op referencing a model the schema no longer has —
613
+ // previously this surfaced as an opaque TypeError on `m.create`.
614
+ throw new Error(`Cannot undo: model "${op.modelKey}" is not part of the current schema.`);
615
+ }
611
616
  switch (op.kind) {
612
617
  case 'create':
613
618
  await m.create(op.data);
@@ -39,9 +39,7 @@ export type MutatorFn<S extends Schema, TArgs, TResult = void> = (options: {
39
39
  * ends up seeing.
40
40
  */
41
41
  export type MutatorDefs<S extends Schema> = {
42
- [K in keyof S['models']]?: {
43
- [mutatorName: string]: MutatorFn<S, never, unknown>;
44
- };
42
+ [K in keyof S['models']]?: Record<string, MutatorFn<S, never, unknown>>;
45
43
  };
46
44
  /**
47
45
  * Identity function that forwards the mutators object while constraining its
@@ -6,7 +6,7 @@
6
6
  * (a participant claims a target someone else is already claiming).
7
7
  * Adding new shapes is additive on the discriminated union.
8
8
  */
9
- import type { ParticipantRef } from '../types/streams.js';
9
+ import type { ParticipantRef } from '../types/participant.js';
10
10
  import type { OnStaleMode } from '../coordination/schema.js';
11
11
  export type ConflictKind = 'stale_context' | 'claim_held';
12
12
  /** Fields shared by every conflict shape. */
@@ -12,6 +12,7 @@
12
12
  * without duplicating the fetch boilerplate.
13
13
  */
14
14
  import type { QueryBatch, QueryBatchResult } from './types.js';
15
+ import { type RecoveryClass } from '../errorCodes.js';
15
16
  import { type AuthTokenGetter } from '../auth/credentialSource.js';
16
17
  export interface PostQueryOptions {
17
18
  /**
@@ -33,6 +34,16 @@ export interface PostQueryOptions {
33
34
  * New SDK internals should pass `getAuthToken`.
34
35
  */
35
36
  capabilityToken?: string;
37
+ /**
38
+ * THE auth-recovery backbone (the store's single-flight re-mint with FSM
39
+ * outcome routing — see `CredentialLifecycle.recoverFromAuthRejection`).
40
+ * When a query is rejected with a 401, the failure's `RecoveryClass` is
41
+ * passed here; `'retry'` means a fresh credential landed in the credential
42
+ * source and the request is replayed ONCE (the PowerSync/axios-interceptor
43
+ * pattern: invalidate on 401, single-flight refresh, one-shot replay —
44
+ * never a retry loop). Absent ⇒ the pre-backbone behavior: log + empty.
45
+ */
46
+ recoverCredential?: (recovery: RecoveryClass) => Promise<'retry' | 'stop'>;
36
47
  }
37
48
  /**
38
49
  * POST a batch of queries to /sync/query. Returns the parsed
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { z } from 'zod';
15
15
  import { translateHttpError } from '../errors.js';
16
+ import { classifyRecovery } from '../errorCodes.js';
16
17
  import { withAuthHeaders } from '../auth/credentialSource.js';
17
18
  import { getContext } from '../context.js';
18
19
  // ── Response validation ─────────────────────────────────────────────────
@@ -46,61 +47,89 @@ const QueryBatchResultSchema = z
46
47
  export async function postQuery(options, batch) {
47
48
  const url = `${options.baseUrl}/sync/query`;
48
49
  const timeout = options.fetchTimeout ?? 30_000;
49
- // Race the fetch against a timeout so hung requests don't block
50
- // the calling helper indefinitely.
51
- const controller = new AbortController();
52
- const timer = setTimeout(() => controller.abort(), timeout);
53
- try {
54
- const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken);
55
- const response = await fetch(url, {
56
- method: 'POST',
57
- headers,
58
- body: JSON.stringify(batch),
59
- signal: controller.signal,
60
- });
61
- if (!response.ok) {
62
- // Build the typed AbloError for this HTTP failure (same code→class
63
- // map the throwing paths use) so the log is tagged + carries a
64
- // registry `code` (e.g. AbloAuthenticationError/session_expired on a
65
- // 401) instead of a bare status. We deliberately DON'T throw —
66
- // fire-and-forget callers would kill the Next.js router on an
67
- // unhandled rejection — and still return empty slots, but the failure
68
- // is now legible as an Ablo error. Routed through the gated logger so it
69
- // obeys ABLO_LOG_LEVEL like everything else: a consumer-register `warn`
70
- // (their models + the typed message + a wire `code`) with the forensics on
71
- // a `debug` companion. Actionable and not self-healing — the read returns
72
- // empty until the underlying cause (auth, network) is resolved.
73
- let body = null;
74
- try {
75
- body = await response.clone().json();
50
+ // At most TWO attempts: the original request, plus ONE replay after a
51
+ // successful credential recovery (see `recoverCredential`). Bounded by
52
+ // construction a second auth rejection falls through to the log+empty
53
+ // path, so a wedged credential can never retry-loop.
54
+ for (let attempt = 0;; attempt++) {
55
+ // Race the fetch against a timeout so hung requests don't block
56
+ // the calling helper indefinitely. Fresh controller per attempt.
57
+ const controller = new AbortController();
58
+ const timer = setTimeout(() => { controller.abort(); }, timeout);
59
+ try {
60
+ // Recomputed per attempt: `withAuthHeaders` reads the live credential
61
+ // source, so a replay after recovery carries the freshly-minted key.
62
+ const headers = withAuthHeaders(options.getAuthToken, { 'Content-Type': 'application/json' }, options.capabilityToken);
63
+ const response = await fetch(url, {
64
+ method: 'POST',
65
+ headers,
66
+ body: JSON.stringify(batch),
67
+ signal: controller.signal,
68
+ });
69
+ if (!response.ok) {
70
+ // Build the typed AbloError for this HTTP failure (same code→class
71
+ // map the throwing paths use) so the log is tagged + carries a
72
+ // registry `code` (e.g. AbloAuthenticationError/session_expired on a
73
+ // 401) instead of a bare status. We deliberately DON'T throw —
74
+ // fire-and-forget callers would kill the Next.js router on an
75
+ // unhandled rejection — and still return empty slots, but the failure
76
+ // is now legible as an Ablo error.
77
+ let body = null;
78
+ try {
79
+ body = await response.clone().json();
80
+ }
81
+ catch {
82
+ // non-JSON error page — translateHttpError falls back to status text
83
+ }
84
+ const err = translateHttpError(response.status, body);
85
+ // 401 → hand the failure to the auth-recovery backbone, ONCE. The
86
+ // class routes the decision: `access_credential_expiry` re-mints
87
+ // silently and replays; `session_expiry` reports terminal session
88
+ // loss (sign-out is the FSM's call, not ours); everything else stops.
89
+ // A bare 401 with no readable code is classified as an expired access
90
+ // key — the NetworkProbe precedent: the only terminal path is the
91
+ // re-mint itself resolving null, never an ambiguous status.
92
+ if (attempt === 0 && response.status === 401 && options.recoverCredential) {
93
+ const recovery = typeof err.code === 'string'
94
+ ? classifyRecovery(err.code)
95
+ : 'access_credential_expiry';
96
+ const outcome = await options.recoverCredential(recovery);
97
+ if (outcome === 'retry') {
98
+ getContext().logger.debug('[postQuery] credential recovered — replaying query once', {
99
+ code: err.code ?? response.status,
100
+ });
101
+ continue;
102
+ }
103
+ }
104
+ // Routed through the gated logger so it obeys ABLO_LOG_LEVEL like
105
+ // everything else: a consumer-register `warn` (their models + the
106
+ // typed message + a wire `code`) with the forensics on a `debug`
107
+ // companion. Actionable and not self-healing — the read returns
108
+ // empty until the underlying cause (auth, network) is resolved.
109
+ const models = batch.queries.map((q) => q.model).join(', ');
110
+ getContext().logger.warn(`Could not load ${models} — ${err.message} (code: ${err.code ?? response.status}). No results were returned.`);
111
+ getContext().logger.debug('[postQuery.error] query http failure', {
112
+ type: err.type,
113
+ code: err.code ?? response.status,
114
+ models,
115
+ message: err.message,
116
+ });
117
+ return { results: batch.queries.map(() => []) };
76
118
  }
77
- catch {
78
- // non-JSON error page — translateHttpError falls back to status text
119
+ const raw = await response.json();
120
+ const parsed = QueryBatchResultSchema.safeParse(raw);
121
+ if (!parsed.success) {
122
+ // A malformed server response isn't something the consumer can act on
123
+ // (server/protocol issue) → debug, gated like everything else.
124
+ getContext().logger.debug('[postQuery.error] malformed response', {
125
+ issues: parsed.error.issues,
126
+ });
127
+ return { results: batch.queries.map(() => []) };
79
128
  }
80
- const err = translateHttpError(response.status, body);
81
- const models = batch.queries.map((q) => q.model).join(', ');
82
- getContext().logger.warn(`Could not load ${models} — ${err.message} (code: ${err.code ?? response.status}). No results were returned.`);
83
- getContext().logger.debug('[postQuery.error] query http failure', {
84
- type: err.type,
85
- code: err.code ?? response.status,
86
- models,
87
- message: err.message,
88
- });
89
- return { results: batch.queries.map(() => []) };
129
+ return parsed.data;
90
130
  }
91
- const raw = await response.json();
92
- const parsed = QueryBatchResultSchema.safeParse(raw);
93
- if (!parsed.success) {
94
- // A malformed server response isn't something the consumer can act on
95
- // (server/protocol issue) → debug, gated like everything else.
96
- getContext().logger.debug('[postQuery.error] malformed response', {
97
- issues: parsed.error.issues,
98
- });
99
- return { results: batch.queries.map(() => []) };
131
+ finally {
132
+ clearTimeout(timer);
100
133
  }
101
- return parsed.data;
102
- }
103
- finally {
104
- clearTimeout(timer);
105
134
  }
106
135
  }
@@ -34,13 +34,9 @@ import { type SyncStoreContract } from './context.js';
34
34
  *
35
35
  * ```tsx
36
36
  * // Build once at module scope — a new instance per render tears down the socket.
37
- * const ablo = Ablo({
38
- * schema,
39
- * apiKey: () =>
40
- * fetch('/api/ablo-session', { method: 'POST' })
41
- * .then((r) => r.json())
42
- * .then((d) => d.token),
43
- * });
37
+ * // The endpoint string points at your session-mint route (`ablo init`
38
+ * // scaffolds it); the SDK fetches it and keeps the token fresh.
39
+ * const ablo = Ablo({ schema, authEndpoint: '/api/ablo-session' });
44
40
  *
45
41
  * <AbloProvider client={ablo}>
46
42
  * <App />
@@ -152,9 +148,9 @@ export type MeshParticipantStatus = ParticipantStatus;
152
148
  export interface UseWatchReturn {
153
149
  readonly participant: EngineParticipant | null;
154
150
  /** Everyone else on the engine's sync groups (`participant.presence.others`), bridged to React. */
155
- readonly peers: ReadonlyArray<Peer>;
151
+ readonly peers: readonly Peer[];
156
152
  /** Active claim claims by peers (`participant.claims.others`), bridged to React. */
157
- readonly claims: ReadonlyArray<Claim>;
153
+ readonly claims: readonly Claim[];
158
154
  readonly status: ParticipantStatus;
159
155
  readonly error: Error | null;
160
156
  }
@@ -196,7 +192,7 @@ export declare function useWatch(opts: UseWatchOptions): UseWatchReturn;
196
192
  * const alone = !peers.some((p) => p.participantKind === 'user');
197
193
  * ```
198
194
  */
199
- export declare function usePeers(scope?: ParticipantScope): ReadonlyArray<Peer>;
195
+ export declare function usePeers(scope?: ParticipantScope): readonly Peer[];
200
196
  /**
201
197
  * Returns the raw `SyncEngine` proxy. Typically you want the typed
202
198
  * hooks (`useQuery`, `useOne`, `useMutate`) — this is for rare cases
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useContext, useEffect, useMemo, useRef, useState, } from 'react';
3
+ import { useCallback, useContext, useEffect, useMemo, useRef, useState, createContext, } from 'react';
4
+ import { Ablo } from '../client/Ablo.js';
4
5
  import { createParticipantClaimId, parseParticipantTtlSeconds, resolveParticipantSyncGroups, } from '../sync/participants.js';
5
6
  import { SyncContext } from './context.js';
6
7
  import { AbloInternalContext } from './internalContext.js';
@@ -77,18 +78,24 @@ export function AbloProvider(props) {
77
78
  // It does NOT dispose the client (consumer-owned) and does NOT touch auth.
78
79
  useEffect(() => {
79
80
  let stale = false;
80
- const unsubscribeSession = engine.onSessionError(async (err) => {
81
+ const unsubscribeSession = engine.onSessionError((err) => {
81
82
  errorEmitter.emit(err);
82
- try {
83
- await engine.purge();
84
- }
85
- catch { }
86
- try {
87
- await onSessionExpiredRef.current?.();
88
- }
89
- catch (hookErr) {
90
- errorEmitter.emit(hookErr);
91
- }
83
+ void (async () => {
84
+ try {
85
+ await engine.purge();
86
+ }
87
+ catch { }
88
+ try {
89
+ await onSessionExpiredRef.current?.();
90
+ }
91
+ catch (hookErr) {
92
+ errorEmitter.emit(hookErr);
93
+ }
94
+ })().catch(() => {
95
+ // Only a throwing errorEmitter subscriber can land here — it was
96
+ // already the error-reporting path, so swallow rather than surface
97
+ // an unhandled rejection loop.
98
+ });
92
99
  });
93
100
  engine
94
101
  .ready()
@@ -122,7 +129,7 @@ export function AbloProvider(props) {
122
129
  }
123
130
  };
124
131
  window.addEventListener('beforeunload', handler);
125
- return () => window.removeEventListener('beforeunload', handler);
132
+ return () => { window.removeEventListener('beforeunload', handler); };
126
133
  }, [engine, preventUnsavedChanges]);
127
134
  // ── SyncContext value (for useQuery/useOne/useMutate hooks) ──────
128
135
  //
@@ -389,7 +396,7 @@ export function usePeers(scope) {
389
396
  // rarely; a frame of stale presence is harmless (same rationale as
390
397
  // useWatch's peers bridge).
391
398
  setPeers(compute());
392
- return presence.onChange(() => setPeers(compute()));
399
+ return presence.onChange(() => { setPeers(compute()); });
393
400
  }, [engine, scopeKey]);
394
401
  return peers;
395
402
  }
@@ -442,7 +449,7 @@ export function useSync() {
442
449
  */
443
450
  export function useSyncStore() {
444
451
  const sync = useContext(SyncContext);
445
- if (!sync || !sync.store) {
452
+ if (!sync?.store) {
446
453
  throw new AbloValidationError('useSyncStore: the sync engine has not yet initialized. Wrap ' +
447
454
  'consumers in <ClientSideSuspense> or guard on useSyncStatus().', { code: 'sync_not_ready' });
448
455
  }