// The view shim, tested directly — because the contract does not reach the part that matters.
//
// `contract.test.js` creates two users and both pass, which proves the shim's codec. It does NOT
// prove the merge-scan: each `createUser` appends and then updates, so the two ops land in separate
// apply batches and `hasAnyUser` never has to see an unflushed write.
//
// The case that needs the merge is two CREATE_USER ops in ONE batch, and that is awkward to force
// through the registry — it depends on autobee's batching, which is timing, not contract. So the
// merge is asserted here against BatchView directly: deterministic, no engine lifecycle, and it
// fails loudly if someone later "simplifies" the scan back into a plain pending map.
//
// ⛔ What is at stake, concretely: apply decides admin promotion with
//
// const isFirstUser = !(await hasAnyUser(b, prefix))
//
// If a scan cannot see a user written earlier in the same batch, every user in that batch is
// promoted. A permissions bug caused by a storage detail, with no error anywhere.
import test from 'brittle'
import Corestore from 'corestore'
import Hyperbee2 from 'hyperbee2'
import path from '#node:path'
import os from '#node:os'
import crypto from 'hypercore-crypto'
import { Hyperbee2View } from '../lib/view-shim.js'
async function view () {
const dir = path.join(os.tmpdir(), 'shim-' + crypto.randomBytes(8).toString('hex'))
const store = new Corestore(dir)
await store.ready()
// ⚠ Hyperbee2 takes a STORE, not a core — index.js:15 `constructor(store, opts)`. autobase's
// Hyperbee took a core, which is one more place the two substrates differ quietly.
const bee = new Hyperbee2(store.namespace('v'))
await bee.ready()
return { view: new Hyperbee2View(bee), cleanup: async () => { await bee.close(); await store.close() } }
}
const drain = async (stream) => { const out = []; for await (const e of stream) out.push(e); return out }
test('values round-trip as JSON with utf-8 keys', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
const b = v.batch({ update: false })
await b.put('user:1', { name: 'Ada — éè你好', nested: { a: [1, 2] } })
await b.flush()
const got = await v.get('user:1')
t.is(got.key, 'user:1', 'the key comes back as a string')
t.is(got.value.name, 'Ada — éè你好', 'unicode survives')
t.alike(got.value.nested, { a: [1, 2] }, 'and so does structure')
t.is(await v.get('user:nope'), null, 'a missing key is null, not a throw')
})
test('★ a scan SEES writes made earlier in the same batch', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
const b = v.batch({ update: false })
await b.put('user:a', { id: 'a' })
// This is `hasAnyUser`, exactly: does anything exist under the prefix RIGHT NOW.
const seen = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff', limit: 1 }))
t.is(seen.length, 1, 'the unflushed write is visible — otherwise the next user is admin too')
t.is(seen[0].key, 'user:a')
await b.flush()
})
test('a scan merges pending and committed in key order', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
const first = v.batch({ update: false })
await first.put('user:b', { id: 'b' })
await first.put('user:d', { id: 'd' })
await first.flush()
const b = v.batch({ update: false })
await b.put('user:a', { id: 'a' }) // sorts before everything committed
await b.put('user:c', { id: 'c' }) // between two committed keys
await b.put('user:e', { id: 'e' }) // after everything committed
await b.put('user:b', { id: 'B' }) // supersedes a committed key
const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
t.alike(rows.map((r) => r.key), ['user:a', 'user:b', 'user:c', 'user:d', 'user:e'], 'ordered, no gaps')
t.is(rows.find((r) => r.key === 'user:b').value.id, 'B', 'pending supersedes committed on a tie')
})
test('a pending delete removes a committed key from the scan', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
const first = v.batch({ update: false })
await first.put('user:a', { id: 'a' })
await first.put('user:b', { id: 'b' })
await first.flush()
const b = v.batch({ update: false })
await b.del('user:a')
t.is(await b.get('user:a'), null, 'and by key')
const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
t.alike(rows.map((r) => r.key), ['user:b'], 'the deleted key is gone, not yielded as a tombstone')
})
test('⛔ a stored null is not a deletion', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
// The pending map uses a Symbol for deletion precisely so a legitimately stored `null` value
// cannot be mistaken for one. Using `null` as the sentinel would silently drop this row.
const b = v.batch({ update: false })
await b.put('user:a', null)
const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
t.is(rows.length, 1, 'a null VALUE is still a row')
t.is(rows[0].value, null)
})
test('limit stops the scan, counting merged rows not committed ones', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
const first = v.batch({ update: false })
for (const k of ['user:b', 'user:c']) await first.put(k, { id: k })
await first.flush()
const b = v.batch({ update: false })
await b.put('user:a', { id: 'a' })
const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff', limit: 2 }))
t.is(rows.length, 2, 'limit is honoured across the merge')
t.alike(rows.map((r) => r.key), ['user:a', 'user:b'], 'and takes the first two in order')
})
test('range bounds exclude neighbouring prefixes', async (t) => {
const { view: v, cleanup } = await view()
t.teardown(cleanup)
const b = v.batch({ update: false })
await b.put('user:a', { id: 'a' })
await b.put('username:a', { id: 'other' }) // shares the 'user' prefix, must NOT appear
await b.put('writer:a', { id: 'other' })
const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
t.alike(rows.map((r) => r.key), ['user:a'], 'only the addressed prefix')
})