diff --git a/src/brainy.ts b/src/brainy.ts index 148b4639..2d1a9a31 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -13014,16 +13014,38 @@ export class Brainy implements BrainyInterface { } // FINALIZE: mark migrated (makes re-open a no-op), then drain the head - // branch (its entities moved; the rest is stale 7.x derived state). - // Non-head branches under branches/ are 7.x version history 8.0's MVCC does - // not import — they are left in place. + // branch. Non-head branches under branches/ are 7.x version history 8.0's + // MVCC does not import — they are left in place. await probe.writeRawObject('_system/migration-layout.json', { layout: 'flat-v8', version: 8, fromBranch: head, entitiesMoved: moved }) - await probe.removeRawPrefix(`branches/${head}`) + + // Parity guard (defense-in-depth; the VFS `_cow/` stranding lesson). The + // entity move deleted every `branches//entities/*` key, so anything + // STILL under `branches//` is non-entity durable state a 7.x engine + // wrote outside `entities/` (branch-scoped blobs, an index dir, a field + // registry). Draining it unconditionally would silently DELETE it — the + // same failure class as the VFS content blobs stranded in `_cow/`. So + // drain only a clean branch (nothing but the moved entities); if any + // non-entity object survives, PRESERVE the branch (skip the drain) so the + // state stays recoverable, and surface it loudly. + const residual = ( + await probe.listRawObjects(`branches/${head}`) + ).filter((k: string) => !k.startsWith(`branches/${head}/entities/`)) + if (residual.length === 0) { + await probe.removeRawPrefix(`branches/${head}`) + } else if (!this.config.silent) { + const sample = residual.slice(0, 8).join(', ') + console.warn( + `[brainy] 7→8 migration preserved ${residual.length} non-entity object(s) under ` + + `branches/${head}/ — branch-scoped durable state written outside entities/. The ` + + `branch was NOT drained, so this state is recoverable; inspect it before removing ` + + `branches/ manually. Keys: ${sample}${residual.length > 8 ? ', …' : ''}` + ) + } } finally { if (canLock && typeof lockable.releaseWriterLock === 'function') { await lockable.releaseWriterLock() diff --git a/tests/integration/migration-7x-to-8x.test.ts b/tests/integration/migration-7x-to-8x.test.ts index dccf8f88..6071d659 100644 --- a/tests/integration/migration-7x-to-8x.test.ts +++ b/tests/integration/migration-7x-to-8x.test.ts @@ -191,4 +191,39 @@ describe('7.x → 8.0 layout migration', () => { expect(await captureReference(reopened)).toEqual(ref) expect(fs.existsSync(path.join(dir, 'branches'))).toBe(false) }) + + it('PARITY GUARD: branch-scoped non-entity state survives migration (not silently drained)', async () => { + const dir = freshLegacyDir() + + // Simulate a 7.x engine that wrote durable state under the HEAD branch + // OUTSIDE entities/ (a branch-scoped index/blob/registry). The migration + // rescues entities/ only; the guard must PRESERVE the rest rather than let + // removeRawPrefix silently delete it — the VFS `_cow/` stranding lesson. + const raw: any = new FileSystemStorage(dir) + await raw.init() + await raw.writeRawObject('branches/main/_legacy_engine/state', { keep: 'me', n: 42 }) + await raw.flush?.() + raw.stopFlushRequestWatcher?.() + await raw.releaseWriterLock?.() + + // Migrate (entities collapse to the root as usual). + const brain = await openBrain(dir) + brains.push(brain) + await brain.init() + expect(await captureReference(brain)).toEqual(reference) + await brain.close() + brains.splice(brains.indexOf(brain), 1) + + // The non-entity branch-scoped state is PRESERVED (branch NOT drained) — + // recoverable, not lost. + const check: any = new FileSystemStorage(dir) + await check.init() + expect(await check.readRawObject('branches/main/_legacy_engine/state')).toMatchObject({ + keep: 'me', + n: 42 + }) + await check.flush?.() + check.stopFlushRequestWatcher?.() + await check.releaseWriterLock?.() + }) })