Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 7 additions & 0 deletions .changeset/progressive-persisted-initial-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/db': minor
'@tanstack/electric-db-collection': minor
'@tanstack/db-sqlite-persistence-core': minor
---

Allow persisted initial rendering from previously completed Electric progressive snapshots. Validate adapter-owned completion metadata and durable row-set evidence before local fallback. Cold, partial, and legacy progressive caches wait for network data; legacy cursors without completion evidence trigger a fresh full sync. Report unavailable snapshots without hanging initial-render waits on network failure.
14 changes: 14 additions & 0 deletions docs/collections/electric-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,20 @@ await todosCollection.utils.awaitMatch(
)
```

### Persisted initial rendering with progressive sync

Progressive sources expose completion evidence to SQLite persistence. Configure
`initialRender: { strategy: 'network-first', networkTimeoutMs: 0 }` on the
persisted options to render a previously completed snapshot while syncing.
Partial caches and cold starts continue to wait for the network. See
[SQLite persistence](../guides/sqlite-persistence.md#render-after-sqlite-restore).

Electric writes `snapshotComplete` in its durable resume metadata in the same
transaction as a completed progressive snapshot. Progressive caches written by
older versions lack this evidence and conservatively fetch a fresh full snapshot
once; subsequent launches can resume normally. This also prevents a cursor
left by a subset-only source from being treated as a complete progressive cache.

### Cleanup and resume safety

Transaction evidence and pending `awaitTxId`/`awaitMatch` calls belong to one
Expand Down
27 changes: 25 additions & 2 deletions docs/contributing/oracle-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -2441,8 +2441,8 @@ a hostile wrong-answer control, and an explicit statement of remaining limits.
The persisted-readiness oracle in
`packages/db-sqlite-persistence-core/tests/persisted-readiness-oracle.test.ts`
owns the opted-in eager restore boundary through the persisted wrapper,
Collection, live query, and observer. A finite two-source model checks all 36
opt-in/outcome combinations independently of the observer classifier. Its
Collection, live query, and observer. A finite two-source model checks all 64
opt-in/outcome combinations, including an unavailable completed snapshot independently of the observer classifier. Its
controlled persistence histories check nonempty
and empty restore while upstream remains pending, all-source conjunction,
mixed opt-out, nested live-query sources, exact failure, SSR seed handoff,
Expand Down Expand Up @@ -2472,6 +2472,29 @@ network provider; it does not prove browser scheduling or arbitrary histories.
The framework identity scenario does not cover every possible rejection value
or an error already present before a hook mounts.

Progressive sources supply an adapter-owned completed-snapshot capability.
The empty/nonempty controlled histories exercise that capability through core's
on-demand representation, with a held subset acquisition. Joined restoration and
stale cleanup/restart histories cover both eager and progressive representations.
A network failure before or after discovering an unavailable snapshot must reject,
not render or hang. The finite conjunction includes unavailable source outcomes.
The React Suspense receiving case holds rendering when restoration is unavailable.

The cases in `electric-resume-snapshot-races.test.ts` use the Electric adapter,
real Node SQLite managed caches, and controlled SDK delivery. Completed empty and
nonempty snapshots are eligible before delivery. Cold, partial, legacy, reset,
shape-mismatched, tag-dependent, and row-loss caches are ineligible. Explicit
cursor starts cannot certify an unseen full baseline. Network completion saves
the marker and rows together; reopening renders those rows without SDK delivery.
Removing the completion check makes the partial/cold/legacy cases report ready
at their unavailable checkpoint; all three fail under that mutation. Existing
resume/recovery fixtures explicitly seed completion evidence when their contract
starts from a complete snapshot; the separate legacy case retains missing evidence.
These witnesses do not establish browser OPFS scheduling, multi-tab coordination,
or cache rotation during a pending initial fallback. Those host-specific paths
remain with the persistence lifecycle owners; this change makes no new guarantees
about them.

## Deferred contracts and evidence

The maintainer assigned offline policy work to
Expand Down
46 changes: 43 additions & 3 deletions docs/guides/sqlite-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,54 @@ An on-demand cache is not yet a certified offline snapshot. The first managed ge

## Render after SQLite restore

`initialRender` requires an eager persisted Collection. It does not support `syncMode: 'on-demand'`, which has no complete startup snapshot. Set it on every source Collection in a live query. If one source does not opt in, the query cannot report persisted readiness or use the restored snapshot as its initial-render fallback.
`initialRender` supports eager persisted Collections and Electric progressive sources with a certified completed snapshot. Genuine `syncMode: 'on-demand'` sources remain unsupported. Set it on every source Collection in a live query. If one source does not opt in, the query cannot report persisted readiness or use the restored snapshot as its initial-render fallback.

Install the React binding to use the following components:

```sh
npm install @tanstack/react-db
```

Electric progressive sources are represented as on-demand Collections internally. Electric supplies the completion capability automatically; no additional flag is needed:

```ts
persistedCollectionOptions({
...electricCollectionOptions({
id: 'todos',
syncMode: 'progressive',
getKey: (todo: Todo) => todo.id,
shapeOptions: {
url: 'http://localhost:3000/v1/shape',
params: { table: 'todos' },
},
}),
persistence: syncedPersistence,
initialRender: {
strategy: 'network-first',
networkTimeoutMs: 0,
},
})
```

Import `electricCollectionOptions` from `@tanstack/electric-db-collection`.
For progressive sources, local fallback requires a full snapshot that completed
in a previous sync, for the same Electric shape, with consistent stored row-set
evidence. Electric saves the completion marker atomically with the full snapshot.
A completed empty snapshot is valid; a new empty cache is not.

If the cache is partial, has no completion marker, belongs to a different shape,
requires unavailable tag state, uses an explicit cursor, or fails row-set
validation, `persistedStatus`
is `unavailable`. Initial rendering waits for network readiness instead. A
network failure rejects the initial-render wait rather than displaying partial
cached data or waiting indefinitely. Legacy progressive caches without this
marker perform a fresh sync once to establish it.

Restoration still loads the entire completed snapshot into memory. Ordinary
`useLiveQuery` consumers must respect readiness if they want this gating;
`initialRender` does not hide rows from direct Collection reads. The complete
snapshot may be stale; background sync continues after local fallback.

A regular React live query exposes both readiness signals:

```tsx
Expand All @@ -266,9 +306,9 @@ function TodosView() {
}
```

`isReady` reports Collection readiness. `isPersistedReady` reports that every opted-in source finished its current eager restore, even when SQLite had no rows. These flags do not identify where each visible row came from. A regular `useLiveQuery` does not wait for the network-first deadline before it renders rows.
`isReady` reports Collection readiness. `isPersistedReady` reports that every opted-in source finished its current local restore, even when SQLite had no rows. These flags do not identify where each visible row came from. A regular `useLiveQuery` does not wait for the network-first deadline before it renders rows.

The hooks also expose `persistedStatus` and `persistedError`. The status is `unavailable` if a source did not opt in, `loading` during restore, `ready` after every restore completes, or `error` if a restore fails. `persistedError` keeps the restore error. An SSR seed does not count as a SQLite restore.
The hooks also expose `persistedStatus` and `persistedError`. The status is `unavailable` if a source did not opt in or lacks an eligible completed snapshot, `loading` during restore, `ready` after every restore completes, or `error` if a restore fails. `persistedError` keeps the restore error. An SSR seed does not count as a SQLite restore.

React, Solid, and Svelte read these values as properties. Angular reads signals such as `result.isPersistedReady()`. Vue reads refs such as `result.isPersistedReady.value`.

Expand Down
50 changes: 43 additions & 7 deletions packages/db-sqlite-persistence-core/src/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,10 @@ class PersistedCollectionRuntime<
private readonly dbName: string,
private readonly persistedReadiness?: PersistedReadinessTracker,
private readonly optionsClaim?: () => void | (() => void),
private readonly isPersistedSnapshotComplete?: SyncConfig<
T,
TKey
>[`isPersistedSnapshotComplete`],
) {}

private get storageCollectionId(): string {
Expand Down Expand Up @@ -2254,7 +2258,9 @@ class PersistedCollectionRuntime<
}
if (lifecycleGeneration === this.lifecycleGeneration) {
this.startupSettled = true
this.persistedReadiness?.set({ status: `ready` })
if (this.persistedReadiness?.getSnapshot().status === `loading`) {
this.persistedReadiness.set({ status: `ready` })
}
}
resolveStartupMetadata()
})().catch((error) => {
Expand Down Expand Up @@ -2543,6 +2549,7 @@ class PersistedCollectionRuntime<
private async hydrateBaseline(
lifecycleGeneration: number,
adapter: HydrationPersistenceAdapter,
requireCompleteSnapshot = false,
): Promise<number | undefined | false> {
if (lifecycleGeneration !== this.lifecycleGeneration) return undefined

Expand All @@ -2556,6 +2563,7 @@ class PersistedCollectionRuntime<
{
lifecycleGeneration,
bindKeySetEvidence: true,
requireCompleteSnapshot,
},
adapter,
)
Expand Down Expand Up @@ -2604,8 +2612,12 @@ class PersistedCollectionRuntime<
onStartupMetadataLoaded()
}
const appliedCursor =
this.syncMode !== `on-demand`
? await this.hydrateBaseline(lifecycleGeneration, adapter)
this.syncMode !== `on-demand` || this.persistedReadiness !== undefined
? await this.hydrateBaseline(
lifecycleGeneration,
adapter,
this.isPersistedSnapshotComplete !== undefined,
)
: undefined
return lifecycleGeneration === this.lifecycleGeneration
? {
Expand Down Expand Up @@ -3284,6 +3296,7 @@ class PersistedCollectionRuntime<
config: {
lifecycleGeneration: number
bindKeySetEvidence?: boolean
requireCompleteSnapshot?: boolean
requestLocalLoadFailure?: boolean
rejectBufferedReplayFailure?: boolean
},
Expand All @@ -3302,6 +3315,7 @@ class PersistedCollectionRuntime<
try {
let rows: Array<{ key: TKey; value: T; metadata?: unknown }>
let bindResumeSnapshot: (() => void) | undefined
let canHydrate = true
if (config.bindKeySetEvidence && !this.scopedRecovery) {
const snapshot = await adapter.loadResumeSnapshot(
this.storageTargetFor(),
Expand All @@ -3319,6 +3333,20 @@ class PersistedCollectionRuntime<
if (config.lifecycleGeneration !== this.lifecycleGeneration)
return true
if (resetSequence !== this.resetSequence) return true
if (config.requireCompleteSnapshot) {
canHydrate =
snapshot.keySet?.status === `consistent` &&
this.isPersistedSnapshotComplete?.(
new Map(
snapshot.collectionMetadata.map(({ key, value }) => [
key,
value,
]),
),
) === true
if (!canHydrate)
this.persistedReadiness?.set({ status: `unavailable` })
}
bindResumeSnapshot = () => this.bindResumeSnapshotEvidence(snapshot)
boundRowVersion = snapshot.latestRowVersion
} else {
Expand Down Expand Up @@ -3350,8 +3378,9 @@ class PersistedCollectionRuntime<
bindResumeSnapshot?.()

if (
!config.bindKeySetEvidence ||
this.persistedKeySetEvidence?.status !== `incompatible`
canHydrate &&
(!config.bindKeySetEvidence ||
this.persistedKeySetEvidence?.status !== `incompatible`)
) {
for (const row of rows) {
hydrationContext.suppliedRowKeys.add(row.key)
Expand Down Expand Up @@ -6830,9 +6859,13 @@ export function persistedCollectionOptions<
}

const networkTimeoutMs = getNetworkTimeoutMs(options.initialRender)
if (networkTimeoutMs !== undefined && options.syncMode === `on-demand`) {
if (
networkTimeoutMs !== undefined &&
options.syncMode === `on-demand` &&
!(hasOwnSyncKey(options) && options.sync.isPersistedSnapshotComplete)
) {
throw new InvalidPersistedCollectionConfigError(
`network-first initial rendering requires eager persistence syncMode`,
`network-first initial rendering requires eager persistence syncMode or an adapter that certifies completed persisted snapshots`,
)
}
const persistedReadinessTracker =
Expand Down Expand Up @@ -6885,6 +6918,9 @@ export function persistedCollectionOptions<
collectionId,
persistedReadinessTracker,
claimOptions,
persistedReadinessTracker
? syncOptions.sync.isPersistedSnapshotComplete
: undefined,
)
claimedRuntime = runtime

Expand Down
Loading