Skip to content

Commit ae521cf

Browse files
committed
fix(persistence): fence startup and schema generations
1 parent a8c2097 commit ae521cf

14 files changed

Lines changed: 1119 additions & 269 deletions

File tree

.changeset/preserve-resume-baseline-integrity.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
'@tanstack/db-sqlite-persistence-core': minor
33
'@tanstack/db': minor
4+
'@tanstack/browser-db-sqlite-persistence': patch
45
'@tanstack/electron-db-sqlite-persistence': minor
56
'@tanstack/electric-db-collection': patch
67
'@tanstack/query-db-collection': patch

docs/contributing/oracle-coverage.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,26 @@ a hostile wrong-answer control, and an explicit statement of remaining limits.
240240
statement, scan, trigger, or queue-cardinality observations where the
241241
subsystem promises bounded work. Owners: SQLite resume snapshots, Electric
242242
acquisition work, and shared-driver scheduling suites.
243+
- [x] **Startup generation interleavings.** Hold persisted startup between its
244+
metadata and hydration snapshots, then cross no write, a managed mutation,
245+
and hostile raw loss. Compare public rows with the atomic durable snapshot,
246+
and require mutation persistence to wait until the prior stream position is
247+
known. Owners: SQLite resume snapshots and Electric resume snapshot races.
248+
- [x] **Schema-generation fences on cached adapters.** Cross a newer-schema
249+
reset with snapshot, row, metadata, delta, position, and index-lifecycle
250+
operations from the cached older adapter. Every stale operation rejects;
251+
the current adapter remains readable and its index registry remains intact.
252+
Owners: SQLite resume snapshots plus browser/electron coordinator routing.
243253
- [x] **Explicit omission records.** Add a short `Known omissions` section to
244254
each primary executable owner touched above and keep this map synchronized as
245255
laws land. An omission record narrows evidence; it does not waive a product
246256
obligation.
257+
- [ ] **Atomic active-subset full reload.** Load collection metadata and every
258+
active subset from one adapter generation, including filtered and paginated
259+
on-demand subsets. A sound implementation needs an atomic multi-subset API;
260+
loading all rows or accepting a metadata/row torn pair is not equivalent.
261+
RED evidence and the deferred executable placeholder live in
262+
`packages/db-sqlite-persistence-core/tests/persisted.test.ts` under R5-007.
247263

248264
## Deferred contracts and evidence
249265

packages/browser-db-sqlite-persistence/src/browser-coordinator.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
122122
private readonly nodeId = safeRandomUUID()
123123
private readonly dbName: string
124124
private adapter: AdapterWithPullSince | null
125+
private readonly collectionAdapters = new Map<string, AdapterWithPullSince>()
125126
private readonly channel: BroadcastChannel
126127
private readonly collections = new Map<string, CollectionState>()
127128
private readonly pendingRPCs = new Map<string, PendingRPC>()
@@ -133,13 +134,14 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
133134
return this.disposed
134135
}
135136

136-
private requireAdapter(): AdapterWithPullSince {
137-
if (!this.adapter) {
137+
private requireAdapter(collectionId: string): AdapterWithPullSince {
138+
const adapter = this.collectionAdapters.get(collectionId) ?? this.adapter
139+
if (!adapter) {
138140
throw new Error(
139141
`BrowserCollectionCoordinator: adapter not set. Call setAdapter() before using leader-side operations.`,
140142
)
141143
}
142-
return this.adapter
144+
return adapter
143145
}
144146

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

165+
/** Register the schema/mode-specific adapter for one collection. */
166+
setCollectionAdapter(
167+
collectionId: string,
168+
adapter: AdapterWithPullSince,
169+
): void {
170+
this.collectionAdapters.set(collectionId, adapter)
171+
}
172+
163173
// -----------------------------------------------------------------------
164174
// PersistedCollectionCoordinator interface
165175
// -----------------------------------------------------------------------
@@ -223,7 +233,11 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
223233
spec: PersistedIndexSpec,
224234
): Promise<void> {
225235
if (this.isLeader(collectionId)) {
226-
await this.requireAdapter().ensureIndex(collectionId, signature, spec)
236+
await this.requireAdapter(collectionId).ensureIndex(
237+
collectionId,
238+
signature,
239+
spec,
240+
)
227241
return
228242
}
229243

@@ -305,6 +319,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
305319

306320
this.channel.close()
307321
this.collections.clear()
322+
this.collectionAdapters.clear()
308323
}
309324

310325
// -----------------------------------------------------------------------
@@ -348,7 +363,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
348363

349364
try {
350365
// Restore stream position from DB before claiming leadership
351-
const adapter = this.requireAdapter()
366+
const adapter = this.requireAdapter(collectionId)
352367
if (adapter.getStreamPosition) {
353368
const pos = await adapter.getStreamPosition(collectionId)
354369
state.latestTerm = pos.latestTerm
@@ -610,7 +625,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
610625
},
611626
): Promise<RPCResponse> {
612627
await this.withWriterLock(() =>
613-
this.requireAdapter().ensureIndex(
628+
this.requireAdapter(collectionId).ensureIndex(
614629
collectionId,
615630
request.signature,
616631
request.spec,
@@ -676,7 +691,7 @@ export class BrowserCollectionCoordinator implements PersistedCollectionCoordina
676691
}
677692

678693
await this.withWriterLock(() =>
679-
this.requireAdapter().applyCommittedTx(collectionId, tx),
694+
this.requireAdapter(collectionId).applyCommittedTx(collectionId, tx),
680695
)
681696

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

739-
const adapter = this.requireAdapter()
754+
const adapter = this.requireAdapter(collectionId)
740755
if (!adapter.pullSince) {
741756
return {
742757
type: `rpc:pullSince:res`,

packages/browser-db-sqlite-persistence/src/browser-persistence.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,34 +130,39 @@ export function createBrowserWASQLitePersistence(
130130
...(schemaVersion === undefined ? {} : { schemaVersion }),
131131
})
132132
adapterCache.set(cacheKey, adapter)
133-
134-
// Wire the adapter into the multi-tab coordinator so it can handle
135-
// leader-side RPCs (applyCommittedTx, pullSince, ensureIndex, etc.)
136-
if (resolvedCoordinator instanceof BrowserCollectionCoordinator) {
137-
resolvedCoordinator.setAdapter(adapter)
138-
}
139-
140133
return adapter
141134
}
142135

143136
const createCollectionPersistence = (
137+
collectionId: string | undefined,
144138
mode: PersistedCollectionMode,
145139
schemaVersion: number | undefined,
146-
): PersistedCollectionPersistence => ({
147-
adapter: getAdapterForCollection(mode, schemaVersion),
148-
coordinator: resolvedCoordinator,
149-
})
140+
): PersistedCollectionPersistence => {
141+
const adapter = getAdapterForCollection(mode, schemaVersion)
142+
if (resolvedCoordinator instanceof BrowserCollectionCoordinator) {
143+
if (collectionId === undefined) {
144+
resolvedCoordinator.setAdapter(adapter)
145+
} else {
146+
resolvedCoordinator.setCollectionAdapter(collectionId, adapter)
147+
}
148+
}
149+
return {
150+
adapter,
151+
coordinator: resolvedCoordinator,
152+
}
153+
}
150154

151155
const defaultPersistence = createCollectionPersistence(
156+
undefined,
152157
`sync-absent`,
153158
undefined,
154159
)
155160

156161
return {
157162
...defaultPersistence,
158-
resolvePersistenceForCollection: ({ mode, schemaVersion }) =>
159-
createCollectionPersistence(mode, schemaVersion),
163+
resolvePersistenceForCollection: ({ collectionId, mode, schemaVersion }) =>
164+
createCollectionPersistence(collectionId, mode, schemaVersion),
160165
resolvePersistenceForMode: (mode) =>
161-
createCollectionPersistence(mode, undefined),
166+
createCollectionPersistence(undefined, mode, undefined),
162167
}
163168
}

packages/browser-db-sqlite-persistence/tests/browser-coordinator.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
22
import { BrowserCollectionCoordinator } from '../src/browser-coordinator'
3+
import {
4+
createBrowserWASQLitePersistence,
5+
persistedCollectionOptions,
6+
} from '../src'
7+
import { createWASQLiteTestDatabase } from './helpers/wa-sqlite-test-db'
38
import type { BrowserCollectionCoordinatorOptions } from '../src/browser-coordinator'
49
import type { PersistenceAdapter } from '@tanstack/db-sqlite-persistence-core'
510

@@ -295,6 +300,91 @@ describe(`BrowserCollectionCoordinator`, () => {
295300
coord2.dispose()
296301
})
297302

303+
it.each([
304+
{ schemaV1: 1, schemaV2: 2 },
305+
{ schemaV1: 2, schemaV2: 4 },
306+
])(
307+
`routes each collection through its schema-version adapter: $schemaV1/$schemaV2`,
308+
async ({ schemaV1, schemaV2 }) => {
309+
const database = createWASQLiteTestDatabase({ filename: `:memory:` })
310+
const coordinator = new BrowserCollectionCoordinator({
311+
dbName: `schema-routed-db`,
312+
})
313+
const persistence = createBrowserWASQLitePersistence({
314+
database,
315+
coordinator,
316+
})
317+
const collectionV1 = `schema-routed-v1`
318+
const collectionV2 = `schema-routed-v2`
319+
320+
try {
321+
const optionsV1 = persistedCollectionOptions<
322+
{ id: string; title: string },
323+
string
324+
>({
325+
id: collectionV1,
326+
schemaVersion: schemaV1,
327+
getKey: (row) => row.id,
328+
persistence,
329+
})
330+
await optionsV1.persistence.adapter.loadResumeSnapshot(collectionV1)
331+
332+
const optionsV2 = persistedCollectionOptions<
333+
{ id: string; title: string },
334+
string
335+
>({
336+
id: collectionV2,
337+
schemaVersion: schemaV2,
338+
getKey: (row) => row.id,
339+
persistence,
340+
})
341+
await optionsV2.persistence.adapter.loadResumeSnapshot(collectionV2)
342+
343+
coordinator.subscribe(collectionV1, () => {})
344+
coordinator.subscribe(collectionV2, () => {})
345+
await vi.waitFor(() => {
346+
expect(coordinator.isLeader(collectionV1)).toBe(true)
347+
expect(coordinator.isLeader(collectionV2)).toBe(true)
348+
})
349+
350+
const [resultV1, resultV2] = await Promise.all([
351+
coordinator.requestApplyLocalMutations(collectionV1, [
352+
{
353+
mutationId: `mutation-v1`,
354+
type: `insert`,
355+
key: `v1`,
356+
value: { id: `v1`, title: `schema one` },
357+
},
358+
]),
359+
coordinator.requestApplyLocalMutations(collectionV2, [
360+
{
361+
mutationId: `mutation-v2`,
362+
type: `insert`,
363+
key: `v2`,
364+
value: { id: `v2`, title: `schema two` },
365+
},
366+
]),
367+
])
368+
369+
expect(resultV1.ok).toBe(true)
370+
expect(resultV2.ok).toBe(true)
371+
expect(
372+
await optionsV1.persistence.adapter.loadSubset(collectionV1, {}),
373+
).toMatchObject([
374+
{ key: `v1`, value: { id: `v1`, title: `schema one` } },
375+
])
376+
expect(
377+
await optionsV2.persistence.adapter.loadSubset(collectionV2, {}),
378+
).toMatchObject([
379+
{ key: `v2`, value: { id: `v2`, title: `schema two` } },
380+
])
381+
} finally {
382+
coordinator.dispose()
383+
await Promise.resolve(database.close?.())
384+
}
385+
},
386+
)
387+
298388
it(`different collections have independent leaders`, async () => {
299389
const coord1 = createCoordinator()
300390
const coord2 = createCoordinator()

packages/db-sqlite-persistence-core/src/persisted.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -936,13 +936,14 @@ class PersistedCollectionRuntime<
936936
}
937937
}
938938

939-
async ensureStarted(): Promise<void> {
939+
ensureStarted(): Promise<void> {
940940
if (this.startPromise) {
941941
return this.startPromise
942942
}
943943

944944
const lifecycleGeneration = this.lifecycleGeneration
945945
this.startPromise = this.startInternal(lifecycleGeneration)
946+
void this.startPromise.catch(() => undefined)
946947
return this.startPromise
947948
}
948949

@@ -1247,6 +1248,10 @@ class PersistedCollectionRuntime<
12471248
}
12481249

12491250
await this.applyMutex.run(async () => {
1251+
// Startup metadata establishes the durable term/sequence boundary. A
1252+
// mutation admitted before it resolves must wait rather than allocate a
1253+
// default position that can collide with an already-applied transaction.
1254+
await this.ensureStartupMetadataLoaded()
12501255
const acceptedMutationIds =
12511256
await this.persistCollectionMutationsUnsafe(mutations)
12521257
const acceptedMutationIdSet = new Set(acceptedMutationIds)
@@ -1479,18 +1484,27 @@ class PersistedCollectionRuntime<
14791484
resetEpoch: number
14801485
}): void {
14811486
const generation = this.getResumeSnapshotGeneration(snapshot)
1482-
// Moving between equally uncertified snapshots cannot make either
1483-
// trustworthy; preserve that status so sync performs a fresh replacement.
1484-
const remainsUncertified =
1485-
this.persistedKeySetEvidence?.status === snapshot.keySet?.status &&
1486-
snapshot.keySet?.status !== `consistent`
1487+
const previousEvidenceStatus = this.persistedKeySetEvidence?.status
1488+
// An uncertified sync baseline never authorized a persisted resume cursor,
1489+
// so a later atomic snapshot may replace its evidence while the source
1490+
// performs the already-required fresh snapshot. Local-only collections
1491+
// have no remote cursor to fence; a managed write may advance a still-
1492+
// consistent SQLite baseline during startup. Incompatible evidence remains
1493+
// fail-closed in both modes.
1494+
const mayAcceptUnownedGeneration =
1495+
(this.mode === `sync-present` &&
1496+
previousEvidenceStatus !== `consistent` &&
1497+
previousEvidenceStatus !== `incompatible`) ||
1498+
(this.mode === `sync-absent` &&
1499+
previousEvidenceStatus === `consistent` &&
1500+
snapshot.keySet?.status === `consistent`)
14871501
this.observeStreamPosition(
14881502
snapshot.latestTerm,
14891503
snapshot.latestSeq,
14901504
snapshot.latestRowVersion,
14911505
)
14921506
this.persistedKeySetEvidence =
1493-
this.isExpectedResumeGeneration(generation) || remainsUncertified
1507+
this.isExpectedResumeGeneration(generation) || mayAcceptUnownedGeneration
14941508
? snapshot.keySet
14951509
: { status: `incompatible` }
14961510
}

0 commit comments

Comments
 (0)