Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
64 changes: 64 additions & 0 deletions .changeset/module-state_interval-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
"@equinor/fusion-framework-module-state": major
---

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 }));
```

**Breaking change:** the scheduled pull dispatches a new `onStateSync.poll` event, added to the
exported `StateSyncEventType`/`StateSyncEvent` union. Consumers with an exhaustive `switch`/`if`
chain over `StateSyncEventType` (e.g. a `default: assertNever(event)` branch) need a new case for
`StateSyncEvent.Poll`/`event.type === 'onStateSync.poll'`, or that check will fail to compile
after upgrading:

```typescript
// Before: exhaustive over 4 members
switch (event.type) {
case 'onStateSync.change': /* ... */ break;
case 'onStateSync.complete': /* ... */ break;
case 'onStateSync.error': /* ... */ break;
case 'onStateSync.status': /* ... */ break;
default: assertNever(event);
}

// After: add the new member
switch (event.type) {
case 'onStateSync.change': /* ... */ break;
case 'onStateSync.complete': /* ... */ break;
case 'onStateSync.error': /* ... */ break;
case 'onStateSync.status': /* ... */ break;
case 'onStateSync.poll': /* ... */ break;
default: assertNever(event);
}
```

Consumers that switch over a narrower type, or that only inspect specific event kinds (e.g. via
`StateSyncEvent.Change.is(event)`), are unaffected.

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
9 changes: 8 additions & 1 deletion packages/modules/state/docs/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ a sync session is active.
| `StateSyncCompleteEvent` | `onStateSync.complete` | `result` (`{ push?, pull? }`), optional `id` |
| `StateSyncErrorEvent` | `onStateSync.error` | `error`, `type` (`'error' \| 'denied'`), optional `id` |
| `StateSyncStatusEvent` | `onStateSync.status` | `status`, optional `id` |
| `StateSyncPollEvent` | `onStateSync.poll` | `trigger` (`'initial' \| 'interval' \| 'focus'`), `skipped`, optional `id` |

```typescript
modules.event.addEventListener('onStateSync.error', (event) => {
Expand All @@ -81,9 +82,15 @@ modules.event.addEventListener('onStateSync.error', (event) => {
modules.event.addEventListener('onStateSync.status', (event) => {
console.log('Sync status:', event.status);
});

// Dispatched by PouchDbSyncStorage's non-'live' pull modes ('interval'/'visible-interval') on
// the initial pull, each interval tick, and each focus catch-up - even when nothing changed.
modules.event.addEventListener('onStateSync.poll', (event) => {
console.log('Polled for changes:', event.trigger, 'skipped:', event.skipped);
});
```

Use `StateSyncEvent.is(event)` to match any of the four sync events at once, e.g. for a single
Use `StateSyncEvent.is(event)` to match any of the five sync events at once, e.g. for a single
telemetry hook that logs all sync activity.

## Operation events
Expand Down
48 changes: 48 additions & 0 deletions packages/modules/state/docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,54 @@ enableStateModule(configurator, async (builder) => {
- **`filter`**: Apply custom filters to replicate only specific documents
- **`since`**: Start replication from a specific sequence number

## Configurable Pull Scheduling with PouchDbSyncStorage

`PouchDbSyncStorage` wraps a local/remote database pair and starts replication for you. By
default it behaves like the `db.sync()` example above: a single continuous, bidirectional
connection. A production app with many idle tabs open at once, though, rarely needs pulled
changes in real time - the `pull` option lets push stay live (so local writes are never
delayed) while pull is scheduled instead of continuous:

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

const storage = new PouchDbSyncStorage({
localDb: { name_or_instance: 'my-app-state' },
remoteDb: { name_or_instance: 'http://localhost:5984/my-app-state' },
syncOptions: { retry: true },
// Push stays live; pull runs once now, then every 60s, and again whenever the tab regains focus.
pull: { mode: 'interval', intervalMs: 60000, refreshOnFocus: true },
});
```

`pull.mode` options:
- **`'live'`** (default): unchanged - a single continuous bidirectional `db.sync()` connection.
- **`'interval'`**: keeps push live via `db.replicate.to`, and replaces the live pull with
one-shot `db.replicate.from` calls run on `pull.intervalMs` (default `60000`) and, unless
`pull.refreshOnFocus: false`, whenever the document becomes visible again - regardless of
whether the tab is currently visible.
- **`'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.

A scheduled pull dispatches an `onStateSync.poll` event (with `{ trigger, skipped }`) each time
it runs or is skipped because a previous pull is still in flight - see
[Monitoring Sync Progress](#monitoring-sync-progress) below.

### Default Storage

`@equinor/fusion-framework-module-state/default-storage` exports `createDefaultStorage`, the
factory the framework itself uses to resolve service discovery, auth, and the per-user CouchDB
proxy into a `PouchDbSyncStorage`. It defaults to `pull: { mode: 'visible-interval',
refreshOnFocus: true }` (a 60s `intervalMs`). Call it directly to reuse that resolution with a
different `pull` schedule instead of reimplementing it:

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

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

## Monitoring Sync Progress

The state module provides comprehensive sync event monitoring through RxJS observables:
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
Loading
Loading