@abloatai/ablo 0.3.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 +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- package/package.json +147 -0
|
@@ -0,0 +1,1555 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncClient - Mutation and offline queue manager
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Handle model mutations (create, update, delete, archive)
|
|
6
|
+
* - Manage offline mutation queue with persistence
|
|
7
|
+
* - Send mutations to server via API client
|
|
8
|
+
* - Handle conflict resolution for local changes
|
|
9
|
+
*/
|
|
10
|
+
import { ModelScope } from './ObjectPool.js';
|
|
11
|
+
// ModelRegistry instance accessed via this.objectPool.registry
|
|
12
|
+
import { LoadStrategy } from './types/index.js';
|
|
13
|
+
import { getContext } from './context.js';
|
|
14
|
+
import { AbloAuthenticationError, AbloError, AbloValidationError } from './errors.js';
|
|
15
|
+
import { EventEmitter } from 'events';
|
|
16
|
+
import { NetworkMonitor } from './NetworkMonitor.js';
|
|
17
|
+
import { TransactionQueue } from './transactions/TransactionQueue.js';
|
|
18
|
+
import { OptimisticEchoTracker, } from './transactions/OptimisticEchoTracker.js';
|
|
19
|
+
export class SyncClient extends EventEmitter {
|
|
20
|
+
objectPool;
|
|
21
|
+
database;
|
|
22
|
+
get mutationExecutor() { return getContext().mutationExecutor; }
|
|
23
|
+
networkMonitor;
|
|
24
|
+
transactionQueue;
|
|
25
|
+
observers = new Set();
|
|
26
|
+
// Authentication context
|
|
27
|
+
userId = null;
|
|
28
|
+
organizationId = null;
|
|
29
|
+
// Pending mutations queue
|
|
30
|
+
pendingMutations = [];
|
|
31
|
+
/**
|
|
32
|
+
* Tracks transaction ids the client has optimistically applied but
|
|
33
|
+
* the server has not yet confirmed. The receive path consults it
|
|
34
|
+
* to recognize delta echoes of own mutations and suppress the
|
|
35
|
+
* (otherwise-redundant) pool mutation — the IDB write still runs
|
|
36
|
+
* because the delta is the authoritative version of the row.
|
|
37
|
+
*
|
|
38
|
+
* The receive-layer discriminator named in
|
|
39
|
+
* `apps/sync-server/docs/OPTIMISTIC_RECONCILIATION.md`. Without
|
|
40
|
+
* it, an optimistically-applied DELETE followed by a
|
|
41
|
+
* server-confirming CREATE echo resurrects the row for the window
|
|
42
|
+
* between the two confirmations (the chart-delete flicker).
|
|
43
|
+
*
|
|
44
|
+
* Bounded with FIFO eviction; observability via `getEchoMetrics()`.
|
|
45
|
+
*/
|
|
46
|
+
echoTracker = new OptimisticEchoTracker();
|
|
47
|
+
// Connection state
|
|
48
|
+
connectionState = 'disconnected';
|
|
49
|
+
offlineSince;
|
|
50
|
+
// Configuration
|
|
51
|
+
maxRetries = 3;
|
|
52
|
+
isDisposed = false;
|
|
53
|
+
constructor(objectPool, database) {
|
|
54
|
+
super();
|
|
55
|
+
this.objectPool = objectPool;
|
|
56
|
+
this.database = database;
|
|
57
|
+
this.networkMonitor = new NetworkMonitor();
|
|
58
|
+
// Initialize TransactionQueue with proper configuration
|
|
59
|
+
this.transactionQueue = new TransactionQueue({
|
|
60
|
+
maxBatchSize: 50, // Increased from 10 to reduce batch count for large operations
|
|
61
|
+
// Lower delay for snappier dev UX; batching still happens via coalescing
|
|
62
|
+
batchDelay: 150,
|
|
63
|
+
maxRetries: 3,
|
|
64
|
+
enableOptimistic: true,
|
|
65
|
+
enablePersistence: true,
|
|
66
|
+
conflictResolution: {
|
|
67
|
+
strategy: 'last-write-wins',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
// Provide connection state to TransactionQueue - prevents rollbacks during disconnection
|
|
71
|
+
this.transactionQueue.setConnectionChecker(() => this.connectionState === 'connected');
|
|
72
|
+
// LINEAR PATTERN: Subscribe to rollback events to restore ObjectPool state
|
|
73
|
+
// When a transaction fails (server rejects or timeout), we need to restore the model
|
|
74
|
+
// Since we no longer write to IndexedDB optimistically, IndexedDB already has correct state
|
|
75
|
+
this.setupTransactionRollbackHandling();
|
|
76
|
+
// REPLICACHE PATTERN: Forward reconciliation requests from TransactionQueue
|
|
77
|
+
// When delta confirmation times out, instead of rolling back we request the sync layer
|
|
78
|
+
// to cycle the WebSocket connection, triggering a delta catch-up from the server
|
|
79
|
+
this.setupReconciliationForwarding();
|
|
80
|
+
// LINEAR PATTERN: Persist unconfirmed transactions to IndexedDB
|
|
81
|
+
// When delta retries exhaust, cache in IDB so they survive tab close
|
|
82
|
+
this.setupAwaitingTransactionPersistence();
|
|
83
|
+
// Setup network monitoring
|
|
84
|
+
this.setupNetworkMonitoring();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Setup network monitoring handlers
|
|
88
|
+
*/
|
|
89
|
+
setupNetworkMonitoring() {
|
|
90
|
+
this.networkMonitor.on('online', () => this.handleReconnection());
|
|
91
|
+
this.networkMonitor.on('offline', () => this.handleDisconnection());
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Handle transaction rollback. Two distinct shapes flow through this
|
|
95
|
+
* event:
|
|
96
|
+
*
|
|
97
|
+
* 1. **Server-rejected rollback** (`reason === 'permanent_error'`,
|
|
98
|
+
* `'max_retries_exhausted'`, `'conflict_server_wins'`) — the
|
|
99
|
+
* optimistic state is wrong, the row exists, restore previous
|
|
100
|
+
* state and notify the UI.
|
|
101
|
+
*
|
|
102
|
+
* 2. **Local-cancellation cleanup** (`reason === 'model_cancelled'`,
|
|
103
|
+
* `'cascade_parent_deleted'`) — the user deleted this model (or
|
|
104
|
+
* its parent), so a pending UPDATE on it gets cancelled. There's
|
|
105
|
+
* nothing to restore (the model is doomed) and no UI notification
|
|
106
|
+
* needed (the delete itself already triggered re-renders). Just
|
|
107
|
+
* discard the optimistic state silently.
|
|
108
|
+
*
|
|
109
|
+
* Treating both paths the same caused the deletion-flicker bug: every
|
|
110
|
+
* cancelled update on a multi-layer chart fired a per-model observer
|
|
111
|
+
* event and a `[SyncClient.rollback]` warn, producing N renders and N
|
|
112
|
+
* spam log lines for one user-initiated delete.
|
|
113
|
+
*/
|
|
114
|
+
setupTransactionRollbackHandling() {
|
|
115
|
+
this.transactionQueue.on('optimistic:rollback', (event) => {
|
|
116
|
+
const { model, previousState, transaction, reason, error } = event;
|
|
117
|
+
// Local cleanup path — discard quietly. The optimistic state was
|
|
118
|
+
// applied to a model that's already disposed by the cascading
|
|
119
|
+
// delete, and emitting per-model observer events here would
|
|
120
|
+
// re-render N times for one user-initiated cascade.
|
|
121
|
+
if (reason === 'model_cancelled' || reason === 'cascade_parent_deleted') {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Surface the typed AbloError fields directly — `type`/`code`/
|
|
125
|
+
// `httpStatus`/`requestId` are what tell us the rollback cause
|
|
126
|
+
// (e.g. `AbloValidationError` with `code: 'schema_...'`,
|
|
127
|
+
// `AbloServerError` with `httpStatus: 500`). Falling back to
|
|
128
|
+
// generic message lets us still see unstructured errors.
|
|
129
|
+
const abloErr = error instanceof AbloError ? error : undefined;
|
|
130
|
+
getContext().logger.warn('[SyncClient.rollback]', {
|
|
131
|
+
txType: transaction.type,
|
|
132
|
+
modelName: transaction.modelName,
|
|
133
|
+
modelId: transaction.modelId.slice(0, 12),
|
|
134
|
+
reason: reason ?? 'unknown',
|
|
135
|
+
errorType: abloErr?.type ?? error?.name,
|
|
136
|
+
errorCode: abloErr?.code,
|
|
137
|
+
httpStatus: abloErr?.httpStatus,
|
|
138
|
+
requestId: abloErr?.requestId,
|
|
139
|
+
message: error?.message,
|
|
140
|
+
});
|
|
141
|
+
getContext().observability.captureRollback({
|
|
142
|
+
transactionType: transaction.type,
|
|
143
|
+
modelName: transaction.modelName,
|
|
144
|
+
modelId: transaction.modelId,
|
|
145
|
+
reason: reason ?? 'unknown',
|
|
146
|
+
error: error?.message,
|
|
147
|
+
connectionState: this.connectionState,
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
if (transaction.type === 'create') {
|
|
151
|
+
// CREATE rollback: remove the optimistically created entity
|
|
152
|
+
this.objectPool.remove(transaction.modelId);
|
|
153
|
+
}
|
|
154
|
+
else if (transaction.type === 'delete' &&
|
|
155
|
+
reason === 'permanent_error' &&
|
|
156
|
+
error?.message?.includes('not found')) {
|
|
157
|
+
// DELETE "not found" rollback: the entity doesn't exist on the server.
|
|
158
|
+
// Instead of restoring a ghost entity, remove it locally too.
|
|
159
|
+
// Both sides agree: this entity should not exist.
|
|
160
|
+
getContext().observability.breadcrumb('DELETE rolled back with "not found" - removing ghost entity', 'sync.conflict', 'info', {
|
|
161
|
+
modelId: transaction.modelId,
|
|
162
|
+
modelName: transaction.modelName,
|
|
163
|
+
});
|
|
164
|
+
this.objectPool.remove(transaction.modelId);
|
|
165
|
+
}
|
|
166
|
+
else if (model) {
|
|
167
|
+
// For update/delete/archive: restore model (with previousState if available)
|
|
168
|
+
// Guard: if the model was disposed (e.g. by a concurrent DELETE rollback or
|
|
169
|
+
// cascade), don't re-add it — Object.assign cannot restore the private
|
|
170
|
+
// isDisposed flag, so the model would be added in a broken state.
|
|
171
|
+
if (model.disposed) {
|
|
172
|
+
getContext().logger.warn('[SyncClient] Skipping rollback restore for disposed model', {
|
|
173
|
+
modelId: transaction.modelId,
|
|
174
|
+
modelName: transaction.modelName,
|
|
175
|
+
reason,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
if (previousState)
|
|
180
|
+
Object.assign(model, previousState);
|
|
181
|
+
this.objectPool.add(model, ModelScope.live);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
this.notifyObservers({
|
|
185
|
+
type: 'rollback',
|
|
186
|
+
modelType: transaction.modelName,
|
|
187
|
+
modelId: transaction.modelId,
|
|
188
|
+
transactionType: transaction.type,
|
|
189
|
+
});
|
|
190
|
+
// Emit event so SyncedStore can clear pendingDeletes on delete rollback
|
|
191
|
+
this.emit('sync:rollback', {
|
|
192
|
+
modelId: transaction.modelId,
|
|
193
|
+
modelName: transaction.modelName,
|
|
194
|
+
transactionType: transaction.type,
|
|
195
|
+
reason,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
getContext().observability.captureTransactionFailure({
|
|
200
|
+
context: 'rollback-failed',
|
|
201
|
+
transactionId: transaction.id,
|
|
202
|
+
modelName: transaction.modelName,
|
|
203
|
+
modelId: transaction.modelId,
|
|
204
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Forward reconciliation requests from TransactionQueue to the sync layer.
|
|
211
|
+
* When delta confirmation times out, TransactionQueue emits 'reconciliation:needed'
|
|
212
|
+
* instead of rolling back — following the Replicache/PowerSync pattern of never
|
|
213
|
+
* destroying optimistic state that the server may have committed.
|
|
214
|
+
*/
|
|
215
|
+
setupReconciliationForwarding() {
|
|
216
|
+
this.transactionQueue.on('reconciliation:needed', (event) => {
|
|
217
|
+
getContext().observability.captureReconciliation({
|
|
218
|
+
reason: event.reason,
|
|
219
|
+
model: event.model,
|
|
220
|
+
modelId: event.modelId,
|
|
221
|
+
syncIdNeeded: event.syncIdNeeded,
|
|
222
|
+
lastSeenSyncId: event.lastSeenSyncId,
|
|
223
|
+
retryCount: event.retryCount,
|
|
224
|
+
connectionState: this.connectionState,
|
|
225
|
+
});
|
|
226
|
+
// Forward to SyncedStore via event — it has access to the WebSocket
|
|
227
|
+
this.emit('reconciliation:needed', event);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* LINEAR PATTERN: Persist unconfirmed transactions to IndexedDB.
|
|
232
|
+
* When delta confirmation retries exhaust, the transaction data is cached in IDB
|
|
233
|
+
* so it survives tab close. On next session, WebSocket reconnect + delta catch-up
|
|
234
|
+
* will deliver the missing deltas and naturally confirm the transaction.
|
|
235
|
+
*/
|
|
236
|
+
setupAwaitingTransactionPersistence() {
|
|
237
|
+
this.transactionQueue.on('transaction:persist_awaiting', async (event) => {
|
|
238
|
+
if (!this.database)
|
|
239
|
+
return;
|
|
240
|
+
try {
|
|
241
|
+
await this.database.saveTransaction({
|
|
242
|
+
id: `awaiting_${event.txId}`,
|
|
243
|
+
type: 'awaiting_delta',
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
awaitingDelta: {
|
|
246
|
+
syncIdNeeded: event.syncIdNeeded ?? 0,
|
|
247
|
+
modelName: event.model,
|
|
248
|
+
modelId: event.modelId,
|
|
249
|
+
operationType: event.operationType,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
getContext().observability.breadcrumb('Persisted unconfirmed transaction to IDB', 'sync.transaction', 'info', {
|
|
253
|
+
txId: event.txId,
|
|
254
|
+
model: event.model,
|
|
255
|
+
modelId: event.modelId,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
getContext().observability.captureTransactionFailure({
|
|
260
|
+
context: 'persist-awaiting-transaction',
|
|
261
|
+
modelName: event.model,
|
|
262
|
+
modelId: event.modelId,
|
|
263
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
// Clean up persisted awaiting transactions when they're finally confirmed
|
|
268
|
+
this.transactionQueue.on('transaction:completed', async (tx) => {
|
|
269
|
+
if (!this.database)
|
|
270
|
+
return;
|
|
271
|
+
try {
|
|
272
|
+
await this.database.removeTransaction(`awaiting_${tx.id}`);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Ignore — might not have been persisted
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
// Echo detection bridge. When the queue stages a transaction, the
|
|
279
|
+
// client has already optimistically applied the change to the
|
|
280
|
+
// pool — record the tx id so the matching server delta echo gets
|
|
281
|
+
// recognized in `applyDeltaBatchToPool`. The set is drained when
|
|
282
|
+
// the echo lands; if a transaction is rolled back before the
|
|
283
|
+
// server processes it, we drain on rollback too so a stale id
|
|
284
|
+
// doesn't permanently silence a foreign delta sharing the same id
|
|
285
|
+
// (vanishingly unlikely for UUIDs, but cheap insurance).
|
|
286
|
+
this.transactionQueue.on('transaction:created', (tx) => this.echoTracker.markPending(tx.id));
|
|
287
|
+
this.transactionQueue.on('optimistic:rollback', (event) => {
|
|
288
|
+
this.echoTracker.drainOnRollback(event.transaction.id);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Initialize sync client with authentication
|
|
293
|
+
*/
|
|
294
|
+
async initialize(userId, organizationId) {
|
|
295
|
+
this.userId = userId;
|
|
296
|
+
this.organizationId = organizationId;
|
|
297
|
+
getContext().observability.setContext(userId, organizationId);
|
|
298
|
+
// Restore queued mutations from previous session
|
|
299
|
+
await this.restoreMutationQueue();
|
|
300
|
+
// Check network status via the DI'd OnlineStatusProvider (see interfaces.ts:192).
|
|
301
|
+
// In the browser this is wired to the service worker's connectivity signal via
|
|
302
|
+
// abloOnlineStatus in ablo-sync-adapters.ts; in Node it returns true (assume
|
|
303
|
+
// online) via the browserOnlineStatus fallback. NetworkMonitor still drives
|
|
304
|
+
// event-based online/offline transitions below; this read is just the initial
|
|
305
|
+
// status snapshot at registerUser() time.
|
|
306
|
+
if (getContext().onlineStatus.isOnline()) {
|
|
307
|
+
this.setConnectionState('connected');
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
// Offline - start in offline mode
|
|
311
|
+
this.setConnectionState('disconnected');
|
|
312
|
+
this.offlineSince = new Date();
|
|
313
|
+
this.emit('sync:offline');
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Self-healing helper for individual model records.
|
|
318
|
+
*
|
|
319
|
+
* Two registry-driven repair passes run on every row hydrated from
|
|
320
|
+
* IDB or merged from a delta:
|
|
321
|
+
*
|
|
322
|
+
* 1. **Auto-fill** — for each `autoFill` rule the consumer's schema
|
|
323
|
+
* declares on this model, copy the corresponding identity value
|
|
324
|
+
* (`organizationId` / `userId`) onto the row when it's missing.
|
|
325
|
+
* Repairs rows from a past version that didn't write the field.
|
|
326
|
+
*
|
|
327
|
+
* 2. **Required-field gate** — if the row is missing any field listed
|
|
328
|
+
* in the model's `requiredFields`, return `null` so the caller
|
|
329
|
+
* skips this record. Used for FK columns whose absence renders the
|
|
330
|
+
* row unrecoverable (e.g. a SlideLayer with no slideId).
|
|
331
|
+
*
|
|
332
|
+
* The engine itself is product-neutral: model identity (which fields
|
|
333
|
+
* to back-fill, which absences are fatal) lives entirely in the
|
|
334
|
+
* consumer schema.
|
|
335
|
+
*/
|
|
336
|
+
healModelRecord(modelType, data) {
|
|
337
|
+
const meta = this.objectPool.registry.getMetadata(modelType);
|
|
338
|
+
if (!meta)
|
|
339
|
+
return { data, healed: false };
|
|
340
|
+
const idPrefix = data.id?.slice(0, 8) ?? 'unknown';
|
|
341
|
+
let result = data;
|
|
342
|
+
let healed = false;
|
|
343
|
+
if (meta.autoFill) {
|
|
344
|
+
for (const rule of meta.autoFill) {
|
|
345
|
+
if (result[rule.field])
|
|
346
|
+
continue;
|
|
347
|
+
const replacement = rule.from === 'organizationId' ? this.organizationId : this.userId;
|
|
348
|
+
if (!replacement)
|
|
349
|
+
continue;
|
|
350
|
+
getContext().observability.captureSelfHealing({
|
|
351
|
+
modelName: modelType,
|
|
352
|
+
modelId: idPrefix,
|
|
353
|
+
field: rule.field,
|
|
354
|
+
action: `added missing ${rule.field}`,
|
|
355
|
+
});
|
|
356
|
+
result = { ...result, [rule.field]: replacement };
|
|
357
|
+
healed = true;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (meta.requiredFields) {
|
|
361
|
+
for (const field of meta.requiredFields) {
|
|
362
|
+
if (result[field])
|
|
363
|
+
continue;
|
|
364
|
+
getContext().observability.captureSelfHealing({
|
|
365
|
+
modelName: modelType,
|
|
366
|
+
modelId: idPrefix,
|
|
367
|
+
field,
|
|
368
|
+
action: `skipped corrupted ${modelType} - missing ${field}`,
|
|
369
|
+
});
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { data: result, healed };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Hydrate ObjectPool with data from Database
|
|
377
|
+
* Called after bootstrap is complete
|
|
378
|
+
*/
|
|
379
|
+
async hydrateFromDatabase() {
|
|
380
|
+
if (!this.database) {
|
|
381
|
+
throw new AbloValidationError('Database not available for hydration', {
|
|
382
|
+
code: 'sync_client_db_missing',
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
// Get model types that should be hydrated on startup (skip lazy per LSE)
|
|
386
|
+
const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
|
|
387
|
+
const meta = this.objectPool.registry.getMetadata(name);
|
|
388
|
+
return (meta?.loadStrategy === LoadStrategy.instant || meta?.loadStrategy === LoadStrategy.partial);
|
|
389
|
+
});
|
|
390
|
+
const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
391
|
+
// Phase 1: Fetch all data from IndexedDB and create model instances (async I/O).
|
|
392
|
+
// We collect all models across ALL types before touching MobX, so that Phase 2
|
|
393
|
+
// can add them in a single addBatch() call → ONE MobX action → ONE re-render.
|
|
394
|
+
const allModelsToAdd = [];
|
|
395
|
+
const perTypePerfLogs = [];
|
|
396
|
+
for (const modelType of modelTypes) {
|
|
397
|
+
const typeStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
398
|
+
try {
|
|
399
|
+
// Get raw data from Database (via StoreManager)
|
|
400
|
+
const rawData = await this.database.hydrateModels(modelType);
|
|
401
|
+
const afterFetch = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
402
|
+
// Create models in batch first, collect for deferred addBatch
|
|
403
|
+
const modelsForType = [];
|
|
404
|
+
const recordsToHeal = [];
|
|
405
|
+
for (const data of rawData) {
|
|
406
|
+
let withType = data && typeof data === 'object' && !data.__typename
|
|
407
|
+
? { __typename: modelType, ...data }
|
|
408
|
+
: data;
|
|
409
|
+
// Self-healing: Fix corrupted IndexedDB records missing essential fields
|
|
410
|
+
const healResult = this.healModelRecord(modelType, withType);
|
|
411
|
+
if (healResult === null) {
|
|
412
|
+
continue; // Record is corrupted beyond repair — skip
|
|
413
|
+
}
|
|
414
|
+
withType = healResult.data;
|
|
415
|
+
if (healResult.healed) {
|
|
416
|
+
recordsToHeal.push({ id: healResult.data.id, data: healResult.data });
|
|
417
|
+
}
|
|
418
|
+
const model = this.objectPool.createFromData(withType);
|
|
419
|
+
if (model) {
|
|
420
|
+
modelsForType.push(model);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
// Collect models for the single batched addBatch call in Phase 2
|
|
424
|
+
allModelsToAdd.push(...modelsForType);
|
|
425
|
+
// Persist healed records back to IndexedDB (fire-and-forget, non-blocking)
|
|
426
|
+
if (recordsToHeal.length > 0 && this.database) {
|
|
427
|
+
getContext().logger.info(`[SyncClient.hydrate] Persisting ${recordsToHeal.length} healed ${modelType} records to IndexedDB`);
|
|
428
|
+
// Use fire-and-forget to not block hydration
|
|
429
|
+
Promise.resolve().then(async () => {
|
|
430
|
+
try {
|
|
431
|
+
for (const { id, data } of recordsToHeal) {
|
|
432
|
+
await this.database.putRecord(modelType, id, data);
|
|
433
|
+
}
|
|
434
|
+
getContext().logger.info(`[SyncClient.hydrate] Successfully healed ${recordsToHeal.length} ${modelType} records`);
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
getContext().observability.captureTransactionFailure({
|
|
438
|
+
context: 'persist-healed-records',
|
|
439
|
+
modelName: modelType,
|
|
440
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const typeEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
446
|
+
// Dev-only hydration summary
|
|
447
|
+
if (modelType === 'InboxItem' && process.env.NODE_ENV !== 'production') {
|
|
448
|
+
getContext().logger.debug('[SyncClient] InboxItem hydration summary', {
|
|
449
|
+
fetched: rawData.length,
|
|
450
|
+
added: modelsForType.length,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
perTypePerfLogs.push({
|
|
454
|
+
type: modelType,
|
|
455
|
+
fetched: rawData.length,
|
|
456
|
+
added: modelsForType.length,
|
|
457
|
+
fetchMs: (afterFetch - typeStart).toFixed(2),
|
|
458
|
+
createMs: (typeEnd - afterFetch).toFixed(2),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
getContext().observability.captureBootstrapFailure(error, { type: `hydrate-${modelType}` });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Phase 2: Single MobX action — add ALL models across all types at once.
|
|
466
|
+
const addStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
467
|
+
const totalAdded = this.objectPool.addBatch(allModelsToAdd, ModelScope.live);
|
|
468
|
+
const addEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
469
|
+
// Log per-type perf after the batched add (so logs still show per-type breakdown)
|
|
470
|
+
for (const entry of perTypePerfLogs) {
|
|
471
|
+
getContext().logger.debug('hydrate:type', parseFloat(entry.fetchMs) + parseFloat(entry.createMs), {
|
|
472
|
+
type: entry.type,
|
|
473
|
+
fetched: entry.fetched,
|
|
474
|
+
added: entry.added,
|
|
475
|
+
fetchMs: entry.fetchMs,
|
|
476
|
+
createMs: entry.createMs,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
480
|
+
getContext().logger.debug('hydrate:total', totalEnd - totalStart, {
|
|
481
|
+
totalModels: totalAdded,
|
|
482
|
+
addBatchMs: (addEnd - addStart).toFixed(2),
|
|
483
|
+
});
|
|
484
|
+
// One-line startup summary: types pre-seeded and items per type
|
|
485
|
+
try {
|
|
486
|
+
const preseededTypes = this.objectPool.registry.getRegisteredModelNames();
|
|
487
|
+
const stats = this.objectPool.getStats();
|
|
488
|
+
getContext().logger.info('startup_summary', {
|
|
489
|
+
typesPreseeded: preseededTypes.length,
|
|
490
|
+
poolSize: stats.size,
|
|
491
|
+
typeCounts: stats.typeCounts,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
catch { }
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Re-hydrate ObjectPool from IndexedDB when the pool already has data.
|
|
498
|
+
*
|
|
499
|
+
* Unlike hydrateFromDatabase() (which uses addBatch and skips existing IDs),
|
|
500
|
+
* this method properly:
|
|
501
|
+
* 1. Upserts models — updates existing models in-place, adds new ones
|
|
502
|
+
* 2. Removes ghosts — deletes models from the pool that no longer exist in IndexedDB
|
|
503
|
+
*
|
|
504
|
+
* Used by background bootstrap, network recovery, and server-triggered re-bootstrap.
|
|
505
|
+
*/
|
|
506
|
+
async rehydrateFromDatabase() {
|
|
507
|
+
if (!this.database) {
|
|
508
|
+
throw new AbloValidationError('Database not available for rehydration', {
|
|
509
|
+
code: 'sync_client_db_missing',
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
const totalStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
513
|
+
// Model types to rehydrate (same filter as hydrateFromDatabase)
|
|
514
|
+
const modelTypes = this.objectPool.registry.getRegisteredModelNames().filter((name) => {
|
|
515
|
+
const meta = this.objectPool.registry.getMetadata(name);
|
|
516
|
+
return (meta?.loadStrategy === LoadStrategy.instant || meta?.loadStrategy === LoadStrategy.partial);
|
|
517
|
+
});
|
|
518
|
+
// ── Phase 1: Read from IndexedDB & create model instances (async I/O) ──
|
|
519
|
+
const allModels = [];
|
|
520
|
+
const idbIdsByType = new Map();
|
|
521
|
+
let healedCount = 0;
|
|
522
|
+
let skippedCount = 0;
|
|
523
|
+
for (const modelType of modelTypes) {
|
|
524
|
+
try {
|
|
525
|
+
const rawData = await this.database.hydrateModels(modelType);
|
|
526
|
+
const idsForType = new Set();
|
|
527
|
+
idbIdsByType.set(modelType, idsForType);
|
|
528
|
+
for (const data of rawData) {
|
|
529
|
+
let withType = data && typeof data === 'object' && !data.__typename
|
|
530
|
+
? { __typename: modelType, ...data }
|
|
531
|
+
: data;
|
|
532
|
+
// Self-healing
|
|
533
|
+
const healResult = this.healModelRecord(modelType, withType);
|
|
534
|
+
if (healResult === null) {
|
|
535
|
+
skippedCount++;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
withType = healResult.data;
|
|
539
|
+
if (healResult.healed) {
|
|
540
|
+
healedCount++;
|
|
541
|
+
// Persist heal back to IndexedDB (fire-and-forget)
|
|
542
|
+
if (this.database) {
|
|
543
|
+
const id = healResult.data.id;
|
|
544
|
+
const healedData = healResult.data;
|
|
545
|
+
Promise.resolve().then(async () => {
|
|
546
|
+
try {
|
|
547
|
+
await this.database.putRecord(modelType, id, healedData);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
// Non-critical — will heal again next time
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// Register ID before createFromData — prevents ghost removal
|
|
556
|
+
// if createFromData fails for a record that exists in IDB
|
|
557
|
+
const recordId = withType.id;
|
|
558
|
+
if (recordId) {
|
|
559
|
+
idsForType.add(recordId);
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const model = this.objectPool.createFromData(withType);
|
|
563
|
+
if (model) {
|
|
564
|
+
allModels.push(model);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch (error) {
|
|
568
|
+
getContext().observability.breadcrumb('Model creation failed during rehydration', 'sync.bootstrap', 'warning', {
|
|
569
|
+
modelType,
|
|
570
|
+
modelId: recordId?.slice(0, 8) ?? 'unknown',
|
|
571
|
+
error: error instanceof Error ? error.message : String(error),
|
|
572
|
+
});
|
|
573
|
+
skippedCount++;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
getContext().observability.captureBootstrapFailure(error, { type: `rehydrate-${modelType}` });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// ── Phase 2: Upsert batch (single MobX action) ──
|
|
582
|
+
// createFromData already calls updateFromData() on existing models,
|
|
583
|
+
// so existing models are up-to-date. Upsert adds the new ones and
|
|
584
|
+
// updates scope for any that changed.
|
|
585
|
+
const beforeSize = this.objectPool.size;
|
|
586
|
+
this.objectPool.upsertBatch(allModels, ModelScope.live);
|
|
587
|
+
const addedCount = this.objectPool.size - beforeSize;
|
|
588
|
+
const updatedCount = allModels.length - addedCount;
|
|
589
|
+
// ── Phase 3: Reconcile ghost deletions (single MobX action) ──
|
|
590
|
+
// Only reconcile types that were rehydrated — never touch lazy-loaded types.
|
|
591
|
+
const ghostIds = [];
|
|
592
|
+
for (const modelType of modelTypes) {
|
|
593
|
+
const idbIds = idbIdsByType.get(modelType);
|
|
594
|
+
if (!idbIds)
|
|
595
|
+
continue; // Type had an error during fetch — don't reconcile
|
|
596
|
+
const poolIds = this.objectPool.getIdsByModelType(modelType);
|
|
597
|
+
if (!poolIds)
|
|
598
|
+
continue;
|
|
599
|
+
for (const poolId of poolIds) {
|
|
600
|
+
if (!idbIds.has(poolId)) {
|
|
601
|
+
ghostIds.push(poolId);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const removedCount = this.objectPool.removeBatch(ghostIds);
|
|
606
|
+
// ── Phase 4: Stats & logging ──
|
|
607
|
+
const totalEnd = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
608
|
+
const elapsedMs = Math.round(totalEnd - totalStart);
|
|
609
|
+
const stats = {
|
|
610
|
+
added: addedCount,
|
|
611
|
+
updated: updatedCount,
|
|
612
|
+
removed: removedCount,
|
|
613
|
+
skipped: skippedCount,
|
|
614
|
+
healed: healedCount,
|
|
615
|
+
elapsedMs,
|
|
616
|
+
};
|
|
617
|
+
getContext().logger.info('[SyncClient.rehydrate] Complete', {
|
|
618
|
+
...stats,
|
|
619
|
+
poolSize: this.objectPool.size,
|
|
620
|
+
ghostIds: ghostIds.length > 0 ? ghostIds.slice(0, 5).map((id) => id.slice(0, 8)) : [],
|
|
621
|
+
});
|
|
622
|
+
getContext().observability.breadcrumb('Rehydration complete', 'sync.bootstrap', 'info', {
|
|
623
|
+
added: stats.added,
|
|
624
|
+
updated: stats.updated,
|
|
625
|
+
removed: stats.removed,
|
|
626
|
+
elapsedMs: stats.elapsedMs,
|
|
627
|
+
});
|
|
628
|
+
return stats;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Mutate model optimistically and queue for server sync.
|
|
632
|
+
* IndexedDB is only updated when server confirms via delta packet.
|
|
633
|
+
*
|
|
634
|
+
* CRITICAL: Changes are captured BEFORE poolAction to prevent data loss.
|
|
635
|
+
* The captured changes are frozen and passed to queueMutation.
|
|
636
|
+
*
|
|
637
|
+
* @see src/sync-engine/types/TrackableModel.ts for change capture pattern
|
|
638
|
+
*/
|
|
639
|
+
mutate(type, model, poolAction, writeOptions) {
|
|
640
|
+
// CRITICAL FIX: Capture changes BEFORE pool action
|
|
641
|
+
// Pool operations (especially upsert) can clear _local changes
|
|
642
|
+
// By capturing first, we ensure changes are never lost
|
|
643
|
+
const capturedChanges = type === 'update' || type === 'create' ? this.captureModelChanges(model) : undefined;
|
|
644
|
+
poolAction();
|
|
645
|
+
this.queueMutation({ type, model, timestamp: new Date(), capturedChanges, writeOptions });
|
|
646
|
+
this.notifyObservers({
|
|
647
|
+
type,
|
|
648
|
+
modelType: model.getModelName(),
|
|
649
|
+
model: type !== 'delete' ? model : undefined,
|
|
650
|
+
modelId: model.id,
|
|
651
|
+
});
|
|
652
|
+
// QueryProcessor uses `models:changed` to invalidate caches. Coalesce
|
|
653
|
+
// to one event per microtask: a paste of 100 layers should re-run
|
|
654
|
+
// affected queries ONCE, not 100×.
|
|
655
|
+
this.markModelChanged(model.getModelName());
|
|
656
|
+
}
|
|
657
|
+
pendingChangedTypes = null;
|
|
658
|
+
markModelChanged(modelType) {
|
|
659
|
+
if (!this.pendingChangedTypes) {
|
|
660
|
+
this.pendingChangedTypes = new Set();
|
|
661
|
+
const schedule = typeof queueMicrotask === 'function'
|
|
662
|
+
? queueMicrotask
|
|
663
|
+
: (cb) => Promise.resolve().then(cb);
|
|
664
|
+
schedule(() => {
|
|
665
|
+
const types = this.pendingChangedTypes;
|
|
666
|
+
this.pendingChangedTypes = null;
|
|
667
|
+
if (types && types.size > 0)
|
|
668
|
+
this.emit('models:changed', types);
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
this.pendingChangedTypes.add(modelType);
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Capture model changes immutably BEFORE any pool operations
|
|
675
|
+
* This prevents the fragile pattern of reading changes after state modification
|
|
676
|
+
*/
|
|
677
|
+
captureModelChanges(model) {
|
|
678
|
+
if (typeof model.getChanges !== 'function')
|
|
679
|
+
return undefined;
|
|
680
|
+
const changes = model.getChanges();
|
|
681
|
+
// Return a frozen copy to prevent accidental modification
|
|
682
|
+
return Object.keys(changes).length > 0 ? Object.freeze({ ...changes }) : undefined;
|
|
683
|
+
}
|
|
684
|
+
/** Add new model (CREATE) - works offline */
|
|
685
|
+
add(model, options) {
|
|
686
|
+
this.mutate('create', model, () => this.objectPool.add(model, ModelScope.live), options);
|
|
687
|
+
}
|
|
688
|
+
/** Update existing model (UPDATE) - works offline */
|
|
689
|
+
update(model, options) {
|
|
690
|
+
this.mutate('update', model, () => this.objectPool.upsert(model, ModelScope.live), options);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Update existing model with pre-computed changes.
|
|
694
|
+
* Used by saveManyOptimized when incoming models have empty change-tracking
|
|
695
|
+
* (e.g. freshly constructed SpreadsheetCellModels from decomposeSpreadsheetDocument).
|
|
696
|
+
*/
|
|
697
|
+
updateWithChanges(model, changes) {
|
|
698
|
+
getContext().logger.debug(`SyncClient.updateWithChanges`, {
|
|
699
|
+
modelId: model.id,
|
|
700
|
+
modelType: model.getModelName(),
|
|
701
|
+
});
|
|
702
|
+
// Use pre-computed changes if provided, otherwise fall back to model.getChanges()
|
|
703
|
+
const capturedChanges = changes && Object.keys(changes).length > 0
|
|
704
|
+
? Object.freeze({ ...changes })
|
|
705
|
+
: this.captureModelChanges(model);
|
|
706
|
+
this.objectPool.upsert(model, ModelScope.live);
|
|
707
|
+
this.queueMutation({ type: 'update', model, timestamp: new Date(), capturedChanges });
|
|
708
|
+
this.notifyObservers({
|
|
709
|
+
type: 'update',
|
|
710
|
+
modelType: model.getModelName(),
|
|
711
|
+
model,
|
|
712
|
+
modelId: model.id,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
/** Expose the GraphQL client for atomic mutations (e.g., createSlideWithLayers).
|
|
716
|
+
* Used by SyncedStore for operations that bypass the transaction queue
|
|
717
|
+
* but still need optimistic pool updates at the sync layer. */
|
|
718
|
+
get gql() {
|
|
719
|
+
return this.mutationExecutor;
|
|
720
|
+
}
|
|
721
|
+
/** Delete model (DELETE) - works offline */
|
|
722
|
+
delete(model, options) {
|
|
723
|
+
// Clear pending mutations first to prevent "not found" errors on fast delete
|
|
724
|
+
this.clearPendingMutationsForModel(model.id);
|
|
725
|
+
this.mutate('delete', model, () => this.objectPool.remove(model.id), options);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Clear all pending mutations for a specific model
|
|
729
|
+
* Called before deletion to prevent "layer not found" errors on the server
|
|
730
|
+
*/
|
|
731
|
+
clearPendingMutationsForModel(modelId) {
|
|
732
|
+
const beforeCount = this.pendingMutations.length;
|
|
733
|
+
this.pendingMutations = this.pendingMutations.filter((m) => m.model.id !== modelId);
|
|
734
|
+
const afterCount = this.pendingMutations.length;
|
|
735
|
+
if (beforeCount !== afterCount) {
|
|
736
|
+
getContext().logger.debug('[SyncClient.clearPendingMutationsForModel] Cleared pending mutations', {
|
|
737
|
+
modelId,
|
|
738
|
+
clearedCount: beforeCount - afterCount,
|
|
739
|
+
remainingCount: afterCount,
|
|
740
|
+
});
|
|
741
|
+
// Persist updated queue immediately
|
|
742
|
+
void this.persistMutationQueue();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Upload file and create attachment (UPLOAD operation)
|
|
747
|
+
* Uses Linear-style pattern with immediate URL generation
|
|
748
|
+
*/
|
|
749
|
+
async uploadFile(file, options) {
|
|
750
|
+
if (!this.userId || !this.organizationId) {
|
|
751
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
752
|
+
code: 'file_upload_auth_required',
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
// Use TransactionQueue to handle the upload mutation
|
|
757
|
+
const result = await this.transactionQueue.uploadAttachment(file, {
|
|
758
|
+
id: options.id,
|
|
759
|
+
attachableType: options.attachableType,
|
|
760
|
+
attachableId: options.attachableId,
|
|
761
|
+
metadata: options.metadata,
|
|
762
|
+
}, {
|
|
763
|
+
userId: this.userId,
|
|
764
|
+
organizationId: this.organizationId,
|
|
765
|
+
});
|
|
766
|
+
if (result) {
|
|
767
|
+
// Create model from response using ModelRegistry (generic — no concrete class import)
|
|
768
|
+
const model = this.objectPool.createFromData({
|
|
769
|
+
id: options.id,
|
|
770
|
+
...result,
|
|
771
|
+
});
|
|
772
|
+
if (model) {
|
|
773
|
+
this.objectPool.add(model, ModelScope.live);
|
|
774
|
+
this.notifyObservers({
|
|
775
|
+
type: 'create',
|
|
776
|
+
modelType: model.getModelName(),
|
|
777
|
+
model,
|
|
778
|
+
});
|
|
779
|
+
return model;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
catch (error) {
|
|
785
|
+
getContext().observability.captureTransactionFailure({
|
|
786
|
+
context: 'file-upload',
|
|
787
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
788
|
+
});
|
|
789
|
+
throw error;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Batch upload files — single GraphQL call + parallel S3 PUTs.
|
|
794
|
+
*
|
|
795
|
+
* Returns the raw `Model[]` built by the object pool (typename is
|
|
796
|
+
* determined by the payload the server returns — currently always
|
|
797
|
+
* `Attachment`). The SDK has no knowledge of app-specific model classes,
|
|
798
|
+
* so it cannot honestly claim a narrower return type; consumers that
|
|
799
|
+
* need an `Attachment[]` project through their own typed accessor
|
|
800
|
+
* (e.g. `store.query.attachments.findMany({ where: { id: IN ids } })`)
|
|
801
|
+
* after the upload resolves.
|
|
802
|
+
*/
|
|
803
|
+
async batchUploadFiles(files, options) {
|
|
804
|
+
if (!this.userId || !this.organizationId) {
|
|
805
|
+
throw new AbloAuthenticationError('Authentication required for file uploads', {
|
|
806
|
+
code: 'file_upload_auth_required',
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
const items = options.ids.map((id) => ({
|
|
810
|
+
id,
|
|
811
|
+
attachableType: options.attachableType,
|
|
812
|
+
attachableId: options.attachableId,
|
|
813
|
+
metadata: options.metadata,
|
|
814
|
+
}));
|
|
815
|
+
const results = await this.transactionQueue.batchUploadAttachments(files, items, {
|
|
816
|
+
userId: this.userId,
|
|
817
|
+
organizationId: this.organizationId,
|
|
818
|
+
});
|
|
819
|
+
const models = [];
|
|
820
|
+
for (const result of results) {
|
|
821
|
+
const model = this.objectPool.createFromData({ ...result });
|
|
822
|
+
if (model) {
|
|
823
|
+
this.objectPool.add(model, ModelScope.live);
|
|
824
|
+
this.notifyObservers({
|
|
825
|
+
type: 'create',
|
|
826
|
+
modelType: model.getModelName(),
|
|
827
|
+
model,
|
|
828
|
+
});
|
|
829
|
+
models.push(model);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return models;
|
|
833
|
+
}
|
|
834
|
+
/** Archive model (ARCHIVE) - works offline */
|
|
835
|
+
archive(model) {
|
|
836
|
+
this.mutate('archive', model, () => this.objectPool.updateScope(model.id, ModelScope.archived));
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Append a mutation and schedule its sync work.
|
|
840
|
+
*
|
|
841
|
+
* IDB persistence and the server push are deferred to a microtask so N
|
|
842
|
+
* pushes inside the same tick collapse into ONE IDB serialization + ONE
|
|
843
|
+
* process call. Without the deferral, queueing 100 mutations (paste,
|
|
844
|
+
* PPTX import, AI sandbox layer creation) reserializes the entire
|
|
845
|
+
* growing queue 100× — O(N²) `model.toJSON()`.
|
|
846
|
+
*
|
|
847
|
+
* @param mutation.capturedChanges - Pre-captured changes (frozen), used
|
|
848
|
+
* to avoid re-reading changes after pool ops that might clear them.
|
|
849
|
+
*/
|
|
850
|
+
queueMutation(mutation) {
|
|
851
|
+
this.pendingMutations.push(mutation);
|
|
852
|
+
this.scheduleSync();
|
|
853
|
+
}
|
|
854
|
+
syncScheduled = false;
|
|
855
|
+
scheduleSync() {
|
|
856
|
+
if (this.syncScheduled)
|
|
857
|
+
return;
|
|
858
|
+
this.syncScheduled = true;
|
|
859
|
+
const schedule = typeof queueMicrotask === 'function'
|
|
860
|
+
? queueMicrotask
|
|
861
|
+
: (cb) => Promise.resolve().then(cb);
|
|
862
|
+
schedule(() => {
|
|
863
|
+
this.syncScheduled = false;
|
|
864
|
+
void this.persistMutationQueue();
|
|
865
|
+
if (getContext().onlineStatus.isOnline()) {
|
|
866
|
+
this.processPendingMutations().catch((err) => {
|
|
867
|
+
getContext().observability.breadcrumb('Background sync failed', 'sync.transaction', 'warning', { error: err instanceof Error ? err.message : String(err) });
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Persist mutation queue to IndexedDB
|
|
874
|
+
*/
|
|
875
|
+
async persistMutationQueue() {
|
|
876
|
+
if (!this.database || !this.userId)
|
|
877
|
+
return;
|
|
878
|
+
try {
|
|
879
|
+
const serializedMutations = this.pendingMutations.map((m) => ({
|
|
880
|
+
type: m.type,
|
|
881
|
+
modelData: m.model.toJSON ? m.model.toJSON() : { ...m.model },
|
|
882
|
+
modelName: m.model.getModelName(),
|
|
883
|
+
timestamp: m.timestamp.toISOString(),
|
|
884
|
+
writeOptions: m.writeOptions,
|
|
885
|
+
}));
|
|
886
|
+
await this.database.saveTransaction({
|
|
887
|
+
id: 'mutation-queue',
|
|
888
|
+
type: 'queue',
|
|
889
|
+
mutations: serializedMutations,
|
|
890
|
+
timestamp: Date.now(),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
catch (error) { }
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Restore mutation queue from IndexedDB
|
|
897
|
+
*/
|
|
898
|
+
async restoreMutationQueue() {
|
|
899
|
+
if (!this.database || !this.userId)
|
|
900
|
+
return;
|
|
901
|
+
try {
|
|
902
|
+
const stored = await this.database.getPersistedTransactions();
|
|
903
|
+
const queue = stored.find((t) => t.id === 'mutation-queue');
|
|
904
|
+
if (queue?.mutations) {
|
|
905
|
+
for (const mutation of queue.mutations) {
|
|
906
|
+
const model = this.objectPool.createFromData(mutation.modelData);
|
|
907
|
+
if (model) {
|
|
908
|
+
this.pendingMutations.push({
|
|
909
|
+
type: mutation.type,
|
|
910
|
+
model,
|
|
911
|
+
timestamp: new Date(mutation.timestamp),
|
|
912
|
+
writeOptions: mutation.writeOptions,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch (error) { }
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Process pending mutations - can be called by SyncedStore when online
|
|
922
|
+
*
|
|
923
|
+
* Best Practice: Only sync models that still exist locally (local-first principle)
|
|
924
|
+
* - If a model was deleted locally → skip any pending updates/creates for it
|
|
925
|
+
* - This prevents "layer not found" errors from fast copy-paste-delete workflows
|
|
926
|
+
*/
|
|
927
|
+
async processPendingMutations() {
|
|
928
|
+
if (this.pendingMutations.length === 0)
|
|
929
|
+
return;
|
|
930
|
+
// Identity guard. The early returns here used to be silent — the bug
|
|
931
|
+
// pattern was "every mutation from a logged-in user evaporates" when
|
|
932
|
+
// `SyncClient.initialize()` wasn't called (e.g., missing wiring in
|
|
933
|
+
// the consumer's `BaseSyncedStore.initialize` generator). Warn so
|
|
934
|
+
// this class of misconfiguration surfaces in dev instead of
|
|
935
|
+
// manifesting as "my drag doesn't save."
|
|
936
|
+
if (!this.userId || !this.organizationId) {
|
|
937
|
+
getContext().logger.warn('[sync] mutations dropped — SyncClient has no identity. ' +
|
|
938
|
+
'Did the store call `syncClient.initialize(userId, orgId)`?', {
|
|
939
|
+
pending: this.pendingMutations.length,
|
|
940
|
+
userId: this.userId,
|
|
941
|
+
organizationId: this.organizationId,
|
|
942
|
+
});
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (!getContext().onlineStatus.isOnline())
|
|
946
|
+
return; // Skip if offline
|
|
947
|
+
if (this.isDisposed)
|
|
948
|
+
return; // Skip if disposed
|
|
949
|
+
const mutations = this.pendingMutations;
|
|
950
|
+
this.pendingMutations = [];
|
|
951
|
+
// Clear persisted queue before processing
|
|
952
|
+
await this.persistMutationQueue();
|
|
953
|
+
// LINEAR PATTERN: Stage all mutations synchronously in same event loop tick
|
|
954
|
+
// TransactionQueue's microtask will batch and send them together
|
|
955
|
+
for (const mutation of mutations) {
|
|
956
|
+
// Skip mutations for deleted models (prevents "not found" errors)
|
|
957
|
+
if (mutation.type !== 'delete' && !this.objectPool.get(mutation.model.id)) {
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
// Stage synchronously - TransactionQueue handles batching, retry, and errors
|
|
961
|
+
this.stageMutation(mutation);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
/**
|
|
965
|
+
* Stage mutation to TransactionQueue - mutations in same tick are batched via microtask
|
|
966
|
+
*
|
|
967
|
+
* @param mutation.capturedChanges - Pre-captured changes to use instead of re-reading from model
|
|
968
|
+
*/
|
|
969
|
+
stageMutation(mutation) {
|
|
970
|
+
if (!this.userId || !this.organizationId)
|
|
971
|
+
return;
|
|
972
|
+
const ctx = { userId: this.userId, organizationId: this.organizationId };
|
|
973
|
+
if (mutation.type === 'update') {
|
|
974
|
+
this.transactionQueue.update(mutation.model, ctx, mutation.capturedChanges, mutation.writeOptions);
|
|
975
|
+
}
|
|
976
|
+
else {
|
|
977
|
+
const handler = this.transactionQueue[mutation.type].bind(this.transactionQueue);
|
|
978
|
+
handler(mutation.model, ctx, mutation.writeOptions);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Resolve conflicts between local and server data
|
|
983
|
+
* Used when processing deltas from WebSocket
|
|
984
|
+
*
|
|
985
|
+
* CRITICAL: Always respects certain server states (deletes, deactivations)
|
|
986
|
+
* even when there are local changes, to maintain data consistency.
|
|
987
|
+
*/
|
|
988
|
+
resolveConflicts(localModel, serverData) {
|
|
989
|
+
const hasLocalChanges = localModel.hasChanges;
|
|
990
|
+
// Safely get timestamp, handling both Date objects and strings
|
|
991
|
+
const localUpdatedAt = localModel.updatedAt
|
|
992
|
+
? localModel.updatedAt instanceof Date
|
|
993
|
+
? localModel.updatedAt.getTime()
|
|
994
|
+
: new Date(localModel.updatedAt).getTime()
|
|
995
|
+
: 0;
|
|
996
|
+
const serverUpdatedAt = serverData?.updatedAt ? new Date(serverData.updatedAt).getTime() : 0;
|
|
997
|
+
getContext().logger.debug('Conflict resolution', {
|
|
998
|
+
modelId: localModel.id,
|
|
999
|
+
modelType: localModel.getModelName(),
|
|
1000
|
+
hasLocalChanges,
|
|
1001
|
+
localUpdatedAt: localModel.updatedAt?.toString(),
|
|
1002
|
+
serverUpdatedAt: serverData.updatedAt,
|
|
1003
|
+
localChanges: localModel.getChanges(),
|
|
1004
|
+
serverState: this.extractCriticalState(serverData),
|
|
1005
|
+
});
|
|
1006
|
+
// PRIORITY 1: Check for critical server states that must be respected
|
|
1007
|
+
// These states override any local changes to maintain data consistency
|
|
1008
|
+
const criticalServerStates = this.extractCriticalState(serverData);
|
|
1009
|
+
const shouldForceAcceptServer = this.hasCriticalStateChange(criticalServerStates);
|
|
1010
|
+
if (shouldForceAcceptServer) {
|
|
1011
|
+
getContext().logger.debug('Accepting server update - critical state change detected', {
|
|
1012
|
+
modelId: localModel.id,
|
|
1013
|
+
criticalStates: criticalServerStates,
|
|
1014
|
+
});
|
|
1015
|
+
// Force accept server state for critical changes
|
|
1016
|
+
localModel.updateFromData(serverData);
|
|
1017
|
+
localModel.clearChanges();
|
|
1018
|
+
localModel.markAsSynced();
|
|
1019
|
+
return localModel;
|
|
1020
|
+
}
|
|
1021
|
+
// Local-first: if we have local dirty fields, merge by field.
|
|
1022
|
+
// Keep locally changed fields; apply server for the rest.
|
|
1023
|
+
if (hasLocalChanges) {
|
|
1024
|
+
const localChanges = localModel.getChanges();
|
|
1025
|
+
getContext().logger.debug('Merging server update with local dirty fields', {
|
|
1026
|
+
modelId: localModel.id,
|
|
1027
|
+
keptFields: Object.keys(localChanges || {}),
|
|
1028
|
+
});
|
|
1029
|
+
// Merge: server baseline + local dirty fields win
|
|
1030
|
+
const merged = { ...serverData, ...(localChanges || {}) };
|
|
1031
|
+
// Preserve the most recent updatedAt without clearing dirty flags
|
|
1032
|
+
if (serverData?.updatedAt || localModel.updatedAt) {
|
|
1033
|
+
const mergedUpdatedAt = new Date(Math.max(localUpdatedAt, serverUpdatedAt));
|
|
1034
|
+
// updateFromData accepts Date or ISO string for dates
|
|
1035
|
+
merged.updatedAt = mergedUpdatedAt;
|
|
1036
|
+
}
|
|
1037
|
+
localModel.updateFromData(merged);
|
|
1038
|
+
// Intentionally DO NOT clearChanges here; pending tx will confirm and clear
|
|
1039
|
+
return localModel;
|
|
1040
|
+
}
|
|
1041
|
+
// No local changes: fall back to LWW to converge
|
|
1042
|
+
// Accept server regardless of timestamp equality to stay in sync
|
|
1043
|
+
const acceptReason = serverUpdatedAt > localUpdatedAt ? 'server is newer' : 'no local changes';
|
|
1044
|
+
getContext().logger.debug(`Accepting server update - ${acceptReason}`);
|
|
1045
|
+
localModel.updateFromData(serverData);
|
|
1046
|
+
localModel.clearChanges();
|
|
1047
|
+
localModel.markAsSynced();
|
|
1048
|
+
return localModel;
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Extract critical state fields from server data
|
|
1052
|
+
* These are states that must always be respected, even with local changes
|
|
1053
|
+
*/
|
|
1054
|
+
extractCriticalState(serverData) {
|
|
1055
|
+
const critical = {};
|
|
1056
|
+
if (!serverData || typeof serverData !== 'object') {
|
|
1057
|
+
return critical;
|
|
1058
|
+
}
|
|
1059
|
+
// Deletion/archival states - always critical
|
|
1060
|
+
if (serverData.deletedAt !== undefined) {
|
|
1061
|
+
critical.deletedAt = serverData.deletedAt;
|
|
1062
|
+
}
|
|
1063
|
+
if (serverData.archivedAt !== undefined) {
|
|
1064
|
+
critical.archivedAt = serverData.archivedAt;
|
|
1065
|
+
}
|
|
1066
|
+
// Deactivation states - critical for assignments and similar entities
|
|
1067
|
+
if (serverData.isActive !== undefined && serverData.isActive === false) {
|
|
1068
|
+
critical.isActive = false;
|
|
1069
|
+
}
|
|
1070
|
+
if (serverData.unassignedAt !== undefined) {
|
|
1071
|
+
critical.unassignedAt = serverData.unassignedAt;
|
|
1072
|
+
}
|
|
1073
|
+
return critical;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Check if critical state changes exist that require forcing server state
|
|
1077
|
+
*/
|
|
1078
|
+
hasCriticalStateChange(criticalStates) {
|
|
1079
|
+
// Any critical state present means we should force accept server
|
|
1080
|
+
return (Object.keys(criticalStates).length > 0 &&
|
|
1081
|
+
Object.values(criticalStates).some((v) => v !== null && v !== undefined));
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Handle network reconnection
|
|
1085
|
+
*/
|
|
1086
|
+
async handleReconnection() {
|
|
1087
|
+
getContext().observability.breadcrumb('Network reconnected', 'sync.offline');
|
|
1088
|
+
this.emit('sync:reconnecting');
|
|
1089
|
+
try {
|
|
1090
|
+
// Prefer a single batch flush for pending mutations (fast path)
|
|
1091
|
+
try {
|
|
1092
|
+
await this.transactionQueue.flushOfflineQueue();
|
|
1093
|
+
}
|
|
1094
|
+
catch { }
|
|
1095
|
+
// Process all queued mutations
|
|
1096
|
+
await this.processPendingMutations();
|
|
1097
|
+
this.setConnectionState('connected');
|
|
1098
|
+
this.emit('sync:reconnected');
|
|
1099
|
+
// Clear offline timestamp
|
|
1100
|
+
this.offlineSince = undefined;
|
|
1101
|
+
}
|
|
1102
|
+
catch (error) {
|
|
1103
|
+
getContext().observability.captureTransactionFailure({
|
|
1104
|
+
context: 'reconnection-sync',
|
|
1105
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1106
|
+
});
|
|
1107
|
+
this.emit('sync:error', error);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Handle network disconnection
|
|
1112
|
+
*/
|
|
1113
|
+
async handleDisconnection() {
|
|
1114
|
+
getContext().observability.breadcrumb('Network disconnected', 'sync.offline');
|
|
1115
|
+
this.setConnectionState('disconnected');
|
|
1116
|
+
this.offlineSince = new Date();
|
|
1117
|
+
this.emit('sync:offline');
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Get current sync state
|
|
1121
|
+
*/
|
|
1122
|
+
getState() {
|
|
1123
|
+
return {
|
|
1124
|
+
connectionState: this.connectionState,
|
|
1125
|
+
pendingMutations: this.pendingMutations.length,
|
|
1126
|
+
lastSyncAt: new Date(),
|
|
1127
|
+
error: undefined,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Set connection state
|
|
1132
|
+
*/
|
|
1133
|
+
setConnectionState(state) {
|
|
1134
|
+
const oldState = this.connectionState;
|
|
1135
|
+
this.connectionState = state;
|
|
1136
|
+
if (oldState !== state) {
|
|
1137
|
+
getContext().observability.setConnectionState(state);
|
|
1138
|
+
getContext().observability.breadcrumb(`Connection: ${oldState} → ${state}`, 'sync.websocket');
|
|
1139
|
+
if (state === 'connected') {
|
|
1140
|
+
this.emit('connection:established');
|
|
1141
|
+
this.transactionQueue.setConnectionState('connected');
|
|
1142
|
+
}
|
|
1143
|
+
else if (state === 'disconnected') {
|
|
1144
|
+
this.emit('connection:disconnected');
|
|
1145
|
+
this.transactionQueue.setConnectionState('disconnected');
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Subscribe to events with disposer pattern
|
|
1151
|
+
*/
|
|
1152
|
+
subscribe(event, handler) {
|
|
1153
|
+
super.on(event, handler);
|
|
1154
|
+
// Return disposer function
|
|
1155
|
+
return () => {
|
|
1156
|
+
this.off(event, handler);
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Add observer for sync events
|
|
1161
|
+
*/
|
|
1162
|
+
addObserver(observer) {
|
|
1163
|
+
this.observers.add(observer);
|
|
1164
|
+
}
|
|
1165
|
+
/**
|
|
1166
|
+
* Remove observer
|
|
1167
|
+
*/
|
|
1168
|
+
removeObserver(observer) {
|
|
1169
|
+
this.observers.delete(observer);
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Notify all observers
|
|
1173
|
+
*/
|
|
1174
|
+
notifyObservers(event) {
|
|
1175
|
+
for (const observer of this.observers) {
|
|
1176
|
+
if (observer.onSync) {
|
|
1177
|
+
try {
|
|
1178
|
+
observer.onSync(event);
|
|
1179
|
+
}
|
|
1180
|
+
catch (error) {
|
|
1181
|
+
getContext().observability.breadcrumb('Observer error', 'sync.transaction', 'error', {
|
|
1182
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
/**
|
|
1189
|
+
* Disconnect from sync
|
|
1190
|
+
*/
|
|
1191
|
+
disconnect() {
|
|
1192
|
+
this.setConnectionState('disconnected');
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Mark the sync client as connected
|
|
1196
|
+
* Called when WebSocket successfully connects (can happen independently of browser online/offline)
|
|
1197
|
+
*/
|
|
1198
|
+
markConnected() {
|
|
1199
|
+
this.setConnectionState('connected');
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Dispose and cleanup
|
|
1203
|
+
*/
|
|
1204
|
+
dispose() {
|
|
1205
|
+
this.isDisposed = true;
|
|
1206
|
+
this.disconnect();
|
|
1207
|
+
this.networkMonitor.dispose();
|
|
1208
|
+
this.observers.clear();
|
|
1209
|
+
this.pendingMutations = [];
|
|
1210
|
+
this.removeAllListeners();
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* LINEAR PATTERN: Notify TransactionQueue of incoming delta for sync ID threshold confirmation.
|
|
1214
|
+
* Transactions are confirmed when any delta with id >= their lastSyncId threshold arrives.
|
|
1215
|
+
* @param syncId - The sync ID of the received delta
|
|
1216
|
+
*/
|
|
1217
|
+
onDeltaReceived(syncId) {
|
|
1218
|
+
try {
|
|
1219
|
+
this.transactionQueue.onDeltaReceived(syncId);
|
|
1220
|
+
}
|
|
1221
|
+
catch (e) {
|
|
1222
|
+
getContext().observability.breadcrumb('Failed to notify delta received', 'sync.transaction', 'warning', {
|
|
1223
|
+
syncId,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/**
|
|
1228
|
+
* LINEAR PATTERN: Cancel transactions for orphaned child entities
|
|
1229
|
+
*
|
|
1230
|
+
* Called by SyncedStore when a DELETE delta arrives for a parent entity.
|
|
1231
|
+
* Cancels pending transactions for children that reference the deleted parent.
|
|
1232
|
+
*
|
|
1233
|
+
* @param childModelName - The child model type (e.g., 'SlideLayer')
|
|
1234
|
+
* @param foreignKey - The FK property name (e.g., 'slideId')
|
|
1235
|
+
* @param parentId - The deleted parent's ID
|
|
1236
|
+
* @returns Number of transactions cancelled
|
|
1237
|
+
*/
|
|
1238
|
+
cancelTransactionsByForeignKey(childModelName, foreignKey, parentId) {
|
|
1239
|
+
return this.transactionQueue.cancelTransactionsByForeignKey(childModelName, foreignKey, parentId);
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Wait for a transaction to be confirmed via delta echo (Linear pattern)
|
|
1243
|
+
* Delegates to TransactionQueue which already handles timeouts
|
|
1244
|
+
*/
|
|
1245
|
+
waitForDeltaConfirmation(transactionId) {
|
|
1246
|
+
return this.transactionQueue.waitForConfirmation(transactionId);
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Force sync now - process pending mutations
|
|
1250
|
+
*/
|
|
1251
|
+
async syncNow() {
|
|
1252
|
+
await this.processPendingMutations();
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Get sync statistics. Return type is inferred from the literal so
|
|
1256
|
+
* the call site sees the actual shape — `connectionState` narrowed
|
|
1257
|
+
* to its three states, `objectPoolStats` typed by `ObjectPool.getStats`.
|
|
1258
|
+
*/
|
|
1259
|
+
getSyncStats() {
|
|
1260
|
+
return {
|
|
1261
|
+
connectionState: this.connectionState,
|
|
1262
|
+
pendingMutations: this.pendingMutations.length,
|
|
1263
|
+
objectPoolStats: this.objectPool.getStats(),
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Get pending transaction count from TransactionQueue
|
|
1268
|
+
* Used by SyncedStore to compute hasUnsyncedChanges
|
|
1269
|
+
*/
|
|
1270
|
+
getPendingTransactionCount() {
|
|
1271
|
+
const stats = this.transactionQueue.getStats();
|
|
1272
|
+
// Include pending and executing as "unsynced"
|
|
1273
|
+
// awaiting_delta transactions are included in 'executing' until confirmed
|
|
1274
|
+
// Completed and failed are "synced" (either done or gave up)
|
|
1275
|
+
return stats.pending + stats.executing;
|
|
1276
|
+
}
|
|
1277
|
+
/**
|
|
1278
|
+
* Subscribe to transaction events for sync status tracking
|
|
1279
|
+
* Returns unsubscribe function
|
|
1280
|
+
*/
|
|
1281
|
+
onTransactionEvent(event, callback) {
|
|
1282
|
+
const eventName = `transaction:${event}`;
|
|
1283
|
+
this.transactionQueue.on(eventName, callback);
|
|
1284
|
+
return () => this.transactionQueue.off(eventName, callback);
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Subscribe to mutation failures with the full payload. Mirrors the
|
|
1288
|
+
* underlying TransactionQueue 'transaction:failed' shape so consumers
|
|
1289
|
+
* can render typed UI (toast keyed by `AbloError.type`, route-level
|
|
1290
|
+
* "this entity reverted" boundaries, telemetry).
|
|
1291
|
+
*
|
|
1292
|
+
* Distinct from `onTransactionEvent('failed', cb)`, which exists only
|
|
1293
|
+
* for the legacy parameterless `pendingChanges` counter and intentionally
|
|
1294
|
+
* drops the payload. The two coexist — keep the counter callback fast
|
|
1295
|
+
* and the typed listener for user-visible surfaces.
|
|
1296
|
+
*/
|
|
1297
|
+
onMutationFailure(listener) {
|
|
1298
|
+
this.transactionQueue.on('transaction:failed', listener);
|
|
1299
|
+
return () => this.transactionQueue.off('transaction:failed', listener);
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Wait for the latest in-flight transaction for (modelName, modelId)
|
|
1303
|
+
* to be confirmed by the server, or reject if it's rolled back.
|
|
1304
|
+
* Resolves immediately when no transaction is in flight — see
|
|
1305
|
+
* `TransactionQueue.confirmationFor` for the lookup contract.
|
|
1306
|
+
*
|
|
1307
|
+
* Distinct from `waitForDeltaConfirmation(transactionId)` which keys
|
|
1308
|
+
* off a known tx id; this variant is for call sites that hold a
|
|
1309
|
+
* Model reference but never see the underlying transaction.
|
|
1310
|
+
*/
|
|
1311
|
+
waitForConfirmation(modelName, modelId) {
|
|
1312
|
+
return this.transactionQueue.confirmationFor(modelName, modelId);
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Get detailed debug info for the sync debug page
|
|
1316
|
+
*/
|
|
1317
|
+
getDebugInfo() {
|
|
1318
|
+
return {
|
|
1319
|
+
connectionState: this.connectionState,
|
|
1320
|
+
pendingMutationsCount: this.pendingMutations.length,
|
|
1321
|
+
transactionQueue: this.transactionQueue.getDebugInfo(),
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
// --- Best-practice assignment ops ---
|
|
1325
|
+
async unassignEntity(entityType, entityId) {
|
|
1326
|
+
// Call server-side unassign to avoid per-id races
|
|
1327
|
+
await this.mutationExecutor.executeDelete('Assignment', entityId);
|
|
1328
|
+
}
|
|
1329
|
+
async reassignEntity(entityType, entityId, assigneeType, assigneeId, id) {
|
|
1330
|
+
await this.mutationExecutor.executeCreate('Assignment', id || '', {
|
|
1331
|
+
entityType,
|
|
1332
|
+
entityId,
|
|
1333
|
+
assigneeType,
|
|
1334
|
+
assigneeId,
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
// ── Delta + Bootstrap application (owns ObjectPool writes) ──────────────
|
|
1338
|
+
/**
|
|
1339
|
+
* Apply a batch of delta results from Database to the ObjectPool.
|
|
1340
|
+
* Owns: model creation, upsert, remove, archive, conflict resolution.
|
|
1341
|
+
* Returns: nothing — ObjectPool is updated in place.
|
|
1342
|
+
*/
|
|
1343
|
+
/**
|
|
1344
|
+
* Mark a local transaction as optimistically applied. The matching
|
|
1345
|
+
* server delta (when it arrives with the same `transactionId`) will
|
|
1346
|
+
* be recognized as an echo and skip the pool mutation. Called
|
|
1347
|
+
* automatically by `TransactionQueue` when a transaction is staged;
|
|
1348
|
+
* exposed publicly so tests can drive the API directly.
|
|
1349
|
+
*/
|
|
1350
|
+
markTransactionPending(transactionId) {
|
|
1351
|
+
this.echoTracker.markPending(transactionId);
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Read echo-detection counters: hits, rollbacks, evictions, and the
|
|
1355
|
+
* current pending-set size. Surfaced for production observability
|
|
1356
|
+
* — a sustained `evictions > 0` rate or `rollbacks` spike is a
|
|
1357
|
+
* health signal worth alerting on.
|
|
1358
|
+
*/
|
|
1359
|
+
getEchoMetrics() {
|
|
1360
|
+
return this.echoTracker.getMetrics();
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Package-internal accessor for the TransactionQueue. Used by
|
|
1364
|
+
* `Ablo.commits.create()` to route raw multi-op envelopes through the
|
|
1365
|
+
* same retry-on-reconnect lane as the Model proxy path, and by tests
|
|
1366
|
+
* to exercise the queue ↔ markTransactionPending wiring on the real
|
|
1367
|
+
* instance the SyncClient subscribes to. NOT re-exported to SDK
|
|
1368
|
+
* consumers — `Ablo` itself is the public surface.
|
|
1369
|
+
*/
|
|
1370
|
+
getTransactionQueue() {
|
|
1371
|
+
return this.transactionQueue;
|
|
1372
|
+
}
|
|
1373
|
+
applyDeltaBatchToPool(dbResults, enrichRelations) {
|
|
1374
|
+
const modelsToAdd = [];
|
|
1375
|
+
const modelsToUpsert = [];
|
|
1376
|
+
const idsToRemove = [];
|
|
1377
|
+
const idsToArchive = [];
|
|
1378
|
+
// Pre-pass: collect every id slated for `remove` in this batch. The
|
|
1379
|
+
// chart-delete flicker came from this exact pattern: a peer (or the
|
|
1380
|
+
// user themself) deletes a chart with N layers; the commit produces
|
|
1381
|
+
// BOTH residual `update` deltas (from the optimistic edits that
|
|
1382
|
+
// happened just before the delete) AND `remove` deltas. The
|
|
1383
|
+
// `update` branch below would `createFromData` the row back into
|
|
1384
|
+
// the pool when `existing` was already gone (optimistic remove
|
|
1385
|
+
// happened), and the next loop iteration's `remove` would strip
|
|
1386
|
+
// it again — net effect: pool transitions live → gone → live →
|
|
1387
|
+
// gone in one tick, which the renderer catches mid-frame as a
|
|
1388
|
+
// flicker. Filter ops on doomed ids before they touch the pool.
|
|
1389
|
+
const idsBeingRemoved = new Set();
|
|
1390
|
+
for (const r of dbResults) {
|
|
1391
|
+
if (r.action === 'remove')
|
|
1392
|
+
idsBeingRemoved.add(r.modelId);
|
|
1393
|
+
}
|
|
1394
|
+
for (const result of dbResults) {
|
|
1395
|
+
const { modelName, modelId, action, transactionId } = result;
|
|
1396
|
+
// ECHO DETECTION. If this delta carries a transaction id that
|
|
1397
|
+
// matches one we've optimistically applied locally, the pool
|
|
1398
|
+
// already reflects this mutation — skip the pool op. The
|
|
1399
|
+
// upstream IDB write (in `Database.processDeltaBatch`) still
|
|
1400
|
+
// runs; only the in-memory pool mutation is suppressed. This is
|
|
1401
|
+
// the architectural fix for the chart-delete flicker: a
|
|
1402
|
+
// server-confirmed CREATE arriving AFTER the user has
|
|
1403
|
+
// optimistically deleted the row would otherwise re-add the row
|
|
1404
|
+
// for the ~2s window before the matching DELETE confirmation
|
|
1405
|
+
// lands. See `OPTIMISTIC_RECONCILIATION.md` for the framing.
|
|
1406
|
+
if (this.echoTracker.consumeEcho(transactionId)) {
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
// If a later op in this batch will remove this id, skip earlier
|
|
1410
|
+
// add/update ops on it. Server FK ordering can produce
|
|
1411
|
+
// U(layer)+D(layer) when an optimistic edit and a delete both
|
|
1412
|
+
// commit in the same window; only the final state matters.
|
|
1413
|
+
if ((action === 'add' || action === 'update') && idsBeingRemoved.has(modelId)) {
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
switch (action) {
|
|
1417
|
+
case 'add': {
|
|
1418
|
+
const existing = this.objectPool.get(modelId);
|
|
1419
|
+
if (existing) {
|
|
1420
|
+
existing.markAsSynced();
|
|
1421
|
+
}
|
|
1422
|
+
else if (result.data) {
|
|
1423
|
+
const data = enrichRelations(modelName, { ...result.data, __typename: modelName });
|
|
1424
|
+
const model = this.objectPool.createFromData(data);
|
|
1425
|
+
if (model)
|
|
1426
|
+
modelsToAdd.push(model);
|
|
1427
|
+
}
|
|
1428
|
+
break;
|
|
1429
|
+
}
|
|
1430
|
+
case 'update': {
|
|
1431
|
+
const existing = this.objectPool.get(modelId);
|
|
1432
|
+
if (existing && !existing.disposed && result.data) {
|
|
1433
|
+
enrichRelations(modelName, result.data);
|
|
1434
|
+
const resolved = this.resolveConflicts(existing, result.data);
|
|
1435
|
+
modelsToUpsert.push(resolved);
|
|
1436
|
+
}
|
|
1437
|
+
// Resurrection drop: if `existing` is gone (optimistic delete
|
|
1438
|
+
// discarded it; the matching D delta is in-flight) we used
|
|
1439
|
+
// to call `createFromData` here, which reintroduced the row
|
|
1440
|
+
// for a frame before the D delta stripped it again — the
|
|
1441
|
+
// chart-delete flicker. Trust the local state. If the server
|
|
1442
|
+
// still considers the row alive, a subsequent bootstrap or
|
|
1443
|
+
// resync will reconcile.
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
case 'remove':
|
|
1447
|
+
idsToRemove.push(modelId);
|
|
1448
|
+
break;
|
|
1449
|
+
case 'archive':
|
|
1450
|
+
idsToArchive.push(modelId);
|
|
1451
|
+
break;
|
|
1452
|
+
case 'verify':
|
|
1453
|
+
// `verify` is `Database.processDeltaBatch`'s signal for a delta
|
|
1454
|
+
// whose IDB store transaction FAILED. Pool isn't updated for
|
|
1455
|
+
// this delta — by design, since the persisted view doesn't
|
|
1456
|
+
// reflect it either — and the persistence-gated cursor in
|
|
1457
|
+
// `BaseSyncedStore.flushPendingDeltas` will NOT ack past it,
|
|
1458
|
+
// so the next 30s catch-up poll (or reconnect handshake) will
|
|
1459
|
+
// re-fetch and re-apply. Logged here so silent IDB failures
|
|
1460
|
+
// are observable instead of disappearing into a default switch
|
|
1461
|
+
// fall-through.
|
|
1462
|
+
getContext().logger.warn('[SyncClient.applyDeltaBatchToPool] skipping pool op for unpersisted delta', {
|
|
1463
|
+
modelName,
|
|
1464
|
+
modelId: modelId.slice(0, 12),
|
|
1465
|
+
});
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
// Batch ObjectPool mutations — minimal MobX actions
|
|
1470
|
+
if (modelsToAdd.length > 0)
|
|
1471
|
+
this.objectPool.addBatch(modelsToAdd, ModelScope.live);
|
|
1472
|
+
if (modelsToUpsert.length > 0)
|
|
1473
|
+
this.objectPool.upsertBatch(modelsToUpsert, ModelScope.live);
|
|
1474
|
+
if (idsToRemove.length > 0)
|
|
1475
|
+
this.objectPool.removeBatch(idsToRemove);
|
|
1476
|
+
for (const id of idsToArchive)
|
|
1477
|
+
this.objectPool.updateScope(id, ModelScope.archived);
|
|
1478
|
+
// Emit changed model types so QueryProcessor can auto-invalidate
|
|
1479
|
+
const changedTypes = new Set(dbResults.map(r => r.modelName));
|
|
1480
|
+
if (changedTypes.size > 0)
|
|
1481
|
+
this.emit('models:changed', changedTypes);
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Apply bootstrap data to the ObjectPool with ghost removal.
|
|
1485
|
+
* Owns: model creation, batch upsert, ghost detection + removal.
|
|
1486
|
+
*/
|
|
1487
|
+
applyBootstrapDataToPool(bootstrapData, protectedIds) {
|
|
1488
|
+
if (!bootstrapData.models) {
|
|
1489
|
+
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0 };
|
|
1490
|
+
}
|
|
1491
|
+
const allModels = [];
|
|
1492
|
+
const serverIdsByType = new Map();
|
|
1493
|
+
let healedCount = 0;
|
|
1494
|
+
let skippedCount = 0;
|
|
1495
|
+
const failedTypes = new Set(bootstrapData.failedModels ?? []);
|
|
1496
|
+
for (const [modelType, records] of Object.entries(bootstrapData.models)) {
|
|
1497
|
+
if (failedTypes.has(modelType))
|
|
1498
|
+
continue;
|
|
1499
|
+
const idsForType = new Set();
|
|
1500
|
+
serverIdsByType.set(modelType, idsForType);
|
|
1501
|
+
if (!Array.isArray(records) || records.length === 0)
|
|
1502
|
+
continue;
|
|
1503
|
+
for (const rawRecord of records) {
|
|
1504
|
+
if (!rawRecord || typeof rawRecord !== 'object') {
|
|
1505
|
+
skippedCount++;
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
let data = rawRecord;
|
|
1509
|
+
if (!data.__typename)
|
|
1510
|
+
data = { __typename: modelType, ...data };
|
|
1511
|
+
const healResult = this.healModelRecord(modelType, data);
|
|
1512
|
+
if (healResult === null) {
|
|
1513
|
+
skippedCount++;
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
data = healResult.data;
|
|
1517
|
+
if (healResult.healed)
|
|
1518
|
+
healedCount++;
|
|
1519
|
+
const recordId = data.id;
|
|
1520
|
+
if (recordId)
|
|
1521
|
+
idsForType.add(recordId);
|
|
1522
|
+
try {
|
|
1523
|
+
const model = this.objectPool.createFromData(data);
|
|
1524
|
+
if (model)
|
|
1525
|
+
allModels.push(model);
|
|
1526
|
+
}
|
|
1527
|
+
catch {
|
|
1528
|
+
skippedCount++;
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
// Batch upsert
|
|
1533
|
+
const beforeSize = this.objectPool.size;
|
|
1534
|
+
this.objectPool.upsertBatch(allModels, ModelScope.live);
|
|
1535
|
+
const addedCount = this.objectPool.size - beforeSize;
|
|
1536
|
+
const updatedCount = allModels.length - addedCount;
|
|
1537
|
+
// Ghost removal — remove pool entities not in server snapshot
|
|
1538
|
+
const ghostIds = [];
|
|
1539
|
+
for (const [modelType, serverIds] of serverIdsByType) {
|
|
1540
|
+
const poolIds = this.objectPool.getIdsByModelType(modelType);
|
|
1541
|
+
if (!poolIds)
|
|
1542
|
+
continue;
|
|
1543
|
+
for (const poolId of poolIds) {
|
|
1544
|
+
if (!serverIds.has(poolId) && !protectedIds?.has(poolId))
|
|
1545
|
+
ghostIds.push(poolId);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
const removedCount = this.objectPool.removeBatch(ghostIds);
|
|
1549
|
+
// Emit changed model types so QueryProcessor can auto-invalidate
|
|
1550
|
+
const changedTypes = new Set(Object.keys(bootstrapData.models));
|
|
1551
|
+
if (changedTypes.size > 0)
|
|
1552
|
+
this.emit('models:changed', changedTypes);
|
|
1553
|
+
return { added: addedCount, updated: updatedCount, removed: removedCount, skipped: skippedCount, healed: healedCount };
|
|
1554
|
+
}
|
|
1555
|
+
}
|