@abloatai/ablo 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +208 -0
- package/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +230 -0
- package/dist/BaseSyncedStore.d.ts +709 -0
- package/dist/BaseSyncedStore.js +1843 -0
- package/dist/Database.d.ts +344 -0
- package/dist/Database.js +1259 -0
- package/dist/LazyReferenceCollection.d.ts +181 -0
- package/dist/LazyReferenceCollection.js +460 -0
- package/dist/Model.d.ts +339 -0
- package/dist/Model.js +715 -0
- package/dist/ModelRegistry.d.ts +200 -0
- package/dist/ModelRegistry.js +535 -0
- package/dist/NetworkMonitor.d.ts +27 -0
- package/dist/NetworkMonitor.js +73 -0
- package/dist/ObjectPool.d.ts +202 -0
- package/dist/ObjectPool.js +1106 -0
- package/dist/SyncClient.d.ts +489 -0
- package/dist/SyncClient.js +1555 -0
- package/dist/SyncEngineContext.d.ts +46 -0
- package/dist/SyncEngineContext.js +74 -0
- package/dist/adapters/alwaysOnline.d.ts +16 -0
- package/dist/adapters/alwaysOnline.js +19 -0
- package/dist/adapters/inMemoryStorage.d.ts +30 -0
- package/dist/adapters/inMemoryStorage.js +94 -0
- package/dist/agent/Agent.d.ts +358 -0
- package/dist/agent/Agent.js +500 -0
- package/dist/agent/index.d.ts +115 -0
- package/dist/agent/index.js +128 -0
- package/dist/agent/session.d.ts +90 -0
- package/dist/agent/session.js +156 -0
- package/dist/agent/types.d.ts +73 -0
- package/dist/agent/types.js +10 -0
- package/dist/ai-sdk/coordination-context.d.ts +51 -0
- package/dist/ai-sdk/coordination-context.js +107 -0
- package/dist/ai-sdk/index.d.ts +68 -0
- package/dist/ai-sdk/index.js +68 -0
- package/dist/ai-sdk/intent-broadcast.d.ts +77 -0
- package/dist/ai-sdk/intent-broadcast.js +72 -0
- package/dist/ai-sdk/wrap.d.ts +67 -0
- package/dist/ai-sdk/wrap.js +45 -0
- package/dist/api/index.d.ts +10 -0
- package/dist/api/index.js +9 -0
- package/dist/auth/index.d.ts +137 -0
- package/dist/auth/index.js +246 -0
- package/dist/client/Ablo.d.ts +835 -0
- package/dist/client/Ablo.js +1440 -0
- package/dist/client/ApiClient.d.ts +200 -0
- package/dist/client/ApiClient.js +659 -0
- package/dist/client/auth.d.ts +79 -0
- package/dist/client/auth.js +81 -0
- package/dist/client/createInternalComponents.d.ts +44 -0
- package/dist/client/createInternalComponents.js +88 -0
- package/dist/client/createModelProxy.d.ts +152 -0
- package/dist/client/createModelProxy.js +199 -0
- package/dist/client/identity.d.ts +63 -0
- package/dist/client/identity.js +156 -0
- package/dist/client/index.d.ts +36 -0
- package/dist/client/index.js +33 -0
- package/dist/client/persistence.d.ts +7 -0
- package/dist/client/persistence.js +11 -0
- package/dist/client/validateAbloOptions.d.ts +42 -0
- package/dist/client/validateAbloOptions.js +43 -0
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +12 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +58 -0
- package/dist/core/DatabaseManager.d.ts +108 -0
- package/dist/core/DatabaseManager.js +361 -0
- package/dist/core/QueryProcessor.d.ts +77 -0
- package/dist/core/QueryProcessor.js +262 -0
- package/dist/core/QueryView.d.ts +64 -0
- package/dist/core/QueryView.js +219 -0
- package/dist/core/StoreManager.d.ts +131 -0
- package/dist/core/StoreManager.js +334 -0
- package/dist/core/ViewRegistry.d.ts +20 -0
- package/dist/core/ViewRegistry.js +55 -0
- package/dist/core/index.d.ts +34 -0
- package/dist/core/index.js +59 -0
- package/dist/core/openIDBWithTimeout.d.ts +27 -0
- package/dist/core/openIDBWithTimeout.js +63 -0
- package/dist/core/query-utils.d.ts +37 -0
- package/dist/core/query-utils.js +60 -0
- package/dist/errors.d.ts +235 -0
- package/dist/errors.js +243 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +82 -0
- package/dist/interfaces/headless.d.ts +95 -0
- package/dist/interfaces/headless.js +41 -0
- package/dist/interfaces/index.d.ts +321 -0
- package/dist/interfaces/index.js +8 -0
- package/dist/mutators/RecordingTransaction.d.ts +36 -0
- package/dist/mutators/RecordingTransaction.js +216 -0
- package/dist/mutators/Transaction.d.ts +48 -0
- package/dist/mutators/Transaction.js +64 -0
- package/dist/mutators/UndoManager.d.ts +114 -0
- package/dist/mutators/UndoManager.js +143 -0
- package/dist/mutators/defineMutators.d.ts +55 -0
- package/dist/mutators/defineMutators.js +28 -0
- package/dist/policy/index.d.ts +19 -0
- package/dist/policy/index.js +18 -0
- package/dist/policy/types.d.ts +74 -0
- package/dist/policy/types.js +17 -0
- package/dist/principal.d.ts +44 -0
- package/dist/principal.js +49 -0
- package/dist/query/client.d.ts +43 -0
- package/dist/query/client.js +84 -0
- package/dist/query/index.d.ts +6 -0
- package/dist/query/index.js +5 -0
- package/dist/query/types.d.ts +143 -0
- package/dist/query/types.js +36 -0
- package/dist/react/AbloProvider.d.ts +205 -0
- package/dist/react/AbloProvider.js +398 -0
- package/dist/react/ClientSideSuspense.d.ts +36 -0
- package/dist/react/ClientSideSuspense.js +17 -0
- package/dist/react/DefaultFallback.d.ts +24 -0
- package/dist/react/DefaultFallback.js +43 -0
- package/dist/react/SyncGroupProvider.d.ts +19 -0
- package/dist/react/SyncGroupProvider.js +44 -0
- package/dist/react/context.d.ts +161 -0
- package/dist/react/context.js +35 -0
- package/dist/react/index.d.ts +64 -0
- package/dist/react/index.js +73 -0
- package/dist/react/internalContext.d.ts +35 -0
- package/dist/react/internalContext.js +3 -0
- package/dist/react/useAblo.d.ts +72 -0
- package/dist/react/useAblo.js +63 -0
- package/dist/react/useCurrentUserId.d.ts +21 -0
- package/dist/react/useCurrentUserId.js +33 -0
- package/dist/react/useErrorListener.d.ts +20 -0
- package/dist/react/useErrorListener.js +39 -0
- package/dist/react/useIntent.d.ts +29 -0
- package/dist/react/useIntent.js +42 -0
- package/dist/react/useMutate.d.ts +83 -0
- package/dist/react/useMutate.js +122 -0
- package/dist/react/useMutationFailureListener.d.ts +26 -0
- package/dist/react/useMutationFailureListener.js +38 -0
- package/dist/react/useMutators.d.ts +56 -0
- package/dist/react/useMutators.js +66 -0
- package/dist/react/usePresence.d.ts +32 -0
- package/dist/react/usePresence.js +41 -0
- package/dist/react/useQuery.d.ts +123 -0
- package/dist/react/useQuery.js +145 -0
- package/dist/react/useReactive.d.ts +35 -0
- package/dist/react/useReactive.js +111 -0
- package/dist/react/useReader.d.ts +69 -0
- package/dist/react/useReader.js +73 -0
- package/dist/react/useSyncStatus.d.ts +61 -0
- package/dist/react/useSyncStatus.js +76 -0
- package/dist/react/useUndoScope.d.ts +36 -0
- package/dist/react/useUndoScope.js +73 -0
- package/dist/realtime/index.d.ts +10 -0
- package/dist/realtime/index.js +9 -0
- package/dist/schema/field.d.ts +134 -0
- package/dist/schema/field.js +264 -0
- package/dist/schema/index.d.ts +29 -0
- package/dist/schema/index.js +38 -0
- package/dist/schema/model.d.ts +326 -0
- package/dist/schema/model.js +89 -0
- package/dist/schema/queries.d.ts +203 -0
- package/dist/schema/queries.js +145 -0
- package/dist/schema/relation.d.ts +172 -0
- package/dist/schema/relation.js +104 -0
- package/dist/schema/schema.d.ts +259 -0
- package/dist/schema/schema.js +188 -0
- package/dist/schema/sugar.d.ts +129 -0
- package/dist/schema/sugar.js +94 -0
- package/dist/source/index.d.ts +423 -0
- package/dist/source/index.js +320 -0
- package/dist/source/pushQueue.d.ts +112 -0
- package/dist/source/pushQueue.js +249 -0
- package/dist/stores/ObjectStore.d.ts +103 -0
- package/dist/stores/ObjectStore.js +371 -0
- package/dist/stores/ObjectStoreContract.d.ts +39 -0
- package/dist/stores/ObjectStoreContract.js +1 -0
- package/dist/stores/SyncActionStore.d.ts +101 -0
- package/dist/stores/SyncActionStore.js +481 -0
- package/dist/sync/BootstrapHelper.d.ts +127 -0
- package/dist/sync/BootstrapHelper.js +434 -0
- package/dist/sync/ConnectionManager.d.ts +136 -0
- package/dist/sync/ConnectionManager.js +465 -0
- package/dist/sync/HydrationCoordinator.d.ts +137 -0
- package/dist/sync/HydrationCoordinator.js +468 -0
- package/dist/sync/NetworkProbe.d.ts +43 -0
- package/dist/sync/NetworkProbe.js +113 -0
- package/dist/sync/OfflineFlush.d.ts +9 -0
- package/dist/sync/OfflineFlush.js +22 -0
- package/dist/sync/OfflineTransactionStore.d.ts +37 -0
- package/dist/sync/OfflineTransactionStore.js +263 -0
- package/dist/sync/SyncWebSocket.d.ts +663 -0
- package/dist/sync/SyncWebSocket.js +1336 -0
- package/dist/sync/createIntentStream.d.ts +33 -0
- package/dist/sync/createIntentStream.js +243 -0
- package/dist/sync/createPresenceStream.d.ts +46 -0
- package/dist/sync/createPresenceStream.js +192 -0
- package/dist/sync/createSnapshot.d.ts +33 -0
- package/dist/sync/createSnapshot.js +124 -0
- package/dist/sync/participants.d.ts +114 -0
- package/dist/sync/participants.js +336 -0
- package/dist/sync/schemas.d.ts +79 -0
- package/dist/sync/schemas.js +78 -0
- package/dist/testing/fixtures/bootstrap.d.ts +45 -0
- package/dist/testing/fixtures/bootstrap.js +53 -0
- package/dist/testing/fixtures/deltas.d.ts +86 -0
- package/dist/testing/fixtures/deltas.js +139 -0
- package/dist/testing/fixtures/models.d.ts +82 -0
- package/dist/testing/fixtures/models.js +270 -0
- package/dist/testing/helpers/react-wrapper.d.ts +66 -0
- package/dist/testing/helpers/react-wrapper.js +64 -0
- package/dist/testing/helpers/sync-engine-harness.d.ts +55 -0
- package/dist/testing/helpers/sync-engine-harness.js +70 -0
- package/dist/testing/helpers/wait.d.ts +25 -0
- package/dist/testing/helpers/wait.js +44 -0
- package/dist/testing/index.d.ts +21 -0
- package/dist/testing/index.js +32 -0
- package/dist/testing/mocks/MockMutationExecutor.d.ts +65 -0
- package/dist/testing/mocks/MockMutationExecutor.js +139 -0
- package/dist/testing/mocks/MockNetworkMonitor.d.ts +20 -0
- package/dist/testing/mocks/MockNetworkMonitor.js +46 -0
- package/dist/testing/mocks/MockSyncContext.d.ts +64 -0
- package/dist/testing/mocks/MockSyncContext.js +100 -0
- package/dist/testing/mocks/MockSyncStore.d.ts +88 -0
- package/dist/testing/mocks/MockSyncStore.js +171 -0
- package/dist/testing/mocks/MockWebSocket.d.ts +66 -0
- package/dist/testing/mocks/MockWebSocket.js +117 -0
- package/dist/transactions/OptimisticEchoTracker.d.ts +82 -0
- package/dist/transactions/OptimisticEchoTracker.js +104 -0
- package/dist/transactions/TransactionQueue.d.ts +499 -0
- package/dist/transactions/TransactionQueue.js +1895 -0
- package/dist/transactions/index.d.ts +16 -0
- package/dist/transactions/index.js +7 -0
- package/dist/transactions/mutation-error-handler.d.ts +5 -0
- package/dist/transactions/mutation-error-handler.js +39 -0
- package/dist/types/global.d.ts +107 -0
- package/dist/types/global.js +38 -0
- package/dist/types/index.d.ts +241 -0
- package/dist/types/index.js +70 -0
- package/dist/types/streams.d.ts +495 -0
- package/dist/types/streams.js +11 -0
- package/dist/utils/asyncIterator.d.ts +41 -0
- package/dist/utils/asyncIterator.js +142 -0
- package/dist/utils/duration.d.ts +28 -0
- package/dist/utils/duration.js +47 -0
- package/dist/utils/mobx-setup.d.ts +42 -0
- package/dist/utils/mobx-setup.js +381 -0
- package/docs/api-keys.md +24 -0
- package/docs/api.md +230 -0
- package/docs/audit.md +81 -0
- package/docs/capabilities.md +163 -0
- package/docs/client-behavior.md +202 -0
- package/docs/data-sources.md +214 -0
- package/docs/examples/agent-human.md +84 -0
- package/docs/examples/ai-sdk-tool.md +92 -0
- package/docs/examples/existing-python-backend.md +249 -0
- package/docs/examples/nextjs.md +88 -0
- package/docs/examples/server-agent.md +86 -0
- package/docs/guarantees.md +148 -0
- package/docs/index.md +97 -0
- package/docs/integration-guide.md +493 -0
- package/docs/interaction-model.md +140 -0
- package/docs/mcp/claude-code.md +43 -0
- package/docs/mcp/cursor.md +53 -0
- package/docs/mcp/windsurf.md +46 -0
- package/docs/mcp.md +59 -0
- package/docs/quickstart.md +152 -0
- package/docs/react.md +115 -0
- package/docs/roadmap.md +45 -0
- package/examples/README.md +54 -0
- package/examples/data-source/README.md +102 -0
- package/examples/data-source/ablo-driver.ts +89 -0
- package/examples/data-source/customer-server.ts +208 -0
- package/examples/data-source/run.ts +101 -0
- package/examples/data-source/schema.ts +25 -0
- package/examples/quickstart.ts +54 -0
- package/examples/tsconfig.json +16 -0
- package/llms.txt +143 -0
- package/package.json +147 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HydrationCoordinator — the lazy-load lane of the sync engine.
|
|
3
|
+
*
|
|
4
|
+
* Bridges "I need this entity but bootstrap didn't fetch it" → pool
|
|
5
|
+
* hydration. Replaces the per-app loader files (documentLoaders,
|
|
6
|
+
* slideLayerLoaders, layoutLoaders, ensureVaultFiles, ensureDataroomFiles)
|
|
7
|
+
* with one engine-level path.
|
|
8
|
+
*
|
|
9
|
+
* Lookup order on `fetch(modelName, where)`:
|
|
10
|
+
* 1. ObjectPool — if rows already match the where, return them (cheap).
|
|
11
|
+
* 2. IndexedDB — if matching rows exist locally, hydrate pool, return.
|
|
12
|
+
* 3. Network — `postQuery` against `/sync/query`, hydrate pool + IDB.
|
|
13
|
+
*
|
|
14
|
+
* Single-flight dedup: concurrent calls with the same query key share
|
|
15
|
+
* one in-flight promise. Prevents the loader anti-pattern where N
|
|
16
|
+
* components mount and fire N identical hydrations on first paint.
|
|
17
|
+
*
|
|
18
|
+
* The coordinator does NOT replace bootstrap (full sync of `instant`
|
|
19
|
+
* models) or live deltas (WS push). It only fills the gap for `lazy`
|
|
20
|
+
* models accessed by id/where after the engine is ready.
|
|
21
|
+
*/
|
|
22
|
+
import { ModelScope } from '../ObjectPool.js';
|
|
23
|
+
import { postQuery } from '../query/client.js';
|
|
24
|
+
export class HydrationCoordinator {
|
|
25
|
+
opts;
|
|
26
|
+
inFlight = new Map();
|
|
27
|
+
capabilityTokenProvider = null;
|
|
28
|
+
constructor(opts) {
|
|
29
|
+
this.opts = opts;
|
|
30
|
+
this.capabilityTokenProvider = opts.getCapabilityToken ?? null;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Late-bind the capability token getter. Used by `Ablo.ts` to wire
|
|
34
|
+
* the token closure after the coordinator is constructed (the token
|
|
35
|
+
* isn't known until auth resolves, which happens after component
|
|
36
|
+
* construction). Browser consumers ride session cookies and don't
|
|
37
|
+
* need this; Node consumers (agent-worker) MUST call it or HTTP
|
|
38
|
+
* queries fail with 401 because cookies aren't available.
|
|
39
|
+
*/
|
|
40
|
+
setCapabilityTokenProvider(provider) {
|
|
41
|
+
this.capabilityTokenProvider = provider;
|
|
42
|
+
}
|
|
43
|
+
resolveToken() {
|
|
44
|
+
return this.capabilityTokenProvider?.() ?? undefined;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Fetch matching rows for a model, hydrating the pool from IDB or
|
|
48
|
+
* network if not already present. Idempotent and single-flight
|
|
49
|
+
* deduped on the (modelName, where, orderBy, limit) tuple.
|
|
50
|
+
*/
|
|
51
|
+
async fetch(modelName, options) {
|
|
52
|
+
const typename = this.resolveTypename(modelName);
|
|
53
|
+
const ModelClass = this.opts.registry.getModelByName(typename)
|
|
54
|
+
?? this.opts.registry.getModelByName(modelName);
|
|
55
|
+
if (!ModelClass) {
|
|
56
|
+
throw new Error(`HydrationCoordinator.fetch: unknown model "${modelName}" — ` +
|
|
57
|
+
`not registered in the schema.`);
|
|
58
|
+
}
|
|
59
|
+
const clauses = normalizeWhere(options?.where);
|
|
60
|
+
const queryKey = stableKey(modelName, clauses, options?.orderBy, options?.limit);
|
|
61
|
+
// Single-flight: an identical hydration is already in flight.
|
|
62
|
+
const inFlight = this.inFlight.get(queryKey);
|
|
63
|
+
if (inFlight)
|
|
64
|
+
return inFlight;
|
|
65
|
+
const work = this.runFetch(modelName, typename, ModelClass, clauses, options);
|
|
66
|
+
this.inFlight.set(queryKey, work);
|
|
67
|
+
work.finally(() => {
|
|
68
|
+
this.inFlight.delete(queryKey);
|
|
69
|
+
});
|
|
70
|
+
return work;
|
|
71
|
+
}
|
|
72
|
+
async runFetch(modelName, typename, ModelClass, clauses, options) {
|
|
73
|
+
const wantsComplete = (options?.type ?? 'complete') === 'complete';
|
|
74
|
+
// Step 1 — pool hit. Skip when caller asked for complete:
|
|
75
|
+
// they want server-confirmed state, not a stale pool snapshot.
|
|
76
|
+
if (!wantsComplete) {
|
|
77
|
+
const fromPool = scanPool(this.opts.objectPool, ModelClass, clauses);
|
|
78
|
+
if (fromPool.length > 0)
|
|
79
|
+
return applyLimit(fromPool, options?.limit);
|
|
80
|
+
}
|
|
81
|
+
// Step 2 — IndexedDB. Survives reload + offline.
|
|
82
|
+
const fromIdb = await scanIdb(this.opts.database, typename, clauses);
|
|
83
|
+
const idbModels = fromIdb
|
|
84
|
+
.map((raw) => this.hydrateOne(raw, typename))
|
|
85
|
+
.filter((m) => m !== null);
|
|
86
|
+
if (idbModels.length > 0) {
|
|
87
|
+
this.opts.objectPool.addBatch(idbModels, ModelScope.live);
|
|
88
|
+
if (!wantsComplete)
|
|
89
|
+
return applyLimit(idbModels, options?.limit);
|
|
90
|
+
}
|
|
91
|
+
// Step 3 — network. Last resort. Always runs when wantsComplete.
|
|
92
|
+
const networkRows = await this.queryNetwork(modelName, clauses, options);
|
|
93
|
+
const networkModels = networkRows
|
|
94
|
+
.map((raw) => this.hydrateOne(raw, typename))
|
|
95
|
+
.filter((m) => m !== null);
|
|
96
|
+
if (networkModels.length > 0) {
|
|
97
|
+
this.opts.objectPool.addBatch(networkModels, ModelScope.live);
|
|
98
|
+
// Background IDB write — don't block the caller.
|
|
99
|
+
void this.persistToIdb(modelName, networkRows);
|
|
100
|
+
}
|
|
101
|
+
return applyLimit(networkModels.length > 0 ? networkModels : idbModels, options?.limit);
|
|
102
|
+
}
|
|
103
|
+
hydrateOne(raw, typename) {
|
|
104
|
+
if (!raw || typeof raw !== 'object')
|
|
105
|
+
return null;
|
|
106
|
+
const obj = raw;
|
|
107
|
+
if (typeof obj.id !== 'string')
|
|
108
|
+
return null;
|
|
109
|
+
if (this.opts.objectPool.has(obj.id)) {
|
|
110
|
+
// Pool already has this entity, but the row coming from the
|
|
111
|
+
// network is the freshest server-confirmed state. Apply the new
|
|
112
|
+
// fields onto the existing instance instead of returning the
|
|
113
|
+
// stale model verbatim — otherwise a `load()` that re-fetches
|
|
114
|
+
// after a missed delta (WS dropped, tab slept, redeploy) silently
|
|
115
|
+
// discards the fresh state and the consumer keeps seeing the
|
|
116
|
+
// birth-time snapshot forever. `updateFromData` is the same
|
|
117
|
+
// primitive `ObjectPool.upsert()` uses for delta application,
|
|
118
|
+
// so the behaviour matches "delta-applied" semantics exactly.
|
|
119
|
+
const existing = this.opts.objectPool.get(obj.id);
|
|
120
|
+
if (existing) {
|
|
121
|
+
const stamped = this.stampTypename(obj, typename);
|
|
122
|
+
existing.updateFromData(stamped);
|
|
123
|
+
return existing;
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
// Stamp the known relation typename onto the row when the source
|
|
128
|
+
// (IndexedDB rows, sometimes network rows) didn't carry one. Without
|
|
129
|
+
// this, ObjectPool.createFromData falls through to the 'Unknown'
|
|
130
|
+
// model-name branch and emits the
|
|
131
|
+
// "ObjectPool.createFromData: No model identifier found" warning,
|
|
132
|
+
// failing to hydrate the entity from cache (network path then has to
|
|
133
|
+
// re-populate it). The typename comes from the schema relation
|
|
134
|
+
// (`'SlideLayer'`, `'SlideLayoutLayer'`, etc.) so no guessing involved.
|
|
135
|
+
const stamped = this.stampTypename(obj, typename);
|
|
136
|
+
return this.opts.objectPool.createFromData(stamped);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Stamp `__typename` onto a row when it's known (from the schema's
|
|
140
|
+
* relation target). Strips the mangled `_Typename` key the
|
|
141
|
+
* `postgres.camel` driver leaves behind when the server's SQL
|
|
142
|
+
* bakes `__typename` into a JSONB literal — the driver's
|
|
143
|
+
* snake↔camel transform misreads `__typename` as `_typename` with
|
|
144
|
+
* a leading underscore and produces `_Typename`. ObjectPool only
|
|
145
|
+
* recognises `__typename`, so without this step nested rows fall
|
|
146
|
+
* through to the 'Unknown' branch and never instantiate.
|
|
147
|
+
*/
|
|
148
|
+
stampTypename(item, typename) {
|
|
149
|
+
if (!item || typeof item !== 'object' || !typename)
|
|
150
|
+
return item;
|
|
151
|
+
const obj = item;
|
|
152
|
+
if (obj.__typename === typename)
|
|
153
|
+
return obj;
|
|
154
|
+
const { _Typename: _drop, ...rest } = obj;
|
|
155
|
+
void _drop;
|
|
156
|
+
return { __typename: typename, ...rest };
|
|
157
|
+
}
|
|
158
|
+
async queryNetwork(modelName, clauses, options) {
|
|
159
|
+
const typename = this.resolveTypename(modelName);
|
|
160
|
+
const orderEntries = options?.orderBy ? Object.entries(options.orderBy) : [];
|
|
161
|
+
const firstOrder = orderEntries[0];
|
|
162
|
+
const query = {
|
|
163
|
+
model: typename,
|
|
164
|
+
where: clauses.map((c) => columnizeClause(c)),
|
|
165
|
+
...(firstOrder
|
|
166
|
+
? {
|
|
167
|
+
orderBy: columnize(firstOrder[0]),
|
|
168
|
+
order: firstOrder[1] ?? 'asc',
|
|
169
|
+
}
|
|
170
|
+
: {}),
|
|
171
|
+
...(options?.limit ? { limit: options.limit } : {}),
|
|
172
|
+
...(options?.expand && options.expand.length > 0
|
|
173
|
+
? { related: options.expand }
|
|
174
|
+
: {}),
|
|
175
|
+
};
|
|
176
|
+
const result = await postQuery({
|
|
177
|
+
baseUrl: this.opts.baseUrl,
|
|
178
|
+
capabilityToken: this.resolveToken(),
|
|
179
|
+
}, { queries: [query] });
|
|
180
|
+
const rows = Array.isArray(result.results[0]) ? result.results[0] : [];
|
|
181
|
+
// Normalize: wire rows lack `__typename` when the server elides it.
|
|
182
|
+
const normalized = rows.map((row) => {
|
|
183
|
+
if (row && typeof row === 'object' && !('__typename' in row)) {
|
|
184
|
+
return { __typename: typename, ...row };
|
|
185
|
+
}
|
|
186
|
+
return row;
|
|
187
|
+
});
|
|
188
|
+
// Expand: server returns related entities nested under each row
|
|
189
|
+
// (`row.layers = [{...}, ...]`). Walk the nested shape, stamp the
|
|
190
|
+
// typename from the schema's relation metadata (the server bakes
|
|
191
|
+
// `__typename` into the JSONB but the postgres.camel driver
|
|
192
|
+
// mangles it to `_Typename` mid-flight, so client-side stamping
|
|
193
|
+
// is the only reliable path), hydrate each related row into its
|
|
194
|
+
// own typed pool, then leave the nested arrays in place on the
|
|
195
|
+
// primary row.
|
|
196
|
+
if (options?.expand && options.expand.length > 0) {
|
|
197
|
+
this.hydrateExpanded(modelName, normalized, options.expand);
|
|
198
|
+
}
|
|
199
|
+
return normalized;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Hydrate nested expanded rows. Resolves each relation's target
|
|
203
|
+
* typename via the schema and stamps `__typename` on every nested
|
|
204
|
+
* row before passing to `hydrateOne` — the server's JSONB
|
|
205
|
+
* `__typename` field gets mangled by `postgres.camel` (`__typename`
|
|
206
|
+
* → `_Typename`), so the SDK can't trust whatever string lands.
|
|
207
|
+
*/
|
|
208
|
+
hydrateExpanded(parentModelName, rows, relationNames) {
|
|
209
|
+
const schemaModels = this.opts.schema.models;
|
|
210
|
+
const parentDef = schemaModels?.[parentModelName];
|
|
211
|
+
for (const row of rows) {
|
|
212
|
+
if (!row || typeof row !== 'object')
|
|
213
|
+
continue;
|
|
214
|
+
const obj = row;
|
|
215
|
+
for (const rel of relationNames) {
|
|
216
|
+
const nested = obj[rel];
|
|
217
|
+
if (!nested)
|
|
218
|
+
continue;
|
|
219
|
+
// Resolve target typename via parent's relations map.
|
|
220
|
+
const relDef = parentDef?.relations?.[rel];
|
|
221
|
+
const targetKey = relDef?.target;
|
|
222
|
+
const targetTypename = targetKey ? this.resolveTypename(targetKey) : undefined;
|
|
223
|
+
const items = Array.isArray(nested) ? nested : [nested];
|
|
224
|
+
const models = [];
|
|
225
|
+
for (const item of items) {
|
|
226
|
+
const stamped = this.stampTypename(item, targetTypename);
|
|
227
|
+
const m = this.hydrateOne(stamped);
|
|
228
|
+
if (m)
|
|
229
|
+
models.push(m);
|
|
230
|
+
}
|
|
231
|
+
if (models.length > 0) {
|
|
232
|
+
this.opts.objectPool.addBatch(models, ModelScope.live);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
async persistToIdb(modelName, rows) {
|
|
238
|
+
const store = this.opts.database.getStore(this.resolveTypename(modelName));
|
|
239
|
+
if (!store)
|
|
240
|
+
return;
|
|
241
|
+
for (const row of rows) {
|
|
242
|
+
try {
|
|
243
|
+
await store.put(row);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// IDB writes are best-effort — a transient quota/transaction
|
|
247
|
+
// failure shouldn't break the hydration's primary purpose.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
resolveTypename(modelName) {
|
|
252
|
+
// Schema is the source of truth for wire typenames. The model proxy
|
|
253
|
+
// is keyed by camelCase plural (`slideLayers`) but the wire query +
|
|
254
|
+
// ObjectPool typeIndex use the typename (`SlideLayer`).
|
|
255
|
+
const def = this.opts.schema
|
|
256
|
+
.models?.[modelName];
|
|
257
|
+
return def?.typename ?? modelName;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ── Helpers ────────────────────────────────────────────────────────────
|
|
261
|
+
function stableKey(modelName, clauses, orderBy, limit) {
|
|
262
|
+
// Sort clauses by their stringified form so caller order doesn't
|
|
263
|
+
// produce different dedup keys for semantically identical queries.
|
|
264
|
+
const sorted = [...clauses].map((c) => [...c]).sort((a, b) => {
|
|
265
|
+
const ka = JSON.stringify(a);
|
|
266
|
+
const kb = JSON.stringify(b);
|
|
267
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
268
|
+
});
|
|
269
|
+
return JSON.stringify({ modelName, where: sorted, orderBy, limit });
|
|
270
|
+
}
|
|
271
|
+
function applyLimit(arr, limit) {
|
|
272
|
+
return typeof limit === 'number' ? arr.slice(0, limit) : arr;
|
|
273
|
+
}
|
|
274
|
+
function scanPool(pool, ModelClass, clauses) {
|
|
275
|
+
const all = pool.getByType(ModelClass);
|
|
276
|
+
if (clauses.length === 0)
|
|
277
|
+
return all;
|
|
278
|
+
return all.filter((entity) => matchesClauses(entity, clauses));
|
|
279
|
+
}
|
|
280
|
+
async function scanIdb(database, modelName, clauses) {
|
|
281
|
+
const store = database.getStore(modelName);
|
|
282
|
+
if (!store)
|
|
283
|
+
return [];
|
|
284
|
+
// Fast path: a single equality `id` lookup hits the primary key.
|
|
285
|
+
const eqClauses = extractEqClauses(clauses);
|
|
286
|
+
if (clauses.length === 1 && eqClauses.id !== undefined && typeof eqClauses.id === 'string') {
|
|
287
|
+
try {
|
|
288
|
+
const row = await store.get(eqClauses.id);
|
|
289
|
+
return row ? [row] : [];
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return [];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
// Index-aware path: when every clause is equality and exactly one
|
|
296
|
+
// non-id string column is constrained, hit that column's index for
|
|
297
|
+
// an O(matches) read. Anything involving LIKE/ILIKE/ranges falls
|
|
298
|
+
// through to full-scan + filter.
|
|
299
|
+
if (clausesAreAllEquality(clauses)) {
|
|
300
|
+
const indexedKeys = Object.keys(eqClauses).filter((k) => k !== 'id' && typeof eqClauses[k] === 'string');
|
|
301
|
+
if (indexedKeys.length === 1) {
|
|
302
|
+
const idxKey = indexedKeys[0];
|
|
303
|
+
try {
|
|
304
|
+
const rows = await store.getAllFromIndex(idxKey, eqClauses[idxKey]);
|
|
305
|
+
if (Array.isArray(rows)) {
|
|
306
|
+
return rows.filter((r) => matchesClauses(r, clauses));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// index doesn't exist — fall through to full-scan path.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
try {
|
|
315
|
+
const rows = await store.getAll();
|
|
316
|
+
return Array.isArray(rows)
|
|
317
|
+
? rows.filter((r) => matchesClauses(r, clauses))
|
|
318
|
+
: [];
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Normalize `LoadWhere<T>` input to the canonical `readonly WhereClause[]`
|
|
326
|
+
* tuple form used throughout `runFetch`. Tuple inputs pass through; object
|
|
327
|
+
* inputs become one `['col', '=', val]` or `['col', 'IN', vals]` per key.
|
|
328
|
+
*
|
|
329
|
+
* Detection: an array whose first element is itself an array is treated
|
|
330
|
+
* as tuple form. Object form is the fallback.
|
|
331
|
+
*
|
|
332
|
+
* Exported so callers can pre-normalize (e.g., for tests, or to inspect
|
|
333
|
+
* the canonical clauses before passing them to `load`/`subscribe`).
|
|
334
|
+
*/
|
|
335
|
+
export function normalizeWhere(where) {
|
|
336
|
+
if (where == null)
|
|
337
|
+
return [];
|
|
338
|
+
if (Array.isArray(where)) {
|
|
339
|
+
// Tuple form — assumed to already use server-side column names.
|
|
340
|
+
return where;
|
|
341
|
+
}
|
|
342
|
+
if (typeof where === 'object') {
|
|
343
|
+
const obj = where;
|
|
344
|
+
return Object.entries(obj).map(([key, value]) => {
|
|
345
|
+
if (Array.isArray(value)) {
|
|
346
|
+
return [key, 'IN', value];
|
|
347
|
+
}
|
|
348
|
+
return [key, value];
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return [];
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Apply `columnize` to the column name of a wire-bound clause so the
|
|
355
|
+
* server sees `slide_id` instead of `slideId`. Tuple-form clauses from
|
|
356
|
+
* callers are passed through unchanged — they already supply the wire
|
|
357
|
+
* column name (matches what existing `postQuery` consumers do).
|
|
358
|
+
*/
|
|
359
|
+
function columnizeClause(clause) {
|
|
360
|
+
const col = clause[0];
|
|
361
|
+
// If the column already looks snake_case (no uppercase letters), assume
|
|
362
|
+
// the caller is already using server-side naming. Otherwise camelize→snake.
|
|
363
|
+
const finalCol = /[A-Z]/.test(col) ? columnize(col) : col;
|
|
364
|
+
if (clause.length === 2)
|
|
365
|
+
return [finalCol, clause[1]];
|
|
366
|
+
return [finalCol, clause[1], clause[2]];
|
|
367
|
+
}
|
|
368
|
+
/** Equality-only subset of clauses, keyed by column. Used by IDB fast paths. */
|
|
369
|
+
function extractEqClauses(clauses) {
|
|
370
|
+
const out = {};
|
|
371
|
+
for (const c of clauses) {
|
|
372
|
+
if (c.length === 2) {
|
|
373
|
+
out[c[0]] = c[1];
|
|
374
|
+
}
|
|
375
|
+
else if (c[1] === '=') {
|
|
376
|
+
out[c[0]] = c[2];
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
function clausesAreAllEquality(clauses) {
|
|
382
|
+
return clauses.every((c) => c.length === 2 || c[1] === '=');
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Operator-aware predicate. Mirrors the server's WhereOp semantics for
|
|
386
|
+
* local matching against pool/IDB rows. LIKE/ILIKE use SQL wildcards
|
|
387
|
+
* (`%` = any chars, `_` = one char) translated to a JS regex.
|
|
388
|
+
*
|
|
389
|
+
* Exported so callers can apply the same predicate to in-memory
|
|
390
|
+
* collections (tests, batch operations) using the canonical clauses.
|
|
391
|
+
*/
|
|
392
|
+
export function matchesClauses(entity, clauses) {
|
|
393
|
+
for (const clause of clauses) {
|
|
394
|
+
const col = clause[0];
|
|
395
|
+
const op = clause.length === 2 ? '=' : clause[1];
|
|
396
|
+
const expected = clause.length === 2 ? clause[1] : clause[2];
|
|
397
|
+
const v = entity[col];
|
|
398
|
+
if (!matchOp(v, op, expected))
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
function matchOp(actual, op, expected) {
|
|
404
|
+
switch (op) {
|
|
405
|
+
case '=':
|
|
406
|
+
return actual === expected;
|
|
407
|
+
case '!=':
|
|
408
|
+
return actual !== expected;
|
|
409
|
+
case '<':
|
|
410
|
+
return compareOrdered(actual, expected, (a, b) => a < b);
|
|
411
|
+
case '<=':
|
|
412
|
+
return compareOrdered(actual, expected, (a, b) => a <= b);
|
|
413
|
+
case '>':
|
|
414
|
+
return compareOrdered(actual, expected, (a, b) => a > b);
|
|
415
|
+
case '>=':
|
|
416
|
+
return compareOrdered(actual, expected, (a, b) => a >= b);
|
|
417
|
+
case 'IN':
|
|
418
|
+
return Array.isArray(expected) && expected.some((alt) => alt === actual);
|
|
419
|
+
case 'NOT IN':
|
|
420
|
+
return Array.isArray(expected) && !expected.some((alt) => alt === actual);
|
|
421
|
+
case 'IS':
|
|
422
|
+
// SQL `IS` is null-equality; the only meaningful right-hand side here is null.
|
|
423
|
+
return actual === expected;
|
|
424
|
+
case 'IS NOT':
|
|
425
|
+
return actual !== expected;
|
|
426
|
+
case 'LIKE':
|
|
427
|
+
return typeof actual === 'string' && typeof expected === 'string' && likeRegex(expected, false).test(actual);
|
|
428
|
+
case 'NOT LIKE':
|
|
429
|
+
return typeof actual === 'string' && typeof expected === 'string' && !likeRegex(expected, false).test(actual);
|
|
430
|
+
case 'ILIKE':
|
|
431
|
+
return typeof actual === 'string' && typeof expected === 'string' && likeRegex(expected, true).test(actual);
|
|
432
|
+
case 'NOT ILIKE':
|
|
433
|
+
return typeof actual === 'string' && typeof expected === 'string' && !likeRegex(expected, true).test(actual);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Ordered comparison helper. Both operands must be non-null and the same
|
|
438
|
+
* comparable primitive (string-vs-string or number-vs-number). Mixed
|
|
439
|
+
* types fall back to JS's loose ordering, which would be confusing — so
|
|
440
|
+
* we reject early to match SQL semantics (a NULL operand yields false).
|
|
441
|
+
*/
|
|
442
|
+
function compareOrdered(actual, expected, cmp) {
|
|
443
|
+
if (actual == null || expected == null)
|
|
444
|
+
return false;
|
|
445
|
+
if (typeof actual === 'number' && typeof expected === 'number') {
|
|
446
|
+
return cmp(actual, expected);
|
|
447
|
+
}
|
|
448
|
+
if (typeof actual === 'string' && typeof expected === 'string') {
|
|
449
|
+
return cmp(actual, expected);
|
|
450
|
+
}
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
/** Translate a SQL LIKE/ILIKE pattern to a JS regex (`%` → `.*`, `_` → `.`). */
|
|
454
|
+
function likeRegex(pattern, insensitive) {
|
|
455
|
+
// Escape regex specials *except* `%` and `_`, then translate those.
|
|
456
|
+
const escaped = pattern.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&');
|
|
457
|
+
const body = escaped.replace(/%/g, '.*').replace(/_/g, '.');
|
|
458
|
+
return new RegExp(`^${body}$`, insensitive ? 'i' : '');
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Schema fields are camelCase (`slideId`); the wire query expects
|
|
462
|
+
* the server-side column name. The query server's input resolver
|
|
463
|
+
* casing-folds, but we send snake_case to match the convention used
|
|
464
|
+
* by the existing loaders' postQuery calls (`'slide_id'` etc.).
|
|
465
|
+
*/
|
|
466
|
+
function columnize(field) {
|
|
467
|
+
return field.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
468
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetworkProbe - Reliable network + session connectivity detection
|
|
3
|
+
*
|
|
4
|
+
* navigator.onLine is unreliable: it reports true whenever the device has a LAN
|
|
5
|
+
* connection, even without actual internet access (MDN docs confirm this).
|
|
6
|
+
* After laptop sleep/wake, it may report true before WiFi/DNS are functional.
|
|
7
|
+
*
|
|
8
|
+
* This module provides an authenticated probe against the sync server to verify
|
|
9
|
+
* real connectivity + session validity in a single round-trip. The probe hits
|
|
10
|
+
* `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
|
|
11
|
+
* upgrade path:
|
|
12
|
+
* 204 No Content → reachable, session cookie valid
|
|
13
|
+
* 401/403 → reachable, session expired or invalid
|
|
14
|
+
* network fail → unreachable
|
|
15
|
+
*
|
|
16
|
+
* This closes a real gap: the browser's WebSocket API hides HTTP status from
|
|
17
|
+
* the handshake, so a 401 on the WS upgrade surfaces only as `close code
|
|
18
|
+
* 1006`. Without this HTTP probe, the client cannot distinguish auth failure
|
|
19
|
+
* from a network blip and loops reconnecting forever instead of redirecting
|
|
20
|
+
* the user to sign-in.
|
|
21
|
+
*
|
|
22
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
|
|
23
|
+
*/
|
|
24
|
+
/** Result of a network probe */
|
|
25
|
+
export interface ProbeResult {
|
|
26
|
+
/** Whether the server was reachable */
|
|
27
|
+
reachable: boolean;
|
|
28
|
+
/** Whether the session cookie is still valid (null if server unreachable) */
|
|
29
|
+
sessionValid: boolean | null;
|
|
30
|
+
/** Round-trip time in ms (null if failed) */
|
|
31
|
+
latencyMs: number | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Probe the sync engine server with a lightweight HEAD request.
|
|
35
|
+
*
|
|
36
|
+
* Returns reachability AND session status in a single call, so the
|
|
37
|
+
* ConnectionStore can make the right state transition without guessing.
|
|
38
|
+
*
|
|
39
|
+
* @param baseUrl The sync-server base URL (HTTP or WS scheme accepted).
|
|
40
|
+
* If omitted, falls back to `NEXT_PUBLIC_GO_SERVER_URL` →
|
|
41
|
+
* `http://localhost:8080` for backwards compatibility.
|
|
42
|
+
*/
|
|
43
|
+
export declare function probeNetwork(baseUrl?: string): Promise<ProbeResult>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetworkProbe - Reliable network + session connectivity detection
|
|
3
|
+
*
|
|
4
|
+
* navigator.onLine is unreliable: it reports true whenever the device has a LAN
|
|
5
|
+
* connection, even without actual internet access (MDN docs confirm this).
|
|
6
|
+
* After laptop sleep/wake, it may report true before WiFi/DNS are functional.
|
|
7
|
+
*
|
|
8
|
+
* This module provides an authenticated probe against the sync server to verify
|
|
9
|
+
* real connectivity + session validity in a single round-trip. The probe hits
|
|
10
|
+
* `/api/auth/check`, which runs the SAME auth middleware as the WebSocket
|
|
11
|
+
* upgrade path:
|
|
12
|
+
* 204 No Content → reachable, session cookie valid
|
|
13
|
+
* 401/403 → reachable, session expired or invalid
|
|
14
|
+
* network fail → unreachable
|
|
15
|
+
*
|
|
16
|
+
* This closes a real gap: the browser's WebSocket API hides HTTP status from
|
|
17
|
+
* the handshake, so a 401 on the WS upgrade surfaces only as `close code
|
|
18
|
+
* 1006`. Without this HTTP probe, the client cannot distinguish auth failure
|
|
19
|
+
* from a network blip and loops reconnecting forever instead of redirecting
|
|
20
|
+
* the user to sign-in.
|
|
21
|
+
*
|
|
22
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/onLine
|
|
23
|
+
*/
|
|
24
|
+
import { getContext } from '../context.js';
|
|
25
|
+
import { SyncSessionError } from '../errors.js';
|
|
26
|
+
const PROBE_TIMEOUT_MS = 4000;
|
|
27
|
+
/**
|
|
28
|
+
* Derive the probe URL from a sync-server base URL. Accepts `ws://`,
|
|
29
|
+
* `wss://`, `http://`, `https://`, or a bare host — mirrors the
|
|
30
|
+
* normalisation in `BootstrapHelper` / `createSyncEngine`.
|
|
31
|
+
*/
|
|
32
|
+
function resolveProbeUrl(baseUrl) {
|
|
33
|
+
// Fall back to the legacy env var so callers that haven't been migrated
|
|
34
|
+
// to pass an explicit baseUrl keep working.
|
|
35
|
+
const resolved = baseUrl ??
|
|
36
|
+
(typeof process !== 'undefined' ? process.env?.NEXT_PUBLIC_GO_SERVER_URL : undefined) ??
|
|
37
|
+
'http://localhost:8080';
|
|
38
|
+
// Normalize ws → http so fetch() accepts the URL. Strip any trailing slash
|
|
39
|
+
// so we don't produce `//api/auth/check`.
|
|
40
|
+
const httpBase = resolved.replace(/^ws/, 'http').replace(/\/+$/, '');
|
|
41
|
+
return `${httpBase}/api/auth/check`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Probe the sync engine server with a lightweight HEAD request.
|
|
45
|
+
*
|
|
46
|
+
* Returns reachability AND session status in a single call, so the
|
|
47
|
+
* ConnectionStore can make the right state transition without guessing.
|
|
48
|
+
*
|
|
49
|
+
* @param baseUrl The sync-server base URL (HTTP or WS scheme accepted).
|
|
50
|
+
* If omitted, falls back to `NEXT_PUBLIC_GO_SERVER_URL` →
|
|
51
|
+
* `http://localhost:8080` for backwards compatibility.
|
|
52
|
+
*/
|
|
53
|
+
export async function probeNetwork(baseUrl) {
|
|
54
|
+
const url = resolveProbeUrl(baseUrl);
|
|
55
|
+
// Fast-fail: if navigator.onLine is false, skip the probe entirely.
|
|
56
|
+
// This is the ONE case where navigator.onLine is reliable (MDN: "false
|
|
57
|
+
// means definitely offline"). Use `=== false` rather than `!onLine`
|
|
58
|
+
// because Node 22+ exposes `navigator` with `onLine === undefined`,
|
|
59
|
+
// and `!undefined === true` would short-circuit the probe server-side.
|
|
60
|
+
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
61
|
+
return { reachable: false, sessionValid: null, latencyMs: null };
|
|
62
|
+
}
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
65
|
+
const start = performance.now();
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(url, {
|
|
68
|
+
method: 'HEAD',
|
|
69
|
+
credentials: 'include', // Send cookies for session check
|
|
70
|
+
signal: controller.signal,
|
|
71
|
+
// Cache-bust to avoid stale responses
|
|
72
|
+
headers: { 'Cache-Control': 'no-cache' },
|
|
73
|
+
});
|
|
74
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
75
|
+
if (SyncSessionError.isSessionErrorResponse(response.status)) {
|
|
76
|
+
// Server reachable but session expired/invalid
|
|
77
|
+
getContext().logger.info('[NetworkProbe] Server reachable, session expired', {
|
|
78
|
+
status: response.status,
|
|
79
|
+
latencyMs,
|
|
80
|
+
});
|
|
81
|
+
return { reachable: true, sessionValid: false, latencyMs };
|
|
82
|
+
}
|
|
83
|
+
// 2xx (including 204) means reachable + session valid.
|
|
84
|
+
// 3xx/4xx (non-auth) still prove connectivity even though the probe
|
|
85
|
+
// expected 204; log a warning so misconfigurations surface instead of
|
|
86
|
+
// silently passing.
|
|
87
|
+
if (response.status < 200 || response.status >= 300) {
|
|
88
|
+
getContext().logger.warn('[NetworkProbe] Unexpected probe response', {
|
|
89
|
+
status: response.status,
|
|
90
|
+
url,
|
|
91
|
+
latencyMs,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
getContext().logger.debug('[NetworkProbe] Server reachable, session valid', {
|
|
96
|
+
status: response.status,
|
|
97
|
+
latencyMs,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return { reachable: true, sessionValid: true, latencyMs };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
clearTimeout(timeout);
|
|
104
|
+
const isAbort = error instanceof DOMException && error.name === 'AbortError';
|
|
105
|
+
getContext().logger.info('[NetworkProbe] Probe failed', {
|
|
106
|
+
reason: isAbort ? 'timeout' : error.message,
|
|
107
|
+
});
|
|
108
|
+
return { reachable: false, sessionValid: null, latencyMs: null };
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearTimeout(timeout);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OfflineFlush — Replays queued offline mutations on reconnect.
|
|
3
|
+
*
|
|
4
|
+
* SDK-generic version: delegates to MutationDispatcher from context.
|
|
5
|
+
*/
|
|
6
|
+
import { OfflineTransactionStore } from './OfflineTransactionStore.js';
|
|
7
|
+
import { getContext } from '../context.js';
|
|
8
|
+
let _offlineTxStore = null;
|
|
9
|
+
function getOfflineTxStore() {
|
|
10
|
+
if (!_offlineTxStore) {
|
|
11
|
+
_offlineTxStore = new OfflineTransactionStore();
|
|
12
|
+
}
|
|
13
|
+
return _offlineTxStore;
|
|
14
|
+
}
|
|
15
|
+
export async function flushOfflineQueueOnce() {
|
|
16
|
+
const store = getOfflineTxStore();
|
|
17
|
+
await store.init();
|
|
18
|
+
const dispatcher = getContext().mutationDispatcher;
|
|
19
|
+
return store.flush(async (tx) => {
|
|
20
|
+
await dispatcher.dispatch(tx.opName, tx.request.variables || {});
|
|
21
|
+
});
|
|
22
|
+
}
|