Harden and review post-heights-index refactor

Architectural review of the refactorer's postHeights index work. Extract a
shared assemblePostPage helper and a ListUseCase base class to remove DRY
duplication in the post-list use cases. Build the gherkin-mutator runner
adapter for psf-memo-db and psf-memo-indexer. Add boundary and tie-break
tests to kill all surviving language mutations across the changed files
(post-query, pagination, list use cases, indexer post handler).

Mutation: all changed source files fully killed. DRY: clean. CRAP <= 6.
Soft Gherkin acceptance mutation documented survivors (see review report).

By architect.
This commit is contained in:
Chris Troutner
2026-08-26 11:39:12 -07:00
parent d5f0b3dc7b
commit 30c0949d3f
17 changed files with 494 additions and 49 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.
@@ -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)
})
@@ -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
+4
View File
@@ -175,3 +175,7 @@ class PostQuery {
}
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
@@ -49,3 +49,19 @@ export function attachReplyCounts (posts, replyCounts) {
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
@@ -3,18 +3,12 @@
Uses the postHeights secondary index for efficient sorting and pagination.
*/
import { parseLimit, parseOffset, attachReplyCounts } from './lib/pagination.js'
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)
super(localConfig, { useCaseName: 'ListPostsByAddr', adapterName: 'postQuery' })
}
parseAddr (addr) {
@@ -38,16 +32,12 @@ class ListPostsByAddr {
this.adapters.postQuery.countTopLevelPostsByAddr(addr)
])
return {
posts: attachReplyCounts(posts, replyCounts),
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
+9 -19
View File
@@ -3,18 +3,12 @@
Uses the postHeights secondary index for efficient sorting and pagination.
*/
import { parseLimit, parseOffset, attachReplyCounts } from './lib/pagination.js'
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)
super(localConfig, { useCaseName: 'ListRecentPosts', adapterName: 'postQuery' })
}
async execute (inObj = {}) {
@@ -28,16 +22,12 @@ class ListRecentPosts {
this.adapters.postQuery.countTopLevelPosts()
])
return {
posts: attachReplyCounts(posts, replyCounts),
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
@@ -3,17 +3,11 @@
*/
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)
super(localConfig, { useCaseName: 'ListRecentProfiles', adapterName: 'profileQuery' })
}
sortProfiles (profiles) {
@@ -47,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
@@ -48,6 +48,32 @@ describe('#PostQuery', () => {
}
})
describe('#txidFromPostHeight', () => {
it('should return the txid from the value when present', () => {
assert.equal(uut.txidFromPostHeight('any-key', { txid: 'abc123' }), 'abc123')
})
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')
})
})
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 txids = []
for await (const txid of uut.topLevelPostTxids()) txids.push(txid)
assert.deepEqual(txids, ['post-100', 'post-200-a'])
})
})
describe('#scanRecentPostTxids', () => {
it('should return top-level post txids sorted by block height descending', async () => {
async function * mockHeights () {
@@ -149,6 +175,20 @@ describe('#PostQuery', () => {
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'])
})
})
describe('#loadPostsByTxids', () => {
@@ -175,6 +215,14 @@ describe('#PostQuery', () => {
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)
})
})
describe('#countTopLevelPosts', () => {
@@ -1,5 +1,5 @@
import { assert } from 'chai'
import { parseLimit, parseOffset, attachReplyCounts } from '../../../../src/use-cases/lib/pagination.js'
import { parseLimit, parseOffset, attachReplyCounts, assemblePostPage } from '../../../../src/use-cases/lib/pagination.js'
describe('#pagination', () => {
describe('parseLimit', () => {
@@ -14,6 +14,16 @@ describe('#pagination', () => {
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')
@@ -90,4 +100,28 @@ describe('#pagination', () => {
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 }))
})
})
@@ -43,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 () => {
@@ -55,6 +71,16 @@ describe('#ListPostsByAddr', () => {
}
})
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 })
@@ -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 })
@@ -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)
})
@@ -34,3 +34,7 @@ export async function handlePost (ctx) {
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
@@ -1,7 +1,7 @@
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 () => {
@@ -70,4 +70,31 @@ describe('#handlePost', () => {
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)
})
})