Refactor postHeights index code and add property tests

Deduplicate the postHeights query iteration into a shared generator,
extract shared pagination parsing/enrichment, and extract the idempotent
create-if-missing write pattern. Reduces CRAP for the changed post-height
code to <=6 and removes duplicated parse/attach logic across the post list
use cases. Add property tests covering the postHeight key round-trip and
ordering invariants for both the DB and indexer, exposed via a separate
'property' command.

By refactorer.
This commit is contained in:
Chris Troutner
2026-08-26 11:02:13 -07:00
parent fde0086385
commit 6a8cf6653e
13 changed files with 418 additions and 182 deletions
+1
View File
@@ -8,6 +8,7 @@
"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"
+37 -41
View File
@@ -32,6 +32,8 @@ class PostQuery {
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) {
@@ -70,15 +72,33 @@ class PostQuery {
return counts
}
async scanRecentPostTxids ({ limit, offset }) {
// 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 [key, value] of this.postHeightsDb.iterator({ reverse: true })) {
const txid = this.txidFromPostHeight(key, value)
if (replyTxids.has(txid)) continue
for await (const txid of this.topLevelPostTxids({ reverse: true })) {
if (skipped < offset) {
skipped++
continue
@@ -92,23 +112,12 @@ class PostQuery {
}
async scanPostsByAddrTxids (addr, { limit, offset }) {
const replyTxids = await this.loadReplyTxids()
const txids = []
let skipped = 0
for await (const [key, value] of this.postHeightsDb.iterator({ reverse: true })) {
const txid = this.txidFromPostHeight(key, value)
if (replyTxids.has(txid)) continue
let post
try {
post = await this.postsDb.get(txid)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') continue
throw err
}
if (post.addr !== addr) continue
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++
@@ -126,8 +135,8 @@ class PostQuery {
const posts = []
for (const txid of txids) {
try {
const post = await this.postsDb.get(txid)
const post = await this.getPostOrNull(txid)
if (!post) continue
posts.push({
txid,
addr: post.addr,
@@ -135,22 +144,18 @@ class PostQuery {
seen: post.seen,
blockHeight: post.blockHeight ?? 0
})
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') continue
throw err
}
}
return posts
}
async countTopLevelPosts () {
const replyTxids = await this.loadReplyTxids()
let count = 0
const iterator = this.topLevelPostTxids()
for await (const [key, value] of this.postHeightsDb.iterator()) {
const txid = this.txidFromPostHeight(key, value)
if (replyTxids.has(txid)) continue
for (;;) {
const { done } = await iterator.next()
if (done) break
count++
}
@@ -158,20 +163,11 @@ class PostQuery {
}
async countTopLevelPostsByAddr (addr) {
const replyTxids = await this.loadReplyTxids()
let count = 0
for await (const [key, value] of this.postHeightsDb.iterator()) {
const txid = this.txidFromPostHeight(key, value)
if (replyTxids.has(txid)) continue
try {
const post = await this.postsDb.get(txid)
if (post.addr === addr) count++
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') continue
throw err
}
for await (const txid of this.topLevelPostTxids()) {
const post = await this.getPostOrNull(txid)
if (post && post.addr === addr) count++
}
return count
@@ -0,0 +1,51 @@
/*
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
}))
}
@@ -3,8 +3,7 @@
Uses the postHeights secondary index for efficient sorting and pagination.
*/
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
import { parseLimit, parseOffset, attachReplyCounts } from './lib/pagination.js'
class ListPostsByAddr {
constructor (localConfig = {}) {
@@ -16,38 +15,6 @@ class ListPostsByAddr {
throw new Error('postQuery adapter required for ListPostsByAddr use case.')
}
this.execute = this.execute.bind(this)
this.attachReplyCounts = this.attachReplyCounts.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
}
parseAddr (addr) {
@@ -59,17 +26,10 @@ class ListPostsByAddr {
return addr
}
attachReplyCounts (posts, replyCounts) {
return posts.map((post) => ({
...post,
replyCount: replyCounts.get(post.txid) ?? 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 txids = await this.adapters.postQuery.scanPostsByAddrTxids(addr, { limit, offset })
const [posts, replyCounts, total] = await Promise.all([
@@ -79,7 +39,7 @@ class ListPostsByAddr {
])
return {
posts: this.attachReplyCounts(posts, replyCounts),
posts: attachReplyCounts(posts, replyCounts),
pagination: {
limit,
offset,
+4 -44
View File
@@ -3,8 +3,7 @@
Uses the postHeights secondary index for efficient sorting and pagination.
*/
const DEFAULT_LIMIT = 100
const MAX_LIMIT = 100
import { parseLimit, parseOffset, attachReplyCounts } from './lib/pagination.js'
class ListRecentPosts {
constructor (localConfig = {}) {
@@ -16,50 +15,11 @@ class ListRecentPosts {
throw new Error('postQuery adapter required for ListRecentPosts use case.')
}
this.execute = this.execute.bind(this)
this.attachReplyCounts = this.attachReplyCounts.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
}
attachReplyCounts (posts, replyCounts) {
return posts.map((post) => ({
...post,
replyCount: replyCounts.get(post.txid) ?? 0
}))
}
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 txids = await this.adapters.postQuery.scanRecentPostTxids({ limit, offset })
const [posts, replyCounts, total] = await Promise.all([
@@ -69,7 +29,7 @@ class ListRecentPosts {
])
return {
posts: this.attachReplyCounts(posts, replyCounts),
posts: attachReplyCounts(posts, replyCounts),
pagination: {
limit,
offset,
@@ -2,8 +2,7 @@
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'
class ListRecentProfiles {
constructor (localConfig = {}) {
@@ -17,37 +16,6 @@ class ListRecentProfiles {
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
}
sortProfiles (profiles) {
return profiles.sort((a, b) => {
if (b.blockHeight !== a.blockHeight) {
@@ -58,8 +26,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)
+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' }
)
})
@@ -0,0 +1,93 @@
import { assert } from 'chai'
import { parseLimit, parseOffset, attachReplyCounts } 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 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)
})
})
})
+1
View File
@@ -7,6 +7,7 @@
"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"
},
@@ -1,6 +1,15 @@
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)
@@ -22,15 +31,6 @@ export async function handlePost (ctx) {
const postData = { addr: signerAddr, text, seen, blockHeight }
const heightKey = postHeightKey(blockHeight, txid)
try {
await adapters.postDb.get(txid)
} catch (err) {
await adapters.postDb.create(txid, postData)
}
try {
await adapters.postHeightDb.get(heightKey)
} catch (err) {
await adapters.postHeightDb.create(heightKey, { txid, blockHeight })
}
await createIfMissing(adapters.postDb, txid, postData)
await createIfMissing(adapters.postHeightDb, heightKey, { txid, blockHeight })
}
+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' }
)
})