@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
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* deltaPipeline — the incoming-delta dedup/batch/flush pipeline.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from BaseSyncedStore.ts as a cohesive leaf: state-signature
|
|
5
|
+
* dedup, per-delta bookkeeping + enqueue, the live-traffic debounce, and
|
|
6
|
+
* the IDB→pool flush. The store keeps thin protected delegates with
|
|
7
|
+
* unchanged signatures, and the leaf routes every call to a protected
|
|
8
|
+
* override point (getStateFields, isCustomEntity, deduplicateDeltas,
|
|
9
|
+
* flushPendingDeltas, the G/S handlers, …) back through the minimal
|
|
10
|
+
* {@link DeltaPipelineContext} so subclass dynamic dispatch is preserved.
|
|
11
|
+
* `applyDeltaFrame` — the authoritative-apply correctness seam — stays in
|
|
12
|
+
* BaseSyncedStore and drives this pipeline via `enqueueDelta` +
|
|
13
|
+
* `flushPendingDeltas`.
|
|
14
|
+
*/
|
|
15
|
+
import { runInAction } from 'mobx';
|
|
16
|
+
import { getContext } from '../context.js';
|
|
17
|
+
import { ModelScope } from '../ObjectPool.js';
|
|
18
|
+
/**
|
|
19
|
+
* A 'G'/'S' handler rejected AFTER the applied watermark already advanced —
|
|
20
|
+
* the delta will never be re-delivered, so a failed SECURITY clear (revoked
|
|
21
|
+
* data must not persist) would otherwise be permanently silent. Fall back to
|
|
22
|
+
* the bluntest safe response: drop the whole in-memory pool and force a full
|
|
23
|
+
* re-bootstrap so local state is rebuilt from server truth. Never throws —
|
|
24
|
+
* this runs inside the delta pipeline's fire-and-forget seam.
|
|
25
|
+
*/
|
|
26
|
+
export function handleGroupHandlerFailure(ctx, delta, error) {
|
|
27
|
+
getContext().logger.error('Your access changed but cached data could not be cleared — resetting local data.', {
|
|
28
|
+
syncId: delta.id,
|
|
29
|
+
error: error instanceof Error ? error.message : String(error),
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
ctx.objectPool.clear();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// In-memory clear must never mask the re-bootstrap below.
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
ctx.forceFullRebootstrap();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Best-effort: the reconnect/bootstrap cycle self-heals on next connect.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** State signature for delta deduplication */
|
|
45
|
+
function extractStateSignature(ctx, delta) {
|
|
46
|
+
if (!delta.data || typeof delta.data !== 'object')
|
|
47
|
+
return null;
|
|
48
|
+
const data = typeof delta.data === 'string'
|
|
49
|
+
? JSON.parse(delta.data)
|
|
50
|
+
: (delta.data);
|
|
51
|
+
// Generic state fields — subclasses can override getStateFields() for model-specific fields
|
|
52
|
+
const fieldsToCheck = ctx.getStateFields(delta.modelName);
|
|
53
|
+
const signature = {
|
|
54
|
+
actionType: delta.actionType,
|
|
55
|
+
modelName: delta.modelName,
|
|
56
|
+
};
|
|
57
|
+
for (const field of fieldsToCheck) {
|
|
58
|
+
if (field in data)
|
|
59
|
+
signature[field] = data[field];
|
|
60
|
+
}
|
|
61
|
+
return signature;
|
|
62
|
+
}
|
|
63
|
+
function isSameState(a, b) {
|
|
64
|
+
if (!a || !b)
|
|
65
|
+
return false;
|
|
66
|
+
const keys = Object.keys(a);
|
|
67
|
+
if (keys.length !== Object.keys(b).length)
|
|
68
|
+
return false;
|
|
69
|
+
return keys.every((k) => a[k] === b[k]);
|
|
70
|
+
}
|
|
71
|
+
/** Deduplicate deltas to the same entity — keep meaningful state transitions only */
|
|
72
|
+
export function deduplicateDeltas(ctx, deltas) {
|
|
73
|
+
const byEntity = new Map();
|
|
74
|
+
for (const d of deltas) {
|
|
75
|
+
const key = `${d.modelName}:${d.modelId}`;
|
|
76
|
+
if (!byEntity.has(key))
|
|
77
|
+
byEntity.set(key, []);
|
|
78
|
+
byEntity.get(key).push(d);
|
|
79
|
+
}
|
|
80
|
+
const result = [];
|
|
81
|
+
for (const entityDeltas of byEntity.values()) {
|
|
82
|
+
const sorted = entityDeltas.sort((a, b) => a.id - b.id);
|
|
83
|
+
// DELETE wins — it's the final state
|
|
84
|
+
const del = sorted.find((d) => d.actionType === 'D');
|
|
85
|
+
if (del) {
|
|
86
|
+
result.push(del);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Keep deltas that represent different states
|
|
90
|
+
const unique = [];
|
|
91
|
+
let prev = null;
|
|
92
|
+
for (const d of sorted) {
|
|
93
|
+
const sig = extractStateSignature(ctx, d);
|
|
94
|
+
if (!isSameState(prev, sig)) {
|
|
95
|
+
unique.push(d);
|
|
96
|
+
prev = sig;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (unique.length > 0) {
|
|
100
|
+
result.push(...unique);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// `sorted` is never empty (every byEntity bucket gets at least one
|
|
104
|
+
// delta pushed) — the guard only narrows the indexed access.
|
|
105
|
+
const last = sorted.at(-1);
|
|
106
|
+
if (last)
|
|
107
|
+
result.push(last);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return result.sort((a, b) => a.id - b.id);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Per-delta bookkeeping + enqueue. Returns `true` when the delta was
|
|
114
|
+
* pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
|
|
115
|
+
* subsequent flush must drain), `false` when it was skipped (dedup),
|
|
116
|
+
* deferred (bootstrap queue), or handled immediately out-of-band (G/S
|
|
117
|
+
* sync-group mutations). Does NOT schedule a flush — callers decide
|
|
118
|
+
* whether to debounce (live) or flush atomically (catch-up frame).
|
|
119
|
+
*/
|
|
120
|
+
export function enqueueDelta(ctx, delta, options = {}) {
|
|
121
|
+
// Dedup guard — skip already-processed deltas. The `applied` watermark is a
|
|
122
|
+
// valid skip threshold ONLY for in-order live traffic; an authoritative
|
|
123
|
+
// catch-up frame bypasses it (see `applyDeltaFrame`) so an out-of-order
|
|
124
|
+
// live delta that advanced the watermark can't cause the frame's lower ids
|
|
125
|
+
// to be silently dropped.
|
|
126
|
+
if (!options.authoritative && delta.id > 0 && delta.id <= ctx.highestProcessedSyncId) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
// Confirm awaiting transactions via sync ID threshold (before batching)
|
|
130
|
+
ctx.onDeltaReceived(delta.id);
|
|
131
|
+
// Queue during active bootstrap
|
|
132
|
+
if (ctx.bootstrapDeltaQueue !== null) {
|
|
133
|
+
ctx.bootstrapDeltaQueue.push(delta);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
// Advance watermark
|
|
137
|
+
ctx.advanceApplied(delta.id);
|
|
138
|
+
// Sync group added — handle immediately. Supports both legacy
|
|
139
|
+
// (addedGroups/removedGroups) and incremental (group/userId) payloads.
|
|
140
|
+
// NOT fire-and-forget: the watermark above already advanced, so a rejected
|
|
141
|
+
// handler (a failed security clear) must trigger the fallback, not vanish.
|
|
142
|
+
if (delta.actionType === 'G') {
|
|
143
|
+
void ctx.handleSyncGroupChange(delta).catch((error) => {
|
|
144
|
+
handleGroupHandlerFailure(ctx, delta, error);
|
|
145
|
+
});
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
// Sync group removed — handle immediately. Clears affected local state
|
|
149
|
+
// and forces re-bootstrap with the updated group list. Same fallback:
|
|
150
|
+
// a failed revocation clear escalates instead of leaving revoked rows.
|
|
151
|
+
if (delta.actionType === 'S') {
|
|
152
|
+
void ctx.handleGroupRemoved(delta).catch((error) => {
|
|
153
|
+
handleGroupHandlerFailure(ctx, delta, error);
|
|
154
|
+
});
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
// DELETE — fire the cascade cancel immediately (O(1) via FK index;
|
|
158
|
+
// must run BEFORE any subsequent update on the same model lands so
|
|
159
|
+
// pending update transactions for soon-deleted children don't race
|
|
160
|
+
// their parent's delete) but route the IDB+pool write through the
|
|
161
|
+
// same batched path as UPDATEs. The previous immediate-flush path
|
|
162
|
+
// produced N IDB writes + N pool mutations + N `models:changed`
|
|
163
|
+
// events when a peer deleted a chart with N layers; the batched
|
|
164
|
+
// path produces one of each per microtask flush. Dedup in
|
|
165
|
+
// `flushPendingDeltas` handles the U-then-D-on-same-model case
|
|
166
|
+
// correctly via arrival-order replay through `processDeltaBatch`.
|
|
167
|
+
if (delta.actionType === 'D') {
|
|
168
|
+
ctx.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
|
|
169
|
+
}
|
|
170
|
+
ctx.pendingDeltas.push(delta);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
/** Debounce a flush for live single-delta traffic. */
|
|
174
|
+
export function scheduleDeltaFlush(ctx) {
|
|
175
|
+
if (ctx.batchTimer)
|
|
176
|
+
clearTimeout(ctx.batchTimer);
|
|
177
|
+
if (ctx.pendingDeltas.length >= ctx.smartSyncOptions.maxBatchSize) {
|
|
178
|
+
void ctx.flushPendingDeltas().catch(ctx.handleFlushError);
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
ctx.batchTimer = setTimeout(() => {
|
|
182
|
+
void ctx.flushPendingDeltas().catch(ctx.handleFlushError);
|
|
183
|
+
}, ctx.smartSyncOptions.batchingDelay);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Flush pending deltas with deduplication and batched ObjectPool mutations */
|
|
187
|
+
/** Flush pending deltas with deduplication. Delegates pool writes to SyncClient. */
|
|
188
|
+
export async function flushPendingDeltas(ctx) {
|
|
189
|
+
if (ctx.pendingDeltas.length === 0)
|
|
190
|
+
return;
|
|
191
|
+
const deduplicatedDeltas = ctx.deduplicateDeltas(ctx.pendingDeltas);
|
|
192
|
+
// Custom entities → apply directly to ObjectPool (skip IDB)
|
|
193
|
+
const customDeltas = deduplicatedDeltas.filter((d) => ctx.isCustomEntity(d.modelName));
|
|
194
|
+
if (customDeltas.length > 0) {
|
|
195
|
+
runInAction(() => {
|
|
196
|
+
for (const delta of customDeltas) {
|
|
197
|
+
const data = typeof delta.data === 'string'
|
|
198
|
+
? JSON.parse(delta.data)
|
|
199
|
+
: (delta.data);
|
|
200
|
+
// 'C' (Covering) is treated identically to 'I' here — the client
|
|
201
|
+
// gained permission to see the entity, so we insert it into the
|
|
202
|
+
// pool as if newly created.
|
|
203
|
+
if (delta.actionType === 'I' || delta.actionType === 'U' || delta.actionType === 'C') {
|
|
204
|
+
const existing = ctx.objectPool.get(delta.modelId);
|
|
205
|
+
if (existing) {
|
|
206
|
+
existing.updateFromData(data);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
const model = ctx.createCustomEntity(delta.modelName, delta.modelId, data);
|
|
210
|
+
if (model) {
|
|
211
|
+
model.markAsPersisted();
|
|
212
|
+
ctx.objectPool.add(model, ModelScope.live);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
else if (delta.actionType === 'D') {
|
|
217
|
+
ctx.objectPool.remove(delta.modelId);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
// Regular deltas → IDB then ObjectPool via SyncClient.
|
|
223
|
+
// 'G' and 'S' deltas are routed upstream (handleSyncGroupChange,
|
|
224
|
+
// handleGroupRemoved) and never reach flushPendingDeltas, but the
|
|
225
|
+
// Database.processDelta signature accepts them defensively.
|
|
226
|
+
const regularDeltas = deduplicatedDeltas.filter((d) => !ctx.isCustomEntity(d.modelName));
|
|
227
|
+
const batch = await ctx.processDeltaBatch(regularDeltas.map((d) => ({
|
|
228
|
+
syncId: d.id,
|
|
229
|
+
actionType: d.actionType,
|
|
230
|
+
modelName: d.modelName,
|
|
231
|
+
modelId: d.modelId,
|
|
232
|
+
data: typeof d.data === 'string' ? JSON.parse(d.data) : d.data,
|
|
233
|
+
// Thread `transactionId` through so the receive layer can
|
|
234
|
+
// recognize echoes of locally-applied transactions and skip
|
|
235
|
+
// the pool mutation. See `OPTIMISTIC_RECONCILIATION.md`.
|
|
236
|
+
transactionId: d.transactionId,
|
|
237
|
+
})));
|
|
238
|
+
const dbResults = batch.results;
|
|
239
|
+
// Delegate ObjectPool writes to SyncClient (owns pool operations)
|
|
240
|
+
ctx.applyDeltaBatchToPool(dbResults);
|
|
241
|
+
// Acknowledge + advance sync cursor — gated on IDB persistence.
|
|
242
|
+
//
|
|
243
|
+
// We MUST ack `persistedSyncId` (the high-water mark of deltas whose
|
|
244
|
+
// store transaction actually committed), NOT the input batch's last
|
|
245
|
+
// delta id. Acking by input range advances the server's view past
|
|
246
|
+
// deltas that never wrote to IDB; the next catch-up request would
|
|
247
|
+
// then send the advanced cursor and the server replies "you're up
|
|
248
|
+
// to date" — losing the un-persisted delta forever. This is the
|
|
249
|
+
// Replicache "same-transaction" invariant: the cursor and the
|
|
250
|
+
// persisted view must be consistent.
|
|
251
|
+
const persistedSyncId = batch.persistedSyncId;
|
|
252
|
+
if (persistedSyncId > ctx.lastAckedId) {
|
|
253
|
+
ctx.acknowledge(persistedSyncId);
|
|
254
|
+
ctx.advancePersisted(persistedSyncId);
|
|
255
|
+
}
|
|
256
|
+
// Cache invalidation is automatic via SyncClient 'models:changed' event
|
|
257
|
+
ctx.pendingDeltas = [];
|
|
258
|
+
if (ctx.batchTimer) {
|
|
259
|
+
clearTimeout(ctx.batchTimer);
|
|
260
|
+
ctx.batchTimer = null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groupChange — sync-group change / shrinkage handling.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from BaseSyncedStore.ts as a cohesive leaf: the 'G'/'S' delta
|
|
5
|
+
* handlers (incremental group-added, legacy group-diff, group-removed), the
|
|
6
|
+
* group-set math, the force-re-bootstrap trigger, and the security-critical
|
|
7
|
+
* shrinkage check. The store keeps thin protected delegates with unchanged
|
|
8
|
+
* signatures — subclass override points stay overridable, and the leaf
|
|
9
|
+
* routes every cross-handler call back through the minimal
|
|
10
|
+
* {@link GroupChangeContext} so dynamic dispatch is preserved.
|
|
11
|
+
*/
|
|
12
|
+
import type { SyncDelta, SyncGroupChangePayload, GroupAddedPayload } from './SyncWebSocket.js';
|
|
13
|
+
/**
|
|
14
|
+
* What the group-change handlers need back from their host store —
|
|
15
|
+
* narrow collaborator facades plus the host's own protected hooks (so a
|
|
16
|
+
* subclass override of e.g. `forceFullRebootstrap` still takes effect).
|
|
17
|
+
*/
|
|
18
|
+
export interface GroupChangeContext {
|
|
19
|
+
/** Local persistence — SECURITY clears, subscription metadata, and the
|
|
20
|
+
* full-bootstrap flag. Structural subset of `Database`. */
|
|
21
|
+
readonly database: {
|
|
22
|
+
clear(): Promise<void>;
|
|
23
|
+
getWorkspaceMetadata(): Promise<{
|
|
24
|
+
subscribedSyncGroups?: string[];
|
|
25
|
+
} | null>;
|
|
26
|
+
updateWorkspaceMetadata(metadata: {
|
|
27
|
+
subscribedSyncGroups: string[];
|
|
28
|
+
}): Promise<void>;
|
|
29
|
+
markRequiresFullBootstrap(): void;
|
|
30
|
+
};
|
|
31
|
+
/** The in-memory pool — cleared alongside IDB on revocation. */
|
|
32
|
+
readonly objectPool: {
|
|
33
|
+
clear(): void;
|
|
34
|
+
};
|
|
35
|
+
/** Groups the CURRENT socket is subscribed to (`syncWebSocket?.getSyncGroups() ?? []`). */
|
|
36
|
+
getSubscribedSyncGroups(): readonly string[];
|
|
37
|
+
/** The session's authoritative groups via the host's `resolveSyncGroups`;
|
|
38
|
+
* null when no user context has been set yet. */
|
|
39
|
+
getCurrentSyncGroups(): readonly string[] | null;
|
|
40
|
+
/** `userContext?.bootstrapMode` — 'none' participants never re-bootstrap. */
|
|
41
|
+
getBootstrapMode(): 'full' | 'none' | undefined;
|
|
42
|
+
/** Disconnect the live socket (part of the force-re-bootstrap cycle). */
|
|
43
|
+
disconnectWebSocket(): void;
|
|
44
|
+
/** Forward to the host's `onConnectionEvent` lifecycle hook (no-op when unwired). */
|
|
45
|
+
emitConnectionEvent(event: string): void;
|
|
46
|
+
handleGroupAdded(payload: GroupAddedPayload, syncId: number): Promise<void>;
|
|
47
|
+
computeUpdatedSyncGroups(payload: SyncGroupChangePayload): string[];
|
|
48
|
+
forceFullRebootstrap(): void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Handle an actionType 'G' delta.
|
|
52
|
+
*
|
|
53
|
+
* The server emits 'G' via two distinct pathways, distinguished by payload
|
|
54
|
+
* shape:
|
|
55
|
+
*
|
|
56
|
+
* Incremental (EmitGroupAdded): { group, userId }
|
|
57
|
+
* - The recipient was added to a single sync group.
|
|
58
|
+
* - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
|
|
59
|
+
* - No re-bootstrap — entities arrive via the normal insert path.
|
|
60
|
+
*
|
|
61
|
+
* Legacy (EmitGroupChange): { addedGroups, removedGroups }
|
|
62
|
+
* - Single delta carrying the full group membership diff.
|
|
63
|
+
* - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
|
|
64
|
+
* - Deprecated on the server; kept here for wire-level backward compat.
|
|
65
|
+
*/
|
|
66
|
+
export declare function handleSyncGroupChange(ctx: GroupChangeContext, delta: SyncDelta): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Handle an incremental GroupAdded delta.
|
|
69
|
+
*
|
|
70
|
+
* Adds the new group to the subscription metadata without triggering a
|
|
71
|
+
* re-bootstrap. The server will follow up with 'C' (Covering) deltas for
|
|
72
|
+
* each newly-visible entity, which flow through the normal insert path.
|
|
73
|
+
*/
|
|
74
|
+
export declare function handleGroupAdded(ctx: GroupChangeContext, payload: GroupAddedPayload, syncId: number): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* Handle an actionType 'S' (GroupRemoved) delta.
|
|
77
|
+
*
|
|
78
|
+
* Signals that the recipient has lost access to a sync group. Because
|
|
79
|
+
* the client does not track per-entity group membership, we can't
|
|
80
|
+
* selectively purge entities belonging to that group. The safe fallback
|
|
81
|
+
* is the legacy behavior: clear local state and force a re-bootstrap
|
|
82
|
+
* with the updated group list.
|
|
83
|
+
*
|
|
84
|
+
* Future optimization: track group membership in the ObjectPool so 'S'
|
|
85
|
+
* can do a targeted purge instead of a full re-bootstrap.
|
|
86
|
+
*/
|
|
87
|
+
export declare function handleGroupRemoved(ctx: GroupChangeContext, delta: SyncDelta): Promise<void>;
|
|
88
|
+
/** Compute new sync groups after applying additions and removals */
|
|
89
|
+
export declare function computeUpdatedSyncGroups(ctx: GroupChangeContext, payload: SyncGroupChangePayload): string[];
|
|
90
|
+
/** Force a full re-bootstrap via connection lifecycle event.
|
|
91
|
+
*
|
|
92
|
+
* No-op for `bootstrapMode: 'none'` participants — they never pull
|
|
93
|
+
* baseline state, so a "force re-bootstrap" trigger (sync-group
|
|
94
|
+
* shrink, scope revocation) instead just flushes the local pool and
|
|
95
|
+
* relies on covering deltas to repopulate the data they actually
|
|
96
|
+
* subscribe to.
|
|
97
|
+
*/
|
|
98
|
+
export declare function forceFullRebootstrap(ctx: GroupChangeContext): void;
|
|
99
|
+
/**
|
|
100
|
+
* Single source of truth for the sync-group list this session is
|
|
101
|
+
* subscribed to. Server-issued (`context.syncGroups`) is authoritative.
|
|
102
|
+
* When absent, the SDK subscribes to no explicit groups. Both
|
|
103
|
+
* `checkSyncGroupShrinkage` and `setupWebSocketSync` resolve through
|
|
104
|
+
* here so the WS subscription and the security-critical shrinkage
|
|
105
|
+
* check can never disagree.
|
|
106
|
+
*/
|
|
107
|
+
export declare function resolveSyncGroups(context: {
|
|
108
|
+
syncGroups?: readonly string[];
|
|
109
|
+
}): readonly string[];
|
|
110
|
+
/** Check if sync groups shrank since last session — force full bootstrap if so */
|
|
111
|
+
export declare function checkSyncGroupShrinkage(ctx: GroupChangeContext): Promise<void>;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* groupChange — sync-group change / shrinkage handling.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from BaseSyncedStore.ts as a cohesive leaf: the 'G'/'S' delta
|
|
5
|
+
* handlers (incremental group-added, legacy group-diff, group-removed), the
|
|
6
|
+
* group-set math, the force-re-bootstrap trigger, and the security-critical
|
|
7
|
+
* shrinkage check. The store keeps thin protected delegates with unchanged
|
|
8
|
+
* signatures — subclass override points stay overridable, and the leaf
|
|
9
|
+
* routes every cross-handler call back through the minimal
|
|
10
|
+
* {@link GroupChangeContext} so dynamic dispatch is preserved.
|
|
11
|
+
*/
|
|
12
|
+
import { getContext } from '../context.js';
|
|
13
|
+
/** Sentinel for a 'G'/'S' payload that could not be parsed (vs a valid
|
|
14
|
+
* `null`/absent payload, which the handlers already tolerate). */
|
|
15
|
+
const MALFORMED_PAYLOAD = Symbol('malformed-group-change-payload');
|
|
16
|
+
/**
|
|
17
|
+
* Parse a group-change delta payload without ever throwing. The server
|
|
18
|
+
* serializes these as JSON strings; a corrupt frame used to escape the
|
|
19
|
+
* whole delta pipeline as an unhandled rejection AFTER the watermark had
|
|
20
|
+
* advanced — the delta is never re-delivered, so the security clear it
|
|
21
|
+
* carried was permanently lost.
|
|
22
|
+
*/
|
|
23
|
+
function parseGroupChangePayload(delta) {
|
|
24
|
+
if (typeof delta.data !== 'string')
|
|
25
|
+
return delta.data;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(delta.data);
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
getContext().logger.debug('[BaseSyncedStore] Malformed group-change payload', {
|
|
31
|
+
syncId: delta.id,
|
|
32
|
+
actionType: delta.actionType,
|
|
33
|
+
error: error instanceof Error ? error.message : String(error),
|
|
34
|
+
});
|
|
35
|
+
return MALFORMED_PAYLOAD;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The legacy-clear fallback for an unparseable group-change delta: we know
|
|
40
|
+
* access changed but not how, so treat it as a revocation — clear cached
|
|
41
|
+
* data (IDB + pool) and force a full re-bootstrap with server truth.
|
|
42
|
+
*/
|
|
43
|
+
async function clearForUnknownGroupChange(ctx, delta, kind) {
|
|
44
|
+
getContext().logger.debug(`[BaseSyncedStore] Unreadable ${kind} payload — clearing cached data and re-bootstrapping`, { syncId: delta.id });
|
|
45
|
+
// SECURITY: same rationale as the legacy removedGroups path — revoked data
|
|
46
|
+
// must not persist if the device goes offline before the re-bootstrap.
|
|
47
|
+
await ctx.database.clear();
|
|
48
|
+
ctx.objectPool.clear();
|
|
49
|
+
ctx.forceFullRebootstrap();
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Handle an actionType 'G' delta.
|
|
53
|
+
*
|
|
54
|
+
* The server emits 'G' via two distinct pathways, distinguished by payload
|
|
55
|
+
* shape:
|
|
56
|
+
*
|
|
57
|
+
* Incremental (EmitGroupAdded): { group, userId }
|
|
58
|
+
* - The recipient was added to a single sync group.
|
|
59
|
+
* - Subsequent 'C' (Covering) deltas deliver each newly-visible entity.
|
|
60
|
+
* - No re-bootstrap — entities arrive via the normal insert path.
|
|
61
|
+
*
|
|
62
|
+
* Legacy (EmitGroupChange): { addedGroups, removedGroups }
|
|
63
|
+
* - Single delta carrying the full group membership diff.
|
|
64
|
+
* - Forces a full re-bootstrap (disconnect + reconnect + fetch all).
|
|
65
|
+
* - Deprecated on the server; kept here for wire-level backward compat.
|
|
66
|
+
*/
|
|
67
|
+
export async function handleSyncGroupChange(ctx, delta) {
|
|
68
|
+
const raw = parseGroupChangePayload(delta);
|
|
69
|
+
if (raw === MALFORMED_PAYLOAD) {
|
|
70
|
+
// Malformed payload — we can't know WHICH groups changed, and this
|
|
71
|
+
// delta will never be re-delivered (the watermark already advanced).
|
|
72
|
+
// Degrade to the legacy security path: assume a removal, clear cached
|
|
73
|
+
// data, and rebuild from server truth. Never throw out of the pipeline.
|
|
74
|
+
await clearForUnknownGroupChange(ctx, delta, 'sync-group change');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const rawObj = (raw ?? {});
|
|
78
|
+
// Detect incremental payload shape: { group, userId }
|
|
79
|
+
if (typeof rawObj.group === 'string' && typeof rawObj.userId === 'string') {
|
|
80
|
+
const incremental = {
|
|
81
|
+
group: rawObj.group,
|
|
82
|
+
userId: rawObj.userId,
|
|
83
|
+
};
|
|
84
|
+
await ctx.handleGroupAdded(incremental, delta.id);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Legacy payload: { addedGroups, removedGroups }
|
|
88
|
+
const payload = {
|
|
89
|
+
removedGroups: rawObj.removedGroups ?? [],
|
|
90
|
+
addedGroups: rawObj.addedGroups ?? [],
|
|
91
|
+
};
|
|
92
|
+
getContext().logger.info('[BaseSyncedStore] Sync group change received (legacy)', {
|
|
93
|
+
removedGroups: payload.removedGroups,
|
|
94
|
+
addedGroups: payload.addedGroups,
|
|
95
|
+
syncId: delta.id,
|
|
96
|
+
});
|
|
97
|
+
// SECURITY: If groups were removed, clear cached data immediately.
|
|
98
|
+
// This prevents revoked data from persisting if the device goes offline
|
|
99
|
+
// before the full re-bootstrap completes.
|
|
100
|
+
if (payload.removedGroups.length > 0) {
|
|
101
|
+
await ctx.database.clear();
|
|
102
|
+
ctx.objectPool.clear();
|
|
103
|
+
getContext().logger.info('[BaseSyncedStore] Cleared cached data due to revoked sync groups', {
|
|
104
|
+
removedGroups: payload.removedGroups,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const updatedGroups = ctx.computeUpdatedSyncGroups(payload);
|
|
108
|
+
await ctx.database.updateWorkspaceMetadata({ subscribedSyncGroups: updatedGroups });
|
|
109
|
+
ctx.forceFullRebootstrap();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Handle an incremental GroupAdded delta.
|
|
113
|
+
*
|
|
114
|
+
* Adds the new group to the subscription metadata without triggering a
|
|
115
|
+
* re-bootstrap. The server will follow up with 'C' (Covering) deltas for
|
|
116
|
+
* each newly-visible entity, which flow through the normal insert path.
|
|
117
|
+
*/
|
|
118
|
+
export async function handleGroupAdded(ctx, payload, syncId) {
|
|
119
|
+
getContext().logger.info('[BaseSyncedStore] Group added (incremental)', {
|
|
120
|
+
group: payload.group,
|
|
121
|
+
syncId,
|
|
122
|
+
});
|
|
123
|
+
const current = new Set(ctx.getSubscribedSyncGroups());
|
|
124
|
+
current.add(payload.group);
|
|
125
|
+
await ctx.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
126
|
+
// Note: no forceFullRebootstrap() — covering deltas will bring the entities.
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Handle an actionType 'S' (GroupRemoved) delta.
|
|
130
|
+
*
|
|
131
|
+
* Signals that the recipient has lost access to a sync group. Because
|
|
132
|
+
* the client does not track per-entity group membership, we can't
|
|
133
|
+
* selectively purge entities belonging to that group. The safe fallback
|
|
134
|
+
* is the legacy behavior: clear local state and force a re-bootstrap
|
|
135
|
+
* with the updated group list.
|
|
136
|
+
*
|
|
137
|
+
* Future optimization: track group membership in the ObjectPool so 'S'
|
|
138
|
+
* can do a targeted purge instead of a full re-bootstrap.
|
|
139
|
+
*/
|
|
140
|
+
export async function handleGroupRemoved(ctx, delta) {
|
|
141
|
+
const raw = parseGroupChangePayload(delta);
|
|
142
|
+
if (raw === MALFORMED_PAYLOAD) {
|
|
143
|
+
// Malformed 'S' payload — access WAS revoked but we can't tell which
|
|
144
|
+
// group. Degrade to the legacy clear (the safe direction for a
|
|
145
|
+
// security delta) instead of throwing out of the pipeline.
|
|
146
|
+
await clearForUnknownGroupChange(ctx, delta, 'group-removed');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const rawObj = (raw ?? {});
|
|
150
|
+
const groupKey = typeof rawObj.group === 'string' ? rawObj.group : undefined;
|
|
151
|
+
if (!groupKey) {
|
|
152
|
+
getContext().logger.debug('[BaseSyncedStore] Group removed delta missing group key', {
|
|
153
|
+
syncId: delta.id,
|
|
154
|
+
});
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
getContext().logger.info('[BaseSyncedStore] Group removed', {
|
|
158
|
+
group: groupKey,
|
|
159
|
+
syncId: delta.id,
|
|
160
|
+
});
|
|
161
|
+
// SECURITY: Clear cached data before re-bootstrap. This prevents
|
|
162
|
+
// revoked-group data from persisting if the device goes offline
|
|
163
|
+
// between receiving 'S' and completing the re-bootstrap.
|
|
164
|
+
await ctx.database.clear();
|
|
165
|
+
ctx.objectPool.clear();
|
|
166
|
+
// Update subscription metadata so the re-bootstrap fetches the
|
|
167
|
+
// correct set of groups.
|
|
168
|
+
const current = new Set(ctx.getSubscribedSyncGroups());
|
|
169
|
+
current.delete(groupKey);
|
|
170
|
+
await ctx.database.updateWorkspaceMetadata({ subscribedSyncGroups: Array.from(current) });
|
|
171
|
+
ctx.forceFullRebootstrap();
|
|
172
|
+
}
|
|
173
|
+
/** Compute new sync groups after applying additions and removals */
|
|
174
|
+
export function computeUpdatedSyncGroups(ctx, payload) {
|
|
175
|
+
const current = new Set(ctx.getSubscribedSyncGroups());
|
|
176
|
+
for (const g of payload.removedGroups)
|
|
177
|
+
current.delete(g);
|
|
178
|
+
for (const g of payload.addedGroups)
|
|
179
|
+
current.add(g);
|
|
180
|
+
return Array.from(current);
|
|
181
|
+
}
|
|
182
|
+
/** Force a full re-bootstrap via connection lifecycle event.
|
|
183
|
+
*
|
|
184
|
+
* No-op for `bootstrapMode: 'none'` participants — they never pull
|
|
185
|
+
* baseline state, so a "force re-bootstrap" trigger (sync-group
|
|
186
|
+
* shrink, scope revocation) instead just flushes the local pool and
|
|
187
|
+
* relies on covering deltas to repopulate the data they actually
|
|
188
|
+
* subscribe to.
|
|
189
|
+
*/
|
|
190
|
+
export function forceFullRebootstrap(ctx) {
|
|
191
|
+
if (ctx.getBootstrapMode() === 'none') {
|
|
192
|
+
getContext().logger.info('[BaseSyncedStore] forceFullRebootstrap skipped (bootstrapMode=none)');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
ctx.database.markRequiresFullBootstrap();
|
|
196
|
+
ctx.disconnectWebSocket();
|
|
197
|
+
ctx.emitConnectionEvent('WS_DISCONNECTED');
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Single source of truth for the sync-group list this session is
|
|
201
|
+
* subscribed to. Server-issued (`context.syncGroups`) is authoritative.
|
|
202
|
+
* When absent, the SDK subscribes to no explicit groups. Both
|
|
203
|
+
* `checkSyncGroupShrinkage` and `setupWebSocketSync` resolve through
|
|
204
|
+
* here so the WS subscription and the security-critical shrinkage
|
|
205
|
+
* check can never disagree.
|
|
206
|
+
*/
|
|
207
|
+
export function resolveSyncGroups(context) {
|
|
208
|
+
if (context.syncGroups && context.syncGroups.length > 0) {
|
|
209
|
+
return context.syncGroups;
|
|
210
|
+
}
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
/** Check if sync groups shrank since last session — force full bootstrap if so */
|
|
214
|
+
export async function checkSyncGroupShrinkage(ctx) {
|
|
215
|
+
const currentSyncGroups = ctx.getCurrentSyncGroups();
|
|
216
|
+
if (!currentSyncGroups)
|
|
217
|
+
return;
|
|
218
|
+
try {
|
|
219
|
+
const metadata = await ctx.database.getWorkspaceMetadata();
|
|
220
|
+
const stored = metadata?.subscribedSyncGroups ?? [];
|
|
221
|
+
if (stored.length === 0)
|
|
222
|
+
return;
|
|
223
|
+
const currentGroups = new Set(currentSyncGroups);
|
|
224
|
+
const removedGroups = stored.filter((g) => !currentGroups.has(g));
|
|
225
|
+
if (removedGroups.length > 0) {
|
|
226
|
+
getContext().logger.info('[BaseSyncedStore] Sync groups shrank — forcing full bootstrap', {
|
|
227
|
+
removedGroups,
|
|
228
|
+
storedCount: stored.length,
|
|
229
|
+
currentCount: currentGroups.size,
|
|
230
|
+
});
|
|
231
|
+
// SECURITY: Clear cached data before re-bootstrap to prevent
|
|
232
|
+
// revoked-group data from persisting if device goes offline
|
|
233
|
+
await ctx.database.clear();
|
|
234
|
+
ctx.objectPool.clear();
|
|
235
|
+
ctx.database.markRequiresFullBootstrap();
|
|
236
|
+
}
|
|
237
|
+
await ctx.database.updateWorkspaceMetadata({
|
|
238
|
+
subscribedSyncGroups: Array.from(currentGroups),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
getContext().logger.debug('[BaseSyncedStore] Failed to check sync group shrinkage', {
|
|
243
|
+
error: error instanceof Error ? error.message : String(error),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-level heartbeat for the sync WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* The browser WebSocket API hides RFC 6455 protocol-level ping/pong from
|
|
5
|
+
* JavaScript, so the server's `ws.ping()` keepalive can't be observed by
|
|
6
|
+
* client code — meaning the client cannot tell a healthy idle connection
|
|
7
|
+
* apart from a "zombie" socket where TCP silently broke (laptop sleep,
|
|
8
|
+
* NAT timeout, mobile handoff). We send an application-level
|
|
9
|
+
* `{ type: 'ping' }` every 30s and force-close the socket if no inbound
|
|
10
|
+
* traffic arrives within 10s. ANY inbound message counts as
|
|
11
|
+
* proof-of-life — the explicit `pong` is just a guarantee that something
|
|
12
|
+
* will arrive even on an idle stream.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* One cadence for both sides: the SDK's application-level ping runs at the
|
|
16
|
+
* same `PING_INTERVAL_MS` as the server's RFC 6455 keepalive (and the claim
|
|
17
|
+
* lease window is derived from it — see `wire/protocol.ts`).
|
|
18
|
+
*/
|
|
19
|
+
export declare const HEARTBEAT_INTERVAL_MS = 30000;
|
|
20
|
+
export declare const HEARTBEAT_TIMEOUT_MS = 10000;
|
|
21
|
+
/**
|
|
22
|
+
* The slice of the socket the heartbeat needs — kept minimal so this
|
|
23
|
+
* leaf never depends on the SyncWebSocket class itself.
|
|
24
|
+
*/
|
|
25
|
+
export interface HeartbeatTransport {
|
|
26
|
+
/** True only while the underlying socket is `OPEN`. */
|
|
27
|
+
isSocketOpen(): boolean;
|
|
28
|
+
/** Send the `{ type: 'ping' }` frame; throws when the socket is already dead. */
|
|
29
|
+
sendPing(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Force-close the socket from the client side (private 4xxx code) so
|
|
32
|
+
* `onclose` fires and runs the owner's reconnect / handshake-failed
|
|
33
|
+
* dispatch.
|
|
34
|
+
*/
|
|
35
|
+
forceClose(reason: string): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Every `HEARTBEAT_INTERVAL_MS` while `OPEN`, send `{ type: 'ping' }`
|
|
39
|
+
* and arm a `HEARTBEAT_TIMEOUT_MS` watchdog. Any inbound frame (the
|
|
40
|
+
* owner's `onmessage` calls {@link clearHeartbeatTimeout}) clears the
|
|
41
|
+
* watchdog. If the watchdog fires, we treat the connection as zombie
|
|
42
|
+
* and force-close it — `onclose` then triggers the existing reconnect
|
|
43
|
+
* path.
|
|
44
|
+
*
|
|
45
|
+
* Why both sides need this:
|
|
46
|
+
* - The server sends RFC 6455 protocol pings via `ws.ping()` every
|
|
47
|
+
* 30s. Browsers auto-respond with a pong but DO NOT expose either
|
|
48
|
+
* frame to JavaScript, so the client is blind to its own keepalive.
|
|
49
|
+
* - On a half-open TCP (laptop wake, NAT timeout, mobile handoff)
|
|
50
|
+
* the browser may keep `readyState === OPEN` for minutes before
|
|
51
|
+
* the OS surfaces the broken connection. App-level traffic is
|
|
52
|
+
* the only signal we can observe.
|
|
53
|
+
*/
|
|
54
|
+
export declare class HeartbeatController {
|
|
55
|
+
private readonly transport;
|
|
56
|
+
private heartbeatTimer;
|
|
57
|
+
private heartbeatTimeoutTimer;
|
|
58
|
+
constructor(transport: HeartbeatTransport);
|
|
59
|
+
start(): void;
|
|
60
|
+
stop(): void;
|
|
61
|
+
clearHeartbeatTimeout(): void;
|
|
62
|
+
}
|