mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
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:
@@ -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"
|
||||
|
||||
@@ -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,31 +135,27 @@ class PostQuery {
|
||||
const posts = []
|
||||
|
||||
for (const txid of txids) {
|
||||
try {
|
||||
const post = await this.postsDb.get(txid)
|
||||
posts.push({
|
||||
txid,
|
||||
addr: post.addr,
|
||||
text: post.text,
|
||||
seen: post.seen,
|
||||
blockHeight: post.blockHeight ?? 0
|
||||
})
|
||||
} catch (err) {
|
||||
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') continue
|
||||
throw err
|
||||
}
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user