@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.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +33 -0
- package/README.md +45 -36
- package/dist/BaseSyncedStore.d.ts +72 -195
- package/dist/BaseSyncedStore.js +240 -625
- package/dist/Database.d.ts +8 -9
- package/dist/Database.js +9 -14
- package/dist/LazyReferenceCollection.js +5 -0
- package/dist/Model.js +6 -8
- package/dist/ModelRegistry.d.ts +25 -6
- package/dist/ModelRegistry.js +12 -6
- package/dist/NetworkMonitor.js +1 -1
- package/dist/ObjectPool.js +9 -3
- package/dist/SyncClient.d.ts +15 -5
- package/dist/SyncClient.js +152 -66
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- package/dist/agent/Agent.d.ts +21 -8
- package/dist/agent/Agent.js +26 -13
- package/dist/ai-sdk/coordination-context.js +9 -3
- package/dist/ai-sdk/wrap.d.ts +1 -1
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +10 -2
- package/dist/auth/credentialSource.d.ts +1 -13
- package/dist/auth/credentialSource.js +6 -13
- package/dist/auth/index.js +3 -3
- package/dist/cli.cjs +583 -287
- package/dist/client/Ablo.d.ts +35 -764
- package/dist/client/Ablo.js +64 -688
- package/dist/client/ApiClient.d.ts +14 -1
- package/dist/client/ApiClient.js +63 -9
- package/dist/client/auth.d.ts +8 -17
- package/dist/client/auth.js +42 -6
- package/dist/client/consoleLogger.d.ts +36 -0
- package/dist/client/consoleLogger.js +45 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.js +10 -6
- package/dist/client/credentialEndpoint.d.ts +63 -0
- package/dist/client/credentialEndpoint.js +87 -0
- package/dist/client/hostedEndpoints.d.ts +24 -0
- package/dist/client/hostedEndpoints.js +24 -0
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/identity.js +2 -1
- package/dist/client/modelRegistration.d.ts +14 -0
- package/dist/client/modelRegistration.js +321 -0
- package/dist/client/options.d.ts +396 -0
- package/dist/client/options.js +10 -0
- package/dist/client/resourceTypes.d.ts +344 -0
- package/dist/client/resourceTypes.js +13 -0
- package/dist/client/schemaConfig.d.ts +56 -0
- package/dist/client/schemaConfig.js +188 -0
- package/dist/client/sessionMint.d.ts +1 -1
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/client/wsMutationExecutor.d.ts +33 -0
- package/dist/client/wsMutationExecutor.js +77 -0
- package/dist/context.js +0 -3
- package/dist/coordination/index.d.ts +8 -1
- package/dist/coordination/index.js +24 -1
- package/dist/coordination/trace.d.ts +0 -1
- package/dist/coordination/trace.js +0 -1
- package/dist/core/DatabaseManager.d.ts +0 -1
- package/dist/core/DatabaseManager.js +26 -24
- package/dist/core/QueryProcessor.js +7 -8
- package/dist/core/QueryView.d.ts +14 -2
- package/dist/core/QueryView.js +4 -5
- package/dist/core/StoreManager.d.ts +2 -2
- package/dist/core/StoreManager.js +5 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/openIDBWithTimeout.js +15 -11
- package/dist/core/query-utils.js +1 -1
- package/dist/core/storeContract.d.ts +143 -0
- package/dist/core/storeContract.js +16 -0
- package/dist/errorCodes.d.ts +11 -2
- package/dist/errorCodes.js +155 -137
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +14 -1
- package/dist/index.js +5 -0
- package/dist/interfaces/index.d.ts +6 -20
- package/dist/keys/index.js +18 -9
- package/dist/mutators/UndoManager.js +6 -1
- package/dist/mutators/defineMutators.d.ts +1 -3
- package/dist/policy/types.d.ts +1 -1
- package/dist/query/client.d.ts +11 -0
- package/dist/query/client.js +81 -52
- package/dist/react/AbloProvider.d.ts +6 -10
- package/dist/react/AbloProvider.js +22 -15
- package/dist/react/context.d.ts +2 -118
- package/dist/react/useAblo.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useMutators.js +7 -1
- package/dist/react/useReactive.js +0 -1
- package/dist/react/useUndoScope.js +2 -2
- package/dist/schema/ddlLock.d.ts +39 -0
- package/dist/schema/ddlLock.js +51 -0
- package/dist/schema/field.js +1 -1
- package/dist/schema/index.d.ts +4 -3
- package/dist/schema/index.js +15 -5
- package/dist/schema/model.d.ts +3 -3
- package/dist/schema/model.js +2 -2
- package/dist/schema/openapi.js +5 -1
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -0
- package/dist/schema/residency.d.ts +35 -0
- package/dist/schema/residency.js +27 -0
- package/dist/schema/roles.d.ts +25 -0
- package/dist/schema/roles.js +37 -0
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/schema.js +11 -7
- package/dist/schema/serialize.d.ts +8 -8
- package/dist/schema/sync-delta-row.d.ts +2 -2
- package/dist/schema/sync-delta-row.js +2 -2
- package/dist/server/storage-mode.d.ts +8 -1
- package/dist/server/storage-mode.js +14 -4
- package/dist/source/adapter.d.ts +1 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -3
- package/dist/source/adapters/memory.js +1 -1
- package/dist/source/adapters/prisma.js +5 -4
- package/dist/source/conformance.js +10 -8
- package/dist/source/connector.js +4 -2
- package/dist/source/factory.d.ts +106 -0
- package/dist/source/factory.js +273 -0
- package/dist/source/index.d.ts +11 -461
- package/dist/source/index.js +14 -416
- package/dist/source/next.d.ts +1 -1
- package/dist/source/next.js +1 -1
- package/dist/source/pushQueue.d.ts +1 -1
- package/dist/source/pushQueue.js +6 -3
- package/dist/source/signing.d.ts +59 -0
- package/dist/source/signing.js +142 -0
- package/dist/source/types.d.ts +334 -0
- package/dist/source/types.js +44 -0
- package/dist/stores/ObjectStore.d.ts +1 -1
- package/dist/stores/ObjectStore.js +23 -23
- package/dist/stores/SyncActionStore.d.ts +1 -1
- package/dist/stores/SyncActionStore.js +70 -35
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/dist/sync/AreaOfInterestManager.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.js +1 -1
- package/dist/sync/BootstrapHelper.d.ts +1 -3
- package/dist/sync/BootstrapHelper.js +29 -8
- package/dist/sync/ConnectionManager.d.ts +8 -0
- package/dist/sync/ConnectionManager.js +19 -4
- package/dist/sync/HydrationCoordinator.d.ts +12 -0
- package/dist/sync/HydrationCoordinator.js +25 -5
- package/dist/sync/NetworkProbe.d.ts +3 -2
- package/dist/sync/NetworkProbe.js +7 -6
- package/dist/sync/SyncWebSocket.d.ts +71 -120
- package/dist/sync/SyncWebSocket.js +324 -630
- package/dist/sync/awaitClaimGrant.js +27 -19
- package/dist/sync/bootstrapApply.d.ts +60 -0
- package/dist/sync/bootstrapApply.js +65 -0
- package/dist/sync/commitFrames.d.ts +43 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.js +3 -2
- package/dist/sync/createSnapshot.d.ts +1 -3
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +329 -0
- package/dist/sync/deltaPipeline.d.ts +111 -0
- package/dist/sync/deltaPipeline.js +262 -0
- package/dist/sync/groupChange.d.ts +111 -0
- package/dist/sync/groupChange.js +246 -0
- package/dist/sync/heartbeat.d.ts +62 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +8 -8
- package/dist/sync/syncCursor.d.ts +44 -0
- package/dist/sync/syncCursor.js +59 -0
- package/dist/sync/syncPlan.d.ts +62 -0
- package/dist/sync/syncPlan.js +51 -0
- package/dist/sync/wsFrameHandlers.d.ts +117 -0
- package/dist/sync/wsFrameHandlers.js +378 -0
- package/dist/testing/fixtures/bootstrap.d.ts +9 -11
- package/dist/testing/fixtures/deltas.d.ts +1 -1
- package/dist/testing/fixtures/models.js +1 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
- package/dist/testing/helpers/sync-engine-harness.js +2 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
- package/dist/testing/mocks/MockSyncContext.js +1 -32
- package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
- package/dist/transactions/TransactionQueue.d.ts +20 -56
- package/dist/transactions/TransactionQueue.js +101 -487
- package/dist/transactions/TransactionStore.d.ts +18 -0
- package/dist/transactions/TransactionStore.js +51 -0
- package/dist/transactions/coalesceRules.d.ts +34 -0
- package/dist/transactions/coalesceRules.js +117 -0
- package/dist/transactions/commitPayload.d.ts +134 -0
- package/dist/transactions/commitPayload.js +152 -0
- package/dist/transactions/deltaConfirmation.d.ts +60 -0
- package/dist/transactions/deltaConfirmation.js +223 -0
- package/dist/transactions/optimistic.d.ts +24 -0
- package/dist/transactions/optimistic.js +45 -0
- package/dist/transactions/persistedReplay.d.ts +93 -0
- package/dist/transactions/persistedReplay.js +105 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.js +0 -5
- package/dist/types/modelData.d.ts +12 -0
- package/dist/types/modelData.js +11 -0
- package/dist/types/participant.d.ts +21 -0
- package/dist/types/participant.js +12 -0
- package/dist/types/streams.d.ts +8 -14
- package/dist/utils/mobx-setup.d.ts +1 -1
- package/dist/utils/mobx-setup.js +2 -2
- package/dist/wire/errorEnvelope.d.ts +11 -2
- package/dist/wire/errorEnvelope.js +14 -2
- package/dist/wire/frames.d.ts +90 -0
- package/dist/wire/frames.js +55 -1
- package/dist/wire/index.d.ts +4 -1
- package/dist/wire/index.js +20 -1
- package/dist/wire/protocol.d.ts +45 -0
- package/dist/wire/protocol.js +45 -0
- package/dist/wire/protocolVersion.d.ts +56 -0
- package/dist/wire/protocolVersion.js +63 -0
- package/docs/api-keys.md +4 -3
- package/docs/data-sources.md +12 -5
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/llms.txt +4 -2
- package/package.json +15 -13
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/index.d.ts +0 -16
- 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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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?:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
309
|
+
batchUploadAttachments?(items: {
|
|
317
310
|
id: string;
|
|
318
311
|
input: Record<string, unknown>;
|
|
319
|
-
}
|
|
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
|
package/dist/keys/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
138
|
-
return
|
|
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
|
|
143
|
-
if (
|
|
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)) ===
|
|
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
|
package/dist/policy/types.d.ts
CHANGED
|
@@ -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/
|
|
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. */
|
package/dist/query/client.d.ts
CHANGED
|
@@ -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
|
package/dist/query/client.js
CHANGED
|
@@ -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
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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:
|
|
151
|
+
readonly peers: readonly Peer[];
|
|
156
152
|
/** Active claim claims by peers (`participant.claims.others`), bridged to React. */
|
|
157
|
-
readonly claims:
|
|
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):
|
|
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(
|
|
81
|
+
const unsubscribeSession = engine.onSessionError((err) => {
|
|
81
82
|
errorEmitter.emit(err);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
}
|