| … | … | @@ -0,0 +1 @@ |
| 1 | node_modules/ |
| … | … | @@ -0,0 +1,52 @@ |
| 1 | # hyper-identity2 | |
| 2 | ||
| 3 | The identity layer on **autobee**. Sibling of [`hyper-identity`](../hyper-identity), which is the | |
| 4 | same 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 | ||
| 9 | An `engine:` option would be a second way to say the same thing, and the wrong value is a lie: it | |
| 10 | constructs the other engine while reporting what you asked for. That false green happened once | |
| 11 | during 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 — | |
| 18 | favela-peer — would carry a second multi-writer engine to use the identity layer. This package | |
| 19 | depends on autobee and **not** autobase. | |
| 20 | ||
| 21 | ⚠ The two dependency trees are not freely mixable. autobee needs `hypercore ^11.33.2`; mixing an | |
| 22 | older `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 | ||
| 33 | None of them errors. `lib/adapt-host.js` and `_writerKeyOf` exist so the answers live in the file | |
| 34 | that knows which engine it is. | |
| 35 | ||
| 36 | `fastForwardTo` is **refused**, not ignored: autobase's takes the log key, autobee's takes an oplog | |
| 37 | boot 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 | |
| 42 | no `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 | |
| 46 | scan; against a batch it is not. If two `CREATE_USER` ops land in one batch and the scan misses the | |
| 47 | first, **both** users are promoted. So `BatchView` merges pending writes into every scan rather than | |
| 48 | keeping 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. |
| … | … | @@ -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 | ||
| 10 | export { IdentityRegistry } from './lib/registry.js' | |
| 11 | export { Hyperbee2View, BatchView } from './lib/view-shim.js' | |
| 12 | export { adaptHost } from './lib/adapt-host.js' | |
| 13 | export { OpTypes, encodeOp, decodeOp, validateOp, createApplyFunction } from 'hyper-identity-core' |
| … | … | @@ -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 | ||
| 15 | export 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 | ||
| 24 | export default adaptHost |
| … | … | @@ -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 | ||
| 7 | import Autobee from 'autobee' | |
| 8 | import { BaseIdentityRegistry } from 'hyper-identity-core/registry' | |
| 9 | import { Hyperbee2View } from './view-shim.js' | |
| 10 | import { adaptHost } from './adapt-host.js' | |
| 11 | ||
| 12 | export 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 | ||
| 52 | export default IdentityRegistry |
| … | … | @@ -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 | ||
| 28 | import b4a from 'b4a' | |
| 29 | ||
| 30 | const encKey = (k) => (typeof k === 'string' ? b4a.from(k, 'utf8') : k) | |
| 31 | const decKey = (k) => (typeof k === 'string' ? k : b4a.toString(k, 'utf8')) | |
| 32 | const encVal = (v) => b4a.from(JSON.stringify(v), 'utf8') | |
| 33 | const 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. | |
| 44 | function 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 | ||
| 52 | const 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 | */ | |
| 62 | export 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 | */ | |
| 93 | export 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". | |
| 195 | BatchView.DELETED = Symbol('deleted') | |
| 196 | ||
| 197 | export default { Hyperbee2View, BatchView } |
| … | … | @@ -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 | } |
| … | … | @@ -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 | ||
| 10 | import test from 'brittle' | |
| 11 | import Corestore from 'corestore' | |
| 12 | import path from 'path' | |
| 13 | import os from 'os' | |
| 14 | import crypto from 'hypercore-crypto' | |
| 15 | import { contract } from 'hyper-identity-core/test/contract.js' | |
| 16 | import { IdentityRegistry } from '../index.js' | |
| 17 | ||
| 18 | async 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 | ||
| 27 | test('[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 | ||
| 34 | test('⛔ [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 | ||
| 49 | test('⛔ [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 | }) |
| … | … | @@ -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 | ||
| 19 | import test from 'brittle' | |
| 20 | import Corestore from 'corestore' | |
| 21 | import Hyperbee2 from 'hyperbee2' | |
| 22 | import path from 'path' | |
| 23 | import os from 'os' | |
| 24 | import crypto from 'hypercore-crypto' | |
| 25 | import { Hyperbee2View } from '../lib/view-shim.js' | |
| 26 | ||
| 27 | async 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 | ||
| 38 | const drain = async (stream) => { const out = []; for await (const e of stream) out.push(e); return out } | |
| 39 | ||
| 40 | test('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 | ||
| 55 | test('★ 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 | ||
| 69 | test('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 | ||
| 89 | test('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 | ||
| 106 | test('⛔ 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 | ||
| 119 | test('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 | ||
| 135 | test('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 | }) |