OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity2 commits / 3df73b9

hyper-identity2 0.1.0 — the identity layer on autobee
Sibling of hyper-identity. The shared half is hyper-identity-core and is imported, not copied: same registry, same ops, same apply, same contract file. What is here is the engine — roughly 60 lines of binding plus the view shim. It depends on autobee and NOT autobase, which is the whole point. favela-peer runs autobee and could not use the identity layer without carrying a second multi-writer engine beside it. ## The shim is the port hyperbee2 has no keyEncoding/valueEncoding and no put/del — raw Buffers, and every mutation through a write() batch. The core's 65 call sites expect a Hyperbee, so view-shim.js presents one and none of them changed. ⛔ Its hard requirement is not the codec. apply calls createReadStream ON THE BATCH, and one of those calls is `hasAnyUser`, which decides admin promotion. Against a Hyperbee a put is instantly visible to a scan; against a batch it is not. Two CREATE_USER ops in one batch, a scan that misses the first, and BOTH users are promoted — a permissions bug caused by a storage detail, with no error anywhere. So BatchView merges its pending writes into every scan instead of keeping a plain key map (which is all favela-peer's feed-base needs, because its apply never scans). test/view-shim.test.js proves it directly: the contract does NOT reach it, because each createUser appends and updates, so those ops land in separate batches. A green contract would have said nothing about the case that matters. ## Three silent divergences, each answered in the file that knows the engine writer flag autobase { indexer } vs autobee { isIndexer }, DEFAULT TRUE attribution node.from.key vs node.key open() a store vs a hyperbee2 instance adapt-host.js is not a passthrough even though autobee already speaks { isIndexer }: the default is TRUE, so an omitted flag is the difference between a plain writer and an indexer. fastForwardTo is REFUSED rather than ignored — autobase's takes the log key, autobee's takes an oplog boot head a joiner cannot derive from one. And fastForward is off: unconfigured, autobee probes for candidate heads and pays a 5s timeout per wakeup batch looking for something that does not exist. ⚠ The dependency trees are not freely mixable. Mixing hyper-identity's corestore with autobee's hypercore fails in compact-encoding as "Cannot read properties of undefined (reading 'encode')" — measured while wiring this up. 10/10, 37/37. hyper-identity is 122/122 on the same contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3df73b99b93a73d11350d498000eb896f7d1db13
by plankatron <fleeky@gmail.com> · 2026-09-06T21:10:54.000Z

Changed files (9) +570 -0

.gitignore +1 -0
……@@ -0,0 +1 @@
1node_modules/
README.md +52 -0
……@@ -0,0 +1,52 @@
1# hyper-identity2
2
3The identity layer on **autobee**. Sibling of [`hyper-identity`](../hyper-identity), which is the
4same layer on **autobase**. Everything they share — the registry, the ops, the apply — lives in
5[`hyper-identity-core`](../hyper-identity-core) and is imported by both, not copied.
6
7## The engine is the package, not an option
8
9An `engine:` option would be a second way to say the same thing, and the wrong value is a lie: it
10constructs the other engine while reporting what you asked for. That false green happened once
11during this work and passed a parity suite that was comparing autobase to autobase.
12
13⛔ **Do not add one.** `static engine = 'autobee'`; a package cannot misreport what it is.
14
15## Why it exists
16
17`hyper-identity` depends on autobase, hyperbee and hypercore. A consumer already running autobee —
18favela-peer — would carry a second multi-writer engine to use the identity layer. This package
19depends on autobee and **not** autobase.
20
21⚠ The two dependency trees are not freely mixable. autobee needs `hypercore ^11.33.2`; mixing an
22older `corestore`/`hypercore-storage` with it fails in `compact-encoding` as
23`Cannot read properties of undefined (reading 'encode')` — measured, not theorised.
24
25## Three differences that fail silently, and what each costs
26
27| | autobase | autobee | if you forget |
28|---|---|---|---|
29| writer flag | `{ indexer }` | `{ isIndexer }`, **default `true`** | every writer becomes an indexer |
30| attribution | `node.from.key` | `node.key` | the contract permission check silently skips |
31| `open()` receives | a store | a **hyperbee2 instance** | — (this is why the view shim exists) |
32
33None of them errors. `lib/adapt-host.js` and `_writerKeyOf` exist so the answers live in the file
34that knows which engine it is.
35
36`fastForwardTo` is **refused**, not ignored: autobase's takes the log key, autobee's takes an oplog
37boot head (`{ key, length }` of a writer core) that a joiner cannot derive from a log key.
38
39## The view shim
40
41`lib/view-shim.js` is the real cost of the port. hyperbee2 has no `keyEncoding`/`valueEncoding` and
42no `put`/`del`; the core's 65 call sites expect a Hyperbee, and this presents one.
43
44⛔ Its hardest requirement is not the codec. apply calls `createReadStream` **on the batch** —
45`hasAnyUser` decides admin promotion that way. Against a Hyperbee a `put` is instantly visible to a
46scan; against a batch it is not. If two `CREATE_USER` ops land in one batch and the scan misses the
47first, **both** users are promoted. So `BatchView` merges pending writes into every scan rather than
48keeping a plain key map. `test/view-shim.test.js` is where that is proven.
49
50## Tests
51
52`npm test` — 10/10, 37/37. The contract file is shared with hyper-identity; the shim tests are local.
index.js +13 -0
……@@ -0,0 +1,13 @@
1// hyper-identity2 — the identity layer on autobee.
2//
3// Same registry, same ops, same apply as `hyper-identity`; all of that lives in
4// `hyper-identity-core` and is shared, not copied. What differs is the engine, and the engine is
5// the PACKAGE — pick the one you want rather than passing a flag that could be wrong.
6//
7// It depends on autobee and NOT on autobase, which is the whole point: a consumer already running
8// autobee (favela-peer) can use the identity layer without carrying a second multi-writer engine.
9
10export { IdentityRegistry } from './lib/registry.js'
11export { Hyperbee2View, BatchView } from './lib/view-shim.js'
12export { adaptHost } from './lib/adapt-host.js'
13export { OpTypes, encodeOp, decodeOp, validateOp, createApplyFunction } from 'hyper-identity-core'
lib/adapt-host.js +24 -0
……@@ -0,0 +1,24 @@
1// The autobee spelling of the core's writer-set contract.
2//
3// autobee already speaks `{ isIndexer }`, so this looks like it could be omitted. ⛔ It could not.
4//
5// addWriter(key, { isIndexer = true, weight = isIndexer ? 2 : 1 } = {})
6// ^^^^ autobee 2.1.0 lib/apply-calls.js:33
7//
8// The default is TRUE. So the difference between passing the flag and forgetting it is the
9// difference between a plain writer and an indexer — and forgetting it is what a "no translation
10// needed, just pass through" binding does the first time the core adds a call site that omits it.
11//
12// Pinning it here means the absent case is decided in the file that knows autobee's defaults,
13// rather than inherited from them silently.
14
15export function adaptHost (host) {
16 if (!host || typeof host.addWriter !== 'function') return host
17 return {
18 ...host,
19 addWriter: (key, opts = {}) => host.addWriter(key, { isIndexer: !!opts.isIndexer }),
20 removeWriter: (key) => host.removeWriter(key)
21 }
22}
23
24export default adaptHost
lib/registry.js +52 -0
……@@ -0,0 +1,52 @@
1// IdentityRegistry — the AUTOBEE binding of hyper-identity-core's registry.
2//
3// Sibling of `hyper-identity`, which is the autobase binding. Same class, same ops, same apply,
4// same reads — the engine is a package, not an option, so nothing here can silently be the other
5// one.
6
7import Autobee from 'autobee'
8import { BaseIdentityRegistry } from 'hyper-identity-core/registry'
9import { Hyperbee2View } from './view-shim.js'
10import { adaptHost } from './adapt-host.js'
11
12export class IdentityRegistry extends BaseIdentityRegistry {
13 static engine = 'autobee'
14
15 _adaptHost () { return adaptHost }
16
17 // ⛔ autobee attributes an op to `node.key`; autobase to `node.from.key`. Without this the core's
18 // contract check reads an undefined `node.from` and skips — a permission gate that stops gating
19 // with no error.
20 _writerKeyOf (node) { return node && node.key ? node.key : null }
21
22 async _createBase ({ apply, rootKey }) {
23 // ⛔ `fastForward` is NOT the same option here. autobase takes `{ key }` — the log's key.
24 // autobee takes a BOOT HEAD, `{ boot: { head: { key, length } } }` naming a writer's core at a
25 // length, which a joiner cannot derive from a log key: it has to be conveyed or remembered.
26 //
27 // So `fastForwardTo` has no autobee meaning, and quietly ignoring it would be the same class of
28 // bug as the addWriter flag — an option accepted and dropped.
29 if (this._fastForwardTo) {
30 throw new Error(
31 'hyper-identity2: fastForwardTo is an autobase option and has no autobee equivalent — ' +
32 'autobee fast-forward needs an oplog BOOT HEAD ({ key, length } of a writer core), not a ' +
33 'log key. Omit it, or convey a head and pass fastForward directly.')
34 }
35
36 return new Autobee(this._store, rootKey, {
37 apply,
38 // autobee's open() receives a hyperbee2 INSTANCE, where autobase's receives a store. That
39 // one-line difference is why the view shim exists.
40 open: (bee) => new Hyperbee2View(bee),
41 keyPair: this._keyPair || undefined,
42 optimistic: true,
43 // ⚠ OFF, deliberately, and not a workaround. Unconfigured, autobee PROBES for candidate
44 // oplog heads and pays DEFAULT_OP_TIMEOUT (5s, lib/constants.js:5) per wakeup batch looking
45 // for something that does not exist when there are no stamped heads. Measured at 25 writers:
46 // 25s versus 0.2s. Declining to search is not the same as disabling a feature that works.
47 fastForward: false
48 })
49 }
50}
51
52export default IdentityRegistry
lib/view-shim.js +197 -0
……@@ -0,0 +1,197 @@
1// The view shim — hyperbee2 wearing hyperbee's face.
2//
3// hyper-identity-core's registry and apply were written against a Hyperbee configured
4// `keyEncoding: 'utf-8', valueEncoding: 'json'`. hyperbee2 has NEITHER, and no `put`/`del` at all:
5// keys and values are raw Buffers and every mutation goes through a `write()` batch
6// (`tryPut`/`tryDelete` → `await flush()`). Measured against hyperbee2 2.13.2.
7//
8// So this file is the entire cost of the port. 65 call sites in the core depend on the Hyperbee
9// shape — 31 puts and 13 deletes in apply, 14 gets and 7 range scans in the registry — and every
10// one of them stays unchanged because this presents that shape.
11//
12// ── ⛔ THE PART THAT IS NOT A CODEC ────────────────────────────────────────────────────────────
13//
14// apply calls `createReadStream` ON THE BATCH, at three sites. One of them is `hasAnyUser`, which
15// decides whether a new user is promoted to admin:
16//
17// const isFirstUser = !(await hasAnyUser(b, prefix))
18//
19// Against a Hyperbee that is trivially correct — a `put` is visible to a read stream immediately.
20// Against a batch it is not: writes sit unflushed. If two CREATE_USER ops land in ONE apply batch
21// and the scan cannot see the first, the second is ALSO promoted, and the log now has two admins.
22// No error, no warning; a permissions bug produced by a storage detail.
23//
24// That is why `BatchView` merges its pending writes into every scan rather than keeping a plain
25// map of them. A pending map is enough for read-your-own-writes by KEY (favela-peer's feed-base.js
26// gets away with exactly that) and is NOT enough the moment apply iterates a RANGE.
27
28import b4a from 'b4a'
29
30const encKey = (k) => (typeof k === 'string' ? b4a.from(k, 'utf8') : k)
31const decKey = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'utf8'))
32const encVal = (v) => b4a.from(JSON.stringify(v), 'utf8')
33const decVal = (v) => {
34 if (v == null) return null
35 // A value that will not parse is data we did not write. Returning null loses it; throwing inside
36 // apply wedges the log (apply must be TOTAL). Neither is good, so it surfaces as null and the
37 // caller treats it as absent — the same thing a missing key does.
38 try { return JSON.parse(b4a.toString(v, 'utf8')) } catch { return null }
39}
40
41// Range bounds are encoded with the SAME function as keys, so `user:\xff` bounds `user:…` exactly
42// as it did under utf-8 keyEncoding. Comparison is by BYTES, never by JS string order — those
43// diverge above the BMP, and a username is user-supplied text.
44function inRange (key, range) {
45 if (range.gt !== undefined && b4a.compare(key, encKey(range.gt)) <= 0) return false
46 if (range.gte !== undefined && b4a.compare(key, encKey(range.gte)) < 0) return false
47 if (range.lt !== undefined && b4a.compare(key, encKey(range.lt)) >= 0) return false
48 if (range.lte !== undefined && b4a.compare(key, encKey(range.lte)) > 0) return false
49 return true
50}
51
52const streamOpts = (range) => {
53 const o = {}
54 for (const b of ['gt', 'gte', 'lt', 'lte']) if (range[b] !== undefined) o[b] = encKey(range[b])
55 return o
56}
57
58/**
59 * The view the core sees. `get` + `createReadStream` for the registry's reads, and `batch()` for
60 * apply — which calls `view.batch({ update: false })`, then `flush()` or `close()` on what it gets.
61 */
62export class Hyperbee2View {
63 constructor (bee) { this.bee = bee }
64
65 // apply's write handle. The option is accepted and ignored: hyperbee2 has no auto-update on a
66 // write batch, so `{ update: false }` is already its only behaviour.
67 batch (opts = {}) { return new BatchView(this.bee) }
68
69 async get (key) {
70 const node = await this.bee.get(encKey(key))
71 if (!node) return null
72 return { key: decKey(node.key), value: decVal(node.value) }
73 }
74
75 async * createReadStream (range = {}) {
76 let n = 0
77 // `limit` is applied here rather than passed down, because it is not part of hyperbee2's
78 // documented range options and a silently-ignored limit reads as "scan found everything".
79 for await (const node of this.bee.createReadStream(streamOpts(range))) {
80 yield { key: decKey(node.key), value: decVal(node.value) }
81 if (range.limit !== undefined && ++n >= range.limit) return
82 }
83 }
84}
85
86/**
87 * A writable view backed by ONE hyperbee2 batch, with pending writes visible to reads.
88 *
89 * One batch per apply CALL, not per mutation. The autobee spike's shim flushed a batch per write
90 * and was never benchmarked; apply performs up to 44 mutations in a call, so that is 44 commits
91 * where a Hyperbee did none.
92 */
93export class BatchView {
94 constructor (bee) {
95 this.bee = bee
96 this.batch = bee.write()
97 // key (utf-8 string) -> value, or DELETED. Ordered by insertion; scans sort what they need.
98 this.pending = new Map()
99 }
100
101 async get (key) {
102 const k = decKey(key)
103 if (this.pending.has(k)) {
104 const v = this.pending.get(k)
105 return v === BatchView.DELETED ? null : { key: k, value: v }
106 }
107 const node = await this.bee.get(encKey(key))
108 if (!node) return null
109 return { key: decKey(node.key), value: decVal(node.value) }
110 }
111
112 async put (key, value) {
113 const k = decKey(key)
114 this.pending.set(k, value)
115 this.batch.tryPut(encKey(key), encVal(value))
116 }
117
118 async del (key) {
119 const k = decKey(key)
120 this.pending.set(k, BatchView.DELETED)
121 this.batch.tryDelete(encKey(key))
122 }
123
124 /**
125 * A range scan that sees this batch's own unflushed writes.
126 *
127 * ⛔ Read the header. Without the merge, `hasAnyUser` misses a user created earlier in the same
128 * batch and every user in that batch is promoted to admin.
129 *
130 * Merges two ordered sources by byte order: the committed stream, and the pending writes that
131 * fall in range. Pending wins on a tie — it is newer — and a pending deletion removes the key
132 * from the result rather than yielding a tombstone.
133 */
134 async * createReadStream (range = {}) {
135 const pend = []
136 for (const [k, v] of this.pending) {
137 const ek = encKey(k)
138 if (inRange(ek, range)) pend.push({ ek, key: k, value: v })
139 }
140 pend.sort((a, b) => b4a.compare(a.ek, b.ek))
141
142 let i = 0
143 let n = 0
144 const take = (key, value) => {
145 if (range.limit !== undefined && n >= range.limit) return 'stop'
146 n++
147 return { key, value }
148 }
149
150 for await (const node of this.bee.createReadStream(streamOpts(range))) {
151 const ek = node.key
152 // Emit every pending key that sorts before this one.
153 while (i < pend.length && b4a.compare(pend[i].ek, ek) < 0) {
154 const p = pend[i++]
155 if (p.value === BatchView.DELETED) continue
156 const out = take(p.key, p.value)
157 if (out === 'stop') return
158 yield out
159 }
160 // Same key: the pending write supersedes the committed one.
161 if (i < pend.length && b4a.compare(pend[i].ek, ek) === 0) {
162 const p = pend[i++]
163 if (p.value === BatchView.DELETED) continue
164 const out = take(p.key, p.value)
165 if (out === 'stop') return
166 yield out
167 continue
168 }
169 const out = take(decKey(ek), decVal(node.value))
170 if (out === 'stop') return
171 yield out
172 }
173
174 // Whatever pending keys sort after everything committed.
175 while (i < pend.length) {
176 const p = pend[i++]
177 if (p.value === BatchView.DELETED) continue
178 const out = take(p.key, p.value)
179 if (out === 'stop') return
180 yield out
181 }
182 }
183
184 async flush () { await this.batch.flush() }
185
186 // apply calls this when a batch throws, to drop the uncommitted writes rather than half-apply
187 // them. hyperbee2's write batch has its own close(); the pending map goes with it.
188 async close () {
189 this.pending.clear()
190 if (typeof this.batch.close === 'function') await this.batch.close()
191 }
192}
193
194// A sentinel, not `null` — `null` is a legal stored value and would read as "deleted".
195BatchView.DELETED = Symbol('deleted')
196
197export default { Hyperbee2View, BatchView }
package.json +26 -0
……@@ -0,0 +1,26 @@
1{
2 "name": "hyper-identity2",
3 "version": "0.1.0",
4 "description": "The identity layer on autobee. Sibling of hyper-identity (autobase); the shared half is hyper-identity-core.",
5 "type": "module",
6 "main": "index.js",
7 "exports": {
8 ".": "./index.js",
9 "./registry": "./lib/registry.js",
10 "./view-shim": "./lib/view-shim.js"
11 },
12 "scripts": {
13 "test": "brittle test/*.test.js",
14 "test:bare": "bare ./node_modules/.bin/brittle test/*.test.js"
15 },
16 "dependencies": {
17 "autobee": "^2.1.0",
18 "b4a": "^1.6.4",
19 "hyper-identity-core": "file:../hyper-identity-core",
20 "hypercore-crypto": "^3.0.0"
21 },
22 "devDependencies": {
23 "brittle": "^4.0.2",
24 "corestore": "^7.12.3"
25 }
26}
test/contract.test.js +59 -0
……@@ -0,0 +1,59 @@
1// hyper-identity2 must satisfy the SAME contract as hyper-identity, assertion for assertion.
2//
3// The contract is imported from hyper-identity-core, not restated here. A binding that kept its
4// own copy would drift the moment one was edited, and "both engines agree" would quietly become
5// "each engine agrees with its own expectations".
6//
7// ⚠ hyper-identity (autobase) is a devDependency purely so the control can run beside this. If the
8// two ever disagree, this file is where it shows.
9
10import test from 'brittle'
11import Corestore from 'corestore'
12import path from 'path'
13import os from 'os'
14import crypto from 'hypercore-crypto'
15import { contract } from 'hyper-identity-core/test/contract.js'
16import { IdentityRegistry } from '../index.js'
17
18async function open () {
19 const dir = path.join(os.tmpdir(), 'hi2-' + crypto.randomBytes(8).toString('hex'))
20 const store = new Corestore(dir)
21 await store.ready()
22 const registry = new IdentityRegistry(store, {})
23 await registry.ready()
24 return { registry, cleanup: async () => { await registry.close(); await store.close() } }
25}
26
27test('[autobee] the IdentityRegistry contract', async (t) => {
28 const { registry, cleanup } = await open()
29 t.teardown(cleanup)
30 t.is(registry.engine, 'autobee', 'the package IS the engine — it cannot report otherwise')
31 await contract(t, registry)
32})
33
34test('⛔ [autobee] adaptHost pins isIndexer, which autobee defaults to TRUE', async (t) => {
35 const { adaptHost } = await import('../lib/adapt-host.js')
36 const seen = []
37 const host = adaptHost({ addWriter: (k, o) => seen.push([k, o]), removeWriter: () => {} })
38
39 host.addWriter('k1', { isIndexer: true })
40 host.addWriter('k2', { isIndexer: false })
41 host.addWriter('k3', {})
42
43 t.alike(seen[0], ['k1', { isIndexer: true }])
44 t.alike(seen[1], ['k2', { isIndexer: false }])
45 // The whole reason this binding is not a bare passthrough.
46 t.alike(seen[2], ['k3', { isIndexer: false }], 'an ABSENT flag is false — never autobee’s true default')
47})
48
49test('⛔ [autobee] fastForwardTo is refused rather than silently dropped', async (t) => {
50 const dir = path.join(os.tmpdir(), 'hi2-ff-' + crypto.randomBytes(6).toString('hex'))
51 const store = new Corestore(dir)
52 await store.ready()
53 t.teardown(() => store.close())
54
55 // autobase's fastForward takes the log key; autobee's takes an oplog BOOT HEAD. Accepting the
56 // option and ignoring it is the same class of defect as a dropped addWriter flag.
57 const r = new IdentityRegistry(store, { fastForwardTo: 'ab'.repeat(32) })
58 await t.exception(r.ready(), /no autobee equivalent/, 'it says so, loudly')
59})
test/view-shim.test.js +146 -0
……@@ -0,0 +1,146 @@
1// The view shim, tested directly — because the contract does not reach the part that matters.
2//
3// `contract.test.js` creates two users and both pass, which proves the shim's codec. It does NOT
4// prove the merge-scan: each `createUser` appends and then updates, so the two ops land in separate
5// apply batches and `hasAnyUser` never has to see an unflushed write.
6//
7// The case that needs the merge is two CREATE_USER ops in ONE batch, and that is awkward to force
8// through the registry — it depends on autobee's batching, which is timing, not contract. So the
9// merge is asserted here against BatchView directly: deterministic, no engine lifecycle, and it
10// fails loudly if someone later "simplifies" the scan back into a plain pending map.
11//
12// ⛔ What is at stake, concretely: apply decides admin promotion with
13//
14// const isFirstUser = !(await hasAnyUser(b, prefix))
15//
16// If a scan cannot see a user written earlier in the same batch, every user in that batch is
17// promoted. A permissions bug caused by a storage detail, with no error anywhere.
18
19import test from 'brittle'
20import Corestore from 'corestore'
21import Hyperbee2 from 'hyperbee2'
22import path from 'path'
23import os from 'os'
24import crypto from 'hypercore-crypto'
25import { Hyperbee2View } from '../lib/view-shim.js'
26
27async function view () {
28 const dir = path.join(os.tmpdir(), 'shim-' + crypto.randomBytes(8).toString('hex'))
29 const store = new Corestore(dir)
30 await store.ready()
31 // ⚠ Hyperbee2 takes a STORE, not a core — index.js:15 `constructor(store, opts)`. autobase's
32 // Hyperbee took a core, which is one more place the two substrates differ quietly.
33 const bee = new Hyperbee2(store.namespace('v'))
34 await bee.ready()
35 return { view: new Hyperbee2View(bee), cleanup: async () => { await bee.close(); await store.close() } }
36}
37
38const drain = async (stream) => { const out = []; for await (const e of stream) out.push(e); return out }
39
40test('values round-trip as JSON with utf-8 keys', async (t) => {
41 const { view: v, cleanup } = await view()
42 t.teardown(cleanup)
43
44 const b = v.batch({ update: false })
45 await b.put('user:1', { name: 'Ada — éè你好', nested: { a: [1, 2] } })
46 await b.flush()
47
48 const got = await v.get('user:1')
49 t.is(got.key, 'user:1', 'the key comes back as a string')
50 t.is(got.value.name, 'Ada — éè你好', 'unicode survives')
51 t.alike(got.value.nested, { a: [1, 2] }, 'and so does structure')
52 t.is(await v.get('user:nope'), null, 'a missing key is null, not a throw')
53})
54
55test('★ a scan SEES writes made earlier in the same batch', async (t) => {
56 const { view: v, cleanup } = await view()
57 t.teardown(cleanup)
58
59 const b = v.batch({ update: false })
60 await b.put('user:a', { id: 'a' })
61
62 // This is `hasAnyUser`, exactly: does anything exist under the prefix RIGHT NOW.
63 const seen = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff', limit: 1 }))
64 t.is(seen.length, 1, 'the unflushed write is visible — otherwise the next user is admin too')
65 t.is(seen[0].key, 'user:a')
66 await b.flush()
67})
68
69test('a scan merges pending and committed in key order', async (t) => {
70 const { view: v, cleanup } = await view()
71 t.teardown(cleanup)
72
73 const first = v.batch({ update: false })
74 await first.put('user:b', { id: 'b' })
75 await first.put('user:d', { id: 'd' })
76 await first.flush()
77
78 const b = v.batch({ update: false })
79 await b.put('user:a', { id: 'a' }) // sorts before everything committed
80 await b.put('user:c', { id: 'c' }) // between two committed keys
81 await b.put('user:e', { id: 'e' }) // after everything committed
82 await b.put('user:b', { id: 'B' }) // supersedes a committed key
83
84 const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
85 t.alike(rows.map((r) => r.key), ['user:a', 'user:b', 'user:c', 'user:d', 'user:e'], 'ordered, no gaps')
86 t.is(rows.find((r) => r.key === 'user:b').value.id, 'B', 'pending supersedes committed on a tie')
87})
88
89test('a pending delete removes a committed key from the scan', async (t) => {
90 const { view: v, cleanup } = await view()
91 t.teardown(cleanup)
92
93 const first = v.batch({ update: false })
94 await first.put('user:a', { id: 'a' })
95 await first.put('user:b', { id: 'b' })
96 await first.flush()
97
98 const b = v.batch({ update: false })
99 await b.del('user:a')
100
101 t.is(await b.get('user:a'), null, 'and by key')
102 const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
103 t.alike(rows.map((r) => r.key), ['user:b'], 'the deleted key is gone, not yielded as a tombstone')
104})
105
106test('⛔ a stored null is not a deletion', async (t) => {
107 const { view: v, cleanup } = await view()
108 t.teardown(cleanup)
109
110 // The pending map uses a Symbol for deletion precisely so a legitimately stored `null` value
111 // cannot be mistaken for one. Using `null` as the sentinel would silently drop this row.
112 const b = v.batch({ update: false })
113 await b.put('user:a', null)
114 const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
115 t.is(rows.length, 1, 'a null VALUE is still a row')
116 t.is(rows[0].value, null)
117})
118
119test('limit stops the scan, counting merged rows not committed ones', async (t) => {
120 const { view: v, cleanup } = await view()
121 t.teardown(cleanup)
122
123 const first = v.batch({ update: false })
124 for (const k of ['user:b', 'user:c']) await first.put(k, { id: k })
125 await first.flush()
126
127 const b = v.batch({ update: false })
128 await b.put('user:a', { id: 'a' })
129
130 const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff', limit: 2 }))
131 t.is(rows.length, 2, 'limit is honoured across the merge')
132 t.alike(rows.map((r) => r.key), ['user:a', 'user:b'], 'and takes the first two in order')
133})
134
135test('range bounds exclude neighbouring prefixes', async (t) => {
136 const { view: v, cleanup } = await view()
137 t.teardown(cleanup)
138
139 const b = v.batch({ update: false })
140 await b.put('user:a', { id: 'a' })
141 await b.put('username:a', { id: 'other' }) // shares the 'user' prefix, must NOT appear
142 await b.put('writer:a', { id: 'other' })
143
144 const rows = await drain(b.createReadStream({ gt: 'user:', lt: 'user:\xff' }))
145 t.alike(rows.map((r) => r.key), ['user:a'], 'only the addressed prefix')
146})