Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
07f8d91
fix: preserve persisted resume baseline integrity
KyleAMathews Sep 17, 2026
758532d
chore: add persisted resume integrity changeset
KyleAMathews Sep 17, 2026
8024319
Merge github/main into rfc-1659-ws1-red-oracle
KyleAMathews Sep 17, 2026
41c2645
test(sqlite): allow CLI oracle for CI load
KyleAMathews Sep 17, 2026
9d74e9b
fix(sqlite): preserve runtime-owned resume resets
KyleAMathews Sep 17, 2026
de7412f
ci: trigger PR checks
KyleAMathews Sep 18, 2026
f6c293e
docs(test): explain persisted resume oracles
KyleAMathews Sep 21, 2026
c44e908
Merge github/main into rfc-1659-ws1-red-oracle
KyleAMathews Sep 21, 2026
f80fa5c
fix: require complete sync persistence capability
KyleAMathews Sep 21, 2026
9c85990
fix: enforce complete persistence forwarding
KyleAMathews Sep 21, 2026
a330d43
fix(electric-db-collection): require certified resume evidence
KyleAMathews Sep 21, 2026
8533802
test(electric): cover on-demand resume evidence loss
KyleAMathews Sep 21, 2026
230190a
Merge remote-tracking branch 'origin/main' into rfc-1659-ws1-red-oracle
KyleAMathews Sep 21, 2026
b5c5d79
Merge github/main into rfc-1659-ws1-red-oracle
KyleAMathews Sep 21, 2026
2cafeb8
Merge concurrent PR head reconciliation
KyleAMathews Sep 21, 2026
6613b2c
fix(sqlite): reduce resume evidence overhead
KyleAMathews Sep 22, 2026
17bfedb
Merge github/main into rfc-1659-ws1-red-oracle
KyleAMathews Sep 22, 2026
abc74ad
test(sqlite-persistence): update adapter type oracle
KyleAMathews Sep 22, 2026
382b38b
fix(sqlite): preserve on-demand rows and trim evidence work
KyleAMathews Sep 22, 2026
063158c
docs(db): mark sync persistence protocol internal
KyleAMathews Sep 22, 2026
4d80452
docs(oracles): track reusable boundary laws
KyleAMathews Sep 22, 2026
924c689
docs(oracles): record reusable law owners
KyleAMathews Sep 22, 2026
46e7442
test(persistence): prove membership scan observer
KyleAMathews Sep 22, 2026
a8c2097
test(persistence): execute reusable boundary laws
KyleAMathews Sep 22, 2026
ae521cf
fix(persistence): fence startup and schema generations
KyleAMathews Sep 23, 2026
be80327
fix(node-sqlite): coordinate shared database handles
KyleAMathews Sep 23, 2026
f817fbe
Merge remote-tracking branch 'github/main' into rfc-1659-ws1-red-oracle
KyleAMathews Sep 23, 2026
8f31e50
fix(react-native-sqlite): coordinate shared database handles
KyleAMathews Sep 23, 2026
0bdd139
fix(sync): accept omitted optional metadata
KyleAMathews Sep 23, 2026
06eb562
test(oracles): add persistence replay lanes
KyleAMathews Sep 23, 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
14 changes: 14 additions & 0 deletions .changeset/preserve-resume-baseline-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/db-sqlite-persistence-core': minor
'@tanstack/db': minor
'@tanstack/browser-db-sqlite-persistence': patch
'@tanstack/electron-db-sqlite-persistence': minor
'@tanstack/electric-db-collection': patch
'@tanstack/query-db-collection': patch
'@tanstack/node-db-sqlite-persistence': patch
'@tanstack/react-native-db-sqlite-persistence': patch
---

Preserve persisted resume integrity with atomic SQLite baseline evidence and stale-writer rejection, expose persistence sync metadata as one versioned capability, and refresh uncertified Electric baselines before publishing resumed data.

This changes the public persistence contracts: custom `PersistenceAdapter` implementations must now implement `loadResumeSnapshot`, and `SyncMetadataApi.persistence` is required with `null` explicitly representing no persistence. Custom sync wrappers that receive metadata must forward `metadata.persistence` unchanged so consumers receive either that sentinel or the complete versioned capability. A direct sync invocation may still omit the optional metadata object entirely, which consumers treat as no persistence. The Electron bridge now transports the atomic resume snapshot through IPC protocol v2; Electron main and renderer integrations must upgrade together because mixed v1/v2 peers fail closed. Node and React Native persistence instances that wrap one database handle now share transaction admission so concurrent collection startup cannot overlap transactions on that connection.
86 changes: 83 additions & 3 deletions docs/contributing/oracle-coverage.md

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions docs/contributing/oracle-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -624,4 +624,39 @@ For a new oracle or a claimed repair, ask:
6. Can capture, cleanup or shrinking turn this into a different failure?
7. Which larger promises remain outside this test, and where are they tracked?

### Reusable boundary-law checklist

Adapter and lifecycle oracles should consider these laws when the contract has
the corresponding boundary. They are prompts, not universal requirements. State
why an inapplicable law does not belong to the owner instead of adding a vacuous
case.

- **Real-provider conformance:** freeze representative values from each
supported provider version. Prove the fixture accepts those values before it
stands in for that provider.
- **Minimal ambiguity:** include the smallest valid input for every classifier
branch. Rich values that carry several redundant signals do not cover a
one-field collision.
- **Name invariance:** changing a user-controlled name or SQL alias must not
change envelope classification unless the public contract assigns that name
structural meaning.
- **Representation symmetry:** equivalent array/object forms and coexisting
carriers must produce the same public result or the same documented error.
- **Await-boundary transitions:** hold each relevant `await`, change ownership,
leadership, generation, abort, cleanup, or restart state, then release it.
Compare the result with the contract for that transition.
- **Local/transport refinement:** immutable transported data must have the same
meaning on local and remote paths. Live local references must remain local,
and cleanup must receive the exact lifecycle object delivered locally.
- **Partial-construction cleanup:** fail each construction step after it acquires
a resource. Preserve the primary error and prove every acquired resource is
released exactly once.
- **Value-and-work refinement:** when bounded work is promised, check the exact
result and a deterministic work/cardinality measure. Correct rows alone do
not establish the work law.

Every owner should also state its known omissions beside the contract. The
coverage map tracks open reusable laws; an unchecked item is not evidence that
the neighboring laws are absent.

The payoff is not a bigger test framework. It is a smaller distance between “this test is green” and a precise account of what that green result protects.
2 changes: 1 addition & 1 deletion docs/guides/collection-options-creator.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ sync: {
For complete, production-ready examples, see the collection packages in the TanStack DB repository:

- **[@tanstack/query-db-collection](https://github.com/TanStack/db/tree/main/packages/query-db-collection)** - Pattern A: User-provided handlers with full refetch strategy
- **[@tanstack/trailbase-db-collection](https://github.com/TanStack/db/tree/main/packages/trailbase-db-collection)** - Pattern B: Built-in handlers with ID-based tracking
- **[@tanstack/trailbase-db-collection](https://github.com/TanStack/db/tree/main/packages/trailbase-db-collection)** - Pattern B: Built-in handlers with ID-based tracking
- **[@tanstack/electric-db-collection](https://github.com/TanStack/db/tree/main/packages/electric-db-collection)** - Pattern A: Transaction ID tracking with complex sync protocols
- **[@tanstack/rxdb-db-collection](https://github.com/TanStack/db/tree/main/packages/rxdb-db-collection)** - Pattern B: Built-in handlers that bridge [RxDB](https://rxdb.info) change streams into TanStack DB's sync lifecycle

Expand Down
31 changes: 23 additions & 8 deletions packages/browser-db-sqlite-persistence/src/browser-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
private readonly nodeId = safeRandomUUID()
private readonly dbName: string
private adapter: AdapterWithPullSince | null
private readonly collectionAdapters = new Map<string, AdapterWithPullSince>()
private readonly channel: BroadcastChannel
private readonly collections = new Map<string, CollectionState>()
private readonly pendingRPCs = new Map<string, PendingRPC>()
Expand All @@ -133,13 +134,14 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
return this.disposed
}

private requireAdapter(): AdapterWithPullSince {
if (!this.adapter) {
private requireAdapter(collectionId: string): AdapterWithPullSince {
const adapter = this.collectionAdapters.get(collectionId) ?? this.adapter
if (!adapter) {
throw new Error(
`BrowserCollectionCoordinator: adapter not set. Call setAdapter() before using leader-side operations.`,
)
}
return this.adapter
return adapter
}

constructor(options: BrowserCollectionCoordinatorOptions) {
Expand All @@ -160,6 +162,14 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
this.adapter = adapter
}

/** Register the schema/mode-specific adapter for one collection. */
setCollectionAdapter(
collectionId: string,
adapter: AdapterWithPullSince,
): void {
this.collectionAdapters.set(collectionId, adapter)
}

// -----------------------------------------------------------------------
// PersistedCollectionCoordinator interface
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -223,7 +233,11 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
spec: PersistedIndexSpec,
): Promise<void> {
if (this.isLeader(collectionId)) {
await this.requireAdapter().ensureIndex(collectionId, signature, spec)
await this.requireAdapter(collectionId).ensureIndex(
collectionId,
signature,
spec,
)
return
}

Expand Down Expand Up @@ -305,6 +319,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina

this.channel.close()
this.collections.clear()
this.collectionAdapters.clear()
}

// -----------------------------------------------------------------------
Expand Down Expand Up @@ -348,7 +363,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina

try {
// Restore stream position from DB before claiming leadership
const adapter = this.requireAdapter()
const adapter = this.requireAdapter(collectionId)
if (adapter.getStreamPosition) {
const pos = await adapter.getStreamPosition(collectionId)
state.latestTerm = pos.latestTerm
Expand Down Expand Up @@ -610,7 +625,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
},
): Promise<RPCResponse> {
await this.withWriterLock(() =>
this.requireAdapter().ensureIndex(
this.requireAdapter(collectionId).ensureIndex(
collectionId,
request.signature,
request.spec,
Expand Down Expand Up @@ -676,7 +691,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
}

await this.withWriterLock(() =>
this.requireAdapter().applyCommittedTx(collectionId, tx),
this.requireAdapter(collectionId).applyCommittedTx(collectionId, tx),
)

// Track envelope for dedup
Expand Down Expand Up @@ -736,7 +751,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
): Promise<PullSinceResponse> {
const state = this.collections.get(collectionId)

const adapter = this.requireAdapter()
const adapter = this.requireAdapter(collectionId)
if (!adapter.pullSince) {
return {
type: `rpc:pullSince:res`,
Expand Down
33 changes: 19 additions & 14 deletions packages/browser-db-sqlite-persistence/src/browser-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,34 +130,39 @@ export function createBrowserWASQLitePersistence(
...(schemaVersion === undefined ? {} : { schemaVersion }),
})
adapterCache.set(cacheKey, adapter)

// Wire the adapter into the multi-tab coordinator so it can handle
// leader-side RPCs (applyCommittedTx, pullSince, ensureIndex, etc.)
if (resolvedCoordinator instanceof BrowserCollectionCoordinator) {
resolvedCoordinator.setAdapter(adapter)
}

return adapter
}

const createCollectionPersistence = (
collectionId: string | undefined,
mode: PersistedCollectionMode,
schemaVersion: number | undefined,
): PersistedCollectionPersistence => ({
adapter: getAdapterForCollection(mode, schemaVersion),
coordinator: resolvedCoordinator,
})
): PersistedCollectionPersistence => {
const adapter = getAdapterForCollection(mode, schemaVersion)
if (resolvedCoordinator instanceof BrowserCollectionCoordinator) {
if (collectionId === undefined) {
resolvedCoordinator.setAdapter(adapter)
} else {
resolvedCoordinator.setCollectionAdapter(collectionId, adapter)
}
}
return {
adapter,
coordinator: resolvedCoordinator,
}
}

const defaultPersistence = createCollectionPersistence(
undefined,
`sync-absent`,
undefined,
)

return {
...defaultPersistence,
resolvePersistenceForCollection: ({ mode, schemaVersion }) =>
createCollectionPersistence(mode, schemaVersion),
resolvePersistenceForCollection: ({ collectionId, mode, schemaVersion }) =>
createCollectionPersistence(collectionId, mode, schemaVersion),
resolvePersistenceForMode: (mode) =>
createCollectionPersistence(mode, undefined),
createCollectionPersistence(undefined, mode, undefined),
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BrowserCollectionCoordinator } from '../src/browser-coordinator'
import {
createBrowserWASQLitePersistence,
persistedCollectionOptions,
} from '../src'
import { createWASQLiteTestDatabase } from './helpers/wa-sqlite-test-db'
import type { BrowserCollectionCoordinatorOptions } from '../src/browser-coordinator'
import type { PersistenceAdapter } from '@tanstack/db-sqlite-persistence-core'

Expand Down Expand Up @@ -193,6 +198,16 @@ function createStubAdapter(): PersistenceAdapter & {
return {
appliedTxs,
loadSubset: () => Promise.resolve([]),
loadResumeSnapshot: () =>
Promise.resolve({
rows: [],
keySet: { status: `consistent` },
collectionMetadata: [],
latestTerm: 0,
latestSeq: 0,
latestRowVersion: 0,
resetEpoch: 0,
}),
applyCommittedTx: (collectionId, tx) => {
appliedTxs.push({ collectionId, txId: tx.txId })
return Promise.resolve()
Expand Down Expand Up @@ -285,6 +300,91 @@ describe(`BrowserCollectionCoordinator`, () => {
coord2.dispose()
})

it.each([
{ schemaV1: 1, schemaV2: 2 },
{ schemaV1: 2, schemaV2: 4 },
])(
`routes each collection through its schema-version adapter: $schemaV1/$schemaV2`,
async ({ schemaV1, schemaV2 }) => {
const database = createWASQLiteTestDatabase({ filename: `:memory:` })
const coordinator = new BrowserCollectionCoordinator({
dbName: `schema-routed-db`,
})
const persistence = createBrowserWASQLitePersistence({
database,
coordinator,
})
const collectionV1 = `schema-routed-v1`
const collectionV2 = `schema-routed-v2`

try {
const optionsV1 = persistedCollectionOptions<
{ id: string; title: string },
string
>({
id: collectionV1,
schemaVersion: schemaV1,
getKey: (row) => row.id,
persistence,
})
await optionsV1.persistence.adapter.loadResumeSnapshot(collectionV1)

const optionsV2 = persistedCollectionOptions<
{ id: string; title: string },
string
>({
id: collectionV2,
schemaVersion: schemaV2,
getKey: (row) => row.id,
persistence,
})
await optionsV2.persistence.adapter.loadResumeSnapshot(collectionV2)

coordinator.subscribe(collectionV1, () => {})
coordinator.subscribe(collectionV2, () => {})
await vi.waitFor(() => {
expect(coordinator.isLeader(collectionV1)).toBe(true)
expect(coordinator.isLeader(collectionV2)).toBe(true)
})

const [resultV1, resultV2] = await Promise.all([
coordinator.requestApplyLocalMutations(collectionV1, [
{
mutationId: `mutation-v1`,
type: `insert`,
key: `v1`,
value: { id: `v1`, title: `schema one` },
},
]),
coordinator.requestApplyLocalMutations(collectionV2, [
{
mutationId: `mutation-v2`,
type: `insert`,
key: `v2`,
value: { id: `v2`, title: `schema two` },
},
]),
])

expect(resultV1.ok).toBe(true)
expect(resultV2.ok).toBe(true)
expect(
await optionsV1.persistence.adapter.loadSubset(collectionV1, {}),
).toMatchObject([
{ key: `v1`, value: { id: `v1`, title: `schema one` } },
])
expect(
await optionsV2.persistence.adapter.loadSubset(collectionV2, {}),
).toMatchObject([
{ key: `v2`, value: { id: `v2`, title: `schema two` } },
])
} finally {
coordinator.dispose()
await Promise.resolve(database.close?.())
}
},
)

it(`different collections have independent leaders`, async () => {
const coord1 = createCoordinator()
const coord2 = createCoordinator()
Expand Down
33 changes: 33 additions & 0 deletions packages/db-sqlite-persistence-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ binding. Provide a runtime `SQLiteDriver` implementation from a wrapper package.
- `PullSinceResponse`
- `CollectionReset`
- `PersistedIndexSpec`
- `PersistedKeySetEvidence`
- `PersistedTx<T, TKey>`
- `PersistenceAdapter<T, TKey>`
- `SQLiteDriver`
Expand Down Expand Up @@ -64,6 +65,38 @@ and resolves persistence using:
This lets runtime wrappers expose one shared persistence instance per database
while still handling per-collection schema versions correctly.

### Atomic resume snapshots

Persistence adapters implement
`loadResumeSnapshot(collectionId, options)` to let a sync source certify a
persisted resume baseline. One call must read rows, collection metadata, stream
position, reset epoch, and key-set evidence from the same atomic database
snapshot. `includeRows: false` requests the same certification data without
materializing rows; `requiredIndexSignatures` carries the indexes needed by a
row-bearing snapshot.

`PersistedKeySetEvidence.status` has three states:

- `consistent`: the persisted rows match the adapter's durable expected-key
ledger.
- `incompatible`: row loss, substitution, or a reset-generation change makes
the saved resume baseline unsafe.
- `unknown`: the adapter has no authoritative pre-migration key set and does
not claim completeness.

The method is required because the versioned `metadata.persistence` capability
always carries hydration, durable row scanning, certification, evidence, and
generation ownership as one complete bundle. Sync wrappers must forward the
capability object unchanged rather than copying individual methods. Adapter
methods are invoked with their receiver and may rely on instance state through
`this`.

`SyncMetadataApi.persistence` is always present. Core sync sources receive
`null`, which explicitly means that no persistence bridge is active. The
persisted wrapper replaces that sentinel with the complete versioned
capability. A wrapper that omits the property is invalid and fails before a
consumer can resume or query against uncertified durable state.

### SQLite core adapter APIs

- `SQLiteCoreAdapterOptions`
Expand Down
Loading
Loading