Merge architect post-heights-index work

By specifier.
This commit is contained in:
Chris Troutner
2026-08-26 11:42:22 -07:00
42 changed files with 2390 additions and 351 deletions
@@ -0,0 +1,78 @@
# Review summary: post-heights-index
**Architect review of the refactorer handoff for task `post-heights-index`.**
## Commits reviewed
- `6a8cf6653e` (refactorer): "Refactor postHeights index code and add property tests" —
deduplicated postHeights query iteration into a shared generator, extracted shared
pagination parsing/enrichment and the idempotent create-if-missing write, added property
tests for postHeight key round-trips and ordering in both `psf-memo-db` and
`psf-memo-indexer`, exposed them via a separate `property` command.
- Merged into the architect worktree with the prior chain commits (specifier features,
coder acceptance pipelines) that the refactorer branch carried.
## Architectural findings and fixes applied
The refactorer's module structure was sound (shared `lib/pagination.js`, generator
encapsulation of the secondary-index iteration, `createIfMissing` for idempotent writes).
I applied the following structural fixes:
- **Extracted `assemblePostPage`** into `psf-memo-db/src/use-cases/lib/pagination.js` and
used it from `list-posts-by-addr` and `list-recent-posts`, removing the duplicated
post-page assembly tail (attach-reply-counts + pagination object) flagged by DRY (0.84).
- **Extracted `ListUseCase`** base class into `psf-memo-db/src/use-cases/lib/use-case.js`
and made the three list use cases extend it, removing the identical constructor
validation boilerplate flagged by DRY at 1.00 across all three files.
- **Built the runner adapter** required by the APS `gherkin-mutator` for `psf-memo-db` and
`psf-memo-indexer` (`acceptance/lib/runner-worker.js` in each). These route library
stdout logging to stderr so the persistent worker's stdout carries only JSON responses.
## Verification results
### Language mutation (`mutate4javascript`, `--max-workers 8`, all covered sites)
Every changed source file is fully killed (0 survivors, 0 uncovered):
- `psf-memo-db/src/adapters/post-query.js` — 22/22 killed
- `psf-memo-db/src/use-cases/lib/pagination.js` — 13/13 killed
- `psf-memo-db/src/use-cases/lib/use-case.js` — 0 sites (constructor only)
- `psf-memo-db/src/use-cases/list-posts-by-addr.js` — killed
- `psf-memo-db/src/use-cases/list-recent-posts.js` — 0 sites (post-constructor)
- `psf-memo-db/src/use-cases/list-recent-profiles.js` — 9/9 killed
- `psf-memo-indexer/src/use-cases/action-types/post.js` — 2/2 killed
Tests added to kill all initially-surviving mutants: `parseLimit`/`parseOffset` boundary
values (1 and 100), `hasMore` exact-page boundaries, the `sortProfiles` `seen` tie-break
with falsy values, `scanPostsByAddrTxids`/`scanRecentPostTxids` limit boundary,
`txidFromPostHeight` with absent value, `loadPostsByTxids` `blockHeight` fallback, and the
`MAX_POST_SIZE` exact-boundary in the indexer.
### DRY (`dry4javascript`)
No duplicate candidates found on any changed source file.
### CRAP / cyclomatic complexity (`crap4javascript`)
All changed functions within threshold (max CC 6, CRAP ≤ 6.0). Highest:
`PostQuery.scanPostsByAddrTxids` CC 6 / CRAP 6.0; `handlePost` CC 4 / CRAP 4.8.
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
- **psf-memo-db** `efficient-post-pagination.feature`: 45 mutations discovered, 27
executed (1 scenario reused from an earlier fully-killed run, 18 skipped). 23 killed,
**4 survived** — all `limit` example-value mutations (`2→6`, `3→7`, `3→11`, `3→5`).
Documented equivalents: increasing `limit` above the fixture's post count produces the
same accepted page because the scenarios assert the returned posts plus a "no more than
limit" bound rather than an exact page size.
- **psf-memo-indexer** `efficient-post-indexing.feature`: 27 mutations executed, 5 killed,
**22 survived** across `addr`, `height`, `text`, `txid`, `parentTxid`. The scenarios
process a transaction with the example values and then assert those same values appear in
the store, so any mutated input echoes back into the assertion (weak/tautological
example-to-assertion connection). These are specifier-side feature-quality improvements.
## Suite status
- `psf-memo-db`: unit **58 passing**, property **3 passing**, acceptance **pass**.
- `psf-memo-indexer`: unit **29 passing**, property **2 passing**, acceptance **pass**.
## Handoffs sent
- `git_handoff` priority 00 to **coder** and **refactorer** (follow-up review of the
architectural changes).
- No specifier handoff: no specification changes in this commit (the feature-file manifest
churn is tool-generated metadata). The weak-scenario findings above are recorded here for
the specifier in the durable report.
By architect.
+88
View File
@@ -0,0 +1,88 @@
/*
Normal acceptance runner for psf-memo-db.
Orchestrates the acceptance pipeline:
feature file -> bb gherkin-parser -> JSON IR -> acceptance entrypoint
generator -> generated test entry points -> node test runner
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const specsDir = path.join(root, 'specs')
const buildDir = path.join(root, 'build', 'acceptance')
const irDir = path.join(buildDir, 'ir')
const genDir = path.join(buildDir, 'generated')
const apsDir = path.join(root, '..', 'tmp', 'aps-spec')
function sh (cmd, args, opts = {}) {
return execFileSync(cmd, args, {
stdio: ['pipe', 'pipe', 'pipe'],
...opts
}).toString()
}
function ensureAps () {
if (fs.existsSync(apsDir)) return
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
sh('git', ['clone', '--depth', '1',
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
}
function main () {
ensureAps()
const features = fs
.readdirSync(specsDir)
.filter((f) => f.endsWith('.feature'))
.sort()
if (features.length === 0) {
console.log('No feature files found under specs/.')
return
}
fs.mkdirSync(irDir, { recursive: true })
fs.mkdirSync(genDir, { recursive: true })
for (const featureFile of features) {
const base = featureFile.replace(/\.feature$/i, '')
const featurePath = path.join(specsDir, featureFile)
const irPath = path.join(irDir, `${base}.json`)
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir])
}
const tests = fs
.readdirSync(genDir)
.filter((f) => f.endsWith('.acceptance.test.js'))
.sort()
let failures = 0
for (const testFile of tests) {
try {
const out = sh('node', [path.join(genDir, testFile)])
process.stdout.write(out)
console.log(`ACCEPTANCE PASS: ${testFile}`)
} catch (err) {
failures++
process.stdout.write(err.stdout || '')
process.stderr.write(err.stderr || '')
console.error(`ACCEPTANCE FAIL: ${testFile}`)
}
}
if (failures > 0) {
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
process.exit(1)
} else {
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
}
}
main()
+100
View File
@@ -0,0 +1,100 @@
/*
Project-specific acceptance entrypoint generator for psf-memo-db.
Reads parser JSON IR and writes executable generated test entry points plus
per-feature metadata. Generated tests delegate all step behavior to the
acceptance runtime and project step handlers.
*/
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function metadataName (featureName) {
const slug = featureName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `${slug || 'feature'}.json`
}
function relativeImport (fromDir, targetFile) {
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
if (!rel.startsWith('.')) rel = `./${rel}`
return rel
}
function main () {
const irArg = process.argv[2]
const outArg = process.argv[3]
if (!irArg || !outArg) {
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
process.exit(2)
}
let ir
try {
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
} catch (err) {
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
process.exit(1)
}
const genDir = outArg
fs.mkdirSync(genDir, { recursive: true })
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
const relRuntime = relativeImport(genDir, path.join(__dirname, 'runtime.js'))
const body = `import { runFeature } from '${relRuntime}'
const ir = ${JSON.stringify(ir, null, 2)}
async function main () {
const report = await runFeature(ir)
for (const r of report.results) {
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
if (r.detail) console.log(' ' + r.detail)
}
if (report.failures > 0) {
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
process.exitCode = 1
}
}
main().catch((err) => { console.error(err); process.exit(1) })
`
try {
fs.writeFileSync(testFile, body)
} catch (err) {
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
process.exit(1)
}
const metaDir = path.join(genDir, 'metadata')
fs.mkdirSync(metaDir, { recursive: true })
const hash = crypto
.createHash('sha256')
.update(fs.readFileSync(testFile))
.digest('hex')
const metadata = {
schema_version: 1,
feature_path: `${featureKey}.feature`,
ir_path: path.resolve(irArg),
implementation_hash: `sha256:${hash}`,
hash_scope: 'generated_files',
generated_files: [testFile]
}
fs.writeFileSync(
path.join(metaDir, metadataName(featureKey)),
JSON.stringify(metadata, null, 2)
)
process.exit(0)
}
main()
+289
View File
@@ -0,0 +1,289 @@
/*
Project step handlers for the psf-memo-db acceptance pipeline.
These handlers spin up a real psf-memo-db adapter set against a temporary
LevelDB directory, load the Gherkin fixtures, and exercise the real use cases
for recent posts and posts-by-address. Iterators and get calls are wrapped
so the efficiency steps can assert bounded reads.
*/
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
import { DB_NAMES } from '../../src/adapters/level-db.js'
import Adapters from '../../src/adapters/index.js'
import ListRecentPosts from '../../src/use-cases/list-recent-posts.js'
import ListPostsByAddr from '../../src/use-cases/list-posts-by-addr.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
function resolveParam (value, example) {
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
if (match) {
const param = match[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
return example[param]
}
return String(value).trim()
}
function wrapIterator (db, counter) {
const original = db.iterator.bind(db)
db.iterator = function (...args) {
counter.calls++
return original(...args)
}
}
function wrapGet (db, counter) {
const original = db.get.bind(db)
db.get = async function (...args) {
counter.calls++
return original(...args)
}
}
async function createWorld () {
const levelDir = path.join(tmpDir, `level-${Date.now()}-${Math.random().toString(36).slice(2)}`)
const adapters = new Adapters()
// Force the LevelDB adapter to use a temporary directory for this scenario.
adapters.levelDb.openDbs = function () {
const dbs = {}
fs.mkdirSync(levelDir, { recursive: true })
for (const name of DB_NAMES) {
const prop = `${name}Db`
const storeDir = path.join(levelDir, name)
fs.mkdirSync(storeDir, { recursive: true })
dbs[prop] = adapters.levelDb.level(storeDir, {
valueEncoding: 'json',
cacheSize: name === 'posts' ? 512 * 1024 * 1024 : 64 * 1024 * 1024
})
this[prop] = dbs[prop]
}
return dbs
}
adapters.start()
const postHeightsIteratorCounter = { calls: 0 }
const postChildrenIteratorCounter = { calls: 0 }
const postsGetCounter = { calls: 0 }
wrapIterator(adapters.level.postHeightsDb, postHeightsIteratorCounter)
wrapIterator(adapters.level.postChildrenDb, postChildrenIteratorCounter)
wrapGet(adapters.level.postsDb, postsGetCounter)
const listRecentPosts = new ListRecentPosts({ adapters })
const listPostsByAddr = new ListPostsByAddr({ adapters })
let lastResponse = null
return {
adapters,
listRecentPosts,
listPostsByAddr,
postHeightsIteratorCounter,
postChildrenIteratorCounter,
postsGetCounter,
getLastResponse: () => lastResponse,
setLastResponse: (resp) => { lastResponse = resp },
close: async () => {
try {
await adapters.levelDb.closeDbs()
} catch (err) {
// ignore close errors
}
}
}
}
async function loadFixture (world, name) {
if (name !== 'three-top-level-posts-and-one-reply') {
throw new Error(`Unknown fixture: ${name}`)
}
const posts = [
{ txid: 'post-200-b', addr: 'bitcoincash:qaddr-b', text: 'b', seen: 200, blockHeight: 600200 },
{ txid: 'post-200-a', addr: 'bitcoincash:qaddr-a', text: 'a', seen: 100, blockHeight: 600200 },
{ txid: 'post-100', addr: 'bitcoincash:qaddr-a', text: 'c', seen: 50, blockHeight: 600100 }
]
const reply = { txid: 'reply-1', parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600050 }
for (const post of posts) {
await world.adapters.level.postsDb.put(post.txid, {
addr: post.addr,
text: post.text,
seen: post.seen,
blockHeight: post.blockHeight
})
await world.adapters.level.postHeightsDb.put(
String(post.blockHeight).padStart(12, '0') + ':' + post.txid,
{ txid: post.txid, blockHeight: post.blockHeight }
)
}
await world.adapters.level.postsDb.put('reply-1', {
addr: 'bitcoincash:qaddr-a',
text: 'reply body',
seen: 10,
blockHeight: 600050
})
await world.adapters.level.postHeightsDb.put(
'000000600050:reply-1',
{ txid: 'reply-1', blockHeight: 600050 }
)
await world.adapters.level.postParentsDb.put('reply-1', reply)
await world.adapters.level.postChildrenDb.put('post-200-a:reply-1', reply)
}
const handlers = [
{
name: 'db instance with posts and postHeights stores',
pattern: /^a psf-memo-db instance with a posts store and a postHeights secondary index$/,
async run () {
// World is already created with both stores.
}
},
{
name: 'load fixture',
pattern: /^the fixture "(.+)" is loaded into the posts store$/,
async run (m, example, world) {
await loadFixture(world, m[1])
}
},
{
name: 'request recent posts',
pattern: /^the client requests \/posts\/recent with limit (<limit>) and offset (<offset>)$/,
async run (m, example, world) {
const limit = parseInt(resolveParam(m[1], example), 10)
const offset = parseInt(resolveParam(m[2], example), 10)
const resp = await world.listRecentPosts.execute({ limit, offset })
world.setLastResponse(resp)
}
},
{
name: 'request posts by addr',
pattern: /^the client requests \/posts\/by\/(<addr>) with limit (<limit>) and offset (<offset>)$/,
async run (m, example, world) {
const addr = resolveParam(m[1], example)
const limit = parseInt(resolveParam(m[2], example), 10)
const offset = parseInt(resolveParam(m[3], example), 10)
const resp = await world.listPostsByAddr.execute({ addr, limit, offset })
world.setLastResponse(resp)
}
},
{
name: 'posts sorted by block height descending',
pattern: /^the response posts are sorted by block height descending$/,
run (m, example, world) {
const posts = world.getLastResponse().posts
for (let i = 1; i < posts.length; i++) {
if (posts[i].blockHeight > posts[i - 1].blockHeight) {
throw new Error(`Posts not sorted by descending block height at index ${i}`)
}
}
}
},
{
name: 'response contains expected txids',
pattern: /^the response contains the txids (<expected_txids>)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example).split(',').map((s) => s.trim())
const actual = world.getLastResponse().posts.map((p) => p.txid)
if (expected.join(',') !== actual.join(',')) {
throw new Error(`Expected txids ${expected.join(',')}, got ${actual.join(',')}`)
}
}
},
{
name: 'response contains only posts by addr',
pattern: /^the response contains only posts by (<addr>)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const posts = world.getLastResponse().posts
for (const post of posts) {
if (post.addr !== addr) {
throw new Error(`Expected post by ${addr}, got ${post.addr}`)
}
}
}
},
{
name: 'response pagination metadata',
pattern: /^the response pagination shows total (<total>) and hasMore (<hasMore>)$/,
run (m, example, world) {
const expectedTotal = parseInt(resolveParam(m[1], example), 10)
const expectedHasMore = resolveParam(m[2], example) === 'true'
const pagination = world.getLastResponse().pagination
if (pagination.total !== expectedTotal) {
throw new Error(`Expected total ${expectedTotal}, got ${pagination.total}`)
}
if (pagination.hasMore !== expectedHasMore) {
throw new Error(`Expected hasMore ${expectedHasMore}, got ${pagination.hasMore}`)
}
}
},
{
name: 'bounded postHeights reads',
pattern: /^no more than (<limit>) postHeights entries are read after applying the offset$/,
run (m, example, world) {
const limit = parseInt(resolveParam(m[1], example), 10)
const reads = world.postHeightsIteratorCounter.calls
if (reads > limit) {
throw new Error(`Read ${reads} postHeights entries, expected at most ${limit}`)
}
}
},
{
name: 'bounded posts loaded by txid',
pattern: /^no more than (<limit>) posts are loaded by txid$/,
run (m, example, world) {
const limit = parseInt(resolveParam(m[1], example), 10)
const reads = world.postsGetCounter.calls
if (reads > limit) {
throw new Error(`Loaded ${reads} posts by txid, expected at most ${limit}`)
}
}
},
{
name: 'post has replyCount',
pattern: /^the response post with txid (<txid>) has replyCount (<replyCount>)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const expected = parseInt(resolveParam(m[2], example), 10)
const post = world.getLastResponse().posts.find((p) => p.txid === txid)
if (!post) {
throw new Error(`Post ${txid} not found in response`)
}
if (post.replyCount !== expected) {
throw new Error(`Expected replyCount ${expected} for ${txid}, got ${post.replyCount}`)
}
}
},
{
name: 'single postChildren scan',
pattern: /^the postChildren store was iterated exactly once$/,
run (m, example, world) {
const calls = world.postChildrenIteratorCounter.calls
if (calls !== 1) {
throw new Error(`Expected exactly one postChildren scan, got ${calls}`)
}
}
}
]
async function handleStep (step, example, world) {
for (const handler of handlers) {
const match = handler.pattern.exec(step.text)
if (match) {
await handler.run(match, example, world, step)
return
}
}
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
}
export { createWorld, handleStep }
@@ -0,0 +1,73 @@
/*
Persistent runner adapter for the APS gherkin-mutator (psf-memo-db).
The mutator starts this process (once per worker) and sends mutation jobs
over newline-delimited JSON on stdin. Each job carries the path to a mutated
feature JSON IR; this worker evaluates it through the same acceptance runtime
and step handlers used by the normal acceptance pipeline and replies with the
runner outcome.
Protocol (mutator-spec.md):
request: { "id", "feature_json", "generated_dir", "work_dir" }
response: { "id", "outcome", "output", "error", "duration" }
outcome: test_success | test_failure | infrastructure_error
test_failure (acceptance failed) -> mutation killed
test_success (acceptance passed) -> mutation survived
stdout is reserved for JSON responses. The db libraries (config, adapters)
log to stdout via console.log, so that logging is redirected to stderr below;
otherwise the mutator would read non-JSON lines as worker responses.
*/
import readline from 'node:readline'
import fs from 'node:fs'
console.log = (...args) => console.error('[worker]', ...args)
const { runFeature } = await import('./runtime.js')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
})
rl.on('line', async (line) => {
const started = Date.now()
const respond = (payload) => {
process.stdout.write(`${JSON.stringify(payload)}\n`)
}
let job
try {
job = JSON.parse(line)
} catch (err) {
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
return
}
try {
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
const report = await runFeature(ir)
respond({
id: job.id,
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
error: '',
duration: Date.now() - started
})
} catch (err) {
respond({
id: job.id,
outcome: 'infrastructure_error',
output: '',
error: err.message,
duration: Date.now() - started
})
}
})
rl.on('close', () => {
process.exit(0)
})
+64
View File
@@ -0,0 +1,64 @@
/*
Acceptance runtime for psf-memo-db.
Expands each scenario (and each example row) from parser JSON IR into
scenario executions, prepends background steps, and dispatches every step to
the project step handlers.
*/
import { createWorld, handleStep } from './handlers.js'
// Expand the IR scenarios into concrete executions.
function expandScenarios (ir) {
const executions = []
const background = ir.background || []
for (const scenario of ir.scenarios) {
const examples = (scenario.examples && scenario.examples.length > 0)
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
: [{ example: {}, suffix: 'example_1' }]
for (const { example, suffix } of examples) {
executions.push({
name: `${scenario.name}/${suffix}`,
steps: [...background, ...scenario.steps],
example
})
}
}
return executions
}
async function runFeature (ir) {
const executions = expandScenarios(ir)
const results = []
let failures = 0
for (const ex of executions) {
const world = await createWorld()
let failure = null
for (const step of ex.steps) {
try {
await handleStep(step, ex.example, world)
} catch (err) {
failure = err.message
break
}
}
await world.close()
if (failure) {
failures++
results.push({ name: ex.name, status: 'failed', detail: failure })
} else {
results.push({ name: ex.name, status: 'passed' })
}
}
return { feature: ir.name, results, failures, total: results.length }
}
export { expandScenarios, runFeature }
+2
View File
@@ -8,6 +8,8 @@
"prestart": "npm run docs",
"start": "node index.js",
"test": "export SVC_ENV=test && c8 --reporter=text mocha --exit --timeout 15000 --recursive test/unit/",
"property": "node --test \"test/property/*.test.js\"",
"acceptance": "node acceptance/acceptance.js",
"lint": "standard --env mocha --fix",
"docs": "./node_modules/.bin/apidoc -i src/ -o docs"
},
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-26T18:32:03.661642374Z","feature_name":"Efficient post pagination","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/efficient-post-pagination.feature","background_hash":"09cc76dccab8b5ac80dd4f72dfbca640087c6bd80ed29d9b9687a41fa20a038f","implementation_hash":"unknown","scenarios":[{"index":1,"name":"Efficient post pagination - 2 GET /posts/by/:addr returns a page of top-level posts for that address sorted by block height descending","scenario_hash":"1d87e78064f9c2b2f227b4cc405e2ea898b15ab34dc245a97f205a8edf1cc8fc","mutation_count":18,"result":{"Total":18,"Killed":18,"Survived":0,"Errors":0},"tested_at":"2026-08-26T18:23:09.918773322Z"}]}
# acceptance-mutation-manifest-end
# Scenarios: Efficient post pagination - 1, Efficient post pagination - 2, Efficient post pagination - 3
Feature: Efficient post pagination
+1
View File
@@ -24,6 +24,7 @@ class Adapters {
})
this.postQuery = new PostQuery({
postsDb: level.postsDb,
postHeightsDb: level.postHeightsDb,
postParentsDb: level.postParentsDb,
postChildrenDb: level.postChildrenDb
})
+1
View File
@@ -12,6 +12,7 @@ const dbDir = `${__dirname}/../../leveldb`
const DB_NAMES = [
'status',
'posts',
'postHeights',
'postParents',
'postChildren',
'likes',
+115 -53
View File
@@ -1,14 +1,18 @@
/*
Adapter for scanning posts with stored block height.
Excludes reply posts (txids present in postParentsDb).
Adapter for efficient post queries using a postHeights secondary index.
*/
const HEIGHT_PAD = 12
class PostQuery {
constructor (localConfig = {}) {
const { postsDb, postParentsDb, postChildrenDb } = localConfig
const { postsDb, postHeightsDb, postParentsDb, postChildrenDb } = localConfig
if (!postsDb) {
throw new Error('postsDb required when instantiating PostQuery adapter.')
}
if (!postHeightsDb) {
throw new Error('postHeightsDb required when instantiating PostQuery adapter.')
}
if (!postParentsDb) {
throw new Error('postParentsDb required when instantiating PostQuery adapter.')
}
@@ -16,12 +20,34 @@ class PostQuery {
throw new Error('postChildrenDb required when instantiating PostQuery adapter.')
}
this.postsDb = postsDb
this.postHeightsDb = postHeightsDb
this.postParentsDb = postParentsDb
this.postChildrenDb = postChildrenDb
this.scanPostsWithBlockHeight = this.scanPostsWithBlockHeight.bind(this)
this.scanPostsByAddr = this.scanPostsByAddr.bind(this)
this.scanRecentPostTxids = this.scanRecentPostTxids.bind(this)
this.scanPostsByAddrTxids = this.scanPostsByAddrTxids.bind(this)
this.loadPostsByTxids = this.loadPostsByTxids.bind(this)
this.countTopLevelPosts = this.countTopLevelPosts.bind(this)
this.countTopLevelPostsByAddr = this.countTopLevelPostsByAddr.bind(this)
this.loadReplyTxids = this.loadReplyTxids.bind(this)
this.buildReplyCountMap = this.buildReplyCountMap.bind(this)
this.txidFromPostHeight = this.txidFromPostHeight.bind(this)
this.getPostOrNull = this.getPostOrNull.bind(this)
this.topLevelPostTxids = this.topLevelPostTxids.bind(this)
}
static padHeight (height) {
return String(height).padStart(HEIGHT_PAD, '0')
}
static postHeightKey (blockHeight, txid) {
return `${PostQuery.padHeight(blockHeight)}:${txid}`
}
txidFromPostHeight (key, value) {
if (value && typeof value.txid === 'string') return value.txid
const parts = String(key).split(':')
return parts[parts.length - 1]
}
async loadReplyTxids () {
@@ -38,7 +64,7 @@ class PostQuery {
const counts = new Map()
for await (const [, child] of this.postChildrenDb.iterator()) {
const parentTxid = child.parentTxid
const parentTxid = child?.parentTxid
if (!parentTxid) continue
counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1)
}
@@ -46,74 +72,110 @@ class PostQuery {
return counts
}
async scanPostsWithBlockHeight () {
const [replyTxids, replyCounts] = await Promise.all([
this.loadReplyTxids(),
this.buildReplyCountMap()
])
// Fetch a post by txid, returning null when the post is not found.
async getPostOrNull (txid) {
try {
return await this.postsDb.get(txid)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null
throw err
}
}
// Iterate the txids of top-level posts (replies excluded) in postHeights
// key order. Pass { reverse: true } for newest-first iteration.
async * topLevelPostTxids ({ reverse = false } = {}) {
const replyTxids = await this.loadReplyTxids()
for await (const [key, value] of this.postHeightsDb.iterator({ reverse })) {
const txid = this.txidFromPostHeight(key, value)
if (replyTxids.has(txid)) continue
yield txid
}
}
async scanRecentPostTxids ({ limit, offset }) {
const txids = []
let skipped = 0
for await (const txid of this.topLevelPostTxids({ reverse: true })) {
if (skipped < offset) {
skipped++
continue
}
txids.push(txid)
if (txids.length >= limit) break
}
return txids
}
async scanPostsByAddrTxids (addr, { limit, offset }) {
const txids = []
let skipped = 0
for await (const txid of this.topLevelPostTxids({ reverse: true })) {
const post = await this.getPostOrNull(txid)
if (!post || post.addr !== addr) continue
if (skipped < offset) {
skipped++
continue
}
txids.push(txid)
if (txids.length >= limit) break
}
return txids
}
async loadPostsByTxids (txids) {
const posts = []
for await (const [txid, post] of this.postsDb.iterator()) {
if (replyTxids.has(txid)) continue
for (const txid of txids) {
const post = await this.getPostOrNull(txid)
if (!post) continue
posts.push({
txid,
addr: post.addr,
text: post.text,
seen: post.seen,
blockHeight: post.blockHeight ?? 0,
replyCount: replyCounts.get(txid) ?? 0
blockHeight: post.blockHeight ?? 0
})
}
return posts
}
async scanPostsByAddr (addr) {
const [replyTxids, replyCounts] = await Promise.all([
this.loadReplyTxids(),
this.buildReplyCountMap()
])
const posts = []
async countTopLevelPosts () {
let count = 0
const iterator = this.topLevelPostTxids()
for await (const [txid, post] of this.postsDb.iterator()) {
if (post.addr !== addr) continue
if (replyTxids.has(txid)) continue
posts.push({
txid,
addr: post.addr,
text: post.text,
seen: post.seen,
blockHeight: post.blockHeight ?? 0,
replyCount: replyCounts.get(txid) ?? 0
})
for (;;) {
const { done } = await iterator.next()
if (done) break
count++
}
return posts
return count
}
async buildReplyCountMap () {
const counts = new Map()
let total = 0
async countTopLevelPostsByAddr (addr) {
let count = 0
for await (const [childTxid, child] of this.postChildrenDb.iterator()) {
total++
for await (const txid of this.topLevelPostTxids()) {
const post = await this.getPostOrNull(txid)
if (post && post.addr === addr) count++
}
console.log('Indexed reply:', {
childTxid,
child,
parentTxid: child?.parentTxid
})
const parentTxid = child?.parentTxid
if (!parentTxid) continue
counts.set(parentTxid, (counts.get(parentTxid) || 0) + 1)
return count
}
console.log(`Total postChildrenDb records: ${total}`)
return counts
}
}
export default PostQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:11:36.339Z","module_hash":"9d849ae3741dcb65f12ea75f911bf873e36f5748183df777be6296c709c5e598","functions":[{"id":"func/PostQuery.constructor","name":"PostQuery.constructor","line":8,"end_line":37,"hash":"75efd21f33ee6a78e6c41e71b2b7dea75a7b66bb2f7e27569864d0c9a4843383"},{"id":"func/PostQuery.padHeight","name":"PostQuery.padHeight","line":39,"end_line":41,"hash":"be6c442a4d3d86ab3b60314756b7f7c0592479c21cb3b2e1273dcf139a84fb00"},{"id":"func/PostQuery.postHeightKey","name":"PostQuery.postHeightKey","line":43,"end_line":45,"hash":"2d4dff9464aa4c1e805da5de2ba314fbd856530c046705237ea848d5feff8c7c"},{"id":"func/PostQuery.txidFromPostHeight","name":"PostQuery.txidFromPostHeight","line":47,"end_line":51,"hash":"691bcf6f1ab70e0608e3f05da9b9f7d88cc8ac710cdcb14e6dc4a1c1c0546744"},{"id":"func/PostQuery.loadReplyTxids","name":"PostQuery.loadReplyTxids","line":53,"end_line":61,"hash":"a397af5257d234a2aa9c18b1de49aa3738bf31645e0efbb9ed71bd0940abdb18"},{"id":"func/PostQuery.buildReplyCountMap","name":"PostQuery.buildReplyCountMap","line":63,"end_line":73,"hash":"1d9762d70dca3439c0bb382b09882faee215f1d53f0656aff8bcbde429ec63b4"},{"id":"func/PostQuery.getPostOrNull","name":"PostQuery.getPostOrNull","line":76,"end_line":83,"hash":"792ce2a8d5f19ed3d159c7af7e95c310e5b0c05cbef4de5be8f8f78403680b91"},{"id":"func/PostQuery.topLevelPostTxids","name":"PostQuery.topLevelPostTxids","line":87,"end_line":95,"hash":"0457d8b43b692d7bbfde283e63663d74542778d3893b45202fcb6ae0f1fc6776"},{"id":"func/PostQuery.scanRecentPostTxids","name":"PostQuery.scanRecentPostTxids","line":97,"end_line":112,"hash":"bed887c0eeec051e082657cac518bbd2f60df84bbb86c9d8aa76015194e7a73a"},{"id":"func/PostQuery.scanPostsByAddrTxids","name":"PostQuery.scanPostsByAddrTxids","line":114,"end_line":132,"hash":"6485e255e529be0172debb67749fc2fc1757e5c41f6d03fd14f8d277aea4b91a"},{"id":"func/PostQuery.loadPostsByTxids","name":"PostQuery.loadPostsByTxids","line":134,"end_line":150,"hash":"86b05107f3ecc240b2e734217cedf29ef44c48474bf31c1c8f75f2e4b4f84bea"},{"id":"func/PostQuery.countTopLevelPosts","name":"PostQuery.countTopLevelPosts","line":152,"end_line":163,"hash":"a26a6fdd12201de71965545f4113e3598f325fa4d96076289d371e4157c667ff"},{"id":"func/PostQuery.countTopLevelPostsByAddr","name":"PostQuery.countTopLevelPostsByAddr","line":165,"end_line":174,"hash":"d5bcd0c7140f6c05cc3e66037ca96627ff262dd975a5553483bbd8f91bfdd7f5"}]}
// mutate4javascript-manifest-end
@@ -36,6 +36,7 @@ export function makeCrudHandlers ({ dbProp, keyParam, bodyIdField, bodyDataField
export const ENTITY_CONFIG = [
{ route: 'post', dbProp: 'postsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'postData' },
{ route: 'postheight', dbProp: 'postHeightsDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'postHeightData' },
{ route: 'postparent', dbProp: 'postParentsDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'parentData' },
{ route: 'postchild', dbProp: 'postChildrenDb', keyParam: 'key', bodyIdField: 'key', bodyDataField: 'childData' },
{ route: 'like', dbProp: 'likesDb', keyParam: 'txid', bodyIdField: 'txid', bodyDataField: 'likeData' },
@@ -26,7 +26,7 @@ class PostsRouter {
attach (app) {
this.router.get('/recent', this.postsRESTController.getRecentPosts)
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
this.router.get('/:txid/thread',this.postsRESTController.getPostThread)
this.router.get('/:txid/thread', this.postsRESTController.getPostThread)
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
+1 -1
View File
@@ -98,4 +98,4 @@ class GetPostThread {
}
}
export default GetPostThread
export default GetPostThread
-1
View File
@@ -47,4 +47,3 @@ class UseCases {
}
export default UseCases
@@ -0,0 +1,67 @@
/*
Shared pagination parsing and post-list enrichment for post use cases.
Both the recent-posts and posts-by-address list use cases share identical
limit/offset validation and reply-count enrichment. Centralizing them keeps
the validation behavior identical across all list endpoints and avoids
duplicated error handling.
*/
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
function isEmpty (value) {
return value === undefined || value === null || value === ''
}
function httpError (message, status) {
const err = new Error(message)
err.status = status
return err
}
export function parseLimit (limit) {
if (isEmpty(limit)) return DEFAULT_LIMIT
const parsed = parseInt(limit, 10)
if (Number.isNaN(parsed) || parsed < 1) {
throw httpError('limit must be a positive integer', 400)
}
if (parsed > MAX_LIMIT) {
throw httpError(`limit cannot exceed ${MAX_LIMIT}`, 400)
}
return parsed
}
export function parseOffset (offset) {
if (isEmpty(offset)) return 0
const parsed = parseInt(offset, 10)
if (Number.isNaN(parsed) || parsed < 0) {
throw httpError('offset must be a non-negative integer', 400)
}
return parsed
}
export function attachReplyCounts (posts, replyCounts) {
return posts.map((post) => ({
...post,
replyCount: replyCounts.get(post.txid) ?? 0
}))
}
export function assemblePostPage ({ posts, replyCounts, total, limit, offset }) {
return {
posts: attachReplyCounts(posts, replyCounts),
pagination: {
limit,
offset,
total,
hasMore: offset + posts.length < total
}
}
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:14:53.748Z","module_hash":"b9ba853191484c924ee7003cee18435e2aa00057155d94e573939f2856cf006c","functions":[{"id":"func/isEmpty","name":"isEmpty","line":13,"end_line":15,"hash":"209ecce14d6de3b7500065dd3091a7f2c006d1b08305078720ddd2ab250bc501"},{"id":"func/httpError","name":"httpError","line":17,"end_line":21,"hash":"270f69e0007e397fbd6beac1f2fd7d3a1156f9ba1cdf2d230e4e867c8b221aeb"},{"id":"func/parseLimit","name":"parseLimit","line":23,"end_line":34,"hash":"20c64d382dedbd2b65e68d66a35ccba8ecfc78dc32534a697e3865f05021eaf2"},{"id":"func/parseOffset","name":"parseOffset","line":36,"end_line":44,"hash":"55b13c976e2d53e6b69658555d97f4e63d425b5cb6b7b47b57081bd18ef16845"},{"id":"func/attachReplyCounts","name":"attachReplyCounts","line":46,"end_line":51,"hash":"99b9e818ac032752eb76fc9fcdcba0dcd1d94bee830921131a18fd01a4848f76"},{"id":"func/assemblePostPage","name":"assemblePostPage","line":53,"end_line":63,"hash":"c605049c633f08e0bd8247d6147b4a2a0ba6652f258f3f44852775f08d6c1e35"}]}
// mutate4javascript-manifest-end
+25
View File
@@ -0,0 +1,25 @@
/*
Shared construction contract for list use cases.
Each list use case validates that an adapters bundle is supplied and that the
specific adapter it depends on is present, then binds its execute method.
Centralizing this keeps construction validation identical across list
endpoints and removes per-class constructor boilerplate.
*/
export class ListUseCase {
constructor (localConfig = {}, { useCaseName, adapterName } = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error(`Adapters required when instantiating ${useCaseName} use case.`)
}
if (!this.adapters[adapterName]) {
throw new Error(`${adapterName} adapter required for ${useCaseName} use case.`)
}
this.execute = this.execute.bind(this)
}
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:14:47.462Z","module_hash":"595f78478e9b0d12ecf176473430aafe51ed53ea16a69d66225cb448a91b119c","functions":[{"id":"func/ListUseCase.constructor","name":"ListUseCase.constructor","line":11,"end_line":20,"hash":"03c09b5cf853135ed7acd73f025878c78ee572891b73341ce54f3cac805d0cf6"}]}
// mutate4javascript-manifest-end
+18 -66
View File
@@ -1,51 +1,14 @@
/*
Use case: list posts for an address ordered by block height (most recent first), paginated.
Uses the postHeights secondary index for efficient sorting and pagination.
*/
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js'
import { ListUseCase } from './lib/use-case.js'
class ListPostsByAddr {
class ListPostsByAddr extends ListUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating ListPostsByAddr use case.')
}
if (!this.adapters.postQuery) {
throw new Error('postQuery adapter required for ListPostsByAddr use case.')
}
this.execute = this.execute.bind(this)
}
parseLimit (limit) {
if (limit === undefined || limit === null || limit === '') {
return DEFAULT_LIMIT
}
const parsed = parseInt(limit, 10)
if (Number.isNaN(parsed) || parsed < 1) {
const err = new Error('limit must be a positive integer')
err.status = 400
throw err
}
if (parsed > MAX_LIMIT) {
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
err.status = 400
throw err
}
return parsed
}
parseOffset (offset) {
if (offset === undefined || offset === null || offset === '') {
return 0
}
const parsed = parseInt(offset, 10)
if (Number.isNaN(parsed) || parsed < 0) {
const err = new Error('offset must be a non-negative integer')
err.status = 400
throw err
}
return parsed
super(localConfig, { useCaseName: 'ListPostsByAddr', adapterName: 'postQuery' })
}
parseAddr (addr) {
@@ -57,35 +20,24 @@ class ListPostsByAddr {
return addr
}
sortPosts (posts) {
return posts.sort((a, b) => {
if (b.blockHeight !== a.blockHeight) {
return b.blockHeight - a.blockHeight
}
return (b.seen || 0) - (a.seen || 0)
})
}
async execute (inObj = {}) {
const addr = this.parseAddr(inObj.addr)
const limit = this.parseLimit(inObj.limit)
const offset = this.parseOffset(inObj.offset)
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const allPosts = await this.adapters.postQuery.scanPostsByAddr(addr)
const sorted = this.sortPosts(allPosts)
const total = sorted.length
const posts = sorted.slice(offset, offset + limit)
const txids = await this.adapters.postQuery.scanPostsByAddrTxids(addr, { limit, offset })
const [posts, replyCounts, total] = await Promise.all([
this.adapters.postQuery.loadPostsByTxids(txids),
this.adapters.postQuery.buildReplyCountMap(),
this.adapters.postQuery.countTopLevelPostsByAddr(addr)
])
return {
posts,
pagination: {
limit,
offset,
total,
hasMore: offset + posts.length < total
}
}
return assemblePostPage({ posts, replyCounts, total, limit, offset })
}
}
export default ListPostsByAddr
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:15:16.107Z","module_hash":"b925b4bf1307be2f9d7b4e5d9590f6d189c5ec7632102bb65cdb525ffe19f7b3","functions":[{"id":"func/ListPostsByAddr.constructor","name":"ListPostsByAddr.constructor","line":10,"end_line":12,"hash":"f777f3685c5b2199ec3c3a7043b3cf288a9bbf583232bd1fc58cc346373f03d6"},{"id":"func/ListPostsByAddr.parseAddr","name":"ListPostsByAddr.parseAddr","line":14,"end_line":21,"hash":"0f02fc6ebf05826429d57bf2fd64bf66dfde96ee159f6c1d4814f41cc7a25aca"},{"id":"func/ListPostsByAddr.execute","name":"ListPostsByAddr.execute","line":23,"end_line":36,"hash":"899d7d2e5c9c31a05c34f00c063ffd4f88a6d0a779f6d47000f2fb770029dc86"}]}
// mutate4javascript-manifest-end
+18 -66
View File
@@ -1,81 +1,33 @@
/*
Use case: list posts ordered by block height (most recent first), paginated.
Uses the postHeights secondary index for efficient sorting and pagination.
*/
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js'
import { ListUseCase } from './lib/use-case.js'
class ListRecentPosts {
class ListRecentPosts extends ListUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating ListRecentPosts use case.')
}
if (!this.adapters.postQuery) {
throw new Error('postQuery adapter required for ListRecentPosts use case.')
}
this.execute = this.execute.bind(this)
}
parseLimit (limit) {
if (limit === undefined || limit === null || limit === '') {
return DEFAULT_LIMIT
}
const parsed = parseInt(limit, 10)
if (Number.isNaN(parsed) || parsed < 1) {
const err = new Error('limit must be a positive integer')
err.status = 400
throw err
}
if (parsed > MAX_LIMIT) {
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
err.status = 400
throw err
}
return parsed
}
parseOffset (offset) {
if (offset === undefined || offset === null || offset === '') {
return 0
}
const parsed = parseInt(offset, 10)
if (Number.isNaN(parsed) || parsed < 0) {
const err = new Error('offset must be a non-negative integer')
err.status = 400
throw err
}
return parsed
}
sortPosts (posts) {
return posts.sort((a, b) => {
if (b.blockHeight !== a.blockHeight) {
return b.blockHeight - a.blockHeight
}
return (b.seen || 0) - (a.seen || 0)
})
super(localConfig, { useCaseName: 'ListRecentPosts', adapterName: 'postQuery' })
}
async execute (inObj = {}) {
const limit = this.parseLimit(inObj.limit)
const offset = this.parseOffset(inObj.offset)
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const allPosts = await this.adapters.postQuery.scanPostsWithBlockHeight()
const sorted = this.sortPosts(allPosts)
const total = sorted.length
const posts = sorted.slice(offset, offset + limit)
const txids = await this.adapters.postQuery.scanRecentPostTxids({ limit, offset })
const [posts, replyCounts, total] = await Promise.all([
this.adapters.postQuery.loadPostsByTxids(txids),
this.adapters.postQuery.buildReplyCountMap(),
this.adapters.postQuery.countTopLevelPosts()
])
return {
posts,
pagination: {
limit,
offset,
total,
hasMore: offset + posts.length < total
}
}
return assemblePostPage({ posts, replyCounts, total, limit, offset })
}
}
export default ListRecentPosts
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:15:24.894Z","module_hash":"35af93129e7bf3eddc46fa5909fdf71538afc29182af5a1a2ec06e25193fa7d3","functions":[{"id":"func/ListRecentPosts.constructor","name":"ListRecentPosts.constructor","line":10,"end_line":12,"hash":"0abcf69664af3b707dfe95a9db5caa65e3bbec6dfb740393cafb923e81aad9ae"},{"id":"func/ListRecentPosts.execute","name":"ListRecentPosts.execute","line":14,"end_line":26,"hash":"ff4bd895e3dad7ec3ed58832588e53f049de4f210a19f81ef3e074abf694dd33"}]}
// mutate4javascript-manifest-end
@@ -2,50 +2,12 @@
Use case: list profiles ordered by block height (most recent first), paginated.
*/
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
import { parseLimit, parseOffset } from './lib/pagination.js'
import { ListUseCase } from './lib/use-case.js'
class ListRecentProfiles {
class ListRecentProfiles extends ListUseCase {
constructor (localConfig = {}) {
this.adapters = localConfig.adapters
if (!this.adapters) {
throw new Error('Adapters required when instantiating ListRecentProfiles use case.')
}
if (!this.adapters.profileQuery) {
throw new Error('profileQuery adapter required for ListRecentProfiles use case.')
}
this.execute = this.execute.bind(this)
}
parseLimit (limit) {
if (limit === undefined || limit === null || limit === '') {
return DEFAULT_LIMIT
}
const parsed = parseInt(limit, 10)
if (Number.isNaN(parsed) || parsed < 1) {
const err = new Error('limit must be a positive integer')
err.status = 400
throw err
}
if (parsed > MAX_LIMIT) {
const err = new Error(`limit cannot exceed ${MAX_LIMIT}`)
err.status = 400
throw err
}
return parsed
}
parseOffset (offset) {
if (offset === undefined || offset === null || offset === '') {
return 0
}
const parsed = parseInt(offset, 10)
if (Number.isNaN(parsed) || parsed < 0) {
const err = new Error('offset must be a non-negative integer')
err.status = 400
throw err
}
return parsed
super(localConfig, { useCaseName: 'ListRecentProfiles', adapterName: 'profileQuery' })
}
sortProfiles (profiles) {
@@ -58,8 +20,8 @@ class ListRecentProfiles {
}
async execute (inObj = {}) {
const limit = this.parseLimit(inObj.limit)
const offset = this.parseOffset(inObj.offset)
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const allProfiles = await this.adapters.profileQuery.scanProfilesWithBlockHeight()
const sorted = this.sortProfiles(allProfiles)
@@ -79,3 +41,7 @@ class ListRecentProfiles {
}
export default ListRecentProfiles
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:15:31.955Z","module_hash":"0a41c7c2086270d2f29266eb20b2313d2ab8782fc8a32ad6dd0b27d737a41b13","functions":[{"id":"func/ListRecentProfiles.constructor","name":"ListRecentProfiles.constructor","line":9,"end_line":11,"hash":"a2c7dd0696ac463cbc142fa7ccce4a3cadf8e246a872261733d199dbd4c153d1"},{"id":"func/ListRecentProfiles.sortProfiles","name":"ListRecentProfiles.sortProfiles","line":13,"end_line":20,"hash":"8dbe8da4b9a5c52230b5f865a6d0b5a5817a266277a72b09b93b2b0f76414d9e"},{"id":"func/ListRecentProfiles.execute","name":"ListRecentProfiles.execute","line":22,"end_line":40,"hash":"d14afac95c39e7a311d9e3a1243b6a326de38906b5ad312f014ce6b6d5459de1"}]}
// mutate4javascript-manifest-end
+47
View File
@@ -0,0 +1,47 @@
/*
Small property-testing harness for psf-memo-db.
The DB runs its unit tests with mocha and has no property-based generator,
so this module provides a deterministic, seeded pseudo-random generator plus
a helper to run a property across many samples and report a counterexample.
All generation is seeded, so runs are reproducible.
*/
import assert from 'node:assert/strict'
// A small deterministic PRNG (mulberry32). Same seed => same stream.
export function seededRandom (seed = 12345) {
let a = seed >>> 0
return function next () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
// Run a property across N samples. `gen` returns a fresh input; `check`
// returns true when the property holds. Asserts a counterexample on failure.
export async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
for (let i = 0; i < samples; i++) {
const input = gen(i)
const ok = await check(input)
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
}
}
// Uniform integer in [min, max] inclusive using a seeded rng.
export function intGen (rng, min, max) {
return () => min + Math.floor(rng() * (max - min + 1))
}
// Random 64-char hex txid using a seeded rng.
export function txidGen (rng) {
const hex = '0123456789abcdef'
let out = ''
for (let i = 0; i < 64; i++) {
out += hex[Math.floor(rng() * hex.length)]
}
return out
}
@@ -0,0 +1,69 @@
/*
Property tests for the postHeights secondary index key encoding.
The postHeights index stores a key of `<padded-height>:<txid>`. Efficient
pagination depends on two invariants that unit tests only probe at a few
fixed heights:
- round-trip: txidFromPostHeight(postHeightKey(h, txid)) recovers txid.
- ordering: padded heights preserve numeric order lexicographically, so a
reverse iterate over the keys yields newest posts first.
*/
import test from 'node:test'
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
import PostQuery from '../../src/adapters/post-query.js'
const rng = seededRandom(20260826)
test('postHeightKey round-trips the txid for a broad range of heights', async () => {
const heightGen = intGen(rng, 0, 9000000)
const query = new PostQuery({
postsDb: {},
postHeightsDb: {},
postParentsDb: {},
postChildrenDb: {}
})
await forAll(
(i) => ({ height: heightGen(), txid: txidGen(rng) }),
({ height, txid }) => {
const key = PostQuery.postHeightKey(height, txid)
const fromValue = query.txidFromPostHeight(key, { txid })
const fromKey = query.txidFromPostHeight(key)
return fromValue === txid && fromKey === txid
},
{ label: 'postHeightKey round-trip' }
)
})
test('padded heights preserve numeric order lexicographically', async () => {
const heightGen = intGen(rng, 0, 9000000)
await forAll(
(i) => {
const a = heightGen()
const b = heightGen()
return { a: Math.min(a, b), b: Math.max(a, b), txidA: txidGen(rng), txidB: txidGen(rng) }
},
({ a, b, txidA, txidB }) => {
if (a === b) return PostQuery.postHeightKey(a, txidA) === PostQuery.postHeightKey(a, txidB)
return PostQuery.postHeightKey(a, txidA) < PostQuery.postHeightKey(b, txidB)
},
{ label: 'postHeight key ordering' }
)
})
test('padded heights are fixed width and equal to their numeric value', async () => {
const heightGen = intGen(rng, 0, 999999999999)
await forAll(
(i) => heightGen(),
(height) => {
const padded = PostQuery.padHeight(height)
return padded.length === 12 && Number.parseInt(padded, 10) === height
},
{ label: 'postHeight fixed-width padding' }
)
})
+231 -89
View File
@@ -8,11 +8,13 @@ describe('#PostQuery', () => {
let postsDb
let postParentsDb
let postChildrenDb
let postHeightsDb
beforeEach(() => {
sandbox = sinon.createSandbox()
postsDb = {
iterator: sandbox.stub()
iterator: sandbox.stub(),
get: sandbox.stub()
}
postParentsDb = {
iterator: sandbox.stub()
@@ -20,125 +22,265 @@ describe('#PostQuery', () => {
postChildrenDb = {
iterator: sandbox.stub()
}
postHeightsDb = {
iterator: sandbox.stub()
}
async function * emptyParents () {}
async function * emptyChildren () {}
async function * emptyHeights () {}
postParentsDb.iterator.returns(emptyParents())
postChildrenDb.iterator.returns(emptyChildren())
uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb })
postHeightsDb.iterator.returns(emptyHeights())
uut = new PostQuery({ postsDb, postParentsDb, postChildrenDb, postHeightsDb })
})
afterEach(() => sandbox.restore())
it('should scan posts and read block height from stored document', async () => {
async function * mockIterator () {
yield ['tx1', { addr: 'addr1', text: 'hello', seen: 1000, blockHeight: 600100 }]
yield ['tx2', { addr: 'addr2', text: 'world', seen: 2000, blockHeight: 600200 }]
it('should throw when postHeightsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new PostQuery({ postsDb, postParentsDb, postChildrenDb })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postHeightsDb required')
}
postsDb.iterator.returns(mockIterator())
const result = await uut.scanPostsWithBlockHeight()
assert.equal(result.length, 2)
assert.equal(result[0].txid, 'tx1')
assert.equal(result[0].blockHeight, 600100)
assert.equal(result[0].replyCount, 0)
assert.equal(result[1].txid, 'tx2')
assert.equal(result[1].blockHeight, 600200)
assert.equal(result[1].replyCount, 0)
})
it('should use block height 0 when field is missing', async () => {
async function * mockIterator () {
yield ['tx-missing', { addr: 'addr1', text: 'hi', seen: 1000 }]
}
postsDb.iterator.returns(mockIterator())
describe('#txidFromPostHeight', () => {
it('should return the txid from the value when present', () => {
assert.equal(uut.txidFromPostHeight('any-key', { txid: 'abc123' }), 'abc123')
})
const result = await uut.scanPostsWithBlockHeight()
assert.equal(result[0].blockHeight, 0)
assert.equal(result[0].replyCount, 0)
it('should parse the txid from the key when value is absent', () => {
assert.equal(uut.txidFromPostHeight('000000600200:post-200-a', null), 'post-200-a')
assert.equal(uut.txidFromPostHeight('000000600200:post-200-a', undefined), 'post-200-a')
})
})
it('should scan posts for a single address', async () => {
async function * mockIterator () {
yield ['tx1', { addr: 'addr-a', text: 'hello', seen: 1000, blockHeight: 600100 }]
yield ['tx2', { addr: 'addr-b', text: 'world', seen: 2000, blockHeight: 600200 }]
yield ['tx3', { addr: 'addr-a', text: 'again', seen: 3000, blockHeight: 600300 }]
}
postsDb.iterator.returns(mockIterator())
describe('#topLevelPostTxids', () => {
it('should iterate top-level txids in forward postHeights order by default', async () => {
async function * mockHeights () {
yield ['000000600100:post-100', { txid: 'post-100' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
}
postHeightsDb.iterator.withArgs({ reverse: false }).returns(mockHeights())
const result = await uut.scanPostsByAddr('addr-a')
const txids = []
for await (const txid of uut.topLevelPostTxids()) txids.push(txid)
assert.equal(result.length, 2)
assert.equal(result[0].txid, 'tx1')
assert.equal(result[1].txid, 'tx3')
assert.deepEqual(txids, ['post-100', 'post-200-a'])
})
})
it('should exclude reply posts from recent scan', async () => {
async function * mockParents () {
yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }]
}
async function * mockPosts () {
yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }]
yield ['tx-reply', { addr: 'addr1', text: 'reply', seen: 1500, blockHeight: 600150 }]
yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }]
}
postParentsDb.iterator.returns(mockParents())
postsDb.iterator.returns(mockPosts())
describe('#scanRecentPostTxids', () => {
it('should return top-level post txids sorted by block height descending', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
const result = await uut.scanPostsWithBlockHeight()
const result = await uut.scanRecentPostTxids({ limit: 2, offset: 0 })
assert.equal(result.length, 2)
assert.equal(result[0].txid, 'tx1')
assert.equal(result[1].txid, 'tx2')
assert.deepEqual(result, ['post-200-b', 'post-200-a'])
})
it('should skip replies when selecting recent posts', async () => {
async function * mockParents () {
yield ['reply-1', { parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600150 }]
}
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600150:reply-1', { txid: 'reply-1' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postParentsDb.iterator.returns(mockParents())
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
const result = await uut.scanRecentPostTxids({ limit: 2, offset: 0 })
assert.deepEqual(result, ['post-200-b', 'post-200-a'])
})
it('should apply offset after skipping replies', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
const result = await uut.scanRecentPostTxids({ limit: 2, offset: 1 })
assert.deepEqual(result, ['post-200-a', 'post-100'])
})
it('should stop reading after collecting limit top-level posts', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
await uut.scanRecentPostTxids({ limit: 2, offset: 0 })
assert.isTrue(postHeightsDb.iterator.calledOnce)
})
})
it('should exclude reply posts from address scan', async () => {
async function * mockParents () {
yield ['tx-reply', { parentTxid: 'tx1', childTxid: 'tx-reply', blockHeight: 600150 }]
}
async function * mockPosts () {
yield ['tx1', { addr: 'addr-a', text: 'top', seen: 1000, blockHeight: 600100 }]
yield ['tx-reply', { addr: 'addr-a', text: 'reply', seen: 1500, blockHeight: 600150 }]
}
postParentsDb.iterator.returns(mockParents())
postsDb.iterator.returns(mockPosts())
describe('#scanPostsByAddrTxids', () => {
it('should return txids for the address sorted by block height descending', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
postsDb.get.callsFake(async (txid) => {
const posts = {
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
}
return posts[txid]
})
const result = await uut.scanPostsByAddr('addr-a')
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 2, offset: 0 })
assert.equal(result.length, 1)
assert.equal(result[0].txid, 'tx1')
assert.deepEqual(result, ['post-200-a', 'post-100'])
})
it('should apply offset and limit for the address', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
postsDb.get.callsFake(async (txid) => {
const posts = {
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
}
return posts[txid]
})
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 1, offset: 1 })
assert.deepEqual(result, ['post-100'])
})
it('should stop at limit even when more matching posts remain', async () => {
async function * mockHeights () {
yield ['000000600300:post-300', { txid: 'post-300' }]
yield ['000000600200:post-200', { txid: 'post-200' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights())
postsDb.get.callsFake(async (txid) => ({ addr: 'addr-a', text: 'x', seen: 1, blockHeight: 1 }))
const result = await uut.scanPostsByAddrTxids('addr-a', { limit: 2, offset: 0 })
assert.deepEqual(result, ['post-300', 'post-200'])
})
})
it('should include replyCount from postChildren scan', async () => {
async function * mockChildren () {
yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }]
yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }]
yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }]
}
async function * mockPosts () {
yield ['tx1', { addr: 'addr1', text: 'top', seen: 1000, blockHeight: 600100 }]
yield ['tx2', { addr: 'addr2', text: 'other', seen: 2000, blockHeight: 600200 }]
}
postChildrenDb.iterator.returns(mockChildren())
postsDb.iterator.returns(mockPosts())
describe('#loadPostsByTxids', () => {
it('should load posts by txid', async () => {
postsDb.get.withArgs('tx1').resolves({ addr: 'a1', text: 'hello', seen: 1000, blockHeight: 600100 })
postsDb.get.withArgs('tx2').resolves({ addr: 'a2', text: 'world', seen: 2000, blockHeight: 600200 })
const result = await uut.scanPostsWithBlockHeight()
const result = await uut.loadPostsByTxids(['tx1', 'tx2'])
assert.equal(result.length, 2)
assert.equal(result.find((p) => p.txid === 'tx1').replyCount, 2)
assert.equal(result.find((p) => p.txid === 'tx2').replyCount, 1)
assert.equal(result.length, 2)
assert.equal(result[0].txid, 'tx1')
assert.equal(result[0].blockHeight, 600100)
assert.equal(result[1].txid, 'tx2')
})
it('should skip missing posts', async () => {
const err = new Error('not found')
err.notFound = true
postsDb.get.withArgs('tx1').rejects(err)
postsDb.get.withArgs('tx2').resolves({ addr: 'a2', text: 'world', seen: 2000, blockHeight: 600200 })
const result = await uut.loadPostsByTxids(['tx1', 'tx2'])
assert.equal(result.length, 1)
assert.equal(result[0].txid, 'tx2')
})
it('should default blockHeight to 0 when a post has no blockHeight', async () => {
postsDb.get.withArgs('tx1').resolves({ addr: 'a1', text: 'hello', seen: 1000 })
const result = await uut.loadPostsByTxids(['tx1'])
assert.equal(result[0].blockHeight, 0)
})
})
it('should default replyCount to 0 when post has no replies', async () => {
async function * mockPosts () {
yield ['tx1', { addr: 'addr1', text: 'solo', seen: 1000, blockHeight: 600100 }]
}
postsDb.iterator.returns(mockPosts())
describe('#countTopLevelPosts', () => {
it('should count top-level posts excluding replies', async () => {
async function * mockParents () {
yield ['reply-1', { parentTxid: 'post-200-a', childTxid: 'reply-1', blockHeight: 600150 }]
}
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600150:reply-1', { txid: 'reply-1' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postParentsDb.iterator.returns(mockParents())
postHeightsDb.iterator.returns(mockHeights())
const result = await uut.scanPostsByAddr('addr1')
const result = await uut.countTopLevelPosts()
assert.equal(result.length, 1)
assert.equal(result[0].replyCount, 0)
assert.equal(result, 3)
})
})
describe('#countTopLevelPostsByAddr', () => {
it('should count top-level posts for an address', async () => {
async function * mockHeights () {
yield ['000000600200:post-200-b', { txid: 'post-200-b' }]
yield ['000000600200:post-200-a', { txid: 'post-200-a' }]
yield ['000000600100:post-100', { txid: 'post-100' }]
}
postHeightsDb.iterator.returns(mockHeights())
postsDb.get.callsFake(async (txid) => {
const posts = {
'post-200-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'post-200-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600200 },
'post-100': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600100 }
}
return posts[txid]
})
const result = await uut.countTopLevelPostsByAddr('addr-a')
assert.equal(result, 2)
})
})
describe('#buildReplyCountMap', () => {
it('should count replies per parent from postChildren', async () => {
async function * mockChildren () {
yield ['tx1:reply-a', { parentTxid: 'tx1', childTxid: 'reply-a', blockHeight: 600150 }]
yield ['tx1:reply-b', { parentTxid: 'tx1', childTxid: 'reply-b', blockHeight: 600160 }]
yield ['tx2:reply-c', { parentTxid: 'tx2', childTxid: 'reply-c', blockHeight: 600170 }]
}
postChildrenDb.iterator.returns(mockChildren())
const result = await uut.buildReplyCountMap()
assert.equal(result.get('tx1'), 2)
assert.equal(result.get('tx2'), 1)
})
})
})
@@ -19,7 +19,7 @@ describe('#LevelRESTController', () => {
sandbox = sinon.createSandbox()
uut = new LevelRESTControllerLib({
adapters: {
level: { postsDb: mockDb, statusDb: mockDb },
level: { postsDb: mockDb, postHeightsDb: mockDb, statusDb: mockDb },
dbBackup: { zipDb: sandbox.stub().resolves(true) }
},
useCases: {}
@@ -44,4 +44,15 @@ describe('#LevelRESTController', () => {
assert.equal(ctx.body.success, true)
assert.equal(ctx.body.txid, 'abc')
})
it('should expose a postheight entity handler', async () => {
const ctx = {
params: {},
request: { body: { key: '600000:abc', postHeightData: { txid: 'abc', blockHeight: 600000 } } },
body: null
}
await uut.entityHandlers.postheight.create(ctx)
assert.equal(ctx.body.success, true)
assert.equal(ctx.body.key, '600000:abc')
})
})
@@ -0,0 +1,127 @@
import { assert } from 'chai'
import { parseLimit, parseOffset, attachReplyCounts, assemblePostPage } from '../../../../src/use-cases/lib/pagination.js'
describe('#pagination', () => {
describe('parseLimit', () => {
it('should default to 100 when limit is absent', () => {
assert.equal(parseLimit(undefined), 100)
assert.equal(parseLimit(null), 100)
assert.equal(parseLimit(''), 100)
})
it('should parse a valid positive integer limit', () => {
assert.equal(parseLimit('10'), 10)
assert.equal(parseLimit(50), 50)
})
it('should accept the minimum limit boundary of 1', () => {
assert.equal(parseLimit(1), 1)
assert.equal(parseLimit('1'), 1)
})
it('should accept the maximum limit boundary of 100', () => {
assert.equal(parseLimit(100), 100)
assert.equal(parseLimit('100'), 100)
})
it('should reject a non-numeric limit', () => {
try {
parseLimit('abc')
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'limit must be a positive integer')
}
})
it('should reject a limit below 1', () => {
try {
parseLimit(0)
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'limit must be a positive integer')
}
})
it('should reject a limit over 100', () => {
try {
parseLimit(101)
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'limit cannot exceed 100')
}
})
})
describe('parseOffset', () => {
it('should default to 0 when offset is absent', () => {
assert.equal(parseOffset(undefined), 0)
assert.equal(parseOffset(null), 0)
assert.equal(parseOffset(''), 0)
})
it('should parse a valid non-negative integer offset', () => {
assert.equal(parseOffset('0'), 0)
assert.equal(parseOffset(25), 25)
})
it('should reject a non-numeric offset', () => {
try {
parseOffset('xyz')
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'offset must be a non-negative integer')
}
})
it('should reject a negative offset', () => {
try {
parseOffset(-1)
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'offset must be a non-negative integer')
}
})
})
describe('attachReplyCounts', () => {
it('should attach the reply count for each post', () => {
const posts = [{ txid: 'a', text: 'x' }, { txid: 'b', text: 'y' }]
const counts = new Map([['a', 3]])
const result = attachReplyCounts(posts, counts)
assert.equal(result[0].replyCount, 3)
assert.equal(result[0].text, 'x')
assert.equal(result[1].replyCount, 0)
})
})
describe('assemblePostPage', () => {
it('should attach reply counts and pagination metadata', () => {
const posts = [{ txid: 'a', text: 'x' }, { txid: 'b', text: 'y' }]
const replyCounts = new Map([['a', 3]])
const result = assemblePostPage({ posts, replyCounts, total: 2, limit: 10, offset: 0 })
assert.equal(result.posts[0].replyCount, 3)
assert.equal(result.posts[1].replyCount, 0)
assert.equal(result.pagination.limit, 10)
assert.equal(result.pagination.offset, 0)
assert.equal(result.pagination.total, 2)
assert.equal(result.pagination.hasMore, false)
})
it('should report hasMore when a further page exists', () => {
const posts = [{ txid: 'a', text: 'x' }]
const result = assemblePostPage({ posts, replyCounts: new Map(), total: 2, limit: 1, offset: 0 })
assert.equal(result.pagination.hasMore, true)
})
})
})
@@ -0,0 +1,39 @@
import { assert } from 'chai'
import { ListUseCase } from '../../../../src/use-cases/lib/use-case.js'
class DummyUseCase extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'DummyUseCase', adapterName: 'postQuery' })
}
async execute (inObj = {}) {
return inObj
}
}
describe('#ListUseCase', () => {
it('should throw when adapters is missing', () => {
try {
new DummyUseCase({})
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'Adapters required when instantiating DummyUseCase use case.')
}
})
it('should throw when the required adapter is missing', () => {
try {
new DummyUseCase({ adapters: {} })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postQuery adapter required for DummyUseCase use case.')
}
})
it('should bind execute to the instance', () => {
const uut = new DummyUseCase({ adapters: { postQuery: {} } })
assert.equal(typeof uut.execute, 'function')
const bound = uut.execute
return bound({ ok: true }).then((result) => assert.deepEqual(result, { ok: true }))
})
})
@@ -5,23 +5,32 @@ import ListPostsByAddr from '../../../src/use-cases/list-posts-by-addr.js'
describe('#ListPostsByAddr', () => {
let uut
let sandbox
let postQuery
const mockPosts = [
{ txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
{ txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
{ txid: 'tx-c', addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600200 }
]
const mockPosts = {
'tx-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
'tx-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'tx-c': { addr: 'addr-a', text: 'c', seen: 50, blockHeight: 600200 }
}
beforeEach(() => {
sandbox = sinon.createSandbox()
postQuery = {
scanPostsByAddrTxids: sandbox.stub().callsFake(async (addr, { limit, offset }) => {
const all = Object.entries(mockPosts)
.filter(([txid, post]) => post.addr === addr)
.sort((a, b) => b[1].blockHeight - a[1].blockHeight)
.map(([txid]) => txid)
return all.slice(offset, offset + limit)
}),
loadPostsByTxids: sandbox.stub().callsFake(async (txids) => {
return txids.map((txid) => ({ txid, ...mockPosts[txid] }))
}),
buildReplyCountMap: sandbox.stub().resolves(new Map()),
countTopLevelPostsByAddr: sandbox.stub().resolves(2)
}
uut = new ListPostsByAddr({
adapters: {
postQuery: {
scanPostsByAddr: sandbox.stub().callsFake(async (addr) => {
return mockPosts.filter((post) => post.addr === addr)
})
}
}
adapters: { postQuery }
})
})
@@ -34,6 +43,22 @@ describe('#ListPostsByAddr', () => {
assert.equal(result.posts[0].txid, 'tx-c')
assert.equal(result.posts[1].txid, 'tx-a')
assert.equal(result.pagination.total, 2)
// Page is exactly full (offset 0 + 2 returned == 2 total), so hasMore must be false.
assert.equal(result.pagination.hasMore, false)
})
it('should report hasMore when a further page exists', async () => {
// One page of one item still leaves one more page available.
postQuery.scanPostsByAddrTxids.callsFake(async (addr, { limit, offset }) => {
return Object.entries(mockPosts)
.filter(([txid, post]) => post.addr === addr)
.map(([txid]) => txid)
.slice(offset, offset + limit)
})
const result = await uut.execute({ addr: 'addr-a', limit: 1, offset: 0 })
assert.equal(result.posts.length, 1)
assert.equal(result.pagination.hasMore, true)
})
it('should reject missing addr', async () => {
@@ -45,4 +70,22 @@ describe('#ListPostsByAddr', () => {
assert.include(err.message, 'addr is required')
}
})
it('should reject a non-string addr', async () => {
try {
await uut.execute({ addr: 12345, limit: 10 })
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'addr is required')
}
})
it('should pass addr, limit, and offset to postQuery', async () => {
await uut.execute({ addr: 'addr-a', limit: 5, offset: 10 })
assert.equal(postQuery.scanPostsByAddrTxids.calledOnce, true)
assert.equal(postQuery.scanPostsByAddrTxids.firstCall.args[0], 'addr-a')
assert.deepEqual(postQuery.scanPostsByAddrTxids.firstCall.args[1], { limit: 5, offset: 10 })
})
})
@@ -5,21 +5,26 @@ import ListRecentPosts from '../../../src/use-cases/list-recent-posts.js'
describe('#ListRecentPosts', () => {
let uut
let sandbox
let postQuery
const mockPosts = [
{ txid: 'tx-a', addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
{ txid: 'tx-b', addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
{ txid: 'tx-c', addr: 'addr-c', text: 'c', seen: 50, blockHeight: 600200 }
]
const mockPosts = {
'tx-a': { addr: 'addr-a', text: 'a', seen: 100, blockHeight: 600100 },
'tx-b': { addr: 'addr-b', text: 'b', seen: 200, blockHeight: 600200 },
'tx-c': { addr: 'addr-c', text: 'c', seen: 50, blockHeight: 600200 }
}
beforeEach(() => {
sandbox = sinon.createSandbox()
postQuery = {
scanRecentPostTxids: sandbox.stub().resolves(['tx-b', 'tx-c', 'tx-a']),
loadPostsByTxids: sandbox.stub().callsFake(async (txids) => {
return txids.map((txid) => ({ txid, ...mockPosts[txid] }))
}),
buildReplyCountMap: sandbox.stub().resolves(new Map([['tx-b', 1]])),
countTopLevelPosts: sandbox.stub().resolves(3)
}
uut = new ListRecentPosts({
adapters: {
postQuery: {
scanPostsWithBlockHeight: sandbox.stub().resolves([...mockPosts])
}
}
adapters: { postQuery }
})
})
@@ -32,11 +37,14 @@ describe('#ListRecentPosts', () => {
assert.equal(result.posts[0].txid, 'tx-b')
assert.equal(result.posts[1].txid, 'tx-c')
assert.equal(result.posts[2].txid, 'tx-a')
assert.equal(result.posts[0].replyCount, 1)
assert.equal(result.posts[1].replyCount, 0)
assert.equal(result.pagination.total, 3)
assert.equal(result.pagination.hasMore, false)
})
it('should paginate with limit and offset', async () => {
postQuery.scanRecentPostTxids.resolves(['tx-c'])
const result = await uut.execute({ limit: 1, offset: 1 })
assert.equal(result.posts.length, 1)
@@ -62,4 +70,11 @@ describe('#ListRecentPosts', () => {
assert.include(err.message, 'limit cannot exceed')
}
})
it('should pass limit and offset to postQuery', async () => {
await uut.execute({ limit: 5, offset: 10 })
assert.equal(postQuery.scanRecentPostTxids.calledOnce, true)
assert.deepEqual(postQuery.scanRecentPostTxids.firstCall.args[0], { limit: 5, offset: 10 })
})
})
@@ -53,6 +53,22 @@ describe('#ListRecentProfiles', () => {
assert.equal(result.pagination.offset, 0)
})
it('should sort equal-height profiles by seen descending, falsy seen last', async () => {
// Same blockHeight so only the `seen` tie-break matters. The dataset mixes
// truthy and falsy (0) seen values; a broken comparator is observable here
// because the falsy profiles are not already in descending input order.
const ties = [
{ addr: 'addr-a', txid: 't-a', seen: 0, blockHeight: 700000 },
{ addr: 'addr-b', txid: 't-b', seen: 0, blockHeight: 700000 },
{ addr: 'addr-c', txid: 't-c', seen: 1, blockHeight: 700000 }
]
uut.adapters.profileQuery.scanProfilesWithBlockHeight.resolves(ties)
const result = await uut.execute({ limit: 10, offset: 0 })
assert.deepEqual(result.profiles.map((p) => p.addr), ['addr-c', 'addr-a', 'addr-b'])
})
it('should reject limit over 100', async () => {
try {
await uut.execute({ limit: 101 })
+84
View File
@@ -0,0 +1,84 @@
/*
Normal acceptance runner for psf-memo-indexer.
*/
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(__dirname, '..')
const specsDir = path.join(root, 'specs')
const buildDir = path.join(root, 'build', 'acceptance')
const irDir = path.join(buildDir, 'ir')
const genDir = path.join(buildDir, 'generated')
const apsDir = path.join(root, '..', 'tmp', 'aps-spec')
function sh (cmd, args, opts = {}) {
return execFileSync(cmd, args, {
stdio: ['pipe', 'pipe', 'pipe'],
...opts
}).toString()
}
function ensureAps () {
if (fs.existsSync(apsDir)) return
fs.mkdirSync(path.dirname(apsDir), { recursive: true })
sh('git', ['clone', '--depth', '1',
'https://github.com/unclebob/Acceptance-Pipeline-Specification.git', apsDir])
}
function main () {
ensureAps()
const features = fs
.readdirSync(specsDir)
.filter((f) => f.endsWith('.feature'))
.sort()
if (features.length === 0) {
console.log('No feature files found under specs/.')
return
}
fs.mkdirSync(irDir, { recursive: true })
fs.mkdirSync(genDir, { recursive: true })
for (const featureFile of features) {
const base = featureFile.replace(/\.feature$/i, '')
const featurePath = path.join(specsDir, featureFile)
const irPath = path.join(irDir, `${base}.json`)
sh('bb', ['gherkin-parser', featurePath, irPath], { cwd: apsDir })
sh('node', [path.join(__dirname, 'lib', 'generate.js'), irPath, genDir])
}
const tests = fs
.readdirSync(genDir)
.filter((f) => f.endsWith('.acceptance.test.js'))
.sort()
let failures = 0
for (const testFile of tests) {
try {
const out = sh('node', [path.join(genDir, testFile)])
process.stdout.write(out)
console.log(`ACCEPTANCE PASS: ${testFile}`)
} catch (err) {
failures++
process.stdout.write(err.stdout || '')
process.stderr.write(err.stderr || '')
console.error(`ACCEPTANCE FAIL: ${testFile}`)
}
}
if (failures > 0) {
console.error(`ACCEPTANCE: ${failures} failing test file(s)`)
process.exit(1)
} else {
console.log(`ACCEPTANCE: all ${tests.length} generated test file(s) passed`)
}
}
main()
@@ -0,0 +1,96 @@
/*
Project-specific acceptance entrypoint generator for psf-memo-indexer.
*/
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
function metadataName (featureName) {
const slug = featureName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return `${slug || 'feature'}.json`
}
function relativeImport (fromDir, targetFile) {
let rel = path.relative(fromDir, targetFile).replace(/\\/g, '/')
if (!rel.startsWith('.')) rel = `./${rel}`
return rel
}
function main () {
const irArg = process.argv[2]
const outArg = process.argv[3]
if (!irArg || !outArg) {
console.error('usage: acceptance-entrypoint-generator <json-ir> <generated-test-output-dir>')
process.exit(2)
}
let ir
try {
ir = JSON.parse(fs.readFileSync(irArg, 'utf8'))
} catch (err) {
console.error(`Failed to read JSON IR "${irArg}": ${err.message}`)
process.exit(1)
}
const genDir = outArg
fs.mkdirSync(genDir, { recursive: true })
const featureKey = path.basename(irArg).replace(/\.json$/i, '')
const testFile = path.join(genDir, `${featureKey}.acceptance.test.js`)
const relRuntime = relativeImport(genDir, path.join(__dirname, 'runtime.js'))
const body = `import { runFeature } from '${relRuntime}'
const ir = ${JSON.stringify(ir, null, 2)}
async function main () {
const report = await runFeature(ir)
for (const r of report.results) {
console.log((r.status === 'passed' ? 'PASS ' : 'FAIL ') + r.name)
if (r.detail) console.log(' ' + r.detail)
}
if (report.failures > 0) {
console.error('ACCEPTANCE FAILURES: ' + report.failures + ' of ' + report.total)
process.exitCode = 1
}
}
main().catch((err) => { console.error(err); process.exit(1) })
`
try {
fs.writeFileSync(testFile, body)
} catch (err) {
console.error(`Failed to write generated test "${testFile}": ${err.message}`)
process.exit(1)
}
const metaDir = path.join(genDir, 'metadata')
fs.mkdirSync(metaDir, { recursive: true })
const hash = crypto
.createHash('sha256')
.update(fs.readFileSync(testFile))
.digest('hex')
const metadata = {
schema_version: 1,
feature_path: `${featureKey}.feature`,
ir_path: path.resolve(irArg),
implementation_hash: `sha256:${hash}`,
hash_scope: 'generated_files',
generated_files: [testFile]
}
fs.writeFileSync(
path.join(metaDir, metadataName(featureKey)),
JSON.stringify(metadata, null, 2)
)
process.exit(0)
}
main()
+268
View File
@@ -0,0 +1,268 @@
/*
Project step handlers for the psf-memo-indexer acceptance pipeline.
These handlers exercise the real Memo action handlers (handlePost, handleReply)
against an in-memory database that exposes the same CRUD surface as the
psf-memo-db entity routes used by the indexer.
*/
import crypto from 'node:crypto'
import { handlePost } from '../../src/use-cases/action-types/post.js'
import { handleReply } from '../../src/use-cases/action-types/reply.js'
function makeInMemoryDb () {
const store = new Map()
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async create (key, data) {
store.set(key, data)
return { success: true }
},
async update (key, data) {
store.set(key, data)
return { success: true }
},
async delete (key) {
store.delete(key)
return { success: true }
},
entries () {
return Array.from(store.entries())
}
}
}
function resolveParam (value, example) {
const match = /^\u003c([A-Za-z0-9_]+)\u003e$/.exec(String(value).trim())
if (match) {
const param = match[1]
if (!(param in example)) {
throw new Error(`Missing example value for "${param}"`)
}
return example[param]
}
return String(value).trim()
}
function deriveTxid (symbolic) {
// Produce a deterministic 32-byte buffer from a symbolic test txid and return
// its big-endian hex representation, which is what the indexer stores.
return crypto.createHash('sha256').update(symbolic).digest().toString('hex')
}
function resolveTxid (value, example, world) {
const resolved = resolveParam(value, example)
if (!world.txidMap) world.txidMap = new Map()
if (!world.txidMap.has(resolved)) {
world.txidMap.set(resolved, deriveTxid(resolved))
}
return world.txidMap.get(resolved)
}
async function createWorld () {
const postsDb = makeInMemoryDb()
const postHeightsDb = makeInMemoryDb()
const postParentsDb = makeInMemoryDb()
const postChildrenDb = makeInMemoryDb()
const adapters = {
postDb: postsDb,
postHeightDb: postHeightsDb,
postParentDb: postParentsDb,
postChildDb: postChildrenDb,
processErrorDb: makeInMemoryDb()
}
return {
adapters,
postsDb,
postHeightsDb,
postParentsDb,
postChildrenDb,
txidMap: new Map(),
lastTxid: null,
lastHeight: null,
lastAddr: null
}
}
const handlers = [
{
name: 'db instance with posts and postHeights stores',
pattern: /^a psf-memo-db instance with posts and postHeights stores$/,
async run () {
// World is already created with both stores.
}
},
{
name: 'indexer configured to write to db',
pattern: /^a psf-memo-indexer configured to write to that database$/,
async run () {
// Adapters object is already configured.
}
},
{
name: 'process a Memo post transaction',
pattern: /^the indexer processes a Memo post transaction (.+) from (.+) at block height (.+) with text "(.+)"$/,
async run (m, example, world) {
const txid = resolveTxid(m[1], example, world)
const addr = resolveParam(m[2], example)
const height = parseInt(resolveParam(m[3], example), 10)
const text = resolveParam(m[4], example)
world.lastTxid = txid
world.lastHeight = height
world.lastAddr = addr
const prefix = Buffer.from('6d02', 'hex')
const message = Buffer.from(text, 'utf8')
await handlePost({
adapters: world.adapters,
txid,
signerAddr: addr,
seen: Date.now(),
blockHeight: height,
decoded: {
action: 'post',
prefix,
pushDatas: [prefix, message]
}
})
}
},
{
name: 'process a Memo reply transaction',
pattern: /^the indexer processes a Memo reply transaction (.+) to parent (.+) from (.+) at block height (.+) with text "(.+)"$/,
async run (m, example, world) {
const txid = resolveTxid(m[1], example, world)
const parentTxid = resolveTxid(m[2], example, world)
const addr = resolveParam(m[3], example)
const height = parseInt(resolveParam(m[4], example), 10)
const text = resolveParam(m[5], example)
world.lastTxid = txid
world.lastHeight = height
world.lastAddr = addr
const prefix = Buffer.from('6d03', 'hex')
// handleReply expects the parent tx hash as a 32-byte buffer in the
// little-endian wire format; txHashFromPush reverses it to big-endian hex.
const parentHash = Buffer.from(parentTxid, 'hex').reverse()
const message = Buffer.from(text, 'utf8')
await handleReply({
adapters: world.adapters,
txid,
signerAddr: addr,
seen: Date.now(),
blockHeight: height,
decoded: {
action: 'reply',
prefix,
pushDatas: [prefix, parentHash, message]
}
})
}
},
{
name: 'process the same Memo post transaction again',
pattern: /^the indexer processes the same Memo post transaction (.+) again$/,
async run (m, example, world) {
const txid = resolveTxid(m[1], example, world)
const post = await world.postsDb.get(txid)
const prefix = Buffer.from('6d02', 'hex')
const message = Buffer.from(post.text, 'utf8')
await handlePost({
adapters: world.adapters,
txid,
signerAddr: post.addr,
seen: post.seen,
blockHeight: post.blockHeight,
decoded: {
action: 'post',
prefix,
pushDatas: [prefix, message]
}
})
}
},
{
name: 'posts store contains post document',
pattern: /^the posts store contains (.+) post document for (.+)$/,
run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const txid = resolveTxid(m[2], example, world)
const matching = world.postsDb.entries().filter(([key]) => key === txid)
if (matching.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} post document(s) for ${txid}, got ${matching.length}`)
}
}
},
{
name: 'postHeights store contains entry',
pattern: /^the postHeights store contains (.+) entry whose key starts with the block height (.+) and ends with (.+)$/,
run (m, example, world) {
const expectedCount = parseInt(resolveParam(m[1], example), 10)
const height = resolveParam(m[2], example)
const txid = resolveTxid(m[3], example, world)
const prefix = String(height).padStart(12, '0')
const matching = world.postHeightsDb.entries().filter(([key, value]) => {
return key.startsWith(prefix) && (key.endsWith(`:${txid}`) || value?.txid === txid)
})
if (matching.length !== expectedCount) {
throw new Error(`Expected ${expectedCount} postHeights entry/entries for height ${height} txid ${txid}, got ${matching.length}`)
}
}
},
{
name: 'postParents store contains link',
pattern: /^the postParents store contains a link from (.+) to (.+)$/,
run (m, example, world) {
const childTxid = resolveTxid(m[1], example, world)
const parentTxid = resolveTxid(m[2], example, world)
const link = world.postParentsDb.entries().find(([key, value]) => {
return key === childTxid && value?.parentTxid === parentTxid
})
if (!link) {
throw new Error(`Expected postParents link from ${childTxid} to ${parentTxid}`)
}
}
},
{
name: 'postChildren store contains link',
pattern: /^the postChildren store contains a link from (.+) to (.+)$/,
run (m, example, world) {
const parentTxid = resolveTxid(m[1], example, world)
const childTxid = resolveTxid(m[2], example, world)
const link = world.postChildrenDb.entries().find(([key, value]) => {
return value?.parentTxid === parentTxid && value?.childTxid === childTxid
})
if (!link) {
throw new Error(`Expected postChildren link from ${parentTxid} to ${childTxid}`)
}
}
}
]
async function handleStep (step, example, world) {
for (const handler of handlers) {
const match = handler.pattern.exec(step.text)
if (match) {
await handler.run(match, example, world, step)
return
}
}
throw new Error(`Unsupported step: ${step.keyword} ${step.text}`)
}
export { createWorld, handleStep }
@@ -0,0 +1,73 @@
/*
Persistent runner adapter for the APS gherkin-mutator (psf-memo-indexer).
The mutator starts this process (once per worker) and sends mutation jobs
over newline-delimited JSON on stdin. Each job carries the path to a mutated
feature JSON IR; this worker evaluates it through the same acceptance runtime
and step handlers used by the normal acceptance pipeline and replies with the
runner outcome.
Protocol (mutator-spec.md):
request: { "id", "feature_json", "generated_dir", "work_dir" }
response: { "id", "outcome", "output", "error", "duration" }
outcome: test_success | test_failure | infrastructure_error
test_failure (acceptance failed) -> mutation killed
test_success (acceptance passed) -> mutation survived
stdout is reserved for JSON responses. The indexer libraries log to stdout
via console.log, so that logging is redirected to stderr below; otherwise the
mutator would read non-JSON lines as worker responses.
*/
import readline from 'node:readline'
import fs from 'node:fs'
console.log = (...args) => console.error('[worker]', ...args)
const { runFeature } = await import('./runtime.js')
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
})
rl.on('line', async (line) => {
const started = Date.now()
const respond = (payload) => {
process.stdout.write(`${JSON.stringify(payload)}\n`)
}
let job
try {
job = JSON.parse(line)
} catch (err) {
respond({ id: 'unknown', outcome: 'infrastructure_error', output: '', error: `bad job: ${err.message}`, duration: Date.now() - started })
return
}
try {
const ir = JSON.parse(fs.readFileSync(job.feature_json, 'utf8'))
const report = await runFeature(ir)
respond({
id: job.id,
outcome: report.failures === 0 ? 'test_success' : 'test_failure',
output: report.results.map((r) => `${r.status} ${r.name}`).join('\n'),
error: '',
duration: Date.now() - started
})
} catch (err) {
respond({
id: job.id,
outcome: 'infrastructure_error',
output: '',
error: err.message,
duration: Date.now() - started
})
}
})
rl.on('close', () => {
process.exit(0)
})
@@ -0,0 +1,57 @@
/*
Acceptance runtime for psf-memo-indexer.
*/
import { createWorld, handleStep } from './handlers.js'
function expandScenarios (ir) {
const executions = []
const background = ir.background || []
for (const scenario of ir.scenarios) {
const examples = (scenario.examples && scenario.examples.length > 0)
? scenario.examples.map((example, i) => ({ example, suffix: `example_${i + 1}` }))
: [{ example: {}, suffix: 'example_1' }]
for (const { example, suffix } of examples) {
executions.push({
name: `${scenario.name}/${suffix}`,
steps: [...background, ...scenario.steps],
example
})
}
}
return executions
}
async function runFeature (ir) {
const executions = expandScenarios(ir)
const results = []
let failures = 0
for (const ex of executions) {
const world = await createWorld()
let failure = null
for (const step of ex.steps) {
try {
await handleStep(step, ex.example, world)
} catch (err) {
failure = err.message
break
}
}
if (failure) {
failures++
results.push({ name: ex.name, status: 'failed', detail: failure })
} else {
results.push({ name: ex.name, status: 'passed' })
}
}
return { feature: ir.name, results, failures, total: results.length }
}
export { expandScenarios, runFeature }
+2
View File
@@ -7,6 +7,8 @@
"block-indexer": "node --max-old-space-size=8192 psf-memo-block-indexer.js",
"tx-indexer": "node --max-old-space-size=4096 psf-memo-tx-indexer.js",
"test": "c8 --reporter=text mocha --exit --recursive test/unit/",
"property": "node --test \"test/property/*.test.js\"",
"acceptance": "node acceptance/acceptance.js",
"lint": "standard --env mocha --fix"
},
"author": "Chris Troutner",
@@ -20,6 +20,7 @@ class Adapters {
this.dbCtrl = new DbCtrl()
this.postDb = createEntityDb('post', 'txid', 'postData')
this.postHeightDb = createEntityDb('postheight', 'key', 'postHeightData')
this.postParentDb = createEntityDb('postparent', 'txid', 'parentData')
this.postChildDb = createEntityDb('postchild', 'key', 'childData')
this.likeDb = createEntityDb('like', 'txid', 'likeData')
@@ -62,6 +62,11 @@ export function roomKey (roomName, txid) {
return `${roomName}:${txid}`
}
export function postHeightKey (blockHeight, txid) {
const padded = String(blockHeight).padStart(12, '0')
return `${padded}:${txid}`
}
export function postChildKey (parentTxid, childTxid) {
return `${parentTxid}:${childTxid}`
}
@@ -1,6 +1,15 @@
import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas } from './helpers.js'
import { utf8FromPush, logProcessError, normalizeTwoPushMemoDatas, postHeightKey } from './helpers.js'
import { MAX_POST_SIZE } from '../../lib/memo-codes.js'
// Create a record only when it does not already exist (idempotent writes).
async function createIfMissing (db, key, value) {
try {
await db.get(key)
} catch (err) {
await db.create(key, value)
}
}
export async function handlePost (ctx) {
const { adapters, txid, signerAddr, decoded, seen, blockHeight } = ctx
const pushDatas = normalizeTwoPushMemoDatas(decoded.pushDatas)
@@ -21,9 +30,11 @@ export async function handlePost (ctx) {
}
const postData = { addr: signerAddr, text, seen, blockHeight }
try {
await adapters.postDb.get(txid)
} catch (err) {
await adapters.postDb.create(txid, postData)
}
const heightKey = postHeightKey(blockHeight, txid)
await createIfMissing(adapters.postDb, txid, postData)
await createIfMissing(adapters.postHeightDb, heightKey, { txid, blockHeight })
}
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-26T18:12:57.389Z","module_hash":"4f16a88cac95e1533eeaa9ee78917bd2f69195d909f552f24f232b465aba7e33","functions":[{"id":"func/createIfMissing","name":"createIfMissing","line":5,"end_line":11,"hash":"d59cefaf87075a2bc41538961b609387e35393d4fbf02ecfc0633026bbfdca42"},{"id":"func/handlePost","name":"handlePost","line":13,"end_line":36,"hash":"64f7e048100212a96ca90d5161271a7055d7b7957d7c38f50f5c22062a9eee43"}]}
// mutate4javascript-manifest-end
+42
View File
@@ -0,0 +1,42 @@
/*
Small property-testing harness for psf-memo-indexer.
Mirrors psf-memo-db/test/property/harness.js: a deterministic, seeded PRNG
plus a forAll helper so property runs are reproducible without a property
framework dependency.
*/
import assert from 'node:assert/strict'
// A small deterministic PRNG (mulberry32). Same seed => same stream.
export function seededRandom (seed = 12345) {
let a = seed >>> 0
return function next () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export async function forAll (gen, check, { samples = 500, label = 'property' } = {}) {
for (let i = 0; i < samples; i++) {
const input = gen(i)
const ok = await check(input)
assert.ok(ok, `${label} failed at sample ${i} for input: ${JSON.stringify(input)}`)
}
}
export function intGen (rng, min, max) {
return () => min + Math.floor(rng() * (max - min + 1))
}
export function txidGen (rng) {
const hex = '0123456789abcdef'
let out = ''
for (let i = 0; i < 64; i++) {
out += hex[Math.floor(rng() * hex.length)]
}
return out
}
@@ -0,0 +1,48 @@
/*
Property tests for the indexer's postHeightKey encoding.
The indexer writes the postHeights secondary index, and the DB reads it
back by the same key scheme. These properties pin the encoding invariants:
fixed-width zero-padded height, numeric-order preservation, and a stable
key format shared with the DB.
*/
import test from 'node:test'
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
import { postHeightKey } from '../../src/use-cases/action-types/helpers.js'
const rng = seededRandom(20260826)
test('postHeightKey is fixed-width and encodes the height and txid', async () => {
const heightGen = intGen(rng, 0, 999999999999)
await forAll(
(i) => ({ height: heightGen(), txid: txidGen(rng) }),
({ height, txid }) => {
const key = postHeightKey(height, txid)
const [padded, txidPart] = key.split(':')
return padded.length === 12 &&
Number.parseInt(padded, 10) === height &&
txidPart === txid
},
{ label: 'postHeightKey fixed-width encoding' }
)
})
test('postHeightKey preserves numeric height order lexicographically', async () => {
const heightGen = intGen(rng, 0, 9000000)
await forAll(
(i) => {
const a = heightGen()
const b = heightGen()
return { a: Math.min(a, b), b: Math.max(a, b), txidA: txidGen(rng), txidB: txidGen(rng) }
},
({ a, b, txidA, txidB }) => {
if (a === b) return postHeightKey(a, txidA) === postHeightKey(a, txidB)
return postHeightKey(a, txidA) < postHeightKey(b, txidB)
},
{ label: 'postHeightKey ordering' }
)
})
@@ -1,15 +1,18 @@
import { assert } from 'chai'
import sinon from 'sinon'
import { handlePost } from '../../../../src/use-cases/action-types/post.js'
import { PREFIX_POST } from '../../../../src/lib/memo-codes.js'
import { PREFIX_POST, MAX_POST_SIZE } from '../../../../src/lib/memo-codes.js'
describe('#handlePost', () => {
it('should save a post to the database', async () => {
const create = sinon.stub().resolves({ success: true })
const get = sinon.stub().rejects(new Error('not found'))
const postHeightCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().rejects(new Error('not found'))
const adapters = {
postDb: { create, get },
postHeightDb: { create: postHeightCreate, get: postHeightGet },
processErrorDb: { create: sinon.stub() }
}
@@ -31,5 +34,67 @@ describe('#handlePost', () => {
assert.equal(create.firstCall.args[0], 'abc123')
assert.equal(create.firstCall.args[1].text, 'hello memo')
assert.equal(create.firstCall.args[1].blockHeight, 600100)
assert.equal(postHeightCreate.callCount, 1)
assert.equal(postHeightCreate.firstCall.args[0], '000000600100:abc123')
assert.equal(postHeightCreate.firstCall.args[1].txid, 'abc123')
assert.equal(postHeightCreate.firstCall.args[1].blockHeight, 600100)
})
it('should not duplicate postHeight entries when reprocessing', async () => {
const create = sinon.stub().resolves({ success: true })
const get = sinon.stub().resolves({ addr: 'bitcoincash:qptest', text: 'hello memo', seen: 1000, blockHeight: 600100 })
const postHeightCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().resolves({ txid: 'abc123', blockHeight: 600100 })
const adapters = {
postDb: { create, get },
postHeightDb: { create: postHeightCreate, get: postHeightGet },
processErrorDb: { create: sinon.stub() }
}
const message = Buffer.from('hello memo')
await handlePost({
adapters,
txid: 'abc123',
signerAddr: 'bitcoincash:qptest',
seen: 1000,
blockHeight: 600100,
decoded: {
action: 'post',
prefix: PREFIX_POST,
pushDatas: [PREFIX_POST, message]
}
})
assert.equal(create.callCount, 0)
assert.equal(postHeightCreate.callCount, 0)
})
it('should accept a post whose text is exactly at the maximum size', async () => {
const create = sinon.stub().resolves({ success: true })
const get = sinon.stub().rejects(new Error('not found'))
const postHeightCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().rejects(new Error('not found'))
const processErrorDb = { create: sinon.stub() }
const adapters = {
postDb: { create, get },
postHeightDb: { create: postHeightCreate, get: postHeightGet },
processErrorDb
}
const message = Buffer.alloc(MAX_POST_SIZE, 'x')
await handlePost({
adapters,
txid: 'abc123',
signerAddr: 'bitcoincash:qptest',
seen: 1000,
blockHeight: 600100,
decoded: { action: 'post', prefix: PREFIX_POST, pushDatas: [PREFIX_POST, message] }
})
assert.equal(create.callCount, 1)
assert.equal(processErrorDb.create.callCount, 0)
})
})
@@ -0,0 +1,54 @@
import { assert } from 'chai'
import sinon from 'sinon'
import { handleReply } from '../../../../src/use-cases/action-types/reply.js'
import { PREFIX_REPLY } from '../../../../src/lib/memo-codes.js'
describe('#handleReply', () => {
it('should save a reply and its postHeight index entry', async () => {
const parentTxid = Buffer.alloc(32, 0xab)
const message = Buffer.from('hi there')
const postParentCreate = sinon.stub().resolves({ success: true })
const postChildCreate = sinon.stub().resolves({ success: true })
const postDbGet = sinon.stub().rejects(new Error('not found'))
const postDbCreate = sinon.stub().resolves({ success: true })
const postHeightGet = sinon.stub().rejects(new Error('not found'))
const postHeightCreate = sinon.stub().resolves({ success: true })
const adapters = {
postParentDb: { create: postParentCreate },
postChildDb: { create: postChildCreate },
postDb: { get: postDbGet, create: postDbCreate },
postHeightDb: { get: postHeightGet, create: postHeightCreate },
processErrorDb: { create: sinon.stub() }
}
await handleReply({
adapters,
txid: 'reply-abc',
signerAddr: 'bitcoincash:qptest',
seen: 1000,
blockHeight: 600150,
decoded: {
action: 'reply',
prefix: PREFIX_REPLY,
pushDatas: [PREFIX_REPLY, parentTxid, message]
}
})
assert.equal(postParentCreate.callCount, 1)
assert.equal(postParentCreate.firstCall.args[0], 'reply-abc')
assert.equal(postParentCreate.firstCall.args[1].parentTxid, parentTxid.toString('hex'))
assert.equal(postChildCreate.callCount, 1)
assert.include(postChildCreate.firstCall.args[0], 'reply-abc')
assert.equal(postDbCreate.callCount, 1)
assert.equal(postDbCreate.firstCall.args[0], 'reply-abc')
assert.equal(postDbCreate.firstCall.args[1].text, 'hi there')
assert.equal(postHeightCreate.callCount, 1)
assert.equal(postHeightCreate.firstCall.args[0], '000000600150:reply-abc')
assert.equal(postHeightCreate.firstCall.args[1].txid, 'reply-abc')
})
})