Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
fc578a7
feat(module-state): add configurable pull scheduling with watchdog fa…
odinr Aug 6, 2026
e1430b0
fix(module-state): guard cancelled replication results, complete watc…
odinr Aug 6, 2026
1dfa6e5
style(module-state): apply biome formatting to observe-pouch-db-repli…
odinr Aug 6, 2026
bf91b75
fix(module-state): surface pull rejection errors and register one-sho…
odinr Aug 6, 2026
f12b1bd
test(module-state): cover visible-interval pull mode
odinr Aug 6, 2026
adc7705
fix(module-state): pass a fake ReplicationResult to the visible-inter…
odinr Aug 6, 2026
d30d99c
fix(module-state): make sync() supersede non-live pull scheduling, us…
odinr Aug 6, 2026
db1ab13
test(module-state): cover watchdog progress-resets and sync()'s takeo…
odinr Aug 6, 2026
5b8498a
fix(module-state): cancel an in-flight one-shot pull when sync() supe…
odinr Aug 6, 2026
ae1b5a0
docs(module-state): mark the pull-scheduling changeset as a major, br…
odinr Aug 6, 2026
79e7541
docs(module-state): document PouchDbSyncStorage's pull option and cre…
odinr Aug 6, 2026
3782f59
test(module-state): fix CI failures in the watchdog and sync() tests
odinr Aug 6, 2026
b1c3862
fix(module-state): snapshot teardown callbacks before disposal
odinr Aug 6, 2026
e773366
fix(module-state): merge direction-specific sync options and guard wa…
odinr Aug 6, 2026
4dea4b3
test(module-state): cover disposal while a pull is still in flight
odinr Aug 6, 2026
ebfd4ee
docs(module-state): document the onStateSync.poll event
odinr Aug 6, 2026
eff8ef4
fix(module-state): add missing removeListener to sync() test mock
odinr Aug 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cookbook-app-react-state_poll-preview.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@equinor/fusion-framework-cookbook-app-react-state": patch
---

`SyncStatusIndicator` now recognizes the `onStateSync.poll` event kind, and the cookbook's default (non-`FUSION_SPA_COUCHDB_URL`) storage now overrides `pull.intervalMs` to 10s via `createDefaultStorage`, so a scheduled pull is visible while previewing the cookbook instead of waiting on the framework's 60s production default.
34 changes: 34 additions & 0 deletions .changeset/module-state_interval-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@equinor/fusion-framework-module-state": minor
---

Add a `pull` option to `PouchDbSyncStorage` for controlling how remote changes are pulled during sync, switch the default storage created by `createDefaultStorage()` to use it, and expose `createDefaultStorage` itself so callers can reuse it with custom overrides.

Previously, `PouchDbSyncStorage` always used a single bidirectional `db.sync()` connection, meaning every client kept a live `_changes` longpoll open for both push and pull. At production user counts this is a large number of concurrently open connections for a direction (pull) that's rarely needed in real time.

The new `pull` option lets push stay live (so local writes are never delayed) while pull is scheduled instead of continuous:

```typescript
new PouchDbSyncStorage({
localDb,
remoteDb,
syncOptions,
// Push stays live; pull runs once now, then every 60s, and again whenever the tab regains focus.
pull: { mode: 'interval', intervalMs: 60000, refreshOnFocus: true },
});
```

- `mode: 'live'` (default, unchanged): behaves exactly as before, via `db.sync()`.
- `mode: 'interval'`: keeps push live via `db.replicate.to`, and replaces the live pull with one-shot `db.replicate.from` calls run on `intervalMs` (default `60000`) and, unless `refreshOnFocus: false`, whenever the document becomes visible again - regardless of whether the tab is currently visible.
- `mode: 'visible-interval'`: the same as `'interval'`, except the timer tick is skipped entirely while the tab is hidden (via the Page Visibility API) - a backgrounded tab has no user waiting on fresh data, so there's no reason to hold a connection open or make a request for it.

`createDefaultStorage()` now uses `pull: { mode: 'visible-interval', refreshOnFocus: true }` by default (a 60s `intervalMs`), so apps using the framework's default state storage no longer keep a continuous pull connection open per idle tab, and pause polling entirely while a tab is backgrounded.

`createDefaultStorage` is now also exported from `@equinor/fusion-framework-module-state/default-storage`, so a caller who wants the framework's default remote-resolution behavior (service discovery, auth, the per-user CouchDB proxy) but with different `pull` scheduling can call it directly instead of reimplementing that resolution:

```typescript
import { createDefaultStorage } from '@equinor/fusion-framework-module-state/default-storage';

config.setStorage((args) => createDefaultStorage(appKey, args, { intervalMs: 10000 }));
```

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface SyncStatusIndicatorProps {
showTimestamp?: boolean;
}

type SyncStatusKind = 'active' | 'paused' | 'change' | 'complete' | 'error' | 'offline';
type SyncStatusKind = 'active' | 'paused' | 'change' | 'complete' | 'poll' | 'error' | 'offline';

const getStatusKind = (event?: StateSyncEventType): SyncStatusKind => {
// No event has arrived yet - either sync hasn't started, or replication isn't configured.
Expand All @@ -19,8 +19,11 @@ const getStatusKind = (event?: StateSyncEventType): SyncStatusKind => {
}
// Every other sync event kind maps 1:1 to an indicator label.
if (StateSyncEvent.Change.is(event)) return 'change';
// Any remaining sync event kind is either a completion or an error.
// A completed replication batch, whether or not anything actually changed.
if (StateSyncEvent.Complete.is(event)) return 'complete';
// Interval-mode polling (timer/focus/initial) - distinct from an actual error.
if (StateSyncEvent.Poll.is(event)) return 'poll';
// Any remaining sync event kind is an error.
return 'error';
};

Expand All @@ -29,6 +32,7 @@ const getStatusColor = (kind: SyncStatusKind) => {
switch (kind) {
case 'active':
case 'change':
case 'poll':
return '#007BFF'; // Blue for syncing
case 'paused':
case 'complete':
Expand All @@ -49,6 +53,8 @@ const getStatusText = (kind: SyncStatusKind) => {
return 'Up to date';
case 'change':
return 'Changes detected';
case 'poll':
return 'Polling...';
case 'error':
return 'Sync error';
case 'complete':
Expand Down
39 changes: 22 additions & 17 deletions cookbooks/app-react-state/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,34 @@
import type { AppModuleInitiator } from '@equinor/fusion-framework-react-app';
import { enableAppState } from '@equinor/fusion-framework-react-app/state';
import { PouchDbSyncStorage } from '@equinor/fusion-framework-module-state/storage';
import { createDefaultStorage } from '@equinor/fusion-framework-module-state/default-storage';
import { enableNavigation } from '@equinor/fusion-framework-module-navigation';

// Set in `.env` (see `.env.example`) to showcase replication against the local Docker
// CouchDB from `couchdb.sh` - unset, the state module falls back to its own default
// storage, the same zero-config behavior any consuming app gets.
// CouchDB from `couchdb.sh` - unset, the state module falls back to the framework's
// default storage below, the same zero-config behavior any consuming app gets.
const couchdbUrl = import.meta.env.FUSION_SPA_COUCHDB_URL;

export const configure: AppModuleInitiator = (appConfigurator, { env }) => {
enableAppState(
appConfigurator,
couchdbUrl
? (config) => {
// `onStateSync.*` events shown on this cookbook's pages come from the framework.
config.setStorage(
new PouchDbSyncStorage({
localDb: { name_or_instance: 'cookbook_app_state' },
remoteDb: { name_or_instance: couchdbUrl },
syncOptions: { live: true, retry: true, heartbeat: 10000, timeout: 30000 },
}),
);
}
: undefined,
);
enableAppState(appConfigurator, (config) => {
// `onStateSync.*` events shown on this cookbook's pages come from the framework.
if (couchdbUrl) {
config.setStorage(
new PouchDbSyncStorage({
localDb: { name_or_instance: 'cookbook_app_state' },
remoteDb: { name_or_instance: couchdbUrl },
syncOptions: { live: true, retry: true, heartbeat: 10000, timeout: 30000 },
}),
);
return;
}
// Use the real default storage, but with a shorter pull interval than production's
// (see `createDefaultStorage`'s own default) - nobody wants to wait a minute to see
// a poll happen while previewing this cookbook.
config.setStorage((args) =>
createDefaultStorage(appConfigurator.manifest.appKey, args, { intervalMs: 10_000 }),
);
});

// Enable navigation module (allow navigation between pages)
enableNavigation(appConfigurator, env.basename);
Expand Down
7 changes: 7 additions & 0 deletions packages/modules/state/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"import": "./dist/esm/storage/index.js",
"types": "./dist/types/storage/index.d.ts"
},
"./default-storage": {
"import": "./dist/esm/create-default-storage.js",
"types": "./dist/types/create-default-storage.d.ts"
},
"./package.json": "./package.json",
"./README.md": "./README.md"
},
Expand All @@ -31,6 +35,9 @@
],
"storage": [
"dist/types/storage/index.d.ts"
],
"default-storage": [
"dist/types/create-default-storage.d.ts"
]
}
},
Expand Down
208 changes: 208 additions & 0 deletions packages/modules/state/src/__tests__/PouchDbSyncStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { PouchDbStorage } from '../storage/PouchDbStorage.js';
import { PouchDbSyncStorage } from '../storage/PouchDbSyncStorage.js';
import { StateSyncEvent, type StateEventType } from '../events/index.js';
import type { StateSyncPollEvent } from '../events/StateSyncPollEvent.js';

describe('PouchDbSyncStorage', () => {
let localDb: PouchDB.Database;
let remoteDb: PouchDB.Database;

beforeEach(() => {
localDb = PouchDbStorage.CreateDb('test-sync-local');
remoteDb = PouchDbStorage.CreateDb('test-sync-remote');
});

afterEach(async () => {
await Promise.all([localDb.destroy(), remoteDb.destroy()]);
});

describe('pull.mode "interval"', () => {
it('keeps push live (local writes reach the remote) while scheduling pulls on a timer', async () => {
const storage = new PouchDbSyncStorage({
localDb: { name_or_instance: localDb },
remoteDb: { name_or_instance: remoteDb },
syncOptions: {},
pull: { mode: 'interval', intervalMs: 20, refreshOnFocus: false },
});

await storage.initialize();
await storage.putItem({ key: 'push-test', value: 'from-local' });

// Push stays live, so the local write should reach the remote without waiting for the pull timer.
await vi.waitFor(
async () => {
const remoteDoc = await remoteDb.get('push-test').catch(() => undefined);
expect(remoteDoc?.value).toBe('from-local');
},
{ timeout: 2000 },
);

storage[Symbol.dispose]();
});

it('surfaces a remote-only write via a scheduled pull, without needing a continuous pull connection', async () => {
const storage = new PouchDbSyncStorage({
localDb: { name_or_instance: localDb },
remoteDb: { name_or_instance: remoteDb },
syncOptions: {},
pull: { mode: 'interval', intervalMs: 20, refreshOnFocus: false },
});

await storage.initialize();
await remoteDb.put({ _id: 'pull-test', value: 'from-remote' });

// The interval-scheduled one-shot pull (not a live connection) should bring this down.
await vi.waitFor(
async () => {
const localItem = await storage.item('pull-test');
expect(localItem?.value).toBe('from-remote');
},
{ timeout: 2000 },
);

storage[Symbol.dispose]();
});

it('dispatches a "onStateSync.poll" event for the initial pull, distinct from status/complete', async () => {
const storage = new PouchDbSyncStorage({
localDb: { name_or_instance: localDb },
remoteDb: { name_or_instance: remoteDb },
syncOptions: {},
pull: { mode: 'interval', intervalMs: 500, refreshOnFocus: false },
});

const events: StateEventType[] = [];
const subscription = storage.events$.subscribe((event) => events.push(event));

await storage.initialize();

// `_initialize()` runs an immediate first pull before the interval ever fires.
await vi.waitFor(
() => {
const initialPoll = events
// Fires even if the poll finds nothing new, unlike PouchDB's own active/paused events.
.find((event): event is StateSyncPollEvent => StateSyncEvent.Poll.is(event));
expect(initialPoll?.detail).toEqual({ trigger: 'initial', skipped: false });
},
{ timeout: 2000 },
);

subscription.unsubscribe();
storage[Symbol.dispose]();
});
});

describe('pull.mode "visible-interval"', () => {
const setVisibility = (state: DocumentVisibilityState) => {
Object.defineProperty(document, 'visibilityState', { value: state, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));
};

afterEach(() => {
// Other describe blocks in this file assume a visible tab by default.
setVisibility('visible');
});

it('skips interval ticks while hidden, then pulls exactly once on returning to visible', async () => {
// Captures each replication's registered handlers so the test can drive 'complete'
// itself - deterministic, instead of racing real PouchDB I/O against the interval timer.
const fakeReplications: Array<{ complete: () => void }> = [];
const replicateFrom = vi.spyOn(localDb.replicate, 'from').mockImplementation(() => {
const handlers: Record<string, Array<(change: { docs: unknown[] }) => void>> = {};
const replication = {
on: vi.fn((event: string, handler: (change: { docs: unknown[] }) => void) => {
if (!handlers[event]) handlers[event] = [];
handlers[event].push(handler);
}),
removeListener: vi.fn(),
// biome-ignore lint/suspicious/noThenProperty: mocking PouchDB's Replication, which is genuinely thenable.
then: vi.fn(),
cancel: vi.fn(),
};
fakeReplications.push({
complete: () => {
// PouchDB's real 'complete' event always carries a result object - onComplete
// (observe-pouch-db-replicate.ts) reads `.docs` off it directly.
handlers.complete?.forEach((handler) => {
handler({ docs: [] });
});
},
});
return replication as unknown as ReturnType<typeof localDb.replicate.from>;
});

setVisibility('hidden');
const storage = new PouchDbSyncStorage({
localDb: { name_or_instance: localDb },
remoteDb: { name_or_instance: remoteDb },
syncOptions: {},
pull: { mode: 'visible-interval', intervalMs: 20, refreshOnFocus: true },
});

await storage.initialize();
expect(replicateFrom).toHaveBeenCalledTimes(1); // the always-runs initial pull

// Several interval ticks pass while hidden - the schedule should skip every one of them.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(replicateFrom).toHaveBeenCalledTimes(1);

// Release the initial pull, so the upcoming focus trigger isn't skipped as already in flight.
fakeReplications[0].complete();

setVisibility('visible');
// Returning to visible triggers exactly one catch-up pull, not one per missed tick.
expect(replicateFrom).toHaveBeenCalledTimes(2);

replicateFrom.mockRestore();
storage[Symbol.dispose]();
});
});

describe('pull watchdog', () => {
it('cancels a hung pull replication and releases it for the next scheduled pull', async () => {
vi.useFakeTimers();
// A stub that never fires 'complete'/'error' and never settles its thenable -
// the exact failure mode the watchdog exists to recover from.
const cancel = vi.fn();
// biome-ignore lint/suspicious/noThenProperty: mocking PouchDB's Replication, which is genuinely thenable.
const hungReplication = { on: vi.fn(), removeListener: vi.fn(), then: vi.fn(), cancel };
const replicateFrom = vi
.spyOn(localDb.replicate, 'from')
.mockReturnValue(hungReplication as unknown as ReturnType<typeof localDb.replicate.from>);

const storage = new PouchDbSyncStorage({
localDb: { name_or_instance: localDb },
remoteDb: { name_or_instance: remoteDb },
syncOptions: { timeout: 1000 },
pull: { mode: 'interval', intervalMs: 10000, refreshOnFocus: false },
});

const events: StateEventType[] = [];
const subscription = storage.events$.subscribe((event) => events.push(event));

await storage.initialize();
expect(cancel).not.toHaveBeenCalled();

// watchdogMs is syncOptions.timeout (1000) + 5000.
await vi.advanceTimersByTimeAsync(6000);
expect(cancel).toHaveBeenCalledTimes(1);

// If the watchdog hadn't released `#pullInFlight`, this next interval tick would
// report `skipped: true` instead of starting a second real pull attempt.
await vi.advanceTimersByTimeAsync(4000);
const pollDetails = events
.filter((event): event is StateSyncPollEvent => StateSyncEvent.Poll.is(event))
.map((event) => event.detail);
expect(pollDetails).toEqual([
{ trigger: 'initial', skipped: false },
{ trigger: 'interval', skipped: false },
]);

subscription.unsubscribe();
storage[Symbol.dispose]();
replicateFrom.mockRestore();
vi.useRealTimers();
});
});
});
Loading
Loading