@abloatai/ablo 0.35.0 → 0.36.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/CHANGELOG.md +713 -629
- package/README.md +56 -519
- package/bin/ablo.cjs +39 -0
- package/dist/BaseSyncedStore.d.ts +24 -4
- package/dist/BaseSyncedStore.js +53 -37
- package/dist/Database.d.ts +8 -20
- package/dist/Database.js +61 -59
- package/dist/InstanceCache.d.ts +6 -2
- package/dist/InstanceCache.js +18 -16
- package/dist/Model.d.ts +17 -7
- package/dist/Model.js +17 -7
- package/dist/ModelRegistry.d.ts +4 -0
- package/dist/ModelRegistry.js +17 -15
- package/dist/NetworkMonitor.d.ts +3 -1
- package/dist/NetworkMonitor.js +7 -5
- package/dist/SyncClient.d.ts +5 -15
- package/dist/SyncClient.js +60 -57
- package/dist/client/Ablo.js +30 -19
- package/dist/client/createInternalComponents.d.ts +4 -0
- package/dist/client/createInternalComponents.js +8 -2
- package/dist/client/createModelProxy.d.ts +21 -1
- package/dist/client/createModelProxy.js +123 -57
- package/dist/client/humans.d.ts +30 -9
- package/dist/client/humans.js +45 -19
- package/dist/client/reactiveEngine.d.ts +16 -11
- package/dist/client/reactiveEngine.js +113 -335
- package/dist/client/storeCluster.d.ts +47 -0
- package/dist/client/storeCluster.js +118 -0
- package/dist/client/storeLifecycle.d.ts +61 -0
- package/dist/client/storeLifecycle.js +231 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +23 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/docs/catalog.js +6 -3
- package/dist/index.d.ts +4 -2
- package/dist/index.js +4 -2
- package/dist/query/client.d.ts +3 -0
- package/dist/query/client.js +6 -5
- package/dist/react/AbloProvider.d.ts +13 -1
- package/dist/react/AbloProvider.js +5 -2
- package/dist/react/context.d.ts +2 -2
- package/dist/react/createAbloReact.d.ts +56 -0
- package/dist/react/createAbloReact.js +51 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.js +3 -0
- package/dist/react/useAblo.d.ts +9 -2
- package/dist/react/useAblo.js +25 -7
- package/dist/schema/coordination.js +5 -1
- package/dist/schema/index.d.ts +1 -0
- package/dist/schema/index.js +4 -0
- package/dist/schema/select.js +3 -0
- package/dist/schema/serialize.js +3 -0
- package/dist/source/adapter.d.ts +7 -5
- package/dist/source/adapter.js +7 -5
- package/dist/source/index.d.ts +1 -1
- package/dist/source/index.js +1 -1
- package/dist/{core/storeContract.d.ts → storeContract.d.ts} +5 -5
- package/dist/{core → stores}/DatabaseManager.d.ts +3 -1
- package/dist/{core → stores}/DatabaseManager.js +13 -12
- package/dist/{core → stores}/StoreManager.d.ts +5 -3
- package/dist/{core → stores}/StoreManager.js +24 -22
- package/dist/stores/SyncActionStore.d.ts +3 -1
- package/dist/stores/SyncActionStore.js +9 -7
- package/dist/sync/BootstrapFetcher.d.ts +4 -0
- package/dist/sync/BootstrapFetcher.js +28 -26
- package/dist/sync/OnDemandLoader.d.ts +3 -0
- package/dist/sync/OnDemandLoader.js +1 -0
- package/dist/sync/bootstrapApply.d.ts +3 -0
- package/dist/sync/bootstrapApply.js +2 -2
- package/dist/sync/deltaPipeline.d.ts +13 -12
- package/dist/sync/deltaPipeline.js +21 -4
- package/dist/sync/groupChange.d.ts +3 -0
- package/dist/sync/groupChange.js +16 -14
- package/dist/sync/participants.d.ts +19 -2
- package/dist/sync/participants.js +3 -1
- package/dist/sync/schemas.d.ts +2 -1
- package/dist/sync/schemas.js +3 -3
- package/dist/syncLog/contract.d.ts +20 -0
- package/dist/syncLog/contract.js +19 -0
- package/dist/syncLog/index.d.ts +1 -0
- package/dist/syncLog/index.js +1 -0
- package/dist/transaction/auth/capability.d.ts +35 -0
- package/dist/transaction/auth/capability.js +25 -0
- package/dist/transaction/coordination/awaitClaimGrant.d.ts +7 -0
- package/dist/transaction/coordination/awaitClaimGrant.js +12 -0
- package/dist/transaction/coordination/claimHeartbeatLoop.d.ts +34 -0
- package/dist/transaction/coordination/claimHeartbeatLoop.js +20 -0
- package/dist/transaction/coordination/index.d.ts +4 -4
- package/dist/transaction/coordination/index.js +4 -3
- package/dist/transaction/coordination/locator.d.ts +23 -2
- package/dist/transaction/coordination/locator.js +22 -2
- package/dist/transaction/coordination/schema.d.ts +125 -62
- package/dist/transaction/coordination/schema.js +228 -64
- package/dist/transaction/coordination/targetConflict.js +32 -28
- package/dist/transaction/errorCodes.d.ts +2 -2
- package/dist/transaction/errorCodes.js +13 -9
- package/dist/transaction/plugin.d.ts +95 -2
- package/dist/transaction/plugin.js +21 -2
- package/dist/transaction/resources/httpResources.d.ts +57 -2
- package/dist/transaction/resources/modelOperations.d.ts +148 -40
- package/dist/transaction/resources/where.d.ts +16 -0
- package/dist/transaction/resources/where.js +45 -0
- package/dist/transaction/schema/field.d.ts +12 -18
- package/dist/transaction/schema/fieldRef.d.ts +38 -0
- package/dist/transaction/schema/fieldRef.js +11 -0
- package/dist/transaction/schema/openapi.d.ts +16 -15
- package/dist/transaction/schema/openapi.js +186 -25
- package/dist/transaction/schema/relation.d.ts +7 -2
- package/dist/transaction/schema/schema.d.ts +27 -0
- package/dist/transaction/schema/schema.js +20 -0
- package/dist/transaction/transactions/settlement/commitEnvelope.d.ts +1 -1
- package/dist/transaction/transactions/settlement/pendingWrite.d.ts +1 -1
- package/dist/transaction/transport/httpClient.d.ts +9 -1
- package/dist/transaction/transport/httpClient.js +1 -0
- package/dist/transaction/transport/httpTransport.js +156 -44
- package/dist/transaction/transport/wsTransport.d.ts +2 -4
- package/dist/transaction/transport/wsTransport.js +16 -10
- package/dist/transaction/types/streams.d.ts +10 -0
- package/dist/transaction/utils/duration.d.ts +25 -0
- package/dist/transaction/utils/duration.js +32 -0
- package/dist/transaction/wire/accountResponses.d.ts +69 -0
- package/dist/transaction/wire/accountResponses.js +36 -1
- package/dist/transaction/wire/auth.d.ts +9 -2
- package/dist/transaction/wire/auth.js +7 -1
- package/dist/transaction/wire/claims.d.ts +164 -97
- package/dist/transaction/wire/claims.js +126 -28
- package/dist/transaction/wire/commit.d.ts +1 -1
- package/dist/transaction/wire/feedEvent.d.ts +27 -0
- package/dist/transaction/wire/feedEvent.js +27 -1
- package/dist/transaction/wire/frames.d.ts +2 -2
- package/dist/transaction/wire/inboundFrames.d.ts +12 -2
- package/dist/transaction/wire/index.d.ts +10 -6
- package/dist/transaction/wire/index.js +12 -3
- package/dist/transaction/wire/modelMutations.d.ts +31 -0
- package/dist/transaction/wire/modelMutations.js +52 -0
- package/dist/transaction/wire/modelShape.d.ts +78 -0
- package/dist/transaction/wire/modelShape.js +74 -0
- package/dist/transactions/mutations/MutationQueue.d.ts +6 -0
- package/dist/transactions/mutations/MutationQueue.js +55 -45
- package/dist/transactions/mutations/commitPayload.d.ts +3 -2
- package/dist/transactions/mutations/commitPayload.js +5 -5
- package/dist/transactions/mutations/deltaConfirmation.d.ts +4 -0
- package/dist/transactions/mutations/deltaConfirmation.js +10 -8
- package/dist/transactions/mutations/replayValidation.d.ts +2 -1
- package/dist/transactions/mutations/replayValidation.js +3 -2
- package/dist/{core → views}/QueryView.d.ts +1 -1
- package/dist/{core → views}/QueryView.js +1 -1
- package/dist/{core → views}/ViewRegistry.d.ts +1 -1
- package/dist/{core/queryUtils.d.ts → views/incrementalView.d.ts} +6 -6
- package/dist/{core/queryUtils.js → views/incrementalView.js} +6 -6
- package/docs/agents.md +1 -1
- package/docs/api-keys.md +6 -6
- package/docs/api.md +5 -43
- package/docs/audit.md +4 -3
- package/docs/cli.md +11 -11
- package/docs/client-behavior.md +4 -4
- package/docs/concurrency-convention.md +28 -42
- package/docs/coordination.md +235 -83
- package/docs/data-sources.md +4 -4
- package/docs/debugging.md +34 -12
- package/docs/deployment.md +8 -8
- package/docs/examples/scoped-agent.md +3 -3
- package/docs/groups.md +57 -3
- package/docs/guarantees.md +37 -10
- package/docs/how-it-works.md +29 -5
- package/docs/idempotency.md +6 -6
- package/docs/identity.md +22 -23
- package/docs/index.md +8 -8
- package/docs/integration-guide.md +14 -3
- package/docs/mcp.md +7 -7
- package/docs/migration.md +34 -15
- package/docs/projects.md +1 -1
- package/docs/react.md +19 -8
- package/docs/sessions.md +1 -1
- package/docs/webhooks.md +9 -9
- package/llms.txt +4 -4
- package/package.json +13 -20
- package/dist/cli.cjs +0 -288600
- package/dist/testing/fixtures/bootstrap.d.ts +0 -49
- package/dist/testing/fixtures/bootstrap.js +0 -59
- package/dist/testing/fixtures/deltas.d.ts +0 -83
- package/dist/testing/fixtures/deltas.js +0 -136
- package/dist/testing/fixtures/httpResponses.d.ts +0 -70
- package/dist/testing/fixtures/httpResponses.js +0 -90
- package/dist/testing/fixtures/models.d.ts +0 -83
- package/dist/testing/fixtures/models.js +0 -272
- package/dist/testing/helpers/reactWrapper.d.ts +0 -69
- package/dist/testing/helpers/reactWrapper.js +0 -67
- package/dist/testing/helpers/syncEngineHarness.d.ts +0 -54
- package/dist/testing/helpers/syncEngineHarness.js +0 -73
- package/dist/testing/helpers/wait.d.ts +0 -30
- package/dist/testing/helpers/wait.js +0 -49
- package/dist/testing/index.d.ts +0 -23
- package/dist/testing/index.js +0 -33
- package/dist/testing/mocks/FakeDatabase.d.ts +0 -18
- package/dist/testing/mocks/FakeDatabase.js +0 -10
- package/dist/testing/mocks/MockMutationExecutor.d.ts +0 -87
- package/dist/testing/mocks/MockMutationExecutor.js +0 -186
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +0 -20
- package/dist/testing/mocks/MockNetworkMonitor.js +0 -46
- package/dist/testing/mocks/MockSyncContext.d.ts +0 -51
- package/dist/testing/mocks/MockSyncContext.js +0 -72
- package/dist/testing/mocks/MockSyncStore.d.ts +0 -88
- package/dist/testing/mocks/MockSyncStore.js +0 -171
- package/dist/testing/mocks/MockWebSocket.d.ts +0 -71
- package/dist/testing/mocks/MockWebSocket.js +0 -118
- package/docs/interaction-model.md +0 -99
- /package/dist/{core → query}/QueryProcessor.d.ts +0 -0
- /package/dist/{core → query}/QueryProcessor.js +0 -0
- /package/dist/{core/storeContract.js → storeContract.js} +0 -0
- /package/dist/{core → stores}/openIDBWithTimeout.d.ts +0 -0
- /package/dist/{core → stores}/openIDBWithTimeout.js +0 -0
- /package/dist/{source → transaction}/footprint.d.ts +0 -0
- /package/dist/{source → transaction}/footprint.js +0 -0
- /package/dist/{core → views}/ViewRegistry.js +0 -0
package/dist/SyncClient.js
CHANGED
|
@@ -14,7 +14,7 @@ import { Model } from './Model.js';
|
|
|
14
14
|
import { snapshotJsonValue } from './transaction/utils/json.js';
|
|
15
15
|
// ModelRegistry instance accessed via this.objectPool.registry
|
|
16
16
|
import { LoadStrategy } from './transaction/types/index.js';
|
|
17
|
-
import {
|
|
17
|
+
import { globalRuntime } from './context.js';
|
|
18
18
|
import { AbloAuthenticationError, AbloError, AbloValidationError } from './transaction/errors.js';
|
|
19
19
|
import { EventEmitter } from 'events';
|
|
20
20
|
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
@@ -67,9 +67,10 @@ function toEpochMs(value) {
|
|
|
67
67
|
return 0;
|
|
68
68
|
}
|
|
69
69
|
export class SyncClient extends EventEmitter {
|
|
70
|
+
runtime;
|
|
70
71
|
objectPool;
|
|
71
72
|
database;
|
|
72
|
-
get mutationExecutor() { return
|
|
73
|
+
get mutationExecutor() { return this.runtime.mutationExecutor; }
|
|
73
74
|
networkMonitor;
|
|
74
75
|
/**
|
|
75
76
|
* @internal — test seam, stripped from the published declarations by
|
|
@@ -111,15 +112,17 @@ export class SyncClient extends EventEmitter {
|
|
|
111
112
|
* responses, and snapshots and claims read `readFloor`.
|
|
112
113
|
*/
|
|
113
114
|
position = new LogPosition();
|
|
114
|
-
constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default') {
|
|
115
|
+
constructor(objectPool, database, commitOutbox = new DatabaseCommitOutboxStore(database), commitOutboxNamespace = 'default', runtime = globalRuntime) {
|
|
115
116
|
super();
|
|
117
|
+
this.runtime = runtime;
|
|
116
118
|
this.objectPool = objectPool;
|
|
117
119
|
this.database = database;
|
|
118
120
|
this.commitOutboxNamespace = commitOutboxNamespace;
|
|
119
|
-
this.networkMonitor = new NetworkMonitor();
|
|
121
|
+
this.networkMonitor = new NetworkMonitor(this.runtime);
|
|
120
122
|
// Initialize MutationQueue with proper configuration
|
|
121
123
|
this.mutationQueue = new MutationQueue({
|
|
122
124
|
position: this.position,
|
|
125
|
+
runtime: this.runtime,
|
|
123
126
|
maxBatchSize: 50, // Larger batches keep the batch count low for bulk operations
|
|
124
127
|
// A short delay keeps writes responsive; coalescing still groups them
|
|
125
128
|
batchDelay: 150,
|
|
@@ -203,7 +206,7 @@ export class SyncClient extends EventEmitter {
|
|
|
203
206
|
// losing a failed reconnect flush silently.
|
|
204
207
|
this.networkMonitor.on('online', () => {
|
|
205
208
|
void this.handleReconnection().catch((error) => {
|
|
206
|
-
|
|
209
|
+
this.runtime.observability.captureMutationFailure({
|
|
207
210
|
context: 'network-online-reconnection',
|
|
208
211
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
209
212
|
});
|
|
@@ -211,7 +214,7 @@ export class SyncClient extends EventEmitter {
|
|
|
211
214
|
});
|
|
212
215
|
this.networkMonitor.on('offline', () => {
|
|
213
216
|
void this.handleDisconnection().catch((error) => {
|
|
214
|
-
|
|
217
|
+
this.runtime.observability.captureMutationFailure({
|
|
215
218
|
context: 'network-offline-handler',
|
|
216
219
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
217
220
|
});
|
|
@@ -261,7 +264,7 @@ export class SyncClient extends EventEmitter {
|
|
|
261
264
|
// rejected write — keep it at `debug` so the rollback mechanics are
|
|
262
265
|
// available when debugging but don't double the console noise.
|
|
263
266
|
const abloErr = error instanceof AbloError ? error : undefined;
|
|
264
|
-
|
|
267
|
+
this.runtime.logger.debug('[SyncClient.rollback]', {
|
|
265
268
|
txType: transaction.type,
|
|
266
269
|
modelName: transaction.modelName,
|
|
267
270
|
modelId: transaction.modelId.slice(0, 12),
|
|
@@ -272,7 +275,7 @@ export class SyncClient extends EventEmitter {
|
|
|
272
275
|
requestId: abloErr?.requestId,
|
|
273
276
|
message: error?.message,
|
|
274
277
|
});
|
|
275
|
-
|
|
278
|
+
this.runtime.observability.captureRollback({
|
|
276
279
|
transactionType: transaction.type,
|
|
277
280
|
modelName: transaction.modelName,
|
|
278
281
|
modelId: transaction.modelId,
|
|
@@ -291,7 +294,7 @@ export class SyncClient extends EventEmitter {
|
|
|
291
294
|
// DELETE "not found" rollback: the entity doesn't exist on the server.
|
|
292
295
|
// Instead of restoring a ghost entity, remove it locally too.
|
|
293
296
|
// Both sides agree: this entity should not exist.
|
|
294
|
-
|
|
297
|
+
this.runtime.observability.breadcrumb('DELETE rolled back with "not found" - removing ghost entity', 'sync.conflict', 'info', {
|
|
295
298
|
modelId: transaction.modelId,
|
|
296
299
|
modelName: transaction.modelName,
|
|
297
300
|
});
|
|
@@ -309,7 +312,7 @@ export class SyncClient extends EventEmitter {
|
|
|
309
312
|
// design (can't revive the private isDisposed flag), so keep this
|
|
310
313
|
// at `debug` instead of emitting a second `warn` that reads as a
|
|
311
314
|
// distinct failure in the console.
|
|
312
|
-
|
|
315
|
+
this.runtime.logger.debug('[SyncClient] Rollback skipped restore (model already disposed)', {
|
|
313
316
|
modelId: transaction.modelId,
|
|
314
317
|
modelName: transaction.modelName,
|
|
315
318
|
reason,
|
|
@@ -336,7 +339,7 @@ export class SyncClient extends EventEmitter {
|
|
|
336
339
|
});
|
|
337
340
|
}
|
|
338
341
|
catch (error) {
|
|
339
|
-
|
|
342
|
+
this.runtime.observability.captureMutationFailure({
|
|
340
343
|
context: 'rollback-failed',
|
|
341
344
|
transactionId: transaction.id,
|
|
342
345
|
modelName: transaction.modelName,
|
|
@@ -354,7 +357,7 @@ export class SyncClient extends EventEmitter {
|
|
|
354
357
|
*/
|
|
355
358
|
setupReconciliationForwarding() {
|
|
356
359
|
this.mutationQueue.on('reconciliation:needed', (event) => {
|
|
357
|
-
|
|
360
|
+
this.runtime.observability.captureReconciliation({
|
|
358
361
|
reason: event.reason,
|
|
359
362
|
model: event.model,
|
|
360
363
|
modelId: event.modelId,
|
|
@@ -415,14 +418,14 @@ export class SyncClient extends EventEmitter {
|
|
|
415
418
|
operationType: event.operationType,
|
|
416
419
|
},
|
|
417
420
|
});
|
|
418
|
-
|
|
421
|
+
this.runtime.observability.breadcrumb('Persisted unconfirmed transaction to IDB', 'sync.transaction', 'info', {
|
|
419
422
|
txId: event.txId,
|
|
420
423
|
model: event.model,
|
|
421
424
|
modelId: event.modelId,
|
|
422
425
|
});
|
|
423
426
|
}
|
|
424
427
|
catch (error) {
|
|
425
|
-
|
|
428
|
+
this.runtime.observability.captureMutationFailure({
|
|
426
429
|
context: 'persist-awaiting-transaction',
|
|
427
430
|
modelName: event.model,
|
|
428
431
|
modelId: event.modelId,
|
|
@@ -447,7 +450,7 @@ export class SyncClient extends EventEmitter {
|
|
|
447
450
|
async initialize(userId, organizationId) {
|
|
448
451
|
this.userId = userId;
|
|
449
452
|
this.organizationId = organizationId;
|
|
450
|
-
|
|
453
|
+
this.runtime.observability.setContext(userId, organizationId);
|
|
451
454
|
this.mutationQueue.setCommitOutboxScope({
|
|
452
455
|
organizationId,
|
|
453
456
|
participantId: userId,
|
|
@@ -470,7 +473,7 @@ export class SyncClient extends EventEmitter {
|
|
|
470
473
|
// reports online by default. NetworkMonitor drives the ongoing
|
|
471
474
|
// online/offline transitions below — this read is only the initial
|
|
472
475
|
// snapshot taken when identity is set.
|
|
473
|
-
if (
|
|
476
|
+
if (this.runtime.onlineStatus.isOnline()) {
|
|
474
477
|
this.setConnectionState('connected');
|
|
475
478
|
}
|
|
476
479
|
else {
|
|
@@ -523,7 +526,7 @@ export class SyncClient extends EventEmitter {
|
|
|
523
526
|
const replacement = rule.from === 'organizationId' ? this.organizationId : this.userId;
|
|
524
527
|
if (!replacement)
|
|
525
528
|
continue;
|
|
526
|
-
|
|
529
|
+
this.runtime.observability.captureSelfHealing({
|
|
527
530
|
modelName: modelType,
|
|
528
531
|
modelId: idPrefix,
|
|
529
532
|
field: rule.field,
|
|
@@ -537,7 +540,7 @@ export class SyncClient extends EventEmitter {
|
|
|
537
540
|
for (const field of meta.requiredFields) {
|
|
538
541
|
if (result[field])
|
|
539
542
|
continue;
|
|
540
|
-
|
|
543
|
+
this.runtime.observability.captureSelfHealing({
|
|
541
544
|
modelName: modelType,
|
|
542
545
|
modelId: idPrefix,
|
|
543
546
|
field,
|
|
@@ -600,7 +603,7 @@ export class SyncClient extends EventEmitter {
|
|
|
600
603
|
allModelsToAdd.push(...modelsForType);
|
|
601
604
|
// Persist healed records back to IndexedDB (fire-and-forget, non-blocking)
|
|
602
605
|
if (recordsToHeal.length > 0 && this.database) {
|
|
603
|
-
|
|
606
|
+
this.runtime.logger.info(`[SyncClient.hydrate] Persisting ${recordsToHeal.length} healed ${modelType} records to IndexedDB`);
|
|
604
607
|
// Use fire-and-forget to not block hydration.
|
|
605
608
|
// void is safe: the handler's body is fully try/catch'd.
|
|
606
609
|
void Promise.resolve().then(async () => {
|
|
@@ -608,10 +611,10 @@ export class SyncClient extends EventEmitter {
|
|
|
608
611
|
for (const { id, data } of recordsToHeal) {
|
|
609
612
|
await this.database.putRecord(modelType, id, data);
|
|
610
613
|
}
|
|
611
|
-
|
|
614
|
+
this.runtime.logger.info(`[SyncClient.hydrate] Successfully healed ${recordsToHeal.length} ${modelType} records`);
|
|
612
615
|
}
|
|
613
616
|
catch (err) {
|
|
614
|
-
|
|
617
|
+
this.runtime.observability.captureMutationFailure({
|
|
615
618
|
context: 'persist-healed-records',
|
|
616
619
|
modelName: modelType,
|
|
617
620
|
error: err instanceof Error ? err : new Error(String(err)),
|
|
@@ -629,7 +632,7 @@ export class SyncClient extends EventEmitter {
|
|
|
629
632
|
});
|
|
630
633
|
}
|
|
631
634
|
catch (error) {
|
|
632
|
-
|
|
635
|
+
this.runtime.observability.captureBootstrapFailure(error, { type: `hydrate-${modelType}` });
|
|
633
636
|
}
|
|
634
637
|
}
|
|
635
638
|
// Phase 2: Single MobX action — add ALL models across all types at once.
|
|
@@ -638,7 +641,7 @@ export class SyncClient extends EventEmitter {
|
|
|
638
641
|
const addEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
639
642
|
// Log per-type perf after the batched add (so logs still show per-type breakdown)
|
|
640
643
|
for (const entry of perTypePerfLogs) {
|
|
641
|
-
|
|
644
|
+
this.runtime.logger.debug('hydrate:type', parseFloat(entry.fetchMs) + parseFloat(entry.createMs), {
|
|
642
645
|
type: entry.type,
|
|
643
646
|
fetched: entry.fetched,
|
|
644
647
|
added: entry.added,
|
|
@@ -647,7 +650,7 @@ export class SyncClient extends EventEmitter {
|
|
|
647
650
|
});
|
|
648
651
|
}
|
|
649
652
|
const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
650
|
-
|
|
653
|
+
this.runtime.logger.debug('hydrate:total', totalEnd - totalStart, {
|
|
651
654
|
totalModels: totalAdded,
|
|
652
655
|
addBatchMs: (addEnd - addStart).toFixed(2),
|
|
653
656
|
});
|
|
@@ -655,7 +658,7 @@ export class SyncClient extends EventEmitter {
|
|
|
655
658
|
try {
|
|
656
659
|
const preseededTypes = this.objectPool.registry.getRegisteredModelNames();
|
|
657
660
|
const stats = this.objectPool.getStats();
|
|
658
|
-
|
|
661
|
+
this.runtime.logger.info('startup_summary', {
|
|
659
662
|
typesPreseeded: preseededTypes.length,
|
|
660
663
|
poolSize: stats.size,
|
|
661
664
|
typeCounts: stats.typeCounts,
|
|
@@ -736,7 +739,7 @@ export class SyncClient extends EventEmitter {
|
|
|
736
739
|
}
|
|
737
740
|
}
|
|
738
741
|
catch (error) {
|
|
739
|
-
|
|
742
|
+
this.runtime.observability.breadcrumb('Model creation failed during rehydration', 'sync.bootstrap', 'warning', {
|
|
740
743
|
modelType,
|
|
741
744
|
modelId: recordId?.slice(0, 8) ?? 'unknown',
|
|
742
745
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -746,7 +749,7 @@ export class SyncClient extends EventEmitter {
|
|
|
746
749
|
}
|
|
747
750
|
}
|
|
748
751
|
catch (error) {
|
|
749
|
-
|
|
752
|
+
this.runtime.observability.captureBootstrapFailure(error, { type: `rehydrate-${modelType}` });
|
|
750
753
|
}
|
|
751
754
|
}
|
|
752
755
|
// ── Phase 2: Upsert batch (single MobX action) ──
|
|
@@ -785,12 +788,12 @@ export class SyncClient extends EventEmitter {
|
|
|
785
788
|
healed: healedCount,
|
|
786
789
|
elapsedMs,
|
|
787
790
|
};
|
|
788
|
-
|
|
791
|
+
this.runtime.logger.info('[SyncClient.rehydrate] Complete', {
|
|
789
792
|
...stats,
|
|
790
793
|
poolSize: this.objectPool.size,
|
|
791
794
|
ghostIds: ghostIds.length > 0 ? ghostIds.slice(0, 5).map((id) => id.slice(0, 8)) : [],
|
|
792
795
|
});
|
|
793
|
-
|
|
796
|
+
this.runtime.observability.breadcrumb('Rehydration complete', 'sync.bootstrap', 'info', {
|
|
794
797
|
added: stats.added,
|
|
795
798
|
updated: stats.updated,
|
|
796
799
|
removed: stats.removed,
|
|
@@ -882,7 +885,7 @@ export class SyncClient extends EventEmitter {
|
|
|
882
885
|
* (e.g. freshly constructed cell models from a bulk document decomposition).
|
|
883
886
|
*/
|
|
884
887
|
updateWithChanges(model, changes) {
|
|
885
|
-
|
|
888
|
+
this.runtime.logger.debug(`SyncClient.updateWithChanges`, {
|
|
886
889
|
modelId: model.id,
|
|
887
890
|
modelType: model.getModelName(),
|
|
888
891
|
});
|
|
@@ -928,7 +931,7 @@ export class SyncClient extends EventEmitter {
|
|
|
928
931
|
this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
|
|
929
932
|
const afterCount = this.pendingMutations.length;
|
|
930
933
|
if (beforeCount !== afterCount) {
|
|
931
|
-
|
|
934
|
+
this.runtime.logger.debug('[SyncClient.clearPendingMutationsForModel] Cleared pending mutations', {
|
|
932
935
|
modelId,
|
|
933
936
|
clearedCount: beforeCount - afterCount,
|
|
934
937
|
remainingCount: afterCount,
|
|
@@ -988,7 +991,7 @@ export class SyncClient extends EventEmitter {
|
|
|
988
991
|
return null;
|
|
989
992
|
}
|
|
990
993
|
catch (error) {
|
|
991
|
-
|
|
994
|
+
this.runtime.observability.captureMutationFailure({
|
|
992
995
|
context: 'file-upload',
|
|
993
996
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
994
997
|
});
|
|
@@ -1187,9 +1190,9 @@ export class SyncClient extends EventEmitter {
|
|
|
1187
1190
|
: (cb) => Promise.resolve().then(cb);
|
|
1188
1191
|
schedule(() => {
|
|
1189
1192
|
this.syncScheduled = false;
|
|
1190
|
-
if (
|
|
1193
|
+
if (this.runtime.onlineStatus.isOnline()) {
|
|
1191
1194
|
this.processPendingMutations().catch((err) => {
|
|
1192
|
-
|
|
1195
|
+
this.runtime.observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
|
|
1193
1196
|
});
|
|
1194
1197
|
}
|
|
1195
1198
|
});
|
|
@@ -1248,7 +1251,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1248
1251
|
const restore = async (mutation, migrateLegacy, legacyMutationId) => {
|
|
1249
1252
|
const parsed = persistedMutationSchema.safeParse(mutation);
|
|
1250
1253
|
if (!parsed.success) {
|
|
1251
|
-
|
|
1254
|
+
this.runtime.logger.debug('[SyncClient] Dropping malformed persisted mutation', {
|
|
1252
1255
|
issues: parsed.error.issues.map((i) => i.path.join('.')).join(', '),
|
|
1253
1256
|
});
|
|
1254
1257
|
return;
|
|
@@ -1260,7 +1263,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1260
1263
|
const age = Date.now() - writtenAt;
|
|
1261
1264
|
if (!(age < PENDING_MUTATION_REPLAY_WINDOW_MS)) {
|
|
1262
1265
|
heldForReview += 1;
|
|
1263
|
-
|
|
1266
|
+
this.runtime.logger.warn('A saved local write is older than the server idempotency window and was held for review.');
|
|
1264
1267
|
return;
|
|
1265
1268
|
}
|
|
1266
1269
|
const mutationId = parsed.data.mutationId ?? legacyMutationId ?? `mutation_${uuid()}`;
|
|
@@ -1304,7 +1307,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1304
1307
|
await restore(legacy.data.mutation, true);
|
|
1305
1308
|
continue;
|
|
1306
1309
|
}
|
|
1307
|
-
|
|
1310
|
+
this.runtime.logger.debug('[SyncClient] Dropping malformed pending mutation record', {
|
|
1308
1311
|
rowId: row.id,
|
|
1309
1312
|
});
|
|
1310
1313
|
continue;
|
|
@@ -1312,7 +1315,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1312
1315
|
if (parsed.data.scope.organizationId !== this.organizationId ||
|
|
1313
1316
|
parsed.data.scope.participantId !== this.userId ||
|
|
1314
1317
|
parsed.data.scope.namespace !== this.commitOutboxNamespace) {
|
|
1315
|
-
|
|
1318
|
+
this.runtime.logger.warn('A saved local write belongs to a different account or server and was held for review.');
|
|
1316
1319
|
continue;
|
|
1317
1320
|
}
|
|
1318
1321
|
await restore(parsed.data.mutation, false);
|
|
@@ -1334,10 +1337,10 @@ export class SyncClient extends EventEmitter {
|
|
|
1334
1337
|
// A restore failure means queued offline writes did NOT rehydrate.
|
|
1335
1338
|
// Self-healing is impossible here (the record may be unreadable), but
|
|
1336
1339
|
// the failure must be visible for diagnosis instead of silent loss.
|
|
1337
|
-
|
|
1340
|
+
this.runtime.logger.debug('[SyncClient] Failed to restore offline mutation queue', {
|
|
1338
1341
|
error: error instanceof Error ? error.message : String(error),
|
|
1339
1342
|
});
|
|
1340
|
-
|
|
1343
|
+
this.runtime.observability.captureMutationFailure({
|
|
1341
1344
|
context: 'restore-mutation-queue',
|
|
1342
1345
|
error: error instanceof Error ? error : String(error),
|
|
1343
1346
|
});
|
|
@@ -1367,14 +1370,14 @@ export class SyncClient extends EventEmitter {
|
|
|
1367
1370
|
// identity, which surfaces downstream as "writes never confirm"; we do
|
|
1368
1371
|
// NOT name internal wiring (`SyncClient.initialize`) here because that
|
|
1369
1372
|
// method isn't part of the @abloatai/ablo surface a reader could act on.
|
|
1370
|
-
|
|
1373
|
+
this.runtime.logger.debug('[sync] writes waiting for identity (user/org not set yet) — queued, will retry', {
|
|
1371
1374
|
pending: this.pendingMutations.length,
|
|
1372
1375
|
userId: this.userId,
|
|
1373
1376
|
organizationId: this.organizationId,
|
|
1374
1377
|
});
|
|
1375
1378
|
return;
|
|
1376
1379
|
}
|
|
1377
|
-
if (!
|
|
1380
|
+
if (!this.runtime.onlineStatus.isOnline())
|
|
1378
1381
|
return; // Skip if offline
|
|
1379
1382
|
if (this.isDisposed)
|
|
1380
1383
|
return; // Skip if disposed
|
|
@@ -1419,7 +1422,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1419
1422
|
this.stagedMutationIds.delete(mutation.mutationId);
|
|
1420
1423
|
this.pendingMutations = this.pendingMutations.filter((pending) => pending.mutationId !== mutation.mutationId);
|
|
1421
1424
|
mutation.rejectStaged?.(outcome.reason);
|
|
1422
|
-
|
|
1425
|
+
this.runtime.observability.captureMutationFailure({
|
|
1423
1426
|
context: 'persist-pending-mutation',
|
|
1424
1427
|
error: outcome.reason instanceof Error
|
|
1425
1428
|
? outcome.reason
|
|
@@ -1451,7 +1454,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1451
1454
|
// apply, store add). That means the write never entered the queue, so
|
|
1452
1455
|
// capture it instead of dropping it silently.
|
|
1453
1456
|
const captureStagingFailure = (error) => {
|
|
1454
|
-
|
|
1457
|
+
this.runtime.observability.captureMutationFailure({
|
|
1455
1458
|
context: `stage-mutation-${mutation.type}`,
|
|
1456
1459
|
modelName: mutation.model.getModelName(),
|
|
1457
1460
|
modelId: mutation.model.id,
|
|
@@ -1484,7 +1487,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1484
1487
|
: new Date(localModel.updatedAt).getTime()
|
|
1485
1488
|
: 0;
|
|
1486
1489
|
const serverUpdatedAt = toEpochMs(serverData.updatedAt);
|
|
1487
|
-
|
|
1490
|
+
this.runtime.logger.debug('Conflict resolution', {
|
|
1488
1491
|
modelId: localModel.id,
|
|
1489
1492
|
modelType: localModel.getModelName(),
|
|
1490
1493
|
hasLocalChanges,
|
|
@@ -1498,7 +1501,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1498
1501
|
const criticalServerStates = this.extractCriticalState(serverData);
|
|
1499
1502
|
const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
|
|
1500
1503
|
if (shouldForceAcceptServer) {
|
|
1501
|
-
|
|
1504
|
+
this.runtime.logger.debug('Accepting server update - critical state change detected', {
|
|
1502
1505
|
modelId: localModel.id,
|
|
1503
1506
|
criticalStates: criticalServerStates,
|
|
1504
1507
|
});
|
|
@@ -1512,7 +1515,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1512
1515
|
// Keep locally changed fields; apply server for the rest.
|
|
1513
1516
|
if (hasLocalChanges) {
|
|
1514
1517
|
const localChanges = localModel.getChanges();
|
|
1515
|
-
|
|
1518
|
+
this.runtime.logger.debug('Merging server update with local dirty fields', {
|
|
1516
1519
|
modelId: localModel.id,
|
|
1517
1520
|
keptFields: Object.keys(localChanges || {}),
|
|
1518
1521
|
});
|
|
@@ -1531,7 +1534,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1531
1534
|
// No local changes: fall back to LWW to converge
|
|
1532
1535
|
// Accept server regardless of timestamp equality to stay in sync
|
|
1533
1536
|
const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
|
|
1534
|
-
|
|
1537
|
+
this.runtime.logger.debug(`Accepting server update - ${acceptReason}`);
|
|
1535
1538
|
localModel.updateFromData(serverData);
|
|
1536
1539
|
localModel.clearChanges();
|
|
1537
1540
|
localModel.markAsSynced();
|
|
@@ -1575,7 +1578,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1575
1578
|
* Handle network reconnection
|
|
1576
1579
|
*/
|
|
1577
1580
|
async handleReconnection() {
|
|
1578
|
-
|
|
1581
|
+
this.runtime.observability.breadcrumb('Network reconnected', 'sync.offline');
|
|
1579
1582
|
this.emit('sync:reconnecting');
|
|
1580
1583
|
try {
|
|
1581
1584
|
// Prefer a single batch flush for pending mutations (fast path)
|
|
@@ -1589,7 +1592,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1589
1592
|
this.emit('sync:reconnected');
|
|
1590
1593
|
}
|
|
1591
1594
|
catch (error) {
|
|
1592
|
-
|
|
1595
|
+
this.runtime.observability.captureMutationFailure({
|
|
1593
1596
|
context: 'reconnection-sync',
|
|
1594
1597
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
1595
1598
|
});
|
|
@@ -1600,7 +1603,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1600
1603
|
* Handle network disconnection
|
|
1601
1604
|
*/
|
|
1602
1605
|
async handleDisconnection() {
|
|
1603
|
-
|
|
1606
|
+
this.runtime.observability.breadcrumb('Network disconnected', 'sync.offline');
|
|
1604
1607
|
this.setConnectionState('disconnected');
|
|
1605
1608
|
this.emit('sync:offline');
|
|
1606
1609
|
}
|
|
@@ -1622,8 +1625,8 @@ export class SyncClient extends EventEmitter {
|
|
|
1622
1625
|
const oldState = this.connectionState;
|
|
1623
1626
|
this.connectionState = state;
|
|
1624
1627
|
if (oldState !== state) {
|
|
1625
|
-
|
|
1626
|
-
|
|
1628
|
+
this.runtime.observability.setConnectionState(state);
|
|
1629
|
+
this.runtime.observability.breadcrumb(`Connection: ${oldState} → ${state}`, 'sync.websocket');
|
|
1627
1630
|
if (state === 'connected') {
|
|
1628
1631
|
this.emit('connection:established');
|
|
1629
1632
|
this.mutationQueue.setConnectionState('connected');
|
|
@@ -1666,7 +1669,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1666
1669
|
observer.onSync(event);
|
|
1667
1670
|
}
|
|
1668
1671
|
catch (error) {
|
|
1669
|
-
|
|
1672
|
+
this.runtime.observability.breadcrumb('Observer error', 'sync.transaction', 'error', {
|
|
1670
1673
|
error: error instanceof Error ? error.message : String(error),
|
|
1671
1674
|
});
|
|
1672
1675
|
}
|
|
@@ -1689,7 +1692,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1689
1692
|
// WebSocket itself was ready. Always kick both durable lanes on the real
|
|
1690
1693
|
// socket event, even when the high-level state did not change.
|
|
1691
1694
|
void this.mutationQueue.flushOfflineQueue().catch((error) => {
|
|
1692
|
-
|
|
1695
|
+
this.runtime.observability.captureMutationFailure({
|
|
1693
1696
|
context: 'restore-commit-outbox',
|
|
1694
1697
|
error: error instanceof Error ? error : new Error(String(error)),
|
|
1695
1698
|
});
|
|
@@ -1720,7 +1723,7 @@ export class SyncClient extends EventEmitter {
|
|
|
1720
1723
|
this.mutationQueue.onDeltaReceived(syncId, transactionId, correlationId);
|
|
1721
1724
|
}
|
|
1722
1725
|
catch (e) {
|
|
1723
|
-
|
|
1726
|
+
this.runtime.observability.breadcrumb('Failed to notify delta received', 'sync.transaction', 'warning', {
|
|
1724
1727
|
syncId,
|
|
1725
1728
|
transactionId,
|
|
1726
1729
|
correlationId,
|
|
@@ -2071,7 +2074,7 @@ export class SyncClient extends EventEmitter {
|
|
|
2071
2074
|
// fall-through.
|
|
2072
2075
|
// Self-healing: the next catch-up poll / reconnect re-fetches and
|
|
2073
2076
|
// re-applies this delta, so it's forensic, not consumer-actionable → debug.
|
|
2074
|
-
|
|
2077
|
+
this.runtime.logger.debug('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
|
|
2075
2078
|
modelName,
|
|
2076
2079
|
modelId: modelId.slice(0, 12),
|
|
2077
2080
|
});
|
package/dist/client/Ablo.js
CHANGED
|
@@ -35,10 +35,10 @@ import { createAbloHttpClient, } from '../transaction/transport/httpClient.js';
|
|
|
35
35
|
import { layerPluginSurface, resolvePlugins, } from '../transaction/plugin.js';
|
|
36
36
|
import { noopLogger } from '../transaction/logger.js';
|
|
37
37
|
import { humans } from './humans.js';
|
|
38
|
+
import { kStoreCluster } from './storeCluster.js';
|
|
38
39
|
import { createCoreClient } from './coreClient.js';
|
|
39
40
|
import { buildReactiveEngine } from './reactiveEngine.js';
|
|
40
41
|
import { resolveClientPrelude } from './clientPrelude.js';
|
|
41
|
-
import { createDefaultMutationExecutor } from './wsMutationExecutor.js';
|
|
42
42
|
export function Ablo(options) {
|
|
43
43
|
if (options.transport === 'http') {
|
|
44
44
|
// Capabilities are declared above transport (ADR 0016): the plugin list
|
|
@@ -63,11 +63,15 @@ export function Ablo(options) {
|
|
|
63
63
|
// `connect()`, and `deferConnect` keeps even that closed until the store
|
|
64
64
|
// has seeded identity and read scope during `ready()` — the late-bound
|
|
65
65
|
// values (`kind`, the credential, `syncGroups`, the resume cursor) are
|
|
66
|
-
// seeded there, not here.
|
|
67
|
-
//
|
|
68
|
-
//
|
|
66
|
+
// seeded there, not here. Whether to build it is read off what the
|
|
67
|
+
// listed plugins DECLARE — a plugin that requires a duplex transport
|
|
68
|
+
// gets one to attach to — never off a plugin's name; interrogating the
|
|
69
|
+
// assembly by id is the context's `hasPlugin`, a capability for
|
|
70
|
+
// plugins, not a dispatch key for this root. The empty list builds
|
|
71
|
+
// nothing here: the core client (`plugins: []`) constructs its own
|
|
72
|
+
// feed, and no plugin runs on that path to read this one.
|
|
69
73
|
const pluginList = options.plugins ?? [humans()];
|
|
70
|
-
const transport = pluginList.some((plugin) => plugin.
|
|
74
|
+
const transport = pluginList.some((plugin) => plugin.requires?.duplex === true)
|
|
71
75
|
? new SyncWebSocket({
|
|
72
76
|
baseUrl: url,
|
|
73
77
|
kind,
|
|
@@ -77,19 +81,15 @@ export function Ablo(options) {
|
|
|
77
81
|
deferConnect: true,
|
|
78
82
|
})
|
|
79
83
|
: null;
|
|
80
|
-
// The default mutation executor sends `{ type: 'commit', ... }` over
|
|
81
|
-
// that connection; before it opens, sends reject with the diagnosed
|
|
82
|
-
// not-ready error and the MutationQueue owns the retry. Caller-supplied
|
|
83
|
-
// executors are still honored for advanced cases (test mocks,
|
|
84
|
-
// alternative transports).
|
|
85
|
-
const executor = internalOptions.mutationExecutor ?? createDefaultMutationExecutor(() => transport);
|
|
86
84
|
// Resolve the capability list before anything heavy is constructed
|
|
87
85
|
// (ADR 0016). The two configuration gates — a duplicate id, a transport
|
|
88
86
|
// mismatch — fire here, while the stack still points at the caller's own
|
|
89
87
|
// setup. With no list given, the reactive materialiser is installed:
|
|
90
88
|
// today's default client. (Flipping the bare default to the stateless core
|
|
91
89
|
// is the mirror-flip step, decided with the published version identity —
|
|
92
|
-
// not here.)
|
|
90
|
+
// not here.) The context carries the host's resolved values — url,
|
|
91
|
+
// credential source, identity — so `humans().init` constructs the store
|
|
92
|
+
// cluster right here, during resolution.
|
|
93
93
|
const installedPlugins = resolvePlugins(pluginList, { duplex: true }, {
|
|
94
94
|
logger,
|
|
95
95
|
observability: internalOptions.observability,
|
|
@@ -99,6 +99,8 @@ export function Ablo(options) {
|
|
|
99
99
|
...(transport ? { transport } : {}),
|
|
100
100
|
participant: { id: participantId, kind },
|
|
101
101
|
syncGroups: internalOptions.syncGroups ?? [],
|
|
102
|
+
url,
|
|
103
|
+
auth: authCredentials,
|
|
102
104
|
});
|
|
103
105
|
const humansSurface = installedPlugins.humans;
|
|
104
106
|
if (!humansSurface) {
|
|
@@ -132,18 +134,27 @@ export function Ablo(options) {
|
|
|
132
134
|
// 'humans', which is exactly the condition the transport was built under.
|
|
133
135
|
throw new AbloValidationError('The reactive client was selected but no connection was constructed.', { code: 'invalid_options', param: 'plugins' });
|
|
134
136
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
137
|
+
const cluster = humansSurface[kStoreCluster];
|
|
138
|
+
if (!cluster) {
|
|
139
|
+
// Unreachable on this path: the context above carries everything the
|
|
140
|
+
// cluster needs (connection, url, credential source, schema). A missing
|
|
141
|
+
// cluster means the context and the plugin disagree — say so rather
|
|
142
|
+
// than building a client with no store.
|
|
143
|
+
throw new AbloValidationError('The reactive client was selected but no store was constructed.', { code: 'invalid_options', param: 'plugins' });
|
|
144
|
+
}
|
|
145
|
+
// The reactive engine is the humans() capability: `init` constructed the
|
|
146
|
+
// store cluster (runtime, component graph, store) from the widened
|
|
147
|
+
// context; what remains here builds in `./reactiveEngine.ts`, fed the
|
|
148
|
+
// prelude plus the plugin's contributions. The host owns assembly;
|
|
149
|
+
// plugins declare and construct their own parts. A plugin surface that
|
|
150
|
+
// hands back a whole-client constructor inverts that relationship —
|
|
151
|
+
// tried and rejected (ADR 0016, follow-up 3b).
|
|
141
152
|
return layerPluginSurface(buildReactiveEngine({
|
|
142
153
|
...prelude,
|
|
143
154
|
options,
|
|
144
|
-
executor,
|
|
145
155
|
transport,
|
|
146
156
|
presence: humansSurface.presence,
|
|
157
|
+
cluster,
|
|
147
158
|
createSibling: (siblingOptions) => Ablo(siblingOptions),
|
|
148
159
|
}), installedPlugins);
|
|
149
160
|
}
|
|
@@ -17,6 +17,7 @@ import type { AuthCredentialSource } from '../transaction/auth/credentialSource.
|
|
|
17
17
|
import type { Schema, SchemaRecord } from '../transaction/schema/schema.js';
|
|
18
18
|
import { type AbloPersistence } from '../transaction/persistence.js';
|
|
19
19
|
import type { DurableWriteStore, DurableWritesConfig } from '../transactions/mutations/durableWriteStore.js';
|
|
20
|
+
import type { RuntimeContext } from '../RuntimeContext.js';
|
|
20
21
|
export interface InternalComponentsInput<S extends SchemaRecord> {
|
|
21
22
|
readonly schema: Schema<S>;
|
|
22
23
|
/** The WebSocket URL. Used to derive the bootstrap HTTP base URL when the
|
|
@@ -34,6 +35,9 @@ export interface InternalComponentsInput<S extends SchemaRecord> {
|
|
|
34
35
|
readonly commitOutbox?: DurableWriteStore;
|
|
35
36
|
};
|
|
36
37
|
readonly auth?: AuthCredentialSource;
|
|
38
|
+
/** The owning client's runtime, threaded into every component built here.
|
|
39
|
+
* Defaults to the module-global bridge for direct construction. */
|
|
40
|
+
readonly runtime?: RuntimeContext;
|
|
37
41
|
}
|
|
38
42
|
export interface InternalComponents {
|
|
39
43
|
readonly modelRegistry: ModelRegistry;
|
|
@@ -16,17 +16,20 @@ import { BootstrapFetcher } from '../sync/BootstrapFetcher.js';
|
|
|
16
16
|
import { loadsAtBootstrap } from '../transaction/schema/loadStrategy.js';
|
|
17
17
|
import { resolveBootstrapBaseUrl } from '../transaction/auth/apiKey.js';
|
|
18
18
|
import { shouldUseInMemoryPersistence } from '../transaction/persistence.js';
|
|
19
|
+
import { globalRuntime } from '../context.js';
|
|
19
20
|
import { resolveDurableWrites } from '../transaction/durableWrites.js';
|
|
20
21
|
export function createInternalComponents(input) {
|
|
21
22
|
const { schema, url, options, auth } = input;
|
|
23
|
+
const runtime = input.runtime ?? globalRuntime;
|
|
22
24
|
// The registry is created here, but model registration happens in the caller,
|
|
23
25
|
// which owns the schema-to-class translation.
|
|
24
26
|
const modelRegistry = new ModelRegistry({
|
|
25
27
|
validateOnRegister: false,
|
|
26
28
|
allowLateReferences: true,
|
|
29
|
+
runtime,
|
|
27
30
|
});
|
|
28
31
|
setActiveRegistry(modelRegistry);
|
|
29
|
-
const objectPool = new InstanceCache({ maxSize: options.maxPoolSize ?? 10000 }, modelRegistry);
|
|
32
|
+
const objectPool = new InstanceCache({ maxSize: options.maxPoolSize ?? 10000, runtime }, modelRegistry);
|
|
30
33
|
const bootstrapBaseUrl = resolveBootstrapBaseUrl({
|
|
31
34
|
url,
|
|
32
35
|
bootstrapBaseUrl: options.bootstrapBaseUrl,
|
|
@@ -36,15 +39,17 @@ export function createInternalComponents(input) {
|
|
|
36
39
|
syncGroups: options.syncGroups,
|
|
37
40
|
instantModels: deriveInstantModels(schema),
|
|
38
41
|
getAuthToken: auth?.getAuthToken,
|
|
42
|
+
runtime,
|
|
39
43
|
});
|
|
40
44
|
const database = new Database(modelRegistry, bootstrapHelper, {
|
|
41
45
|
// By default there is no browser-local durable store unless the caller asks
|
|
42
46
|
// for one. Node and edge runtimes always use the in-memory store because
|
|
43
47
|
// IndexedDB is unavailable there.
|
|
44
48
|
inMemory: shouldUseInMemoryPersistence(options),
|
|
49
|
+
runtime,
|
|
45
50
|
});
|
|
46
51
|
const durableWrites = resolveDurableWrites(options);
|
|
47
|
-
const syncClient = new SyncClient(objectPool, database, durableWrites.store, durableWrites.namespace ?? url);
|
|
52
|
+
const syncClient = new SyncClient(objectPool, database, durableWrites.store, durableWrites.namespace ?? url, runtime);
|
|
48
53
|
// Lazy-load lane: hydrates the object pool and IndexedDB on demand for
|
|
49
54
|
// entities not in scope at bootstrap (`load: 'lazy'` models, or an entity
|
|
50
55
|
// reached by deep link before the pool warmed up). Single-flight, with
|
|
@@ -56,6 +61,7 @@ export function createInternalComponents(input) {
|
|
|
56
61
|
schema,
|
|
57
62
|
baseUrl: bootstrapBaseUrl,
|
|
58
63
|
getAuthToken: auth?.getAuthToken,
|
|
64
|
+
runtime,
|
|
59
65
|
});
|
|
60
66
|
// Drop the lazy-lane hydration ledger on reconnect. While connected, the
|
|
61
67
|
// WebSocket delta stream keeps hydrated rows fresh so repeat reads serve
|