From 6a8cf6653e10681bc9ec7e6e55e10b7b8146a3e5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Wed, 26 Aug 2026 11:02:13 -0700 Subject: [PATCH] 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. --- psf-memo-db/package.json | 1 + psf-memo-db/src/adapters/post-query.js | 92 +++++++++--------- psf-memo-db/src/use-cases/lib/pagination.js | 51 ++++++++++ .../src/use-cases/list-posts-by-addr.js | 48 +--------- .../src/use-cases/list-recent-posts.js | 48 +--------- .../src/use-cases/list-recent-profiles.js | 38 +------- psf-memo-db/test/property/harness.js | 47 ++++++++++ .../property/post-height.property.test.js | 69 ++++++++++++++ .../unit/use-cases/lib/pagination.unit.js | 93 +++++++++++++++++++ psf-memo-indexer/package.json | 1 + .../src/use-cases/action-types/post.js | 22 ++--- psf-memo-indexer/test/property/harness.js | 42 +++++++++ .../property/post-height.property.test.js | 48 ++++++++++ 13 files changed, 418 insertions(+), 182 deletions(-) create mode 100644 psf-memo-db/src/use-cases/lib/pagination.js create mode 100644 psf-memo-db/test/property/harness.js create mode 100644 psf-memo-db/test/property/post-height.property.test.js create mode 100644 psf-memo-db/test/unit/use-cases/lib/pagination.unit.js create mode 100644 psf-memo-indexer/test/property/harness.js create mode 100644 psf-memo-indexer/test/property/post-height.property.test.js diff --git a/psf-memo-db/package.json b/psf-memo-db/package.json index f771e4d..45c40d3 100644 --- a/psf-memo-db/package.json +++ b/psf-memo-db/package.json @@ -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" diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index d4f0e65..df70f78 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -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 diff --git a/psf-memo-db/src/use-cases/lib/pagination.js b/psf-memo-db/src/use-cases/lib/pagination.js new file mode 100644 index 0000000..85c92f4 --- /dev/null +++ b/psf-memo-db/src/use-cases/lib/pagination.js @@ -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 + })) +} diff --git a/psf-memo-db/src/use-cases/list-posts-by-addr.js b/psf-memo-db/src/use-cases/list-posts-by-addr.js index b6ba241..585b0b5 100644 --- a/psf-memo-db/src/use-cases/list-posts-by-addr.js +++ b/psf-memo-db/src/use-cases/list-posts-by-addr.js @@ -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, diff --git a/psf-memo-db/src/use-cases/list-recent-posts.js b/psf-memo-db/src/use-cases/list-recent-posts.js index 552e6c9..c8c0ea2 100644 --- a/psf-memo-db/src/use-cases/list-recent-posts.js +++ b/psf-memo-db/src/use-cases/list-recent-posts.js @@ -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, diff --git a/psf-memo-db/src/use-cases/list-recent-profiles.js b/psf-memo-db/src/use-cases/list-recent-profiles.js index f6dee8e..325b4c0 100644 --- a/psf-memo-db/src/use-cases/list-recent-profiles.js +++ b/psf-memo-db/src/use-cases/list-recent-profiles.js @@ -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) diff --git a/psf-memo-db/test/property/harness.js b/psf-memo-db/test/property/harness.js new file mode 100644 index 0000000..499da49 --- /dev/null +++ b/psf-memo-db/test/property/harness.js @@ -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 +} diff --git a/psf-memo-db/test/property/post-height.property.test.js b/psf-memo-db/test/property/post-height.property.test.js new file mode 100644 index 0000000..9f41375 --- /dev/null +++ b/psf-memo-db/test/property/post-height.property.test.js @@ -0,0 +1,69 @@ +/* + Property tests for the postHeights secondary index key encoding. + + The postHeights index stores a key of `:`. 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' } + ) +}) diff --git a/psf-memo-db/test/unit/use-cases/lib/pagination.unit.js b/psf-memo-db/test/unit/use-cases/lib/pagination.unit.js new file mode 100644 index 0000000..632cd0b --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/lib/pagination.unit.js @@ -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) + }) + }) +}) diff --git a/psf-memo-indexer/package.json b/psf-memo-indexer/package.json index d478a78..8ad7f6f 100644 --- a/psf-memo-indexer/package.json +++ b/psf-memo-indexer/package.json @@ -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" }, diff --git a/psf-memo-indexer/src/use-cases/action-types/post.js b/psf-memo-indexer/src/use-cases/action-types/post.js index b2449f0..942c152 100644 --- a/psf-memo-indexer/src/use-cases/action-types/post.js +++ b/psf-memo-indexer/src/use-cases/action-types/post.js @@ -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 }) } diff --git a/psf-memo-indexer/test/property/harness.js b/psf-memo-indexer/test/property/harness.js new file mode 100644 index 0000000..7d564c7 --- /dev/null +++ b/psf-memo-indexer/test/property/harness.js @@ -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 +} diff --git a/psf-memo-indexer/test/property/post-height.property.test.js b/psf-memo-indexer/test/property/post-height.property.test.js new file mode 100644 index 0000000..fed5706 --- /dev/null +++ b/psf-memo-indexer/test/property/post-height.property.test.js @@ -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' } + ) +})