@abloatai/ablo 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +20 -0
- package/README.md +3 -3
- package/dist/BaseSyncedStore.d.ts +72 -195
- package/dist/BaseSyncedStore.js +240 -625
- package/dist/Database.d.ts +8 -9
- package/dist/Database.js +9 -14
- package/dist/LazyReferenceCollection.js +5 -0
- package/dist/Model.js +6 -8
- package/dist/ModelRegistry.d.ts +25 -6
- package/dist/ModelRegistry.js +12 -6
- package/dist/NetworkMonitor.js +1 -1
- package/dist/ObjectPool.js +9 -3
- package/dist/SyncClient.d.ts +15 -5
- package/dist/SyncClient.js +152 -66
- package/dist/SyncEngineContext.d.ts +1 -3
- package/dist/SyncEngineContext.js +1 -2
- package/dist/agent/Agent.d.ts +21 -8
- package/dist/agent/Agent.js +26 -13
- package/dist/ai-sdk/coordination-context.js +9 -3
- package/dist/ai-sdk/wrap.d.ts +1 -1
- package/dist/ai-sdk/wrap.js +1 -1
- package/dist/auth/credentialPolicy.d.ts +10 -2
- package/dist/auth/credentialSource.d.ts +1 -13
- package/dist/auth/credentialSource.js +6 -13
- package/dist/auth/index.js +3 -3
- package/dist/cli.cjs +328 -262
- package/dist/client/Ablo.d.ts +35 -764
- package/dist/client/Ablo.js +64 -688
- package/dist/client/ApiClient.d.ts +14 -1
- package/dist/client/ApiClient.js +63 -9
- package/dist/client/auth.d.ts +8 -17
- package/dist/client/auth.js +42 -6
- package/dist/client/consoleLogger.d.ts +36 -0
- package/dist/client/consoleLogger.js +45 -0
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.js +10 -6
- package/dist/client/credentialEndpoint.d.ts +63 -0
- package/dist/client/credentialEndpoint.js +87 -0
- package/dist/client/hostedEndpoints.d.ts +24 -0
- package/dist/client/hostedEndpoints.js +24 -0
- package/dist/client/httpClient.d.ts +1 -1
- package/dist/client/identity.js +2 -1
- package/dist/client/modelRegistration.d.ts +14 -0
- package/dist/client/modelRegistration.js +321 -0
- package/dist/client/options.d.ts +396 -0
- package/dist/client/options.js +10 -0
- package/dist/client/resourceTypes.d.ts +344 -0
- package/dist/client/resourceTypes.js +13 -0
- package/dist/client/schemaConfig.d.ts +56 -0
- package/dist/client/schemaConfig.js +188 -0
- package/dist/client/sessionMint.d.ts +1 -1
- package/dist/client/validateAbloOptions.js +1 -1
- package/dist/client/wsMutationExecutor.d.ts +33 -0
- package/dist/client/wsMutationExecutor.js +77 -0
- package/dist/context.js +0 -3
- package/dist/coordination/index.d.ts +8 -1
- package/dist/coordination/index.js +24 -1
- package/dist/coordination/trace.d.ts +0 -1
- package/dist/coordination/trace.js +0 -1
- package/dist/core/DatabaseManager.d.ts +0 -1
- package/dist/core/DatabaseManager.js +26 -24
- package/dist/core/QueryProcessor.js +7 -8
- package/dist/core/QueryView.d.ts +14 -2
- package/dist/core/QueryView.js +4 -5
- package/dist/core/StoreManager.d.ts +2 -2
- package/dist/core/StoreManager.js +5 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/openIDBWithTimeout.js +15 -11
- package/dist/core/query-utils.js +1 -1
- package/dist/core/storeContract.d.ts +143 -0
- package/dist/core/storeContract.js +16 -0
- package/dist/errorCodes.d.ts +11 -2
- package/dist/errorCodes.js +155 -137
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +14 -1
- package/dist/index.js +5 -0
- package/dist/interfaces/index.d.ts +6 -20
- package/dist/keys/index.js +18 -9
- package/dist/mutators/UndoManager.js +6 -1
- package/dist/mutators/defineMutators.d.ts +1 -3
- package/dist/policy/types.d.ts +1 -1
- package/dist/query/client.d.ts +11 -0
- package/dist/query/client.js +81 -52
- package/dist/react/AbloProvider.d.ts +6 -10
- package/dist/react/AbloProvider.js +22 -15
- package/dist/react/context.d.ts +2 -118
- package/dist/react/useAblo.js +1 -1
- package/dist/react/useErrorListener.js +1 -1
- package/dist/react/useMutationFailureListener.js +1 -1
- package/dist/react/useMutators.js +7 -1
- package/dist/react/useReactive.js +0 -1
- package/dist/react/useUndoScope.js +2 -2
- package/dist/schema/ddlLock.d.ts +39 -0
- package/dist/schema/ddlLock.js +51 -0
- package/dist/schema/field.js +1 -1
- package/dist/schema/index.d.ts +4 -3
- package/dist/schema/index.js +15 -5
- package/dist/schema/model.d.ts +3 -3
- package/dist/schema/model.js +2 -2
- package/dist/schema/openapi.js +5 -1
- package/dist/schema/queries.d.ts +1 -1
- package/dist/schema/queries.js +1 -0
- package/dist/schema/residency.d.ts +35 -0
- package/dist/schema/residency.js +27 -0
- package/dist/schema/roles.d.ts +25 -0
- package/dist/schema/roles.js +37 -0
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/schema.js +11 -7
- package/dist/schema/serialize.d.ts +8 -8
- package/dist/schema/sync-delta-row.d.ts +2 -2
- package/dist/schema/sync-delta-row.js +2 -2
- package/dist/server/storage-mode.d.ts +8 -1
- package/dist/server/storage-mode.js +14 -4
- package/dist/source/adapter.d.ts +1 -1
- package/dist/source/adapters/drizzle.js +4 -3
- package/dist/source/adapters/kysely.js +4 -3
- package/dist/source/adapters/memory.js +1 -1
- package/dist/source/adapters/prisma.js +5 -4
- package/dist/source/conformance.js +10 -8
- package/dist/source/connector.js +4 -2
- package/dist/source/factory.d.ts +106 -0
- package/dist/source/factory.js +273 -0
- package/dist/source/index.d.ts +11 -461
- package/dist/source/index.js +14 -416
- package/dist/source/next.d.ts +1 -1
- package/dist/source/next.js +1 -1
- package/dist/source/pushQueue.d.ts +1 -1
- package/dist/source/pushQueue.js +6 -3
- package/dist/source/signing.d.ts +59 -0
- package/dist/source/signing.js +142 -0
- package/dist/source/types.d.ts +334 -0
- package/dist/source/types.js +44 -0
- package/dist/stores/ObjectStore.d.ts +1 -1
- package/dist/stores/ObjectStore.js +23 -23
- package/dist/stores/SyncActionStore.d.ts +1 -1
- package/dist/stores/SyncActionStore.js +70 -35
- package/dist/surface.d.ts +1 -1
- package/dist/surface.js +1 -0
- package/dist/sync/AreaOfInterestManager.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.js +1 -1
- package/dist/sync/BootstrapHelper.d.ts +1 -3
- package/dist/sync/BootstrapHelper.js +29 -8
- package/dist/sync/ConnectionManager.d.ts +8 -0
- package/dist/sync/ConnectionManager.js +19 -4
- package/dist/sync/HydrationCoordinator.d.ts +12 -0
- package/dist/sync/HydrationCoordinator.js +25 -5
- package/dist/sync/NetworkProbe.d.ts +3 -2
- package/dist/sync/NetworkProbe.js +7 -6
- package/dist/sync/SyncWebSocket.d.ts +71 -120
- package/dist/sync/SyncWebSocket.js +324 -630
- package/dist/sync/awaitClaimGrant.js +27 -19
- package/dist/sync/bootstrapApply.d.ts +60 -0
- package/dist/sync/bootstrapApply.js +65 -0
- package/dist/sync/commitFrames.d.ts +43 -0
- package/dist/sync/commitFrames.js +94 -0
- package/dist/sync/createClaimStream.js +3 -2
- package/dist/sync/createSnapshot.d.ts +1 -3
- package/dist/sync/credentialLifecycle.d.ts +175 -0
- package/dist/sync/credentialLifecycle.js +329 -0
- package/dist/sync/deltaPipeline.d.ts +111 -0
- package/dist/sync/deltaPipeline.js +262 -0
- package/dist/sync/groupChange.d.ts +111 -0
- package/dist/sync/groupChange.js +246 -0
- package/dist/sync/heartbeat.d.ts +62 -0
- package/dist/sync/heartbeat.js +91 -0
- package/dist/sync/participants.d.ts +8 -8
- package/dist/sync/syncCursor.d.ts +44 -0
- package/dist/sync/syncCursor.js +59 -0
- package/dist/sync/syncPlan.d.ts +62 -0
- package/dist/sync/syncPlan.js +51 -0
- package/dist/sync/wsFrameHandlers.d.ts +117 -0
- package/dist/sync/wsFrameHandlers.js +378 -0
- package/dist/testing/fixtures/bootstrap.d.ts +9 -11
- package/dist/testing/fixtures/deltas.d.ts +1 -1
- package/dist/testing/fixtures/models.js +1 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +0 -3
- package/dist/testing/helpers/sync-engine-harness.js +2 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/mocks/MockSyncContext.d.ts +1 -17
- package/dist/testing/mocks/MockSyncContext.js +1 -32
- package/dist/testing/mocks/MockSyncStore.d.ts +4 -4
- package/dist/testing/mocks/MockWebSocket.d.ts +2 -2
- package/dist/transactions/TransactionQueue.d.ts +20 -56
- package/dist/transactions/TransactionQueue.js +101 -487
- package/dist/transactions/TransactionStore.d.ts +18 -0
- package/dist/transactions/TransactionStore.js +51 -0
- package/dist/transactions/coalesceRules.d.ts +34 -0
- package/dist/transactions/coalesceRules.js +117 -0
- package/dist/transactions/commitPayload.d.ts +134 -0
- package/dist/transactions/commitPayload.js +152 -0
- package/dist/transactions/deltaConfirmation.d.ts +60 -0
- package/dist/transactions/deltaConfirmation.js +223 -0
- package/dist/transactions/optimistic.d.ts +24 -0
- package/dist/transactions/optimistic.js +45 -0
- package/dist/transactions/persistedReplay.d.ts +93 -0
- package/dist/transactions/persistedReplay.js +105 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.js +0 -5
- package/dist/types/modelData.d.ts +12 -0
- package/dist/types/modelData.js +11 -0
- package/dist/types/participant.d.ts +21 -0
- package/dist/types/participant.js +12 -0
- package/dist/types/streams.d.ts +8 -14
- package/dist/utils/mobx-setup.d.ts +1 -1
- package/dist/utils/mobx-setup.js +2 -2
- package/dist/wire/errorEnvelope.d.ts +11 -2
- package/dist/wire/errorEnvelope.js +14 -2
- package/dist/wire/frames.d.ts +90 -0
- package/dist/wire/frames.js +55 -1
- package/dist/wire/index.d.ts +4 -1
- package/dist/wire/index.js +20 -1
- package/dist/wire/protocol.d.ts +45 -0
- package/dist/wire/protocol.js +45 -0
- package/dist/wire/protocolVersion.d.ts +56 -0
- package/dist/wire/protocolVersion.js +63 -0
- package/docs/api-keys.md +4 -3
- package/docs/examples/existing-python-backend.md +3 -3
- package/docs/identity.md +4 -4
- package/docs/integration-guide.md +1 -1
- package/docs/react.md +1 -1
- package/docs/sessions.md +5 -7
- package/package.json +15 -13
- package/dist/client/index.d.ts +0 -36
- package/dist/client/index.js +0 -33
- package/dist/config/index.d.ts +0 -10
- package/dist/config/index.js +0 -12
- package/dist/interfaces/headless.d.ts +0 -95
- package/dist/interfaces/headless.js +0 -41
- package/dist/query/index.d.ts +0 -6
- package/dist/query/index.js +0 -5
- package/dist/realtime/index.d.ts +0 -10
- package/dist/realtime/index.js +0 -9
- package/dist/schema/plane.d.ts +0 -23
- package/dist/schema/plane.js +0 -19
- package/dist/server/next.d.ts +0 -51
- package/dist/server/next.js +0 -47
- package/dist/sync/OfflineFlush.d.ts +0 -9
- package/dist/sync/OfflineFlush.js +0 -22
- package/dist/sync/OfflineTransactionStore.d.ts +0 -37
- package/dist/sync/OfflineTransactionStore.js +0 -263
- package/dist/transactions/index.d.ts +0 -16
- package/dist/transactions/index.js +0 -7
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -11,19 +11,25 @@
|
|
|
11
11
|
* implementation stays in the app's SyncedStore.ts until we incrementally
|
|
12
12
|
* pull generic methods into this base class.
|
|
13
13
|
*/
|
|
14
|
-
import { makeObservable, observable, computed, runInAction } from 'mobx';
|
|
14
|
+
import { makeObservable, observable, action, computed, runInAction } from 'mobx';
|
|
15
15
|
import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
|
|
16
16
|
import { ConnectionManager } from './sync/ConnectionManager.js';
|
|
17
17
|
import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
|
|
18
18
|
import { resolveParticipantSyncGroups, } from './sync/participants.js';
|
|
19
|
+
import { ModelRegistry } from './ModelRegistry.js';
|
|
19
20
|
import { PropertyType } from './types/index.js';
|
|
20
21
|
import { SyncWebSocket, } from './sync/SyncWebSocket.js';
|
|
21
22
|
import { QueryProcessor } from './core/QueryProcessor.js';
|
|
22
23
|
import { Model, rowAsModel } from './Model.js';
|
|
23
24
|
import { getContext } from './context.js';
|
|
24
|
-
import { SyncSessionError } from './errors.js';
|
|
25
|
+
import { SyncSessionError, isAccessCredentialExpiryCloseReason } from './errors.js';
|
|
25
26
|
import { ModelScope } from './ObjectPool.js';
|
|
26
27
|
import { LazyReferenceCollection } from './LazyReferenceCollection.js';
|
|
28
|
+
import { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
|
|
29
|
+
import { CredentialLifecycle } from './sync/credentialLifecycle.js';
|
|
30
|
+
import * as groupChange from './sync/groupChange.js';
|
|
31
|
+
import * as bootstrapApply from './sync/bootstrapApply.js';
|
|
32
|
+
import * as deltaPipeline from './sync/deltaPipeline.js';
|
|
27
33
|
/** Bootstrap timeout configuration */
|
|
28
34
|
export const BOOTSTRAP_CONFIG = {
|
|
29
35
|
OVERALL_TIMEOUT_MS: 15_000,
|
|
@@ -32,6 +38,9 @@ export const BOOTSTRAP_CONFIG = {
|
|
|
32
38
|
};
|
|
33
39
|
// Re-export for clean API
|
|
34
40
|
export { ModelScope };
|
|
41
|
+
// deriveSyncPlanFromSchema (pure schema → sync-plan derivation) moved to
|
|
42
|
+
// sync/syncPlan.ts; re-exported so importers are unchanged.
|
|
43
|
+
export { deriveSyncPlanFromSchema } from './sync/syncPlan.js';
|
|
35
44
|
// ── Base class ──────────────────────────────────────────────────────────────
|
|
36
45
|
/**
|
|
37
46
|
* BaseSyncedStore — abstract base for app-specific sync stores.
|
|
@@ -61,57 +70,6 @@ export { ModelScope };
|
|
|
61
70
|
* }
|
|
62
71
|
* }
|
|
63
72
|
*/
|
|
64
|
-
/**
|
|
65
|
-
* Walk a schema and derive the three sync-plan arrays consumed by
|
|
66
|
-
* `BaseSyncedStore`'s constructor: version-vector keys, FK indexes to
|
|
67
|
-
* register on the pool, and the enrichment plan.
|
|
68
|
-
*
|
|
69
|
-
* Version vector keys are derived from each model's `typename` (lowercased
|
|
70
|
-
* to match the server's event-type convention — `'Task'` → `'task'`,
|
|
71
|
-
* `'SlideLayer'` → `'slidelayer'`). A fallback to the schema key applies
|
|
72
|
-
* when `typename` is unset, though `defineSchema()` now always resolves
|
|
73
|
-
* it during assembly so the fallback is defensive-only.
|
|
74
|
-
*
|
|
75
|
-
* FK indexes and enrichment entries are pulled from each `belongsTo`
|
|
76
|
-
* relation where `options.index` / `options.enrich` is set. Relations
|
|
77
|
-
* without those options are skipped — this is an opt-in mechanism so
|
|
78
|
-
* adding a `belongsTo` never silently changes delta or lookup semantics.
|
|
79
|
-
*
|
|
80
|
-
* Pure function: takes a Schema, returns three arrays. No side effects,
|
|
81
|
-
* no class state. Called once at construction time from `BaseSyncedStore`.
|
|
82
|
-
*/
|
|
83
|
-
export function deriveSyncPlanFromSchema(schema) {
|
|
84
|
-
const versionVectorKeys = [];
|
|
85
|
-
const enrichmentPlan = [];
|
|
86
|
-
const foreignKeyIndexes = [];
|
|
87
|
-
for (const [modelName, def] of Object.entries(schema.models)) {
|
|
88
|
-
const typename = def.typename ?? modelName;
|
|
89
|
-
versionVectorKeys.push(typename.toLowerCase());
|
|
90
|
-
for (const [relationKey, rel] of Object.entries(def.relations)) {
|
|
91
|
-
if (rel.type === 'belongsTo') {
|
|
92
|
-
if (rel.options?.index) {
|
|
93
|
-
foreignKeyIndexes.push({ modelName: typename, fieldName: rel.foreignKey });
|
|
94
|
-
}
|
|
95
|
-
if (rel.options?.enrich) {
|
|
96
|
-
enrichmentPlan.push({
|
|
97
|
-
modelName: typename,
|
|
98
|
-
foreignKey: rel.foreignKey,
|
|
99
|
-
relationKey,
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
else if (rel.type === 'hasMany' || rel.type === 'hasOne') {
|
|
104
|
-
// hasMany/hasOne: the FK lives on the TARGET model, not the current model.
|
|
105
|
-
// Register the FK index on the target so getByForeignKey works.
|
|
106
|
-
// Target typename is resolved at registration time from the schema.
|
|
107
|
-
const targetDef = schema.models[rel.target];
|
|
108
|
-
const targetTypename = targetDef?.typename ?? rel.target;
|
|
109
|
-
foreignKeyIndexes.push({ modelName: targetTypename, fieldName: rel.foreignKey });
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
return { versionVectorKeys, enrichmentPlan, foreignKeyIndexes };
|
|
114
|
-
}
|
|
115
73
|
export class BaseSyncedStore {
|
|
116
74
|
// ── Observable sync status for UI ──
|
|
117
75
|
syncStatus = {
|
|
@@ -254,11 +212,10 @@ export class BaseSyncedStore {
|
|
|
254
212
|
// ── Internal helpers ──
|
|
255
213
|
queryProcessor;
|
|
256
214
|
/**
|
|
257
|
-
* Runtime behavior flags only — the
|
|
258
|
-
* (`
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
* They don't need to persist on `this.config`.
|
|
215
|
+
* Runtime behavior flags only — the schema/config arrays
|
|
216
|
+
* (`enrichmentPlan`, `foreignKeyIndexes`) are consumed at construction
|
|
217
|
+
* time and stored on the instance as `enrichmentPlan` and
|
|
218
|
+
* pool-registered indexes. They don't need to persist on `this.config`.
|
|
262
219
|
*/
|
|
263
220
|
config;
|
|
264
221
|
disposers = [];
|
|
@@ -276,7 +233,6 @@ export class BaseSyncedStore {
|
|
|
276
233
|
// right place to surface it.
|
|
277
234
|
userContext = null;
|
|
278
235
|
// ── Smart sync ──
|
|
279
|
-
versionVector;
|
|
280
236
|
/**
|
|
281
237
|
* Declarative enrichment plan: "for model X, when a delta arrives,
|
|
282
238
|
* read data[foreignKey] and attach the matching parent from the pool
|
|
@@ -318,11 +274,11 @@ export class BaseSyncedStore {
|
|
|
318
274
|
Model.setStore(this);
|
|
319
275
|
// ── Schema-derived sync plan (Phase 2) ─────────────────────────────
|
|
320
276
|
//
|
|
321
|
-
// When a schema is provided, derive
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
277
|
+
// When a schema is provided, derive FK indexes and the enrichment
|
|
278
|
+
// plan from declarative annotations on the schema's `belongsTo`
|
|
279
|
+
// relations. Explicit config fields layer on top, so subclasses
|
|
280
|
+
// (like Ablo's SyncedStore) can pass hardcoded arrays without
|
|
281
|
+
// needing a full schema.generated.ts.
|
|
326
282
|
//
|
|
327
283
|
// Order matters: schema-derived first, config second, so that in a
|
|
328
284
|
// future where Ablo passes both (schema AND explicit config), the
|
|
@@ -330,7 +286,7 @@ export class BaseSyncedStore {
|
|
|
330
286
|
// accidentally shadowed by schema derivation.
|
|
331
287
|
const derived = dependencies.schema
|
|
332
288
|
? deriveSyncPlanFromSchema(dependencies.schema)
|
|
333
|
-
: {
|
|
289
|
+
: { enrichmentPlan: [], foreignKeyIndexes: [] };
|
|
334
290
|
const mergedForeignKeyIndexes = [
|
|
335
291
|
...derived.foreignKeyIndexes,
|
|
336
292
|
...(config.foreignKeyIndexes ?? []),
|
|
@@ -361,14 +317,6 @@ export class BaseSyncedStore {
|
|
|
361
317
|
batchingDelay: 100,
|
|
362
318
|
maxBatchSize: 50,
|
|
363
319
|
};
|
|
364
|
-
// Version vector: union of schema-derived keys + explicit config keys,
|
|
365
|
-
// each seeded to 0. Empty when neither source supplies keys (unchanged
|
|
366
|
-
// behavior from pre-Phase-2 defaults).
|
|
367
|
-
const mergedVvKeys = [
|
|
368
|
-
...derived.versionVectorKeys,
|
|
369
|
-
...(config.versionVectorKeys ?? []),
|
|
370
|
-
];
|
|
371
|
-
this.versionVector = Object.fromEntries(mergedVvKeys.map((k) => [k, 0]));
|
|
372
320
|
// Create internal helpers
|
|
373
321
|
this.queryProcessor = new QueryProcessor({
|
|
374
322
|
enableCache: this.config.enableCache,
|
|
@@ -465,23 +413,21 @@ export class BaseSyncedStore {
|
|
|
465
413
|
*/
|
|
466
414
|
connectionManager = null;
|
|
467
415
|
/**
|
|
468
|
-
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
* `apiKey` deployment whose credential source refreshes out-of-band).
|
|
416
|
+
* Access-credential re-mint + proactive pre-roll — extracted to
|
|
417
|
+
* sync/credentialLifecycle.ts. Owns the refresher hook, the single-flight
|
|
418
|
+
* guard, and the browser-only refresh timer / wake listener; talks back
|
|
419
|
+
* through three lazily-resolved callbacks (the ConnectionManager doesn't
|
|
420
|
+
* exist until `setupWebSocketSync`). The `setCredentialRefresher` /
|
|
421
|
+
* `performCredentialRefresh` / `startCredentialLifecycle` methods below
|
|
422
|
+
* are thin delegates so the store's public surface is unchanged.
|
|
476
423
|
*/
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
credentialLifecycleTeardown = null;
|
|
424
|
+
credentialLifecycle = new CredentialLifecycle({
|
|
425
|
+
setAuthToken: (token) => { this.auth?.setAuthToken(token); },
|
|
426
|
+
nudgeReconnect: () => { this.nudgeReconnect(); },
|
|
427
|
+
reportSessionExpired: () => {
|
|
428
|
+
this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
429
|
+
},
|
|
430
|
+
});
|
|
485
431
|
/**
|
|
486
432
|
* Listeners registered via `subscribeSessionError()`. Fired when the
|
|
487
433
|
* WebSocket closes with a session-invalid code (1008/4001/4003) or a
|
|
@@ -525,7 +471,7 @@ export class BaseSyncedStore {
|
|
|
525
471
|
}
|
|
526
472
|
/**
|
|
527
473
|
* Observe the LOCAL mutation stream for undo recording (see
|
|
528
|
-
* {@link import('./
|
|
474
|
+
* {@link import('./core/storeContract.js').LocalMutation}). Taps the
|
|
529
475
|
* TransactionQueue's `transaction:created` event — fired once per local
|
|
530
476
|
* create/update/delete/archive with `previousData` already captured.
|
|
531
477
|
* Remote/collaborator deltas apply via `applyDeltaBatchToPool` and never
|
|
@@ -538,7 +484,7 @@ export class BaseSyncedStore {
|
|
|
538
484
|
// the queue's emitter does) — so undo recorded nothing. See
|
|
539
485
|
// `SyncClient.onLocalTransaction` for the full rationale.
|
|
540
486
|
return this.syncClient.onLocalTransaction((tx) => {
|
|
541
|
-
if (!tx
|
|
487
|
+
if (!tx?.type || !tx.modelName || !tx.modelId)
|
|
542
488
|
return;
|
|
543
489
|
handler({
|
|
544
490
|
type: tx.type,
|
|
@@ -695,75 +641,29 @@ export class BaseSyncedStore {
|
|
|
695
641
|
/**
|
|
696
642
|
* Register the access-credential re-mint hook. Called by the React provider
|
|
697
643
|
* with a thunk that mints a fresh `ek_`/`rk_` (typically its `getToken`).
|
|
698
|
-
* See {@link
|
|
644
|
+
* See {@link CredentialLifecycle.setRefresher}.
|
|
699
645
|
*/
|
|
700
646
|
setCredentialRefresher(refresher) {
|
|
701
|
-
this.
|
|
647
|
+
this.credentialLifecycle.setRefresher(refresher);
|
|
702
648
|
}
|
|
703
649
|
/**
|
|
704
650
|
* Re-mint the short-lived access credential and push it into the credential
|
|
705
651
|
* source, reporting a tri-state outcome the {@link ConnectionManager} maps to
|
|
706
|
-
* its FSM.
|
|
707
|
-
*
|
|
708
|
-
* return/throw):
|
|
709
|
-
* - token string → `'refreshed'` (fresh key in place; re-probe & reconnect)
|
|
710
|
-
* - `null` → `'session_error'` (login itself is gone → terminal, sign out)
|
|
711
|
-
* - throw → `'network_error'` (couldn't reach the mint endpoint → transient)
|
|
712
|
-
*
|
|
713
|
-
* SINGLE-FLIGHT: concurrent callers (a wake nudge, an in-flight request, the
|
|
714
|
-
* probe) share one in-flight promise so we never double-mint — the canonical
|
|
715
|
-
* fix for the "every 401 mints a token → thrash → spurious logout" anti-pattern.
|
|
716
|
-
*
|
|
717
|
-
* No refresher wired ⇒ `'refreshed'` (a no-op re-probe): a static-`apiKey`
|
|
718
|
-
* deployment has no session to re-mint from; its credential source refreshes
|
|
719
|
-
* out-of-band, so we just re-probe with whatever it currently holds.
|
|
652
|
+
* its FSM. Single-flight; no refresher wired ⇒ `'refreshed'` (a no-op
|
|
653
|
+
* re-probe). Full contract on {@link CredentialLifecycle.refresh}.
|
|
720
654
|
*/
|
|
721
655
|
async performCredentialRefresh() {
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
return 'session_error';
|
|
734
|
-
}
|
|
735
|
-
this.auth?.setAuthToken(token);
|
|
736
|
-
return 'refreshed';
|
|
737
|
-
}
|
|
738
|
-
catch (error) {
|
|
739
|
-
// A throw = transient (offline / mint endpoint unreachable / 5xx). The
|
|
740
|
-
// login may be perfectly valid; never sign out for this — back off and
|
|
741
|
-
// retry. Mirrors the `getToken` throw-vs-null contract end-to-end.
|
|
742
|
-
const message = error?.message ?? String(error);
|
|
743
|
-
// A relative-URL resolver invoked server-side (Node fetch has no origin
|
|
744
|
-
// to resolve against) emits the opaque "Failed to parse URL" / "Only
|
|
745
|
-
// absolute URLs are supported". Translate it into something actionable
|
|
746
|
-
// instead of a mystery transient blip — the proactive refresh is now
|
|
747
|
-
// browser-only, so hitting this means the resolver fired from SSR/RSC or
|
|
748
|
-
// a server route.
|
|
749
|
-
if (typeof window === 'undefined' && /parse URL|absolute URLs?/i.test(message)) {
|
|
750
|
-
getContext().logger.warn('credential resolver ran on the server with a relative URL — Node fetch needs an absolute URL. ' +
|
|
751
|
-
'Refresh the Ablo client in the browser, or build an absolute URL server-side ' +
|
|
752
|
-
"(e.g. new URL('/api/ablo-session', process.env.NEXT_PUBLIC_APP_URL)).", { error: message });
|
|
753
|
-
}
|
|
754
|
-
else {
|
|
755
|
-
getContext().logger.debug('access-credential re-mint failed (transient)', { error: message });
|
|
756
|
-
}
|
|
757
|
-
return 'network_error';
|
|
758
|
-
}
|
|
759
|
-
})();
|
|
760
|
-
this.inFlightCredentialRefresh = run;
|
|
761
|
-
try {
|
|
762
|
-
return await run;
|
|
763
|
-
}
|
|
764
|
-
finally {
|
|
765
|
-
this.inFlightCredentialRefresh = null;
|
|
766
|
-
}
|
|
656
|
+
return this.credentialLifecycle.refresh();
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* THE auth-recovery backbone for HTTP transports (lazy query lane etc.):
|
|
660
|
+
* classify-driven single-flight re-mint with the same FSM outcome routing
|
|
661
|
+
* the WS probe and proactive pre-roll use. `'retry'` ⇒ a fresh credential
|
|
662
|
+
* is in the credential source, replay the request ONCE. Full contract on
|
|
663
|
+
* {@link CredentialLifecycle.recoverFromAuthRejection}.
|
|
664
|
+
*/
|
|
665
|
+
async recoverFromAuthRejection(recovery) {
|
|
666
|
+
return this.credentialLifecycle.recoverFromAuthRejection(recovery);
|
|
767
667
|
}
|
|
768
668
|
/**
|
|
769
669
|
* Nudge the connection FSM to re-probe with the current credential. Idempotent
|
|
@@ -776,227 +676,70 @@ export class BaseSyncedStore {
|
|
|
776
676
|
this.connectionManager?.send({ type: 'CREDENTIAL_REFRESHED' });
|
|
777
677
|
}
|
|
778
678
|
/**
|
|
779
|
-
* Install the access-credential lifecycle the CLIENT owns
|
|
780
|
-
*
|
|
781
|
-
*
|
|
782
|
-
*
|
|
783
|
-
*
|
|
784
|
-
* timer inside the TTL, plus re-mint on OS wake. The ENTIRE proactive
|
|
785
|
-
* block is browser-gated (`typeof window`): server/SSR has no socket to
|
|
786
|
-
* keep warm and the resolver is browser-oriented, so arming it in Node
|
|
787
|
-
* would fire a relative-URL fetch and throw. (Agents pass a static
|
|
788
|
-
* `apiKey` with no resolver, so this method is never called for them.)
|
|
789
|
-
*
|
|
790
|
-
* Config-driven and invisible, like Supabase's `autoRefreshToken` — consumers
|
|
791
|
-
* never call a refresh method. Idempotent (a second call replaces the first);
|
|
792
|
-
* torn down on {@link disconnect}.
|
|
679
|
+
* Install the access-credential lifecycle the CLIENT owns: register
|
|
680
|
+
* `getToken` as the reactive re-mint hook AND arm the browser-only
|
|
681
|
+
* proactive pre-roll (refresh timer + OS-wake re-mint). Idempotent
|
|
682
|
+
* (a second call replaces the first); torn down on {@link disconnect}.
|
|
683
|
+
* Full rationale on {@link CredentialLifecycle.start}.
|
|
793
684
|
*/
|
|
794
|
-
startCredentialLifecycle(getToken) {
|
|
795
|
-
this.
|
|
796
|
-
this.setCredentialRefresher(getToken);
|
|
797
|
-
// Re-mint through the SAME single-flight path the FSM's reactive probe uses
|
|
798
|
-
// (`performCredentialRefresh`) rather than calling `getToken()` directly. Two
|
|
799
|
-
// wins over the old direct call:
|
|
800
|
-
// - SINGLE-FLIGHT: a wake nudge, an in-flight probe, and this proactive
|
|
801
|
-
// roll share one in-flight promise — no double-mint thrash.
|
|
802
|
-
// - The tri-state is HONOURED. The old code did `if (token) {…}` and
|
|
803
|
-
// dropped a `null` on the floor — a zombie session that re-minted on
|
|
804
|
-
// every tab focus and logged "signing out" forever without ever signing
|
|
805
|
-
// out. `session_error` now drives the FSM to actually expire.
|
|
806
|
-
const refresh = async () => {
|
|
807
|
-
const outcome = await this.performCredentialRefresh();
|
|
808
|
-
if (outcome === 'refreshed') {
|
|
809
|
-
// Fresh key already pushed into the credential source by
|
|
810
|
-
// `performCredentialRefresh`; nudge a parked connection to re-probe.
|
|
811
|
-
this.nudgeReconnect();
|
|
812
|
-
}
|
|
813
|
-
else if (outcome === 'session_error') {
|
|
814
|
-
// The long-lived login is gone (mint answered 401/403). Surface it —
|
|
815
|
-
// the proactive path's job is to report this, not hide it. A no-op in
|
|
816
|
-
// FSM states that don't accept the event (the probe converges on
|
|
817
|
-
// sign-out there anyway); `session_expired`'s onEnter owns the log.
|
|
818
|
-
this.connectionManager?.send({ type: 'BOOTSTRAP_FAILED_SESSION' });
|
|
819
|
-
}
|
|
820
|
-
// 'network_error' → transient (offline / mint hiccup); the next timer tick
|
|
821
|
-
// or the FSM's own probe retries. Never sign out for it.
|
|
822
|
-
};
|
|
823
|
-
const teardowns = [];
|
|
824
|
-
// The ENTIRE proactive pre-roll is BROWSER-ONLY. On the server (Next.js
|
|
825
|
-
// SSR/RSC evaluating the `providers` module) there is no live socket to keep
|
|
826
|
-
// warm AND the scaffolded credential resolver is browser-oriented (a
|
|
827
|
-
// relative-URL `fetch('/api/ablo-session')`). Arming the timer server-side
|
|
828
|
-
// fires that resolver in Node, where fetch has no origin to resolve a
|
|
829
|
-
// relative URL against → "Failed to parse URL" on every tick. Browser-only
|
|
830
|
-
// refresh is the unanimous vendor model (Supabase `autoRefreshToken: isBrowser()`,
|
|
831
|
-
// Clerk/Ably/Stripe refresh client-side). The reactive re-mint hook
|
|
832
|
-
// (`setCredentialRefresher` above) stays UNCONDITIONAL: it only fires on a
|
|
833
|
-
// real connection probe, which can't happen during a bare SSR module eval.
|
|
834
|
-
if (typeof window !== 'undefined') {
|
|
835
|
-
// Comfortably inside the 15m `ek_` TTL; a missed (background-throttled)
|
|
836
|
-
// tick is recovered by the next, or by the reactive probe. The timer is
|
|
837
|
-
// the sole proactive PRE-ROLL — it keeps the key warm ahead of expiry even
|
|
838
|
-
// while the socket sits healthy-`connected` (a state the FSM never probes).
|
|
839
|
-
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;
|
|
840
|
-
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
|
|
841
|
-
teardowns.push(() => clearInterval(timer));
|
|
842
|
-
// OS-wake (desktop only): the Electron shell bridges `powerMonitor`
|
|
843
|
-
// 'resume' to this DOM event. This is the ONE event-trigger the lifecycle
|
|
844
|
-
// still owns, because `visibilitychange` does NOT fire on wake-from-sleep
|
|
845
|
-
// and — unlike `online`/`visibilitychange` — the ConnectionManager's own
|
|
846
|
-
// browser listeners (`setupBrowserListeners`) don't cover wake.
|
|
847
|
-
//
|
|
848
|
-
// The `online` and `visibilitychange` listeners that used to live here
|
|
849
|
-
// were REMOVED: the FSM already re-probes on NETWORK_ONLINE / TAB_VISIBLE
|
|
850
|
-
// through this exact credential path, so registering them here too only
|
|
851
|
-
// fired a second, null-swallowing mint per focus — the "session-key
|
|
852
|
-
// POSTed on every tab focus" spam in the console.
|
|
853
|
-
const onWake = () => void refresh();
|
|
854
|
-
window.addEventListener('ablo:wake', onWake);
|
|
855
|
-
teardowns.push(() => window.removeEventListener('ablo:wake', onWake));
|
|
856
|
-
}
|
|
857
|
-
this.credentialLifecycleTeardown = () => {
|
|
858
|
-
for (const t of teardowns)
|
|
859
|
-
t();
|
|
860
|
-
};
|
|
685
|
+
startCredentialLifecycle(getToken, opts) {
|
|
686
|
+
this.credentialLifecycle.start(getToken, opts);
|
|
861
687
|
}
|
|
862
688
|
/** Tear down the proactive credential lifecycle (idempotent). */
|
|
863
689
|
stopCredentialLifecycle() {
|
|
864
|
-
this.
|
|
865
|
-
this.credentialLifecycleTeardown = null;
|
|
690
|
+
this.credentialLifecycle.stop();
|
|
866
691
|
}
|
|
867
692
|
// ── Sync Group Management ────────────────────────────────────────────────
|
|
693
|
+
//
|
|
694
|
+
// Implementation extracted to sync/groupChange.ts. The methods below stay
|
|
695
|
+
// as thin protected delegates with unchanged signatures — subclass
|
|
696
|
+
// override points remain overridable, and the leaf routes cross-handler
|
|
697
|
+
// calls back through `groupChangeContext()` so dynamic dispatch holds.
|
|
698
|
+
/** Narrow context the group-change leaf talks back through. */
|
|
699
|
+
groupChangeContext() {
|
|
700
|
+
return {
|
|
701
|
+
database: this.database,
|
|
702
|
+
objectPool: this.objectPool,
|
|
703
|
+
getSubscribedSyncGroups: () => this.syncWebSocket?.getSyncGroups() ?? [],
|
|
704
|
+
getCurrentSyncGroups: () => this.userContext ? this.resolveSyncGroups(this.userContext) : null,
|
|
705
|
+
getBootstrapMode: () => this.userContext?.bootstrapMode,
|
|
706
|
+
disconnectWebSocket: () => { this.syncWebSocket?.disconnect(); },
|
|
707
|
+
emitConnectionEvent: (event) => { this.onConnectionEvent?.(event); },
|
|
708
|
+
handleGroupAdded: (payload, syncId) => this.handleGroupAdded(payload, syncId),
|
|
709
|
+
computeUpdatedSyncGroups: (payload) => this.computeUpdatedSyncGroups(payload),
|
|
710
|
+
forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
|
|
711
|
+
};
|
|
712
|
+
}
|
|
868
713
|
/**
|
|
869
|
-
* Handle an actionType 'G' delta
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
* shape:
|
|
873
|
-
*
|
|
874
|
-
* Incremental (EmitGroupAdded): { group, userId }
|
|
875
|
-
* - The recipient was added to a single sync group.
|
|
876
|
-
* - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
|
|
877
|
-
* - No re-bootstrap — entities arrive via the normal insert path.
|
|
878
|
-
*
|
|
879
|
-
* Legacy (EmitGroupChange): { addedGroups, removedGroups }
|
|
880
|
-
* - Single delta carrying the full group membership diff.
|
|
881
|
-
* - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
|
|
882
|
-
* - Deprecated on the server; kept here for wire-level backward compat.
|
|
714
|
+
* Handle an actionType 'G' delta — incremental `{ group, userId }` or
|
|
715
|
+
* legacy `{ addedGroups, removedGroups }` payloads. Full pathway doc on
|
|
716
|
+
* {@link groupChange.handleSyncGroupChange}.
|
|
883
717
|
*/
|
|
884
718
|
async handleSyncGroupChange(delta) {
|
|
885
|
-
|
|
886
|
-
const rawObj = (raw ?? {});
|
|
887
|
-
// Detect incremental payload shape: { group, userId }
|
|
888
|
-
if (typeof rawObj.group === 'string' && typeof rawObj.userId === 'string') {
|
|
889
|
-
const incremental = {
|
|
890
|
-
group: rawObj.group,
|
|
891
|
-
userId: rawObj.userId,
|
|
892
|
-
};
|
|
893
|
-
await this.handleGroupAdded(incremental, delta.id);
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
// Legacy payload: { addedGroups, removedGroups }
|
|
897
|
-
const payload = {
|
|
898
|
-
removedGroups: rawObj.removedGroups ?? [],
|
|
899
|
-
addedGroups: rawObj.addedGroups ?? [],
|
|
900
|
-
};
|
|
901
|
-
getContext().logger.info('[BaseSyncedStore] Sync group change received (legacy)', {
|
|
902
|
-
removedGroups: payload.removedGroups,
|
|
903
|
-
addedGroups: payload.addedGroups,
|
|
904
|
-
syncId: delta.id,
|
|
905
|
-
});
|
|
906
|
-
// SECURITY: If groups were removed, clear cached data immediately.
|
|
907
|
-
// This prevents revoked data from persisting if the device goes offline
|
|
908
|
-
// before the full re-bootstrap completes.
|
|
909
|
-
if (payload.removedGroups.length > 0) {
|
|
910
|
-
await this.database.clear();
|
|
911
|
-
this.objectPool.clear();
|
|
912
|
-
getContext().logger.info('[BaseSyncedStore] Cleared cached data due to revoked sync groups', {
|
|
913
|
-
removedGroups: payload.removedGroups,
|
|
914
|
-
});
|
|
915
|
-
}
|
|
916
|
-
const updatedGroups = this.computeUpdatedSyncGroups(payload);
|
|
917
|
-
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: updatedGroups });
|
|
918
|
-
this.forceFullRebootstrap();
|
|
719
|
+
return groupChange.handleSyncGroupChange(this.groupChangeContext(), delta);
|
|
919
720
|
}
|
|
920
721
|
/**
|
|
921
|
-
* Handle an incremental GroupAdded delta
|
|
922
|
-
*
|
|
923
|
-
* Adds the new group to the subscription metadata without triggering a
|
|
924
|
-
* re-bootstrap. The server will follow up with 'C' (Covering) deltas for
|
|
925
|
-
* each newly-visible entity, which flow through the normal insert path.
|
|
722
|
+
* Handle an incremental GroupAdded delta — metadata only, no re-bootstrap
|
|
723
|
+
* (covering deltas bring the entities). See {@link groupChange.handleGroupAdded}.
|
|
926
724
|
*/
|
|
927
725
|
async handleGroupAdded(payload, syncId) {
|
|
928
|
-
|
|
929
|
-
group: payload.group,
|
|
930
|
-
syncId,
|
|
931
|
-
});
|
|
932
|
-
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
933
|
-
current.add(payload.group);
|
|
934
|
-
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
935
|
-
// Note: no forceFullRebootstrap() — covering deltas will bring the entities.
|
|
726
|
+
return groupChange.handleGroupAdded(this.groupChangeContext(), payload, syncId);
|
|
936
727
|
}
|
|
937
728
|
/**
|
|
938
|
-
* Handle an actionType 'S' (GroupRemoved) delta
|
|
939
|
-
*
|
|
940
|
-
* Signals that the recipient has lost access to a sync group. Because
|
|
941
|
-
* the client does not track per-entity group membership, we can't
|
|
942
|
-
* selectively purge entities belonging to that group. The safe fallback
|
|
943
|
-
* is the legacy behavior: clear local state and force a re-bootstrap
|
|
944
|
-
* with the updated group list.
|
|
945
|
-
*
|
|
946
|
-
* Future optimization: track group membership in the ObjectPool so 'S'
|
|
947
|
-
* can do a targeted purge instead of a full re-bootstrap.
|
|
729
|
+
* Handle an actionType 'S' (GroupRemoved) delta — SECURITY clear of local
|
|
730
|
+
* state + full re-bootstrap. See {@link groupChange.handleGroupRemoved}.
|
|
948
731
|
*/
|
|
949
732
|
async handleGroupRemoved(delta) {
|
|
950
|
-
|
|
951
|
-
const rawObj = (raw ?? {});
|
|
952
|
-
const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
|
|
953
|
-
if (!groupKey) {
|
|
954
|
-
getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
|
|
955
|
-
syncId: delta.id,
|
|
956
|
-
});
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
getContext().logger.info('[BaseSyncedStore] Group removed', {
|
|
960
|
-
group: groupKey,
|
|
961
|
-
syncId: delta.id,
|
|
962
|
-
});
|
|
963
|
-
// SECURITY: Clear cached data before re-bootstrap. This prevents
|
|
964
|
-
// revoked-group data from persisting if the device goes offline
|
|
965
|
-
// between receiving 'S' and completing the re-bootstrap.
|
|
966
|
-
await this.database.clear();
|
|
967
|
-
this.objectPool.clear();
|
|
968
|
-
// Update subscription metadata so the re-bootstrap fetches the
|
|
969
|
-
// correct set of groups.
|
|
970
|
-
const current = new Set(this.syncWebSocket?.getSyncGroups() ?? []);
|
|
971
|
-
current.delete(groupKey);
|
|
972
|
-
await this.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
973
|
-
this.forceFullRebootstrap();
|
|
733
|
+
return groupChange.handleGroupRemoved(this.groupChangeContext(), delta);
|
|
974
734
|
}
|
|
975
735
|
/** Compute new sync groups after applying additions and removals */
|
|
976
736
|
computeUpdatedSyncGroups(payload) {
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
current.add(g);
|
|
982
|
-
return Array.from(current);
|
|
983
|
-
}
|
|
984
|
-
/** Force a full re-bootstrap via connection lifecycle event.
|
|
985
|
-
*
|
|
986
|
-
* No-op for `bootstrapMode: 'none'` participants — they never pull
|
|
987
|
-
* baseline state, so a "force re-bootstrap" trigger (sync-group
|
|
988
|
-
* shrink, scope revocation) instead just flushes the local pool and
|
|
989
|
-
* relies on covering deltas to repopulate the data they actually
|
|
990
|
-
* subscribe to.
|
|
991
|
-
*/
|
|
737
|
+
return groupChange.computeUpdatedSyncGroups(this.groupChangeContext(), payload);
|
|
738
|
+
}
|
|
739
|
+
/** Force a full re-bootstrap via connection lifecycle event (no-op for
|
|
740
|
+
* `bootstrapMode: 'none'` participants — see {@link groupChange.forceFullRebootstrap}). */
|
|
992
741
|
forceFullRebootstrap() {
|
|
993
|
-
|
|
994
|
-
getContext().logger.info('[BaseSyncedStore] forceFullRebootstrap skipped (bootstrapMode=none)');
|
|
995
|
-
return;
|
|
996
|
-
}
|
|
997
|
-
this.database.markRequiresFullBootstrap();
|
|
998
|
-
this.syncWebSocket?.disconnect();
|
|
999
|
-
this.onConnectionEvent?.('WS_DISCONNECTED');
|
|
742
|
+
groupChange.forceFullRebootstrap(this.groupChangeContext());
|
|
1000
743
|
}
|
|
1001
744
|
/**
|
|
1002
745
|
* Single source of truth for the sync-group list this session is
|
|
@@ -1007,71 +750,37 @@ export class BaseSyncedStore {
|
|
|
1007
750
|
* check can never disagree.
|
|
1008
751
|
*/
|
|
1009
752
|
resolveSyncGroups(context) {
|
|
1010
|
-
|
|
1011
|
-
return context.syncGroups;
|
|
1012
|
-
}
|
|
1013
|
-
return [];
|
|
753
|
+
return groupChange.resolveSyncGroups(context);
|
|
1014
754
|
}
|
|
1015
755
|
/** Check if sync groups shrank since last session — force full bootstrap if so */
|
|
1016
756
|
async checkSyncGroupShrinkage() {
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
subscribedSyncGroups: Array.from(currentGroups),
|
|
1040
|
-
});
|
|
1041
|
-
}
|
|
1042
|
-
catch (error) {
|
|
1043
|
-
getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
|
|
1044
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
757
|
+
return groupChange.checkSyncGroupShrinkage(this.groupChangeContext());
|
|
758
|
+
}
|
|
759
|
+
// ── Bootstrap apply ──────────────────────────────────────────────────────
|
|
760
|
+
//
|
|
761
|
+
// Implementation extracted to sync/bootstrapApply.ts. Thin protected
|
|
762
|
+
// delegates below keep the signatures (and subclass overridability)
|
|
763
|
+
// unchanged; the leaf talks back through `poolContext()` — enrichment
|
|
764
|
+
// stays pre-bound to `this.enrichRelations` so that override point holds.
|
|
765
|
+
/** Narrow context the bootstrap-apply leaf talks back through. */
|
|
766
|
+
poolContext() {
|
|
767
|
+
const store = this;
|
|
768
|
+
return {
|
|
769
|
+
applyDeltaBatchToPool: (results) => {
|
|
770
|
+
this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
|
|
771
|
+
},
|
|
772
|
+
applyBootstrapDataToPool: (bootstrapData, protectedIds) => this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds),
|
|
773
|
+
getPoolSize: () => this.objectPool.size,
|
|
774
|
+
getAllPoolIds: () => this.objectPool.getAllIds(),
|
|
775
|
+
get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
|
|
776
|
+
set bootstrapDeltaQueue(queue) { store.bootstrapDeltaQueue = queue; },
|
|
777
|
+
applyDeltaFrame: (deltas) => { this.applyDeltaFrame(deltas); },
|
|
778
|
+
};
|
|
1047
779
|
}
|
|
1048
780
|
/** Apply bootstrap data to the ObjectPool with ghost removal */
|
|
1049
781
|
/** Apply bootstrap data to the ObjectPool. Delegates pool writes to SyncClient. */
|
|
1050
782
|
applyBootstrapToPool(bootstrapResult, protectedIds) {
|
|
1051
|
-
|
|
1052
|
-
// Partial bootstrap: Database.processDeltaBatch already wrote the deltas
|
|
1053
|
-
// to IDB. Route the same results through the delta-apply path so the
|
|
1054
|
-
// in-memory pool evicts deleted entities (and updates modified ones).
|
|
1055
|
-
// Without this, reconnect DELETEs persist to IDB but the canvas keeps
|
|
1056
|
-
// showing ghost layers until a full reload.
|
|
1057
|
-
if (bootstrapData.type === 'partial') {
|
|
1058
|
-
const deltaResults = bootstrapResult.deltaResults;
|
|
1059
|
-
if (deltaResults && deltaResults.length > 0) {
|
|
1060
|
-
this.syncClient.applyDeltaBatchToPool(deltaResults, (name, data) => this.enrichRelations(name, data));
|
|
1061
|
-
}
|
|
1062
|
-
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
|
|
1063
|
-
}
|
|
1064
|
-
if (!bootstrapData.models) {
|
|
1065
|
-
return { added: 0, updated: 0, removed: 0, skipped: 0, healed: 0, elapsedMs: 0 };
|
|
1066
|
-
}
|
|
1067
|
-
const start = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
1068
|
-
// SyncClient owns: model creation, healing, pool upsert, ghost removal
|
|
1069
|
-
const stats = this.syncClient.applyBootstrapDataToPool(bootstrapData, protectedIds);
|
|
1070
|
-
const elapsedMs = Math.round((typeof performance !== 'undefined' ? performance.now() : Date.now()) - start);
|
|
1071
|
-
getContext().logger.info('[BaseSyncedStore] Bootstrap applied', {
|
|
1072
|
-
...stats, elapsedMs, poolSize: this.objectPool.size,
|
|
1073
|
-
});
|
|
1074
|
-
return { ...stats, elapsedMs };
|
|
783
|
+
return bootstrapApply.applyBootstrapToPool(this.poolContext(), bootstrapResult, protectedIds);
|
|
1075
784
|
}
|
|
1076
785
|
// ── Initialize + Lifecycle ───────────────────────────────────────────────
|
|
1077
786
|
/**
|
|
@@ -1108,12 +817,6 @@ export class BaseSyncedStore {
|
|
|
1108
817
|
// Get sync baseline for WebSocket
|
|
1109
818
|
const lastSyncId = (yield this.database.getLastSyncId());
|
|
1110
819
|
this.syncClient.position.advancePersisted(lastSyncId || 0);
|
|
1111
|
-
try {
|
|
1112
|
-
const versions = (yield this.database.getVersionVector());
|
|
1113
|
-
if (versions && typeof versions === 'object')
|
|
1114
|
-
Object.assign(this.versionVector, versions);
|
|
1115
|
-
}
|
|
1116
|
-
catch { }
|
|
1117
820
|
// If local data available, show UI immediately
|
|
1118
821
|
if (hasLocalData) {
|
|
1119
822
|
this.dataReady = true;
|
|
@@ -1154,8 +857,15 @@ export class BaseSyncedStore {
|
|
|
1154
857
|
}
|
|
1155
858
|
else if (requirements.type !== 'local') {
|
|
1156
859
|
if (hasLocalData) {
|
|
1157
|
-
// Background bootstrap — don't block UI
|
|
1158
|
-
this
|
|
860
|
+
// Background bootstrap — don't block UI. The method captures its own
|
|
861
|
+
// operational failures; this backstop covers escapes from the
|
|
862
|
+
// delta-queue replay in withDeltaQueuing's finally (and the error
|
|
863
|
+
// handler itself), which would otherwise vanish unhandled.
|
|
864
|
+
void this.performBackgroundBootstrap(requirements, context, signal).catch((error) => {
|
|
865
|
+
getContext().observability.captureBootstrapFailure(error, {
|
|
866
|
+
type: 'background-orchestration',
|
|
867
|
+
});
|
|
868
|
+
});
|
|
1159
869
|
}
|
|
1160
870
|
else {
|
|
1161
871
|
// First load — must wait for server data
|
|
@@ -1253,27 +963,11 @@ export class BaseSyncedStore {
|
|
|
1253
963
|
}
|
|
1254
964
|
/** Collect IDs that must survive ghost removal (added by deltas during bootstrap) */
|
|
1255
965
|
collectDeltaProtectedIds(preBootstrapIds) {
|
|
1256
|
-
|
|
1257
|
-
for (const id of this.objectPool.getAllIds()) {
|
|
1258
|
-
if (!preBootstrapIds.has(id))
|
|
1259
|
-
protectedIds.add(id);
|
|
1260
|
-
}
|
|
1261
|
-
for (const delta of this.bootstrapDeltaQueue ?? []) {
|
|
1262
|
-
if (delta.actionType !== 'D' && delta.modelId)
|
|
1263
|
-
protectedIds.add(delta.modelId);
|
|
1264
|
-
}
|
|
1265
|
-
return protectedIds;
|
|
966
|
+
return bootstrapApply.collectDeltaProtectedIds(this.poolContext(), preBootstrapIds);
|
|
1266
967
|
}
|
|
1267
|
-
/** Replay deltas queued during bootstrap */
|
|
968
|
+
/** Replay deltas queued during bootstrap (atomically, via `applyDeltaFrame`). */
|
|
1268
969
|
replayQueuedDeltas() {
|
|
1269
|
-
|
|
1270
|
-
this.bootstrapDeltaQueue = null;
|
|
1271
|
-
if (!queue || queue.length === 0)
|
|
1272
|
-
return;
|
|
1273
|
-
// Deltas that landed during bootstrap are a complete frame — apply
|
|
1274
|
-
// them atomically (one flush, one re-render) rather than dribbling
|
|
1275
|
-
// each back through the live debounce path.
|
|
1276
|
-
this.applyDeltaFrame(queue);
|
|
970
|
+
bootstrapApply.replayQueuedDeltas(this.poolContext());
|
|
1277
971
|
}
|
|
1278
972
|
/**
|
|
1279
973
|
* Factory for the internal `ConnectionManager`. Override to return
|
|
@@ -1336,6 +1030,10 @@ export class BaseSyncedStore {
|
|
|
1336
1030
|
}
|
|
1337
1031
|
this.syncClient.disconnect();
|
|
1338
1032
|
this.queryProcessor.clearCache();
|
|
1033
|
+
// Stop the pool's GC interval — the one timer the pool arms itself.
|
|
1034
|
+
// Without this a discarded store retains its whole pool via the interval
|
|
1035
|
+
// closure (and a Node process without `unref` support can't exit).
|
|
1036
|
+
this.objectPool.stopGC();
|
|
1339
1037
|
this.updateSyncStatus({ state: 'offline' });
|
|
1340
1038
|
}
|
|
1341
1039
|
/**
|
|
@@ -1434,7 +1132,6 @@ export class BaseSyncedStore {
|
|
|
1434
1132
|
organizationId: context.organizationId,
|
|
1435
1133
|
syncGroups: [...this.resolveSyncGroups(context)],
|
|
1436
1134
|
lastSyncId,
|
|
1437
|
-
versions: this.versionVector,
|
|
1438
1135
|
kind: context.kind,
|
|
1439
1136
|
capabilityToken: context.capabilityToken,
|
|
1440
1137
|
getAuthToken: this.auth?.getAuthToken,
|
|
@@ -1506,7 +1203,9 @@ export class BaseSyncedStore {
|
|
|
1506
1203
|
this.updateSyncStatus({ state: 'error', error });
|
|
1507
1204
|
}
|
|
1508
1205
|
});
|
|
1509
|
-
|
|
1206
|
+
// Terminal session loss (revocation / the login itself is gone): notify,
|
|
1207
|
+
// route the FSM to its terminal state, and clear local data.
|
|
1208
|
+
const handleTerminalSessionError = (error) => {
|
|
1510
1209
|
getContext().observability.captureWebSocketError({ context: 'session-error', error: error.message });
|
|
1511
1210
|
this.onConnectionEvent?.('WS_SESSION_ERROR');
|
|
1512
1211
|
for (const listener of this.sessionErrorListeners) {
|
|
@@ -1524,6 +1223,54 @@ export class BaseSyncedStore {
|
|
|
1524
1223
|
getContext().logger.debug('[BaseSyncedStore] Failed to clear database on session error', clearErr);
|
|
1525
1224
|
});
|
|
1526
1225
|
this.objectPool.clear();
|
|
1226
|
+
};
|
|
1227
|
+
const onSessionError = this.syncWebSocket.subscribe('session_error', (error) => {
|
|
1228
|
+
// WS analog of HTTP's `apikey_expired` (see SyncSessionError.
|
|
1229
|
+
// isSessionErrorResponse): the hub's keepalive reaper closes sockets
|
|
1230
|
+
// whose SHORT-LIVED access credential (`ek_`/`rk_`) passed its expiry
|
|
1231
|
+
// with `4001 credential_expired`. That is re-mintable from the
|
|
1232
|
+
// still-valid login — recover silently (un-latch, single-flight
|
|
1233
|
+
// re-mint, reconnect) instead of signing out and clearing local data.
|
|
1234
|
+
// Only a mint that answers `null` (the login itself is gone) falls
|
|
1235
|
+
// through to the terminal path. Without this branch, every credential
|
|
1236
|
+
// TTL elapse wedged the socket behind the write-once session latch.
|
|
1237
|
+
if (SyncSessionError.isSessionError(error) && isAccessCredentialExpiryCloseReason(error.message)) {
|
|
1238
|
+
getContext().observability.breadcrumb('WebSocket closed for expired access credential — re-minting', 'sync.websocket', 'warning');
|
|
1239
|
+
// Un-latch BEFORE the async mint so the FSM's own recovery
|
|
1240
|
+
// (probe → refreshing_credential → reconnect) is never blocked on
|
|
1241
|
+
// our .then() ordering.
|
|
1242
|
+
this.syncWebSocket?.clearSessionError();
|
|
1243
|
+
void this.performCredentialRefresh().then((outcome) => {
|
|
1244
|
+
if (outcome === 'refreshed') {
|
|
1245
|
+
if (this.connectionManager) {
|
|
1246
|
+
// Kick a parked FSM; a concurrent probe joins the same
|
|
1247
|
+
// single-flight mint, so this never double-mints.
|
|
1248
|
+
this.nudgeReconnect();
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
// Agent/system clients have no connection FSM
|
|
1252
|
+
// (createConnectionManager returns null for kind 'agent') —
|
|
1253
|
+
// reconnect the socket directly; connect() reads the
|
|
1254
|
+
// freshly-minted credential from the credential source.
|
|
1255
|
+
this.syncWebSocket?.resetReconnectAttempts();
|
|
1256
|
+
this.syncWebSocket?.connect();
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (outcome === 'session_error') {
|
|
1261
|
+
// The mint endpoint rejected: the long-lived login is gone.
|
|
1262
|
+
// Re-latch so writes reject with the permanent session type
|
|
1263
|
+
// (see SyncWebSocket.notConnectedError) instead of parking.
|
|
1264
|
+
this.syncWebSocket?.setSessionErrorDetected();
|
|
1265
|
+
handleTerminalSessionError(error);
|
|
1266
|
+
}
|
|
1267
|
+
// 'network_error' → transient mint failure. The WS_DISCONNECTED
|
|
1268
|
+
// that follows this event already put the FSM on its probe/backoff
|
|
1269
|
+
// loop, which retries through the same single-flight refresh.
|
|
1270
|
+
});
|
|
1271
|
+
return;
|
|
1272
|
+
}
|
|
1273
|
+
handleTerminalSessionError(error);
|
|
1527
1274
|
});
|
|
1528
1275
|
// Handshake failed: WS close before open. The HTTP status is hidden
|
|
1529
1276
|
// behind close code 1006, so we can't tell whether the server rejected
|
|
@@ -1648,68 +1395,65 @@ export class BaseSyncedStore {
|
|
|
1648
1395
|
this.syncWebSocket.connect();
|
|
1649
1396
|
}
|
|
1650
1397
|
// ── Delta Processing Pipeline ─────────────────────────────────────────────
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1398
|
+
//
|
|
1399
|
+
// Implementation extracted to sync/deltaPipeline.ts (dedup, enqueue
|
|
1400
|
+
// bookkeeping, debounce, flush). The methods below stay as thin protected
|
|
1401
|
+
// delegates with unchanged signatures, and the leaf routes every call to
|
|
1402
|
+
// a protected override point back through `deltaPipelineContext` so
|
|
1403
|
+
// subclass dynamic dispatch is preserved. `applyDeltaFrame` — the
|
|
1404
|
+
// authoritative-apply correctness seam — deliberately stays here.
|
|
1405
|
+
/** Memoized pipeline context — `enqueueDelta` runs once per delta, so the
|
|
1406
|
+
* accessor object is built once and reused (the get/set accessors always
|
|
1407
|
+
* read the live host fields). */
|
|
1408
|
+
_deltaPipelineContext = null;
|
|
1409
|
+
get deltaPipelineContext() {
|
|
1410
|
+
if (this._deltaPipelineContext)
|
|
1411
|
+
return this._deltaPipelineContext;
|
|
1412
|
+
const store = this;
|
|
1413
|
+
this._deltaPipelineContext = {
|
|
1414
|
+
// Shared pipeline state, backed by the host fields.
|
|
1415
|
+
get pendingDeltas() { return store.pendingDeltas; },
|
|
1416
|
+
set pendingDeltas(deltas) { store.pendingDeltas = deltas; },
|
|
1417
|
+
get batchTimer() { return store.batchTimer; },
|
|
1418
|
+
set batchTimer(timer) { store.batchTimer = timer; },
|
|
1419
|
+
get bootstrapDeltaQueue() { return store.bootstrapDeltaQueue; },
|
|
1420
|
+
get smartSyncOptions() { return store.smartSyncOptions; },
|
|
1421
|
+
get highestProcessedSyncId() { return store.highestProcessedSyncId; },
|
|
1422
|
+
get lastAckedId() { return store.lastAckedId; },
|
|
1423
|
+
// SyncClient position/transaction bookkeeping.
|
|
1424
|
+
onDeltaReceived: (syncId) => { this.syncClient.onDeltaReceived(syncId); },
|
|
1425
|
+
advanceApplied: (syncId) => { this.syncClient.position.advanceApplied(syncId); },
|
|
1426
|
+
advancePersisted: (syncId) => { this.syncClient.position.advancePersisted(syncId); },
|
|
1427
|
+
// Persistence + pool writes.
|
|
1428
|
+
processDeltaBatch: (deltas) => this.database.processDeltaBatch(deltas),
|
|
1429
|
+
applyDeltaBatchToPool: (results) => {
|
|
1430
|
+
this.syncClient.applyDeltaBatchToPool(results, (name, data) => this.enrichRelations(name, data));
|
|
1431
|
+
},
|
|
1432
|
+
acknowledge: (syncId) => { this.syncWebSocket?.acknowledge?.(syncId); },
|
|
1433
|
+
get objectPool() { return store.objectPool; },
|
|
1434
|
+
// Dynamic-dispatch hooks — protected override points on this class.
|
|
1435
|
+
getStateFields: (modelName) => this.getStateFields(modelName),
|
|
1436
|
+
isCustomEntity: (modelName) => this.isCustomEntity(modelName),
|
|
1437
|
+
createCustomEntity: (modelName, modelId, data) => this.createCustomEntity(modelName, modelId, data),
|
|
1438
|
+
deduplicateDeltas: (deltas) => this.deduplicateDeltas(deltas),
|
|
1439
|
+
flushPendingDeltas: () => this.flushPendingDeltas(),
|
|
1440
|
+
handleFlushError: (error) => { this.handleFlushError(error); },
|
|
1441
|
+
handleSyncGroupChange: (delta) => this.handleSyncGroupChange(delta),
|
|
1442
|
+
handleGroupRemoved: (delta) => this.handleGroupRemoved(delta),
|
|
1443
|
+
forceFullRebootstrap: () => { this.forceFullRebootstrap(); },
|
|
1444
|
+
cascadeCancelTransactionsForDeletedParent: (parentModelName, parentId) => {
|
|
1445
|
+
this.cascadeCancelTransactionsForDeletedParent(parentModelName, parentId);
|
|
1446
|
+
},
|
|
1663
1447
|
};
|
|
1664
|
-
|
|
1665
|
-
if (field in data)
|
|
1666
|
-
signature[field] = data[field];
|
|
1667
|
-
}
|
|
1668
|
-
return signature;
|
|
1448
|
+
return this._deltaPipelineContext;
|
|
1669
1449
|
}
|
|
1670
1450
|
/** Get fields that represent meaningful state for deduplication. Override for model-specific fields. */
|
|
1671
1451
|
getStateFields(_modelName) {
|
|
1672
1452
|
return ['status', 'state', 'isActive'];
|
|
1673
1453
|
}
|
|
1674
|
-
isSameState(a, b) {
|
|
1675
|
-
if (!a || !b)
|
|
1676
|
-
return false;
|
|
1677
|
-
const keys = Object.keys(a);
|
|
1678
|
-
if (keys.length !== Object.keys(b).length)
|
|
1679
|
-
return false;
|
|
1680
|
-
return keys.every((k) => a[k] === b[k]);
|
|
1681
|
-
}
|
|
1682
1454
|
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
1683
1455
|
deduplicateDeltas(deltas) {
|
|
1684
|
-
|
|
1685
|
-
for (const d of deltas) {
|
|
1686
|
-
const key = `${d.modelName}:${d.modelId}`;
|
|
1687
|
-
if (!byEntity.has(key))
|
|
1688
|
-
byEntity.set(key, []);
|
|
1689
|
-
byEntity.get(key).push(d);
|
|
1690
|
-
}
|
|
1691
|
-
const result = [];
|
|
1692
|
-
for (const entityDeltas of byEntity.values()) {
|
|
1693
|
-
const sorted = entityDeltas.sort((a, b) => a.id - b.id);
|
|
1694
|
-
// DELETE wins — it's the final state
|
|
1695
|
-
const del = sorted.find((d) => d.actionType === 'D');
|
|
1696
|
-
if (del) {
|
|
1697
|
-
result.push(del);
|
|
1698
|
-
continue;
|
|
1699
|
-
}
|
|
1700
|
-
// Keep deltas that represent different states
|
|
1701
|
-
const unique = [];
|
|
1702
|
-
let prev = null;
|
|
1703
|
-
for (const d of sorted) {
|
|
1704
|
-
const sig = this.extractStateSignature(d);
|
|
1705
|
-
if (!this.isSameState(prev, sig)) {
|
|
1706
|
-
unique.push(d);
|
|
1707
|
-
prev = sig;
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
result.push(...(unique.length > 0 ? unique : [sorted[sorted.length - 1]]));
|
|
1711
|
-
}
|
|
1712
|
-
return result.sort((a, b) => a.id - b.id);
|
|
1456
|
+
return deltaPipeline.deduplicateDeltas(this.deltaPipelineContext, deltas);
|
|
1713
1457
|
}
|
|
1714
1458
|
/** Process incoming delta with smart batching */
|
|
1715
1459
|
processDeltaWithBatching(delta) {
|
|
@@ -1781,68 +1525,11 @@ export class BaseSyncedStore {
|
|
|
1781
1525
|
* whether to debounce (live) or flush atomically (catch-up frame).
|
|
1782
1526
|
*/
|
|
1783
1527
|
enqueueDelta(delta, options = {}) {
|
|
1784
|
-
|
|
1785
|
-
// valid skip threshold ONLY for in-order live traffic; an authoritative
|
|
1786
|
-
// catch-up frame bypasses it (see `applyDeltaFrame`) so an out-of-order
|
|
1787
|
-
// live delta that advanced the watermark can't cause the frame's lower ids
|
|
1788
|
-
// to be silently dropped.
|
|
1789
|
-
if (!options.authoritative && delta.id > 0 && delta.id <= this.highestProcessedSyncId) {
|
|
1790
|
-
return false;
|
|
1791
|
-
}
|
|
1792
|
-
// Confirm awaiting transactions via sync ID threshold (before batching)
|
|
1793
|
-
this.syncClient.onDeltaReceived(delta.id);
|
|
1794
|
-
// Update version vector
|
|
1795
|
-
const entityType = delta.modelName.toLowerCase();
|
|
1796
|
-
if (this.versionVector[entityType] !== undefined) {
|
|
1797
|
-
this.versionVector[entityType] = Math.max(this.versionVector[entityType], delta.id);
|
|
1798
|
-
}
|
|
1799
|
-
// Queue during active bootstrap
|
|
1800
|
-
if (this.bootstrapDeltaQueue !== null) {
|
|
1801
|
-
this.bootstrapDeltaQueue.push(delta);
|
|
1802
|
-
return false;
|
|
1803
|
-
}
|
|
1804
|
-
// Advance watermark
|
|
1805
|
-
this.syncClient.position.advanceApplied(delta.id);
|
|
1806
|
-
// Sync group added — handle immediately. Supports both legacy
|
|
1807
|
-
// (addedGroups/removedGroups) and incremental (group/userId) payloads.
|
|
1808
|
-
if (delta.actionType === 'G') {
|
|
1809
|
-
void this.handleSyncGroupChange(delta);
|
|
1810
|
-
return false;
|
|
1811
|
-
}
|
|
1812
|
-
// Sync group removed — handle immediately. Clears affected local state
|
|
1813
|
-
// and forces re-bootstrap with the updated group list.
|
|
1814
|
-
if (delta.actionType === 'S') {
|
|
1815
|
-
void this.handleGroupRemoved(delta);
|
|
1816
|
-
return false;
|
|
1817
|
-
}
|
|
1818
|
-
// DELETE — fire the cascade cancel immediately (O(1) via FK index;
|
|
1819
|
-
// must run BEFORE any subsequent update on the same model lands so
|
|
1820
|
-
// pending update transactions for soon-deleted children don't race
|
|
1821
|
-
// their parent's delete) but route the IDB+pool write through the
|
|
1822
|
-
// same batched path as UPDATEs. The previous immediate-flush path
|
|
1823
|
-
// produced N IDB writes + N pool mutations + N `models:changed`
|
|
1824
|
-
// events when a peer deleted a chart with N layers; the batched
|
|
1825
|
-
// path produces one of each per microtask flush. Dedup in
|
|
1826
|
-
// `flushPendingDeltas` handles the U-then-D-on-same-model case
|
|
1827
|
-
// correctly via arrival-order replay through `processDeltaBatch`.
|
|
1828
|
-
if (delta.actionType === 'D') {
|
|
1829
|
-
this.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
|
|
1830
|
-
}
|
|
1831
|
-
this.pendingDeltas.push(delta);
|
|
1832
|
-
return true;
|
|
1528
|
+
return deltaPipeline.enqueueDelta(this.deltaPipelineContext, delta, options);
|
|
1833
1529
|
}
|
|
1834
1530
|
/** Debounce a flush for live single-delta traffic. */
|
|
1835
1531
|
scheduleDeltaFlush() {
|
|
1836
|
-
|
|
1837
|
-
clearTimeout(this.batchTimer);
|
|
1838
|
-
if (this.pendingDeltas.length >= this.smartSyncOptions.maxBatchSize) {
|
|
1839
|
-
void this.flushPendingDeltas().catch(this.handleFlushError);
|
|
1840
|
-
}
|
|
1841
|
-
else {
|
|
1842
|
-
this.batchTimer = setTimeout(() => {
|
|
1843
|
-
void this.flushPendingDeltas().catch(this.handleFlushError);
|
|
1844
|
-
}, this.smartSyncOptions.batchingDelay);
|
|
1845
|
-
}
|
|
1532
|
+
deltaPipeline.scheduleDeltaFlush(this.deltaPipelineContext);
|
|
1846
1533
|
}
|
|
1847
1534
|
/**
|
|
1848
1535
|
* Cancel pending transactions for child entities when a parent is deleted.
|
|
@@ -1881,79 +1568,7 @@ export class BaseSyncedStore {
|
|
|
1881
1568
|
/** Flush pending deltas with deduplication and batched ObjectPool mutations */
|
|
1882
1569
|
/** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
|
|
1883
1570
|
async flushPendingDeltas() {
|
|
1884
|
-
|
|
1885
|
-
return;
|
|
1886
|
-
const deduplicatedDeltas = this.deduplicateDeltas(this.pendingDeltas);
|
|
1887
|
-
// Custom entities → apply directly to ObjectPool (skip IDB)
|
|
1888
|
-
const customDeltas = deduplicatedDeltas.filter((d) => this.isCustomEntity(d.modelName));
|
|
1889
|
-
if (customDeltas.length > 0) {
|
|
1890
|
-
runInAction(() => {
|
|
1891
|
-
for (const delta of customDeltas) {
|
|
1892
|
-
const data = typeof delta.data === 'string'
|
|
1893
|
-
? JSON.parse(delta.data)
|
|
1894
|
-
: delta.data;
|
|
1895
|
-
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
1896
|
-
// gained permission to see the entity, so we insert it into the
|
|
1897
|
-
// pool as if newly created.
|
|
1898
|
-
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
1899
|
-
const existing = this.objectPool.get(delta.modelId);
|
|
1900
|
-
if (existing) {
|
|
1901
|
-
existing.updateFromData(data);
|
|
1902
|
-
}
|
|
1903
|
-
else {
|
|
1904
|
-
const model = this.createCustomEntity(delta.modelName, delta.modelId, data);
|
|
1905
|
-
if (model) {
|
|
1906
|
-
model.markAsPersisted();
|
|
1907
|
-
this.objectPool.add(model, ModelScope.live);
|
|
1908
|
-
}
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
else if (delta.actionType === 'D') {
|
|
1912
|
-
this.objectPool.remove(delta.modelId);
|
|
1913
|
-
}
|
|
1914
|
-
}
|
|
1915
|
-
});
|
|
1916
|
-
}
|
|
1917
|
-
// Regular deltas → IDB then ObjectPool via SyncClient.
|
|
1918
|
-
// 'G' and 'S' deltas are routed upstream (handleSyncGroupChange,
|
|
1919
|
-
// handleGroupRemoved) and never reach flushPendingDeltas, but the
|
|
1920
|
-
// Database.processDelta signature accepts them defensively.
|
|
1921
|
-
const regularDeltas = deduplicatedDeltas.filter((d) => !this.isCustomEntity(d.modelName));
|
|
1922
|
-
const batch = await this.database.processDeltaBatch(regularDeltas.map((d) => ({
|
|
1923
|
-
syncId: d.id,
|
|
1924
|
-
actionType: d.actionType,
|
|
1925
|
-
modelName: d.modelName,
|
|
1926
|
-
modelId: d.modelId,
|
|
1927
|
-
data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
|
|
1928
|
-
// Thread `transactionId` through so the receive layer can
|
|
1929
|
-
// recognize echoes of locally-applied transactions and skip
|
|
1930
|
-
// the pool mutation. See `OPTIMISTIC_RECONCILIATION.md`.
|
|
1931
|
-
transactionId: d.transactionId,
|
|
1932
|
-
})));
|
|
1933
|
-
const dbResults = batch.results;
|
|
1934
|
-
// Delegate ObjectPool writes to SyncClient (owns pool operations)
|
|
1935
|
-
this.syncClient.applyDeltaBatchToPool(dbResults, (name, data) => this.enrichRelations(name, data));
|
|
1936
|
-
// Acknowledge + advance sync cursor — gated on IDB persistence.
|
|
1937
|
-
//
|
|
1938
|
-
// We MUST ack `persistedSyncId` (the high-water mark of deltas whose
|
|
1939
|
-
// store transaction actually committed), NOT the input batch's last
|
|
1940
|
-
// delta id. Acking by input range advances the server's view past
|
|
1941
|
-
// deltas that never wrote to IDB; the next catch-up request would
|
|
1942
|
-
// then send the advanced cursor and the server replies "you're up
|
|
1943
|
-
// to date" — losing the un-persisted delta forever. This is the
|
|
1944
|
-
// Replicache "same-transaction" invariant: the cursor and the
|
|
1945
|
-
// persisted view must be consistent.
|
|
1946
|
-
const persistedSyncId = batch.persistedSyncId;
|
|
1947
|
-
if (persistedSyncId > this.lastAckedId) {
|
|
1948
|
-
this.syncWebSocket?.acknowledge?.(persistedSyncId);
|
|
1949
|
-
this.syncClient.position.advancePersisted(persistedSyncId);
|
|
1950
|
-
}
|
|
1951
|
-
// Cache invalidation is automatic via SyncClient 'models:changed' event
|
|
1952
|
-
this.pendingDeltas = [];
|
|
1953
|
-
if (this.batchTimer) {
|
|
1954
|
-
clearTimeout(this.batchTimer);
|
|
1955
|
-
this.batchTimer = null;
|
|
1956
|
-
}
|
|
1571
|
+
return deltaPipeline.flushPendingDeltas(this.deltaPipelineContext);
|
|
1957
1572
|
}
|
|
1958
1573
|
// ── Core Mutations (thin delegation to SyncClient) ────────────────────────
|
|
1959
1574
|
//
|