mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge txid-wire-encoding from swarmforge-architect
By specifier.
This commit is contained in:
@@ -109,10 +109,27 @@ distinct from per-task verification results, which live in
|
||||
|
||||
- **Capture the `gherkin-mutator` report with `--json` redirected to a file.**
|
||||
The text report (`write-text-report!`, which uses `print`) did not appear in
|
||||
the captured output before the tool's `System/exit`; the JSON report
|
||||
the captured output before the tool's `System/exit`; the JSON report
|
||||
(`--json`) did. Redirect stdout to a file and use `--json` for a reliable,
|
||||
parseable record of killed/survived mutations.
|
||||
|
||||
- **`gherkin-mutator` can run from `tmp/aps` with absolute component paths.**
|
||||
The Babashka task must run where `bb.edn` defines it (`tmp/aps`), but the
|
||||
runner worker resolves the job's `feature_json`/`work_dir`/`generated_dir`
|
||||
paths as given. Run `cd tmp/aps && bb gherkin-mutator --feature
|
||||
<abs>/<component>/specs/x.feature --work-dir <abs>/<component>/build/acceptance-mutation
|
||||
--runner-worker "node <abs>/<component>/acceptance/lib/runner-worker.js"
|
||||
--level soft --workers 8 --status-interval 15s --json > report.json`. The
|
||||
runner command is split on whitespace, so it must be a bare `node <path>`
|
||||
with no spaces in the path. The tool writes its manifest (and, when clean, a
|
||||
`# mutation-stamp`) into the feature file; commit that tool-written change.
|
||||
|
||||
- **`mutate-file.sh` works from every component dir, including the DB.** It
|
||||
defaults `MUTATE4JS_BIN` to the client's installed
|
||||
`node_modules/mutate4javascript`, and the tool's `npm test` baseline runs in
|
||||
the current component, so the DB mutation runs use the DB suite without a
|
||||
second tool install.
|
||||
|
||||
## Workflow observations
|
||||
|
||||
- **`architect-startup.sh` checks `tmp/aps` relative to the worktree, but
|
||||
@@ -142,3 +159,11 @@ distinct from per-task verification results, which live in
|
||||
|
||||
- **Run per-component verification for every component a task touches** before
|
||||
handing off (client, db, indexer), per the monorepo rules.
|
||||
|
||||
- **`verify.sh` records one component per invocation, so multi-component tasks
|
||||
need multiple records.** A task touching the client and the DB cannot use a
|
||||
single `<task>-verification.json`. The first recent two-component task
|
||||
(`txid-wire-encoding`) used the canonical `<task>-verification.json` for the
|
||||
primary component and `<task>-db-verification.json` for the second; both
|
||||
carry the same review `git_sha`. State the mapping explicitly in the review
|
||||
summary, since the specifier's brief names only the canonical file.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task": "txid-wire-encoding",
|
||||
"component": "psf-memo-db",
|
||||
"git_sha": "a2229f7dd4523e6397418e2bb8ac43800940b902",
|
||||
"branch": "swarmforge-architect",
|
||||
"timestamp": "2026-09-16T22:29:57.163Z",
|
||||
"commands": [
|
||||
{
|
||||
"name": "unit",
|
||||
"command": "npm test",
|
||||
"exit": 0,
|
||||
"duration_ms": 5056,
|
||||
"summary": "371 passing"
|
||||
},
|
||||
{
|
||||
"name": "property",
|
||||
"command": "npm run property",
|
||||
"exit": 0,
|
||||
"duration_ms": 20174,
|
||||
"summary": "54 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "acceptance",
|
||||
"command": "npm run acceptance",
|
||||
"exit": 0,
|
||||
"duration_ms": 91309,
|
||||
"summary": "all 13 acceptance suites passed"
|
||||
},
|
||||
{
|
||||
"name": "lint",
|
||||
"command": "npm run lint",
|
||||
"exit": 0,
|
||||
"duration_ms": 4175,
|
||||
"summary": "ok"
|
||||
}
|
||||
],
|
||||
"result": "pass"
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
# txid-wire-encoding — Architect Review
|
||||
|
||||
Task: `txid-wire-encoding`
|
||||
Components: `psf-memo-client`, `psf-memo-db`
|
||||
Base: `bf7edd5` (last merged architect review); inbound refactorer commit `a76fcee`
|
||||
|
||||
## What was reviewed
|
||||
|
||||
Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by
|
||||
fast-forwarding to `a76fcee`. The linear chain reviewed:
|
||||
|
||||
- **`bb0c9ab`** — specifier: *Spec txid wire encoding and database repair*.
|
||||
Adds `psf-memo-client/specs/txid-wire-encoding.feature` (4 scenario outlines
|
||||
pinning the like, reply, poll-option, and poll-vote wire order) and
|
||||
`psf-memo-db/specs/repair-txid-encoding.feature` (6 scenario outlines for the
|
||||
byte-reversed-reference repair utility).
|
||||
- **`dedb7a1`** — coder: *Fix txid wire encoding and add txid repair utility*.
|
||||
Adds `txidToWireBytes` and reverses the txid in `buildTxidTextPayload`, so
|
||||
likes, replies, poll options, and poll votes now embed the referenced txid
|
||||
little-endian; adds the `repair-txid-encoding` library (`correctReference`,
|
||||
`repairTxidEncoding`), the `util/txid` CLI wrapper, unit tests, the DB
|
||||
acceptance fixture/handlers, and decodes the wire txid in the client
|
||||
acceptance handlers.
|
||||
- **`a76fcee`** — refactorer: *Refactor txid wire encoding and repair library*.
|
||||
Generalizes the four near-identical repair loops into `repairStore` plus a
|
||||
`txidIndex` key/value adapter, replaces the reply-only `buildReplyPayload`
|
||||
with the shared `buildTxidTextPayload(txid, text, label)`, and adds client
|
||||
and DB property tests (round trip, involution, idempotence, conservation,
|
||||
resolution, index rebuild).
|
||||
|
||||
**Architect review commit: `a2229f7dd4`** — the `mutate4javascript` footer
|
||||
manifests (client `hex.js`, `memo-like.js`, `memo-reply.js`; DB
|
||||
`src/lib/repair-txid-encoding.js`), the soft `gherkin-mutator`
|
||||
acceptance-mutation manifests on both touched feature files, the hardening
|
||||
tests below, and the extracted shared test double. The summary and verification
|
||||
records are committed on top, so `git diff a2229f7dd4 HEAD` touches only
|
||||
`docs/`. The records' `git_sha` is `a2229f7dd4`, the commit that contains the
|
||||
verified source state.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
|
||||
The refactorer's structure is sound and required no further boundary change.
|
||||
The hardening work was mutation- and duplication-driven.
|
||||
|
||||
1. **UI/Core separation.** `src/services/hex.js` is a pure leaf (byte decode,
|
||||
reverse, payload assembly) with no React, DOM, wallet, or IO. `memo-like.js`
|
||||
and `memo-reply.js` are core services that receive the wallet/feed/thread
|
||||
behind injected adapter doubles. `src/lib/repair-txid-encoding.js` is a pure
|
||||
async core that receives the LevelDB handles; only
|
||||
`util/txid/repair-txid-encoding.js` opens real stores. Every piece of core
|
||||
behavior is exercised without a browser, network, or database file.
|
||||
2. **Dependency rule.** The client services depend inward on `hex.js`; the DB
|
||||
CLI wrapper depends inward on `src/lib`. Nothing in the pure modules reaches
|
||||
out to React, the router, LevelDB, or the file system.
|
||||
3. **Information hiding.** The little-endian wire order now has exactly one
|
||||
definition (`txidToWireBytes`), and the txid+text payload shape has one
|
||||
definition (`buildTxidTextPayload`). The refactorer's `repairStore` +
|
||||
`txidIndex` hide the per-store field/key/value differences behind a small
|
||||
adapter, and `correctReference` hides the "keep / reverse / leave" decision.
|
||||
No persistence structure leaks into callers.
|
||||
4. **Minor coupling, accepted.** `buildTxidTextPayload` defaults its label to
|
||||
`'Poll txid'`, a residue of its poll-only origin. It is now generic and the
|
||||
reply caller passes `'Parent txid'` explicitly; the poll callers rely on the
|
||||
default. A fully neutral default was not worth churning the poll call sites.
|
||||
5. **Test boundaries.** The task-local `FakeDb`/`makeLevel` double was
|
||||
duplicated verbatim across the new unit and property tests, so it was
|
||||
extracted to `psf-memo-db/test/support/level-double.js` (a test helper, kept
|
||||
out of `test/unit/**` and `test/property/*.test.js`). The util CLI wrapper is
|
||||
an environmentally unsuitable adapter shell and is deliberately excluded
|
||||
from the tools that run tests/coverage/mutation.
|
||||
6. **Mutation hardening (see below).** Six pre-existing boundary survivors in
|
||||
`memo-like.js` and one in `memo-reply.js` were killed with focused boundary
|
||||
assertions rather than design changes.
|
||||
|
||||
## Verification results
|
||||
|
||||
### Language mutation (`mutate4javascript`, `--mutate-all` where the wrapper
|
||||
detected differential under-selection, `--max-workers 8`)
|
||||
|
||||
| File | Sites | Killed | Survived | Uncovered |
|
||||
|------|------:|-------:|---------:|----------:|
|
||||
| `psf-memo-client/src/services/hex.js` | 7 | 7 | 0 | 0 |
|
||||
| `psf-memo-client/src/services/memo-like.js` | 34 | 34 | 0 | 0 |
|
||||
| `psf-memo-client/src/services/memo-reply.js` | 2 | 2 | 0 | 0 |
|
||||
| `psf-memo-db/src/lib/repair-txid-encoding.js` | 6 | 6 | 0 | 0 |
|
||||
|
||||
The first `memo-like.js` run left 9 survivors (`true -> false` on the
|
||||
`validate`/`validateTip` return values, the `validateTip`
|
||||
`tipSats > spendableSats` boundary, the `_validateTipAmount` max-tip boundary,
|
||||
the `getSpendableSats` `?? 0` default, the `_requireTipAddress` `<= 0` and
|
||||
`.length > 0` boundaries, the `_buildTipOutput` `tipSats > 0` boundary, and the
|
||||
`_incrementPostCount` `|| 0` default). `memo-reply.js` left the
|
||||
`isTooLong` `> MAX_REPLY_BYTES` boundary. Each was a missing boundary/default
|
||||
assertion, not a design fault; focused unit assertions were added and the
|
||||
re-runs report 34/0 and 2/0. `hex.js` and the DB repair library were clean on
|
||||
the first full run.
|
||||
|
||||
`psf-memo-db/util/txid/repair-txid-encoding.js` is not mutated: it is the
|
||||
file-system adapter shell (opens real LevelDB stores, no unit coverage by
|
||||
design), and the tool that runs tests is scoped to the testable core.
|
||||
|
||||
### DRY (`dry4javascript`)
|
||||
|
||||
Scoped runs over the changed production files, tests, and adapters.
|
||||
|
||||
- **DB.** The verbatim `FakeDb`/`makeLevel` duplication across the two new test
|
||||
files was extracted to `test/support/level-double.js`; the scoped report
|
||||
dropped from 72 to 70 duplicate blocks. Every remaining block is pre-existing
|
||||
`acceptance/lib/handlers.js` step-handler boilerplate, except one structurally
|
||||
identical but semantically distinct pair in the unit test (like/postLikes vs
|
||||
reply/postChildren index-rebuild assertions), left as deliberate test
|
||||
boilerplate.
|
||||
- **Client.** The task-local candidates are two `makeWallet` doubles
|
||||
(`test/property/poll-services.property.test.js` vs
|
||||
`test/unit/memo-reply.test.js`) and one structurally identical pair of
|
||||
like-rejection tests in `memo-like.test.js`. `makeWallet` appears in 28 client
|
||||
test files as an established per-suite fixture convention, so extracting it
|
||||
would be a broad cross-suite refactor beyond this task; both are recorded as
|
||||
documented pattern-boilerplate. The remaining blocks are pre-existing
|
||||
`acceptance/lib/handlers.js` boilerplate.
|
||||
|
||||
### CRAP / cyclomatic complexity (`crap4javascript`)
|
||||
|
||||
- **Client:** every changed function at or below CRAP 6.0 with ~100% coverage —
|
||||
`MemoLike._validateTipAmount` (CC 6, 100%, 6.0), `hexToBytes` (CC 5, 5.0),
|
||||
`MemoLike._incrementPostCount` (CC 5, 5.0), `MemoLike.getSpendableSats`
|
||||
(CC 4, 4.0), the rest CC ≤ 3. `MemoReply.reply` is 90% covered but CRAP 2.0.
|
||||
- **DB:** `repairStore` (CC 5, 100%, 5.0), `correctReference` (CC 4, 4.0),
|
||||
`hasRecord` (CC 4, 4.0), `reverseTxid` (CC 3, 3.0), `repairTxidEncoding`/
|
||||
`txidIndex` (CC 1). All below the 8.0 threshold.
|
||||
|
||||
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
|
||||
|
||||
- **`txid-wire-encoding.feature`** (client): **14 executed, 8 killed,
|
||||
6 survived, 0 errors**. All six survivors are single-character case mutations
|
||||
of values that are never asserted: reply `message` (`hello memo`,
|
||||
`a second one`), poll `option` (`yes`, `no`), and poll-vote `comment`
|
||||
(`yes`, `I choose this`). Each scenario asserts the Memo prefix and the
|
||||
referenced txid, not the carried text, so the mutated text is an intrinsic
|
||||
equivalent / weak example-to-assertion link. The wire-order txid mutations
|
||||
(the point of the feature) were killed.
|
||||
- **`repair-txid-encoding.feature`** (DB): **21 executed, 18 killed,
|
||||
3 survived, 0 errors**. All three survivors mutate the `reversedPostTxid`
|
||||
example value in scenarios 0, 1, and 5, which use it only in the negative
|
||||
assertion "the postLikes store contains 0 entry whose key starts with
|
||||
`<reversedPostTxid>`". Since the key is absent by construction, any mutated
|
||||
value still yields 0 entries — a genuine negative-assertion equivalent. All
|
||||
positive repair and index assertions killed their mutations.
|
||||
|
||||
These survivors are specifier-side feature-quality items (weak text
|
||||
assertions), not implementation gaps; no implementation change is warranted.
|
||||
|
||||
### Suite status
|
||||
|
||||
Canonical records, both against review commit `a2229f7dd4523e6397418e2bb8ac43800940b902`:
|
||||
|
||||
- `swarmforge/scripts/verify.sh client --record
|
||||
docs/reviews/txid-wire-encoding-verification.json --task txid-wire-encoding`
|
||||
-> **pass (5/5)**: unit **417 pass / 0 fail**, property **80 pass / 0 fail**,
|
||||
acceptance **all 30 suites passed**, lint **ok**, build **ok**.
|
||||
- `swarmforge/scripts/verify.sh db --record
|
||||
docs/reviews/txid-wire-encoding-db-verification.json --task txid-wire-encoding`
|
||||
-> **pass (4/4)**: unit **371 passing**, property **54 pass / 0 fail**,
|
||||
acceptance **all 13 suites passed**, lint **ok**.
|
||||
|
||||
This is the first recent task to touch two components, so the client record
|
||||
uses the canonical `<task>-verification.json` name and the DB record uses
|
||||
`<task>-db-verification.json`; both carry the same review `git_sha`.
|
||||
|
||||
## Handoffs sent
|
||||
|
||||
- End-of-chain `git_handoff` to the specifier (task `txid-wire-encoding`) with
|
||||
the review commit `a2229f7dd4` so it can merge `swarmforge-architect` into
|
||||
`master`.
|
||||
- No coder/refactorer handoff: the review is mutation hardening plus a test-double
|
||||
extraction with no follow-up work for those roles.
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task": "txid-wire-encoding",
|
||||
"component": "psf-memo-client",
|
||||
"git_sha": "a2229f7dd4523e6397418e2bb8ac43800940b902",
|
||||
"branch": "swarmforge-architect",
|
||||
"timestamp": "2026-09-16T22:26:58.220Z",
|
||||
"commands": [
|
||||
{
|
||||
"name": "unit",
|
||||
"command": "npm test",
|
||||
"exit": 0,
|
||||
"duration_ms": 4760,
|
||||
"summary": "417 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "property",
|
||||
"command": "npm run test:property",
|
||||
"exit": 0,
|
||||
"duration_ms": 5651,
|
||||
"summary": "80 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "acceptance",
|
||||
"command": "npm run test:acceptance",
|
||||
"exit": 0,
|
||||
"duration_ms": 7965,
|
||||
"summary": "all 30 acceptance suites passed"
|
||||
},
|
||||
{
|
||||
"name": "lint",
|
||||
"command": "npm run lint",
|
||||
"exit": 0,
|
||||
"duration_ms": 4816,
|
||||
"summary": "ok"
|
||||
},
|
||||
{
|
||||
"name": "build",
|
||||
"command": "npm run build",
|
||||
"exit": 0,
|
||||
"duration_ms": 41660,
|
||||
"summary": "ok"
|
||||
}
|
||||
],
|
||||
"result": "pass"
|
||||
}
|
||||
@@ -545,16 +545,18 @@ function createWorld () {
|
||||
}
|
||||
|
||||
// Decode a raw reply payload into its parent txid (hex) and reply text.
|
||||
// The wire stores the txid little-endian, so reverse it into display order.
|
||||
function decodeReplyPayload (raw) {
|
||||
const buf = Buffer.from(raw)
|
||||
const parentTxid = buf.slice(0, 32).toString('hex')
|
||||
const parentTxid = Buffer.from(buf.slice(0, 32)).reverse().toString('hex')
|
||||
const text = buf.slice(32).toString('utf8')
|
||||
return { parentTxid, text }
|
||||
}
|
||||
|
||||
// Decode a raw like payload back into the liked post txid (hex).
|
||||
// Decode a raw like payload back into the liked post txid (hex) in display
|
||||
// order. The wire stores the txid little-endian, so reverse it.
|
||||
function decodeLikeTxid (raw) {
|
||||
return Buffer.from(raw).toString('hex')
|
||||
return Buffer.from(raw).reverse().toString('hex')
|
||||
}
|
||||
|
||||
// Resolve a literal value or a <parameter> placeholder from the example store.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-09-16T22:22:12.675245296Z","feature_name":"Txid wire encoding","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/txid-wire-encoding.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Txid wire encoding - 1 a like broadcasts the post txid in little-endian wire order","scenario_hash":"42f1932ce013d74c217b8becad4e7f496e84e5236a2728dcc2418b57d67bd6e6","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:22:12.675245296Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Txid wire encoding - 1, Txid wire encoding - 2, Txid wire encoding - 3, Txid wire encoding - 4
|
||||
#
|
||||
# Memo actions that reference a parent transaction embed that txid as 32 raw
|
||||
|
||||
@@ -25,10 +25,19 @@ function hexToBytes (hex, byteLength = 32, label = 'Value') {
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Decode a 32-byte txid and reverse it into Memo protocol wire order. Bitcoin
|
||||
// txids are displayed big-endian but embedded in OP_RETURN payloads as
|
||||
// little-endian bytes, so callers pass the display hex and receive the wire
|
||||
// bytes.
|
||||
function txidToWireBytes (txid, label = 'Value') {
|
||||
return hexToBytes(txid, 32, label).reverse()
|
||||
}
|
||||
|
||||
// Build the raw OP_RETURN payload for a txid-referencing Memo action: the
|
||||
// given 32-byte txid followed by a UTF-8 encoded value.
|
||||
function buildTxidTextPayload (txid, text) {
|
||||
const txidBytes = hexToBytes(txid, 32, 'Poll txid')
|
||||
// given 32-byte txid in little-endian wire order followed by a UTF-8 encoded
|
||||
// value. The label customizes the invalid-txid error message.
|
||||
function buildTxidTextPayload (txid, text, label = 'Poll txid') {
|
||||
const txidBytes = txidToWireBytes(txid, label)
|
||||
const textBytes = new TextEncoder().encode(text)
|
||||
const raw = new Uint8Array(txidBytes.length + textBytes.length)
|
||||
raw.set(txidBytes, 0)
|
||||
@@ -36,8 +45,8 @@ function buildTxidTextPayload (txid, text) {
|
||||
return raw
|
||||
}
|
||||
|
||||
module.exports = { hexToBytes, buildTxidTextPayload }
|
||||
module.exports = { hexToBytes, txidToWireBytes, buildTxidTextPayload }
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-28T22:43:42.484Z","module_hash":"7f3445b7df4d792113f2736232f69faee044f71f6b4a5985224615b36909607b","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":30,"end_line":37,"hash":"542a47a13d30c845b7ac9a04079d7a131ece3368a604840ffef75adf97b11f7d"}]}
|
||||
// {"version":1,"tested_at":"2026-09-16T22:01:27.280Z","module_hash":"454b45f3ce08c5ea6346c113db6384f7a44dddbd21e2fb0a35c41ce6701e62c8","functions":[{"id":"func/hexToBytes","name":"hexToBytes","line":12,"end_line":26,"hash":"dbf7e0a434598f85365f5a60a0ca227a17a1bcd6718a78bb5aaf7afb8eb2487b"},{"id":"func/txidToWireBytes","name":"txidToWireBytes","line":32,"end_line":34,"hash":"ac25753fecb32c929134b6e6379cae0bfddd4aa6f7ae438a08da650450e9ea1a"},{"id":"func/buildTxidTextPayload","name":"buildTxidTextPayload","line":39,"end_line":46,"hash":"068504ea13a037adabbbfeab122fb5ec6113625a9e0b009cbbe8b46b6bebdaee"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { hexToBytes } = require('./hex')
|
||||
const { hexToBytes, txidToWireBytes } = require('./hex')
|
||||
|
||||
const MEMO_LIKE_PREFIX = '6d04'
|
||||
const DUST_LIMIT_SATS = 3000
|
||||
@@ -136,7 +136,7 @@ class MemoLike extends MemoAction {
|
||||
this.validateTip(tipSats, spendable)
|
||||
this._requireTipAddress(tipSats, authorAddress)
|
||||
|
||||
const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
|
||||
const raw = txidToWireBytes(postTxid, 'Post txid')
|
||||
const bchOutput = this._buildTipOutput(tipSats, authorAddress)
|
||||
|
||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput)
|
||||
@@ -198,5 +198,5 @@ MemoLike.MAX_TIP_SATS = MAX_TIP_SATS
|
||||
module.exports = MemoLike
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T13:36:49.209Z","module_hash":"f27440db63274125b6b03bafd66222b31966670d8956b865e0bf2c695aacb54b","functions":[{"id":"func/MemoLike.constructor","name":"MemoLike.constructor","line":42,"end_line":47,"hash":"6ebbf231548843e23238988fde00cca861fd89513abba00febba964f3ef32365"},{"id":"func/MemoLike.validate","name":"MemoLike.validate","line":51,"end_line":60,"hash":"d9267f14ac52c7aa3afc07333b621c5faa751efa217bf02ae7695bb640827d44"},{"id":"func/MemoLike.validateTip","name":"MemoLike.validateTip","line":64,"end_line":74,"hash":"504b41cfa8db9754d31b0698550f9fe791d005c5b44bd30ec380202f1df11d6f"},{"id":"func/MemoLike._validateTipAmount","name":"MemoLike._validateTipAmount","line":77,"end_line":95,"hash":"98483bcf6212c2e0a2400d4da1b667b9f1c017a06ee6e4f282ccdf71cb8e397a"},{"id":"func/MemoLike.getSpendableSats","name":"MemoLike.getSpendableSats","line":99,"end_line":106,"hash":"7790154b7489758e6254cc7be00802fde237a33e0bb1440a2da33d303a2bdbe7"},{"id":"func/MemoLike.like","name":"MemoLike.like","line":112,"end_line":139,"hash":"5a748442d77446643448f1835b5e546569524af642af6be7c338e33ca4901051"},{"id":"func/MemoLike.reflect","name":"MemoLike.reflect","line":142,"end_line":145,"hash":"b159b912ba5b88468e5bfb7458385edc932850fe43948f3028e8ed9fea7269e9"},{"id":"func/MemoLike._requireTipAddress","name":"MemoLike._requireTipAddress","line":148,"end_line":154,"hash":"3b838a583b14112523e2cbe856a63896b1045a1ad91dac500e38693332b7c890"},{"id":"func/MemoLike._buildTipOutput","name":"MemoLike._buildTipOutput","line":157,"end_line":161,"hash":"e2a602c0afa9730dee67497869c4d18ed75708f6503d68fb1f0c0ba596826b5a"},{"id":"func/MemoLike._notifyFeed","name":"MemoLike._notifyFeed","line":164,"end_line":173,"hash":"bfc0e888ee106e21ee73146e92ebd3e4ce41d2ed8595a1539f823dc6c8f31d06"},{"id":"func/MemoLike._incrementPostCount","name":"MemoLike._incrementPostCount","line":176,"end_line":182,"hash":"61ae383f836b64a7aeabaf0d04e50ac9a24b87f726d5d6afac5f5585cdc6031c"}]}
|
||||
// {"version":1,"tested_at":"2026-09-16T22:10:37.562Z","module_hash":"62f705760252a76903f00c4f95b1b8f8a8608ad5c257c3c4a9e8b37f2b9e6cdd","functions":[{"id":"func/MemoLike.constructor","name":"MemoLike.constructor","line":44,"end_line":50,"hash":"058496fa3f123b3876e8312ed6effcc78dd2b11fb3e02f815d552dddf5c9bc22"},{"id":"func/MemoLike.validate","name":"MemoLike.validate","line":54,"end_line":63,"hash":"d9267f14ac52c7aa3afc07333b621c5faa751efa217bf02ae7695bb640827d44"},{"id":"func/MemoLike.validateTip","name":"MemoLike.validateTip","line":67,"end_line":77,"hash":"504b41cfa8db9754d31b0698550f9fe791d005c5b44bd30ec380202f1df11d6f"},{"id":"func/MemoLike._validateTipAmount","name":"MemoLike._validateTipAmount","line":80,"end_line":98,"hash":"b0f8a9d775f83c92aec63f0c4bf5b83a035648909cc9e3e4880903ea20c6a6d7"},{"id":"func/MemoLike.getSpendableSats","name":"MemoLike.getSpendableSats","line":102,"end_line":114,"hash":"e9ba4b8f5013276261487d4d5890f27d8724a34f35878416bbbd0aaa2ef221af"},{"id":"func/MemoLike.like","name":"MemoLike.like","line":120,"end_line":147,"hash":"109c1e6dbf56a76166b17cae013fbd4a5ac79d64958d6e28003174139d015a2e"},{"id":"func/MemoLike.reflect","name":"MemoLike.reflect","line":150,"end_line":153,"hash":"b159b912ba5b88468e5bfb7458385edc932850fe43948f3028e8ed9fea7269e9"},{"id":"func/MemoLike._requireTipAddress","name":"MemoLike._requireTipAddress","line":156,"end_line":162,"hash":"3b838a583b14112523e2cbe856a63896b1045a1ad91dac500e38693332b7c890"},{"id":"func/MemoLike._buildTipOutput","name":"MemoLike._buildTipOutput","line":165,"end_line":169,"hash":"e2a602c0afa9730dee67497869c4d18ed75708f6503d68fb1f0c0ba596826b5a"},{"id":"func/MemoLike._notifyFeed","name":"MemoLike._notifyFeed","line":172,"end_line":181,"hash":"bfc0e888ee106e21ee73146e92ebd3e4ce41d2ed8595a1539f823dc6c8f31d06"},{"id":"func/MemoLike._incrementPostCount","name":"MemoLike._incrementPostCount","line":184,"end_line":190,"hash":"61ae383f836b64a7aeabaf0d04e50ac9a24b87f726d5d6afac5f5585cdc6031c"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -18,11 +18,10 @@
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { byteLength } = require('./utf8')
|
||||
const { hexToBytes } = require('./hex')
|
||||
const { buildTxidTextPayload } = require('./hex')
|
||||
|
||||
const MEMO_REPLY_PREFIX = '6d03'
|
||||
const MAX_REPLY_BYTES = 184
|
||||
const PARENT_TXID_BYTES = 32
|
||||
|
||||
class MemoReply extends MemoAction {
|
||||
static config = {
|
||||
@@ -58,7 +57,7 @@ class MemoReply extends MemoAction {
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
// Build the raw payload: parent txid bytes followed by UTF-8 message bytes.
|
||||
const raw = buildReplyPayload(parentTxid, message)
|
||||
const raw = buildTxidTextPayload(parentTxid, message, 'Parent txid')
|
||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix)
|
||||
|
||||
// Reflect the result on the injected thread once broadcast succeeds.
|
||||
@@ -80,22 +79,11 @@ class MemoReply extends MemoAction {
|
||||
}
|
||||
}
|
||||
|
||||
// Build the raw OP_RETURN message payload for a reply.
|
||||
// The protocol wire format is: <parent txid 32 bytes><reply text UTF-8 bytes>.
|
||||
function buildReplyPayload (parentTxid, message) {
|
||||
const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, 'Parent txid')
|
||||
const textBytes = new TextEncoder().encode(message)
|
||||
const raw = new Uint8Array(parentBytes.length + textBytes.length)
|
||||
raw.set(parentBytes, 0)
|
||||
raw.set(textBytes, parentBytes.length)
|
||||
return raw
|
||||
}
|
||||
|
||||
MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX
|
||||
MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
|
||||
|
||||
module.exports = MemoReply
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-08-26T12:18:17.625Z","module_hash":"3eb068f9f90f27c5d7acf2bb5a6c517d1085bcb379123ca2d7134d4fb48a092b","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"2d7b425e350b640caf6ca821146065e9c21fe56f1e8a9d8b1f6cab5504a523c1"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"},{"id":"func/buildReplyPayload","name":"buildReplyPayload","line":84,"end_line":91,"hash":"ef9ee77938593f1dbf2d168c20ea4f8dee0300f64f0fdb4f06a4ba647bb782d5"},{"id":"func/hexToBytes","name":"hexToBytes","line":94,"end_line":107,"hash":"29b401020452eabcb1b54634029d8015758b77b536be3d1ed508e9d560ac93b1"}]}
|
||||
// {"version":1,"tested_at":"2026-09-16T22:14:19.213Z","module_hash":"6c515a91afe94a6be8671a1541302446bb6b1015d03ce0e278644508ebd20090","functions":[{"id":"func/MemoReply.constructor","name":"MemoReply.constructor","line":36,"end_line":39,"hash":"23091c1b8f7847199bab3b54d8d81e9d8432c6da96138d72ac03fcf9426d542c"},{"id":"func/MemoReply.isTooLong","name":"MemoReply.isTooLong","line":42,"end_line":44,"hash":"2e867501d184010313ba9b27a6bb1e446df8f093514ee231b90ce77699ecbaf2"},{"id":"func/MemoReply.reply","name":"MemoReply.reply","line":48,"end_line":67,"hash":"00fb3598d5c3bdc3c6744d1316ad552364574fed105438af4aa011347f3306ce"},{"id":"func/MemoReply.reflect","name":"MemoReply.reflect","line":70,"end_line":79,"hash":"344e1bf304a4dfd475b02824b7ddbf555da0f3ec89f73b4f6009cf0bf097fb02"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -67,12 +67,11 @@ test('buildTxidTextPayload round-trips the canonical Memo wire format', async ()
|
||||
({ txid, text }) => {
|
||||
const bytes = buildTxidTextPayload(txid, text)
|
||||
if (bytes.length !== 32 + byteLength(text)) return false
|
||||
// The payload carries the txid's literal 32 bytes in order, followed by
|
||||
// the UTF-8 bytes of the value.
|
||||
const expectedTxidBytes = hexToBytes(txid, 32, 'Poll txid')
|
||||
for (let i = 0; i < 32; i++) {
|
||||
if (bytes[i] !== expectedTxidBytes[i]) return false
|
||||
}
|
||||
// The payload carries the txid in little-endian wire order, followed by
|
||||
// the UTF-8 bytes of the value. Reversing the wire bytes must recover
|
||||
// the 64-character display txid.
|
||||
const wire = Buffer.from(bytes.slice(0, 32))
|
||||
if (wire.reverse().toString('hex') !== txid) return false
|
||||
const storedText = Buffer.from(bytes.slice(32)).toString('utf8')
|
||||
return storedText === text
|
||||
},
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
Property tests for Memo txid wire encoding.
|
||||
|
||||
Unit tests pin the wire bytes of a few fixed txids. These properties pin the
|
||||
encoding invariants over broad random inputs:
|
||||
|
||||
- Round trip: reversing the wire bytes recovers the display bytes, and
|
||||
reversing twice is the identity.
|
||||
- Wire order: the encoded bytes are exactly the reverse of the display
|
||||
txid's bytes.
|
||||
- Payload shape: a txid-and-text payload starts with the wire txid and
|
||||
ends with the UTF-8 text, with no bytes added or dropped.
|
||||
- Determinism: the same txid always encodes to the same bytes.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const { txidToWireBytes, buildTxidTextPayload } = require('../../src/services/hex')
|
||||
|
||||
const HEX_CHARS = '0123456789abcdef'
|
||||
|
||||
function randomTxid (rng) {
|
||||
let out = ''
|
||||
for (let i = 0; i < 64; i++) {
|
||||
out += HEX_CHARS[Math.floor(rng() * HEX_CHARS.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function randomText (rng) {
|
||||
const alphabet = 'abc XYZ!\u00e9\u4e2d\ud83d\ude00'
|
||||
let out = ''
|
||||
const length = intGen(rng, 0, 24)()
|
||||
for (let i = 0; i < length; i++) {
|
||||
out += alphabet[Math.floor(rng() * alphabet.length)]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
test('txid wire bytes are the byte reverse of the display txid', async () => {
|
||||
const rng = seededRandom(20260916)
|
||||
|
||||
await forAll(
|
||||
() => randomTxid(rng),
|
||||
(txid) => {
|
||||
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
const expected = Buffer.from(txid, 'hex').reverse().toString('hex')
|
||||
return wire === expected
|
||||
},
|
||||
{ label: 'txidToWireBytes byte order' }
|
||||
)
|
||||
})
|
||||
|
||||
test('reversing wire bytes twice recovers the display txid', async () => {
|
||||
const rng = seededRandom(20260917)
|
||||
|
||||
await forAll(
|
||||
() => randomTxid(rng),
|
||||
(txid) => {
|
||||
const bytes = txidToWireBytes(txid)
|
||||
return Buffer.from(Buffer.from(bytes).reverse()).toString('hex') === txid
|
||||
},
|
||||
{ label: 'txidToWireBytes round trip' }
|
||||
)
|
||||
})
|
||||
|
||||
test('txid encoding is deterministic', async () => {
|
||||
const rng = seededRandom(20260918)
|
||||
|
||||
await forAll(
|
||||
() => randomTxid(rng),
|
||||
(txid) => {
|
||||
const first = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
const second = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
return first === second
|
||||
},
|
||||
{ label: 'txidToWireBytes determinism' }
|
||||
)
|
||||
})
|
||||
|
||||
test('payload embeds the wire txid followed by the UTF-8 text', async () => {
|
||||
const rng = seededRandom(20260919)
|
||||
|
||||
await forAll(
|
||||
() => ({ txid: randomTxid(rng), text: randomText(rng) }),
|
||||
({ txid, text }) => {
|
||||
const raw = buildTxidTextPayload(txid, text)
|
||||
const buf = Buffer.from(raw)
|
||||
const wire = Buffer.from(txidToWireBytes(txid)).toString('hex')
|
||||
const expectedText = Buffer.from(text, 'utf8')
|
||||
|
||||
if (buf.length !== 32 + expectedText.length) return false
|
||||
if (buf.subarray(0, 32).toString('hex') !== wire) return false
|
||||
return buf.subarray(32).equals(expectedText)
|
||||
},
|
||||
{ label: 'buildTxidTextPayload shape' }
|
||||
)
|
||||
})
|
||||
|
||||
test('a reply payload rejects an invalid txid with the parent label', () => {
|
||||
assert.throws(
|
||||
() => buildTxidTextPayload('not-a-txid', 'hi', 'Parent txid'),
|
||||
/Parent txid must be a 64-character hex string/
|
||||
)
|
||||
})
|
||||
@@ -11,7 +11,11 @@
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { hexToBytes, buildTxidTextPayload } = require('../../src/services/hex')
|
||||
const { hexToBytes, buildTxidTextPayload, txidToWireBytes } = require('../../src/services/hex')
|
||||
|
||||
// A non-palindromic txid so a missing byte reversal is observable.
|
||||
const DISPLAY_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const WIRE_HEX = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301'
|
||||
|
||||
test('hexToBytes decodes exactly 64 hex characters into 32 bytes', () => {
|
||||
const bytes = hexToBytes('ab'.repeat(32))
|
||||
@@ -61,3 +65,25 @@ test('buildTxidTextPayload prefixes the raw txid bytes', () => {
|
||||
assert.equal(buf[0], 0xab)
|
||||
assert.equal(buf.slice(32).toString('utf8'), 'hi')
|
||||
})
|
||||
|
||||
test('txidToWireBytes reverses the display txid into little-endian wire order', () => {
|
||||
const bytes = txidToWireBytes(DISPLAY_TXID)
|
||||
|
||||
assert.ok(bytes instanceof Uint8Array)
|
||||
assert.equal(Buffer.from(bytes).toString('hex'), WIRE_HEX)
|
||||
})
|
||||
|
||||
test('txidToWireBytes rejects an invalid txid', () => {
|
||||
assert.throws(
|
||||
() => txidToWireBytes('zz'.repeat(32)),
|
||||
/valid hex string/
|
||||
)
|
||||
})
|
||||
|
||||
test('buildTxidTextPayload embeds the txid in little-endian wire order', () => {
|
||||
const raw = buildTxidTextPayload(DISPLAY_TXID, 'hi')
|
||||
const buf = Buffer.from(raw)
|
||||
|
||||
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX)
|
||||
assert.equal(buf.slice(32).toString('utf8'), 'hi')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
Unit tests for the Memo like wire encoding.
|
||||
|
||||
A like action embeds the liked post's transaction txid as 32 raw bytes in
|
||||
little-endian wire order: the reverse of the 64-character display txid. The
|
||||
indexer reverses those bytes back into display order, so the client must
|
||||
reverse before embedding.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const POST_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const WIRE_HEX = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301'
|
||||
|
||||
function makeWallet () {
|
||||
return {
|
||||
walletInfo: { cashAddress: MY_ADDRESS },
|
||||
utxos: [{ txid: 'utxo', value: 100000 }],
|
||||
broadcasts: [],
|
||||
async getUtxos () {
|
||||
return this.utxos
|
||||
},
|
||||
async sendOpReturn (msg, prefix, bchOutput = []) {
|
||||
this.broadcasts.push({ msg, prefix, bchOutput })
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('like broadcasts the post txid in little-endian wire order', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await memoLike.like(POST_TXID)
|
||||
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, MemoLike.MEMO_LIKE_PREFIX)
|
||||
const raw = wallet.broadcasts[0].msg
|
||||
assert.equal(Buffer.from(raw).toString('hex'), WIRE_HEX)
|
||||
})
|
||||
|
||||
test('like reflects the post txid in display order on the feed store', async () => {
|
||||
const wallet = makeWallet()
|
||||
const added = []
|
||||
const feed = {
|
||||
addLike (like) {
|
||||
added.push(like)
|
||||
},
|
||||
posts: []
|
||||
}
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
await memoLike.like(POST_TXID)
|
||||
|
||||
assert.equal(added.length, 1)
|
||||
assert.equal(added[0].postTxid, POST_TXID)
|
||||
})
|
||||
|
||||
test('like rejects a caller without a wallet', async () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
await assert.rejects(() => memoLike.like(POST_TXID), /requires a wallet/)
|
||||
})
|
||||
|
||||
test('like rejects an invalid post txid before broadcasting', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like('not-a-txid'),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('like rejects a balance below the dust limit', async () => {
|
||||
const wallet = makeWallet()
|
||||
wallet.utxos = [{ txid: 'utxo', value: 100 }]
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like(POST_TXID),
|
||||
(err) => err.code === 'like_empty_balance'
|
||||
)
|
||||
})
|
||||
|
||||
test('like rejects a tip that exceeds the spendable balance', async () => {
|
||||
const wallet = makeWallet()
|
||||
wallet.utxos = [{ txid: 'utxo', value: 3000 }]
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like(POST_TXID, 4000, MY_ADDRESS),
|
||||
(err) => err.code === 'like_balance'
|
||||
)
|
||||
})
|
||||
|
||||
test('like requires an author address when a tip is present', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
() => memoLike.like(POST_TXID, 1000),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
})
|
||||
|
||||
test('_validateTipAmount rejects a non-integer or negative tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(() => memoLike._validateTipAmount(1.5), (err) => err.code === 'like_validation')
|
||||
assert.throws(() => memoLike._validateTipAmount(-1), (err) => err.code === 'like_validation')
|
||||
})
|
||||
|
||||
test('_validateTipAmount rejects a tip below the dust limit', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(() => memoLike._validateTipAmount(1), (err) => err.code === 'like_dust')
|
||||
})
|
||||
|
||||
test('_validateTipAmount rejects a tip above the maximum', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(
|
||||
() => memoLike._validateTipAmount(MemoLike.MAX_TIP_SATS + 1),
|
||||
(err) => err.code === 'like_maximum'
|
||||
)
|
||||
})
|
||||
|
||||
test('_validateTipAmount accepts zero and a valid tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.equal(memoLike._validateTipAmount(0), undefined)
|
||||
assert.equal(memoLike._validateTipAmount(1000), undefined)
|
||||
})
|
||||
|
||||
test('getSpendableSats sums the utxoStore bchUtxos shape', () => {
|
||||
const wallet = {
|
||||
utxos: { utxoStore: { bchUtxos: [{ satoshis: 1000 }, { amount: 500 }] } }
|
||||
}
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
assert.equal(memoLike.getSpendableSats(), 1500)
|
||||
})
|
||||
|
||||
test('getSpendableSats is zero without a wallet', () => {
|
||||
assert.equal(new MemoLike().getSpendableSats(), 0)
|
||||
})
|
||||
|
||||
test('like includes a tip output and reflects the tip on the feed', async () => {
|
||||
const wallet = makeWallet()
|
||||
const added = []
|
||||
const feed = {
|
||||
addLike (like) {
|
||||
added.push(like)
|
||||
},
|
||||
posts: [{ txid: POST_TXID, likeCount: 2 }]
|
||||
}
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
await memoLike.like(POST_TXID, 1000, MY_ADDRESS)
|
||||
|
||||
assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: MY_ADDRESS, amountSat: 1000 }])
|
||||
assert.equal(added[0].tipSats, 1000)
|
||||
assert.equal(feed.posts[0].likeCount, 3)
|
||||
})
|
||||
|
||||
test('_buildTipOutput omits the output for a zero tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.deepEqual(memoLike._buildTipOutput(0, MY_ADDRESS), [])
|
||||
})
|
||||
|
||||
test('validate reports ok for a valid post txid', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.deepEqual(memoLike.validate(POST_TXID), { ok: true })
|
||||
})
|
||||
|
||||
test('validateTip reports ok when the tip exactly matches the spendable balance', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.deepEqual(memoLike.validateTip(3000, 3000), { ok: true })
|
||||
})
|
||||
|
||||
test('_validateTipAmount accepts a tip exactly at the maximum', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.equal(memoLike._validateTipAmount(MemoLike.MAX_TIP_SATS), undefined)
|
||||
})
|
||||
|
||||
test('getSpendableSats treats a utxo without a value field as zero', () => {
|
||||
const wallet = { utxos: [{ satoshis: 1000 }, { txid: 'no-value' }] }
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
assert.equal(memoLike.getSpendableSats(), 1000)
|
||||
})
|
||||
|
||||
test('_requireTipAddress requires an address for a positive tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.throws(
|
||||
() => memoLike._requireTipAddress(1, ''),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
})
|
||||
|
||||
test('_requireTipAddress accepts a one-character address', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.equal(memoLike._requireTipAddress(1000, 'a'), undefined)
|
||||
})
|
||||
|
||||
test('_buildTipOutput builds the output for a positive tip', () => {
|
||||
const memoLike = new MemoLike()
|
||||
|
||||
assert.deepEqual(memoLike._buildTipOutput(1, MY_ADDRESS), [
|
||||
{ address: MY_ADDRESS, amountSat: 1 }
|
||||
])
|
||||
})
|
||||
|
||||
test('_incrementPostCount defaults a missing likeCount to zero', () => {
|
||||
const feed = { posts: [{ txid: POST_TXID }] }
|
||||
const memoLike = new MemoLike({ feed })
|
||||
|
||||
memoLike._incrementPostCount(POST_TXID)
|
||||
|
||||
assert.equal(feed.posts[0].likeCount, 1)
|
||||
})
|
||||
@@ -13,7 +13,8 @@ const assert = require('node:assert/strict')
|
||||
const MemoPollOption = require('../../src/services/memo-poll-option')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
// A non-palindromic txid so a missing byte reversal is observable.
|
||||
const POLL_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
|
||||
function makeWallet (address = MY_ADDRESS) {
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,8 @@ const assert = require('node:assert/strict')
|
||||
const MemoPollVote = require('../../src/services/memo-poll-vote')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const POLL_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
// A non-palindromic txid so a missing byte reversal is observable.
|
||||
const POLL_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
|
||||
function makeWallet (address = MY_ADDRESS) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Unit tests for the Memo reply wire encoding.
|
||||
|
||||
A reply action embeds the parent transaction txid as 32 raw bytes in
|
||||
little-endian wire order: the reverse of the 64-character display txid. The
|
||||
indexer reverses those bytes back into display order, so the client must
|
||||
reverse before embedding.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const MemoReply = require('../../src/services/memo-reply')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const PARENT_TXID = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const WIRE_HEX = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301'
|
||||
|
||||
function makeWallet () {
|
||||
return {
|
||||
walletInfo: { cashAddress: MY_ADDRESS },
|
||||
broadcasts: [],
|
||||
async getUtxos () {
|
||||
return []
|
||||
},
|
||||
async sendOpReturn (msg, prefix) {
|
||||
this.broadcasts.push({ msg, prefix })
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('reply broadcasts the parent txid in little-endian wire order', async () => {
|
||||
const wallet = makeWallet()
|
||||
const memoReply = new MemoReply({ wallet })
|
||||
|
||||
await memoReply.reply('hello memo', PARENT_TXID)
|
||||
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, MemoReply.MEMO_REPLY_PREFIX)
|
||||
const buf = Buffer.from(wallet.broadcasts[0].msg)
|
||||
assert.equal(buf.slice(0, 32).toString('hex'), WIRE_HEX)
|
||||
assert.equal(buf.slice(32).toString('utf8'), 'hello memo')
|
||||
})
|
||||
|
||||
test('isTooLong accepts a message exactly at the byte limit', () => {
|
||||
const memoReply = new MemoReply()
|
||||
|
||||
assert.equal(memoReply.isTooLong('a'.repeat(MemoReply.MAX_REPLY_BYTES)), false)
|
||||
})
|
||||
|
||||
test('isTooLong rejects a message over the byte limit', () => {
|
||||
const memoReply = new MemoReply()
|
||||
|
||||
assert.equal(memoReply.isTooLong('a'.repeat(MemoReply.MAX_REPLY_BYTES + 1)), true)
|
||||
})
|
||||
|
||||
test('reply reflects the parent txid in display order on the thread store', async () => {
|
||||
const wallet = makeWallet()
|
||||
const added = []
|
||||
const thread = {
|
||||
addReply (reply) {
|
||||
added.push(reply)
|
||||
}
|
||||
}
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
|
||||
await memoReply.reply('hello memo', PARENT_TXID)
|
||||
|
||||
assert.equal(added.length, 1)
|
||||
assert.equal(added[0].parentTxid, PARENT_TXID)
|
||||
})
|
||||
@@ -27,6 +27,7 @@ import ListMuted from '../../src/use-cases/list-muted.js'
|
||||
import GetPoll from '../../src/use-cases/get-poll.js'
|
||||
import GetPollOptions from '../../src/use-cases/get-poll-options.js'
|
||||
import GetPollVotes from '../../src/use-cases/get-poll-votes.js'
|
||||
import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
|
||||
@@ -204,6 +205,11 @@ async function loadFixture (world, name) {
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'db-with-reversed-txid-references') {
|
||||
await loadReversedTxidFixture(world)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'many-posts-with-replies') {
|
||||
await loadManyPostsWithReplies(world)
|
||||
return
|
||||
@@ -548,6 +554,37 @@ async function loadPollWithOptionsAndVote (world) {
|
||||
})
|
||||
}
|
||||
|
||||
async function loadReversedTxidFixture (world) {
|
||||
const displayPost = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const reversedPost = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301'
|
||||
const displayPoll = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'
|
||||
const reversedPoll = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100'
|
||||
const unknownPost = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
|
||||
await world.adapters.level.postsDb.put(displayPost, {
|
||||
addr: 'bitcoincash:qaddr-a', text: 'referenced post', seen: 1, blockHeight: 600100
|
||||
})
|
||||
await world.adapters.level.pollsDb.put(displayPoll, {
|
||||
addr: 'bitcoincash:qaddr-a', pollType: 1, optionCount: 2, question: 'which?', seen: 2, blockHeight: 600101
|
||||
})
|
||||
|
||||
await world.adapters.level.likesDb.put('like-1', { addr: 'bitcoincash:liker-1', postTxid: reversedPost, seen: 3, blockHeight: 600200 })
|
||||
await world.adapters.level.likesDb.put('like-2', { addr: 'bitcoincash:liker-2', postTxid: displayPost, seen: 4, blockHeight: 600201 })
|
||||
await world.adapters.level.likesDb.put('like-3', { addr: 'bitcoincash:liker-3', postTxid: unknownPost, seen: 5, blockHeight: 600202 })
|
||||
await world.adapters.level.postLikesDb.put(`${reversedPost}:like-1`, { postTxid: reversedPost, txid: 'like-1' })
|
||||
await world.adapters.level.postLikesDb.put(`${displayPost}:like-2`, { postTxid: displayPost, txid: 'like-2' })
|
||||
|
||||
await world.adapters.level.postParentsDb.put('reply-1', { parentTxid: reversedPost, childTxid: 'reply-1', blockHeight: 600050 })
|
||||
await world.adapters.level.postParentsDb.put('reply-2', { parentTxid: displayPost, childTxid: 'reply-2', blockHeight: 600051 })
|
||||
await world.adapters.level.postChildrenDb.put(`${reversedPost}:reply-1`, { parentTxid: reversedPost, childTxid: 'reply-1' })
|
||||
await world.adapters.level.postChildrenDb.put(`${displayPost}:reply-2`, { parentTxid: displayPost, childTxid: 'reply-2' })
|
||||
|
||||
await world.adapters.level.pollOptionsDb.put('option-1', { addr: 'bitcoincash:qaddr-a', pollTxid: reversedPoll, option: 'yes', seen: 6, blockHeight: 600300 })
|
||||
await world.adapters.level.pollOptionsDb.put('option-2', { addr: 'bitcoincash:qaddr-b', pollTxid: displayPoll, option: 'no', seen: 7, blockHeight: 600301 })
|
||||
await world.adapters.level.pollVotesDb.put('vote-1', { addr: 'bitcoincash:qaddr-a', pollTxid: reversedPoll, comment: 'yes', seen: 8, blockHeight: 600302 })
|
||||
await world.adapters.level.pollVotesDb.put('vote-2', { addr: 'bitcoincash:qaddr-b', pollTxid: displayPoll, comment: 'no', seen: 9, blockHeight: 600303 })
|
||||
}
|
||||
|
||||
async function loadMutes (world) {
|
||||
const muter1 = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const muter2 = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
|
||||
@@ -592,6 +629,13 @@ const handlers = [
|
||||
// World is already created with all stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'db instance with txid repair stores',
|
||||
pattern: /^a psf-memo-db instance with posts, likes, postLikes, postParents, postChildren, polls, pollOptions, and pollVotes stores$/,
|
||||
async run () {
|
||||
// World is already created with all stores.
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'load fixture',
|
||||
pattern: /^the fixture "(.+)" is loaded into the posts (?:store|and likes stores)$/,
|
||||
@@ -606,6 +650,13 @@ const handlers = [
|
||||
await loadFixture(world, m[1])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'load bare fixture',
|
||||
pattern: /^the fixture "(.+)" is loaded$/,
|
||||
async run (m, example, world) {
|
||||
await loadFixture(world, m[1])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'run backfill utility',
|
||||
pattern: /^the backfill utility is run$/,
|
||||
@@ -620,6 +671,20 @@ const handlers = [
|
||||
await backfillIndexes(world)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'run txid repair utility',
|
||||
pattern: /^the txid repair utility is run$/,
|
||||
async run (m, example, world) {
|
||||
await repairTxidEncoding(world.adapters.level)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'run txid repair utility again',
|
||||
pattern: /^the txid repair utility is run again$/,
|
||||
async run (m, example, world) {
|
||||
await repairTxidEncoding(world.adapters.level)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'request recent posts',
|
||||
pattern: /^the client requests \/posts\/recent with limit (<limit>) and offset (<offset>)$/,
|
||||
@@ -859,7 +924,7 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'postLikes contains entry',
|
||||
pattern: /^the postLikes store contains (<count>) entry whose key starts with (<postTxid>) and ends with (<likeTxid>)$/,
|
||||
pattern: /^the postLikes store contains (<[A-Za-z0-9_]+>|[0-9]+) entry whose key starts with (<[A-Za-z0-9_]+>) and ends with (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const expectedCount = parseInt(resolveParam(m[1], example), 10)
|
||||
const postTxid = resolveParam(m[2], example)
|
||||
@@ -873,6 +938,70 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'postChildren contains entry',
|
||||
pattern: /^the postChildren store contains (<[A-Za-z0-9_]+>|[0-9]+) entry whose key starts with (<[A-Za-z0-9_]+>) and ends with (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const expectedCount = parseInt(resolveParam(m[1], example), 10)
|
||||
const postTxid = resolveParam(m[2], example)
|
||||
const replyTxid = resolveParam(m[3], example)
|
||||
let count = 0
|
||||
for await (const [key] of world.adapters.level.postChildrenDb.iterator()) {
|
||||
if (key.startsWith(`${postTxid}:`) && key.endsWith(`:${replyTxid}`)) count++
|
||||
}
|
||||
if (count !== expectedCount) {
|
||||
throw new Error(`Expected ${expectedCount} postChildren entry/entries for ${postTxid}/${replyTxid}, got ${count}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'likes store maps like to post',
|
||||
pattern: /^the likes store maps like (<[A-Za-z0-9_]+>) to post (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const likeTxid = resolveParam(m[1], example)
|
||||
const postTxid = resolveParam(m[2], example)
|
||||
const like = await world.adapters.level.likesDb.get(likeTxid)
|
||||
if (like.postTxid !== postTxid) {
|
||||
throw new Error(`Expected like ${likeTxid} to map to post ${postTxid}, got ${like.postTxid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'postParents store maps reply to parent',
|
||||
pattern: /^the postParents store maps reply (<[A-Za-z0-9_]+>) to parent (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const replyTxid = resolveParam(m[1], example)
|
||||
const postTxid = resolveParam(m[2], example)
|
||||
const reply = await world.adapters.level.postParentsDb.get(replyTxid)
|
||||
if (reply.parentTxid !== postTxid) {
|
||||
throw new Error(`Expected reply ${replyTxid} to map to parent ${postTxid}, got ${reply.parentTxid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'pollOptions store maps option to poll',
|
||||
pattern: /^the pollOptions store maps option (<[A-Za-z0-9_]+>) to poll (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const optionTxid = resolveParam(m[1], example)
|
||||
const pollTxid = resolveParam(m[2], example)
|
||||
const option = await world.adapters.level.pollOptionsDb.get(optionTxid)
|
||||
if (option.pollTxid !== pollTxid) {
|
||||
throw new Error(`Expected poll option ${optionTxid} to map to poll ${pollTxid}, got ${option.pollTxid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'pollVotes store maps vote to poll',
|
||||
pattern: /^the pollVotes store maps vote (<[A-Za-z0-9_]+>) to poll (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const voteTxid = resolveParam(m[1], example)
|
||||
const pollTxid = resolveParam(m[2], example)
|
||||
const vote = await world.adapters.level.pollVotesDb.get(voteTxid)
|
||||
if (vote.pollTxid !== pollTxid) {
|
||||
throw new Error(`Expected poll vote ${voteTxid} to map to poll ${pollTxid}, got ${vote.pollTxid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'db instance with follows store',
|
||||
pattern: /^a psf-memo-db instance with a follows store$/,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-09-16T22:24:03.359920055Z","feature_name":"Repair txid encoding","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/repair-txid-encoding.feature","background_hash":"102ec6cf98fcd7e4ef6107baa6284bd1b425d2a2e9716f86bbff8aa3ecf39273","implementation_hash":"unknown","scenarios":[{"index":2,"name":"Repair txid encoding - 3 the repair utility corrects reversed poll option and vote references","scenario_hash":"87dcb9d331d2293c81385b9fa3e26b445626a94f2780b50d3dfa36264d062901","mutation_count":3,"result":{"Total":3,"Killed":3,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:24:03.359920055Z"},{"index":3,"name":"Repair txid encoding - 4 the repair utility leaves correctly-encoded references unchanged","scenario_hash":"bd644eb959c765fa80d54b8d4bc2a413b87e5d125f3491cddb129f9754846e96","mutation_count":6,"result":{"Total":6,"Killed":6,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:24:03.359920055Z"},{"index":4,"name":"Repair txid encoding - 5 the repair utility leaves an unknown reference unchanged","scenario_hash":"e8fcfddce1ae48596f7ef21e6de50d7962fea6eb2a37313ce69a1e22c2a08aaa","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T22:24:03.359920055Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Repair txid encoding - 1, Repair txid encoding - 2, Repair txid encoding - 3, Repair txid encoding - 4, Repair txid encoding - 5, Repair txid encoding - 6
|
||||
#
|
||||
# Older psf-memo-client broadcasts embedded a referenced txid in big-endian
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
Library to repair byte-reversed txid references in a psf-memo-db.
|
||||
|
||||
Older psf-memo-client broadcasts embedded a referenced txid in big-endian
|
||||
display order. The indexer expected little-endian wire order, so it reversed
|
||||
those bytes and stored a reference that is the reverse of the display txid.
|
||||
This library rewrites a reversed reference to display order and rebuilds the
|
||||
affected secondary index.
|
||||
|
||||
It leaves records whose reference already points at a known target
|
||||
untouched, leaves references whose target is unknown in either byte order
|
||||
untouched, and is idempotent: a second run makes no changes.
|
||||
|
||||
The LevelDB handles are injected so the repair logic stays testable and free
|
||||
of file-system concerns; the CLI wrapper in util/txid opens the real stores.
|
||||
*/
|
||||
|
||||
// Reverse the byte order of a 64-character display txid. Returns null for any
|
||||
// value that is not a 64-character hex txid.
|
||||
export function reverseTxid (txid) {
|
||||
if (typeof txid !== 'string' || !/^[0-9a-fA-F]{64}$/.test(txid)) return null
|
||||
return Buffer.from(txid, 'hex').reverse().toString('hex')
|
||||
}
|
||||
|
||||
// True when a store has a record at the given key.
|
||||
async function hasRecord (db, key) {
|
||||
try {
|
||||
await db.get(key)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// Choose the reference in display order. Keep the stored reference when its
|
||||
// target exists. Otherwise use the reversed reference when that target
|
||||
// exists. Otherwise return the stored reference unchanged.
|
||||
export async function correctReference (targetDb, reference) {
|
||||
if (await hasRecord(targetDb, reference)) return reference
|
||||
const reversed = reverseTxid(reference)
|
||||
if (reversed && await hasRecord(targetDb, reversed)) return reversed
|
||||
return reference
|
||||
}
|
||||
|
||||
// Repair one referencing store in place. Every record whose reference can be
|
||||
// corrected is rewritten; an optional secondary index rebuilds its entry for
|
||||
// each corrected record. Returns the number of records corrected.
|
||||
async function repairStore ({ sourceDb, referenceField, targetDb, index }) {
|
||||
let correctedCount = 0
|
||||
|
||||
for await (const [recordTxid, record] of sourceDb.iterator()) {
|
||||
const reference = record[referenceField]
|
||||
if (!reference) continue
|
||||
|
||||
const corrected = await correctReference(targetDb, reference)
|
||||
if (corrected === reference) continue
|
||||
|
||||
await sourceDb.put(recordTxid, { ...record, [referenceField]: corrected })
|
||||
|
||||
if (index) {
|
||||
await index.db.del(index.key(reference, recordTxid))
|
||||
await index.db.put(index.key(corrected, recordTxid), index.value(record, corrected, recordTxid))
|
||||
}
|
||||
|
||||
correctedCount++
|
||||
}
|
||||
|
||||
return correctedCount
|
||||
}
|
||||
|
||||
// A secondary index keyed as <target txid>:<record txid>; both the postLikes
|
||||
// and postChildren indexes use this shape.
|
||||
function txidIndex (db, value) {
|
||||
return {
|
||||
db,
|
||||
key: (targetTxid, recordTxid) => `${targetTxid}:${recordTxid}`,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
// Repair likes, replies, poll options, and poll votes in place. Returns a
|
||||
// summary of how many records of each kind were corrected.
|
||||
export async function repairTxidEncoding (level) {
|
||||
const likes = await repairStore({
|
||||
sourceDb: level.likesDb,
|
||||
referenceField: 'postTxid',
|
||||
targetDb: level.postsDb,
|
||||
index: txidIndex(level.postLikesDb, (record, postTxid, likeTxid) => ({ postTxid, txid: likeTxid }))
|
||||
})
|
||||
|
||||
const replies = await repairStore({
|
||||
sourceDb: level.postParentsDb,
|
||||
referenceField: 'parentTxid',
|
||||
targetDb: level.postsDb,
|
||||
index: txidIndex(level.postChildrenDb, (record, parentTxid) => ({ ...record, parentTxid }))
|
||||
})
|
||||
|
||||
const pollOptions = await repairStore({
|
||||
sourceDb: level.pollOptionsDb,
|
||||
referenceField: 'pollTxid',
|
||||
targetDb: level.pollsDb
|
||||
})
|
||||
|
||||
const pollVotes = await repairStore({
|
||||
sourceDb: level.pollVotesDb,
|
||||
referenceField: 'pollTxid',
|
||||
targetDb: level.pollsDb
|
||||
})
|
||||
|
||||
return { likes, replies, pollOptions, pollVotes }
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-16T22:08:39.973Z","module_hash":"503f8556da96560869090037a52d4e6f25755c56115514d69000bb766c3a34b1","functions":[{"id":"func/reverseTxid","name":"reverseTxid","line":20,"end_line":23,"hash":"34ca3f58033b0a4e34f613eca6dbb92874930d818b4258dd1fc3983dc60f5c59"},{"id":"func/hasRecord","name":"hasRecord","line":26,"end_line":34,"hash":"06580e61d91df680470d1702724aa084dad7f830a3d4be251d4bed430c893008"},{"id":"func/correctReference","name":"correctReference","line":39,"end_line":44,"hash":"d21e349238201c186d0c1216290e1c3bc4c357e42cd1bbe12273ae691f33ff32"},{"id":"func/repairStore","name":"repairStore","line":49,"end_line":70,"hash":"4a03f168d7c9ee3bbcd2dced4cca2903ef27b3d1b5616e77ff8b92302e275ab8"},{"id":"func/txidIndex","name":"txidIndex","line":74,"end_line":80,"hash":"2193de97502a9d50cef2cd9077a29a2bd141562b4a52b6f2b234c16f42e98606"},{"id":"func/repairTxidEncoding","name":"repairTxidEncoding","line":84,"end_line":112,"hash":"b1a18916b2681e83b59b1b14e1727a46d6780f47b8efdcd5b7726d2452a89467"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
Property tests for the txid encoding repair library.
|
||||
|
||||
Unit tests probe the repair at a fixed fixture. These properties pin the
|
||||
invariants over broad random inputs:
|
||||
|
||||
- Selection: correctReference prefers the stored reference when its target
|
||||
exists, falls back to the reversed reference when only that target
|
||||
exists, and leaves an unknown reference unchanged.
|
||||
- Involution: reversing a txid twice returns the original display txid.
|
||||
- Idempotence: a second repair run changes nothing.
|
||||
- Conservation: repair never adds or drops primary records.
|
||||
- Resolution: after repair every reference points at an existing target
|
||||
when either byte order had one.
|
||||
*/
|
||||
|
||||
import test from 'node:test'
|
||||
|
||||
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
|
||||
import {
|
||||
reverseTxid,
|
||||
correctReference,
|
||||
repairTxidEncoding
|
||||
} from '../../src/lib/repair-txid-encoding.js'
|
||||
import { makeLevel } from '../support/level-double.js'
|
||||
|
||||
const rng = seededRandom(20260916)
|
||||
|
||||
// Snapshot every store as a stable string so a second run can be compared.
|
||||
function snapshot (level) {
|
||||
const stores = {}
|
||||
for (const [name, db] of Object.entries(level)) {
|
||||
stores[name] = [...db.map.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
}
|
||||
return JSON.stringify(stores)
|
||||
}
|
||||
|
||||
// A reference that is the display target, its reverse, or an unrelated txid.
|
||||
function referenceTo (rng, targets, unknown) {
|
||||
if (targets.length === 0) return unknown
|
||||
const roll = rng()
|
||||
const target = targets[intGen(rng, 0, targets.length - 1)()]
|
||||
if (roll < 1 / 3) return target
|
||||
if (roll < 2 / 3) return reverseTxid(target)
|
||||
return unknown
|
||||
}
|
||||
|
||||
// Build a random level with display posts/polls and a mix of correct,
|
||||
// reversed, and unknown references in each primary store. FakeDb.put mutates
|
||||
// synchronously, so this builder is synchronous for the property harness.
|
||||
function buildFixture () {
|
||||
const level = makeLevel()
|
||||
const postTxids = []
|
||||
const pollTxids = []
|
||||
|
||||
const postCount = intGen(rng, 0, 5)()
|
||||
for (let i = 0; i < postCount; i++) {
|
||||
const txid = txidGen(rng)
|
||||
postTxids.push(txid)
|
||||
level.postsDb.put(txid, { addr: `addr-${i}`, text: 'post', blockHeight: i })
|
||||
}
|
||||
|
||||
const pollCount = intGen(rng, 0, 4)()
|
||||
for (let i = 0; i < pollCount; i++) {
|
||||
const txid = txidGen(rng)
|
||||
pollTxids.push(txid)
|
||||
level.pollsDb.put(txid, { question: 'q', pollType: 1, optionCount: 2, blockHeight: i })
|
||||
}
|
||||
|
||||
const likeCount = intGen(rng, 0, 6)()
|
||||
for (let i = 0; i < likeCount; i++) {
|
||||
const likeTxid = `like-${i}`
|
||||
const postTxid = referenceTo(rng, postTxids, txidGen(rng))
|
||||
level.likesDb.put(likeTxid, { postTxid, addr: 'addr', blockHeight: i })
|
||||
level.postLikesDb.put(`${postTxid}:${likeTxid}`, { postTxid, txid: likeTxid })
|
||||
}
|
||||
|
||||
const replyCount = intGen(rng, 0, 6)()
|
||||
for (let i = 0; i < replyCount; i++) {
|
||||
const replyTxid = `reply-${i}`
|
||||
const parentTxid = referenceTo(rng, postTxids, txidGen(rng))
|
||||
level.postParentsDb.put(replyTxid, { parentTxid, childTxid: replyTxid, blockHeight: i })
|
||||
level.postChildrenDb.put(`${parentTxid}:${replyTxid}`, { parentTxid, childTxid: replyTxid })
|
||||
}
|
||||
|
||||
const optionCount = intGen(rng, 0, 5)()
|
||||
for (let i = 0; i < optionCount; i++) {
|
||||
const pollTxid = referenceTo(rng, pollTxids, txidGen(rng))
|
||||
level.pollOptionsDb.put(`option-${i}`, { pollTxid, option: `opt-${i}`, blockHeight: i })
|
||||
}
|
||||
|
||||
const voteCount = intGen(rng, 0, 5)()
|
||||
for (let i = 0; i < voteCount; i++) {
|
||||
const pollTxid = referenceTo(rng, pollTxids, txidGen(rng))
|
||||
level.pollVotesDb.put(`vote-${i}`, { pollTxid, comment: `vote-${i}`, blockHeight: i })
|
||||
}
|
||||
|
||||
return { level, postTxids, pollTxids }
|
||||
}
|
||||
|
||||
function primaryCounts (level) {
|
||||
return {
|
||||
likes: level.likesDb.map.size,
|
||||
replies: level.postParentsDb.map.size,
|
||||
pollOptions: level.pollOptionsDb.map.size,
|
||||
pollVotes: level.pollVotesDb.map.size
|
||||
}
|
||||
}
|
||||
|
||||
// True when every reference in a store is either a known target or unknown in
|
||||
// both byte orders. Repair must not leave a resolvable reference unresolved.
|
||||
function allResolvableOrUnknown (db, targetDb, field) {
|
||||
for (const record of db.map.values()) {
|
||||
const reference = record[field]
|
||||
if (targetDb.map.has(reference)) continue
|
||||
if (targetDb.map.has(reverseTxid(reference))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
test('reverseTxid is an involution on valid txids', async () => {
|
||||
await forAll(
|
||||
() => txidGen(rng),
|
||||
(txid) => reverseTxid(reverseTxid(txid)) === txid,
|
||||
{ label: 'reverseTxid involution' }
|
||||
)
|
||||
})
|
||||
|
||||
test('correctReference selects an existing target in either byte order', async () => {
|
||||
await forAll(
|
||||
() => txidGen(rng),
|
||||
async (txid) => {
|
||||
const display = makeLevel()
|
||||
await display.postsDb.put(txid, {})
|
||||
if (await correctReference(display.postsDb, txid) !== txid) return false
|
||||
|
||||
const reversed = makeLevel()
|
||||
await reversed.postsDb.put(txid, {})
|
||||
if (await correctReference(reversed.postsDb, reverseTxid(txid)) !== txid) return false
|
||||
|
||||
const unknown = makeLevel()
|
||||
const missing = txid
|
||||
if (await correctReference(unknown.postsDb, missing) !== missing) return false
|
||||
|
||||
return true
|
||||
},
|
||||
{ label: 'correctReference selection' }
|
||||
)
|
||||
})
|
||||
|
||||
test('repairTxidEncoding is idempotent and conserves records', async () => {
|
||||
await forAll(
|
||||
() => buildFixture(),
|
||||
async ({ level }) => {
|
||||
const before = primaryCounts(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
const afterFirst = snapshot(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
if (snapshot(level) !== afterFirst) return false
|
||||
|
||||
const after = primaryCounts(level)
|
||||
return JSON.stringify(before) === JSON.stringify(after)
|
||||
},
|
||||
{ label: 'repairTxidEncoding idempotence' }
|
||||
)
|
||||
})
|
||||
|
||||
test('repairTxidEncoding resolves every resolvable reference', async () => {
|
||||
await forAll(
|
||||
() => buildFixture(),
|
||||
async ({ level }) => {
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
return allResolvableOrUnknown(level.likesDb, level.postsDb, 'postTxid') &&
|
||||
allResolvableOrUnknown(level.postParentsDb, level.postsDb, 'parentTxid') &&
|
||||
allResolvableOrUnknown(level.pollOptionsDb, level.pollsDb, 'pollTxid') &&
|
||||
allResolvableOrUnknown(level.pollVotesDb, level.pollsDb, 'pollTxid')
|
||||
},
|
||||
{ label: 'repairTxidEncoding resolution' }
|
||||
)
|
||||
})
|
||||
|
||||
test('repairTxidEncoding rebuilds the secondary indexes for corrected records', async () => {
|
||||
await forAll(
|
||||
() => buildFixture(),
|
||||
async ({ level }) => {
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
for (const [likeTxid, like] of level.likesDb.map) {
|
||||
if (!level.postLikesDb.map.has(`${like.postTxid}:${likeTxid}`)) return false
|
||||
}
|
||||
for (const [replyTxid, reply] of level.postParentsDb.map) {
|
||||
if (!level.postChildrenDb.map.has(`${reply.parentTxid}:${replyTxid}`)) return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ label: 'repairTxidEncoding index rebuild' }
|
||||
)
|
||||
})
|
||||
|
||||
test('an unknown reference is left unchanged by repair', async () => {
|
||||
await forAll(
|
||||
() => txidGen(rng),
|
||||
async (unknown) => {
|
||||
const level = makeLevel()
|
||||
await level.likesDb.put('like-unknown', { postTxid: unknown, addr: 'addr' })
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
return (await level.likesDb.get('like-unknown')).postTxid === unknown
|
||||
},
|
||||
{ label: 'repairTxidEncoding unknown reference' }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
In-memory LevelDB double shared by the txid repair unit and property tests.
|
||||
|
||||
FakeDb implements just the get/put/del/iterator surface the repair library
|
||||
uses, plus keys() for assertions. makeLevel() wires the same store names the
|
||||
real psf-memo-db exposes so the repair library can be driven without opening
|
||||
any real LevelDB files.
|
||||
*/
|
||||
|
||||
export class FakeDb {
|
||||
constructor () {
|
||||
this.map = new Map()
|
||||
}
|
||||
|
||||
async get (key) {
|
||||
if (!this.map.has(key)) {
|
||||
const err = new Error(`not found: ${key}`)
|
||||
err.notFound = true
|
||||
throw err
|
||||
}
|
||||
return this.map.get(key)
|
||||
}
|
||||
|
||||
async put (key, value) {
|
||||
this.map.set(key, value)
|
||||
}
|
||||
|
||||
async del (key) {
|
||||
this.map.delete(key)
|
||||
}
|
||||
|
||||
async * iterator () {
|
||||
for (const [key, value] of this.map) {
|
||||
yield [key, value]
|
||||
}
|
||||
}
|
||||
|
||||
keys () {
|
||||
return [...this.map.keys()].sort()
|
||||
}
|
||||
}
|
||||
|
||||
export function makeLevel () {
|
||||
return {
|
||||
postsDb: new FakeDb(),
|
||||
pollsDb: new FakeDb(),
|
||||
likesDb: new FakeDb(),
|
||||
postLikesDb: new FakeDb(),
|
||||
postParentsDb: new FakeDb(),
|
||||
postChildrenDb: new FakeDb(),
|
||||
pollOptionsDb: new FakeDb(),
|
||||
pollVotesDb: new FakeDb()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
Unit tests for the txid encoding repair library.
|
||||
|
||||
Older psf-memo-client broadcasts embedded a referenced txid in big-endian
|
||||
display order. The indexer expected little-endian wire order, so it stored a
|
||||
byte-reversed reference. The repair library rewrites a reversed reference to
|
||||
display order and rebuilds the affected secondary index. It leaves
|
||||
correctly-encoded records untouched, leaves references whose target is
|
||||
unknown in either byte order untouched, and is idempotent.
|
||||
*/
|
||||
|
||||
import { assert } from 'chai'
|
||||
import {
|
||||
reverseTxid,
|
||||
correctReference,
|
||||
repairTxidEncoding
|
||||
} from '../../../src/lib/repair-txid-encoding.js'
|
||||
import { makeLevel } from '../../support/level-double.js'
|
||||
|
||||
const DISPLAY_POST = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const REVERSED_POST = 'efcdab8967452301efcdab8967452301efcdab8967452301efcdab8967452301'
|
||||
const DISPLAY_POLL = '00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff'
|
||||
const REVERSED_POLL = 'ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100'
|
||||
const UNKNOWN = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
|
||||
function loadReversedFixture (level) {
|
||||
level.postsDb.put(DISPLAY_POST, { addr: 'addr-a', text: 'post', blockHeight: 1 })
|
||||
level.pollsDb.put(DISPLAY_POLL, { question: 'q', pollType: 1, optionCount: 2, blockHeight: 2 })
|
||||
|
||||
level.likesDb.put('like-1', { postTxid: REVERSED_POST, addr: 'addr-b', blockHeight: 3 })
|
||||
level.likesDb.put('like-2', { postTxid: DISPLAY_POST, addr: 'addr-c', blockHeight: 4 })
|
||||
level.likesDb.put('like-3', { postTxid: UNKNOWN, addr: 'addr-d', blockHeight: 5 })
|
||||
level.postLikesDb.put(`${REVERSED_POST}:like-1`, { postTxid: REVERSED_POST, txid: 'like-1' })
|
||||
level.postLikesDb.put(`${DISPLAY_POST}:like-2`, { postTxid: DISPLAY_POST, txid: 'like-2' })
|
||||
|
||||
level.postParentsDb.put('reply-1', { parentTxid: REVERSED_POST, childTxid: 'reply-1', blockHeight: 6 })
|
||||
level.postParentsDb.put('reply-2', { parentTxid: DISPLAY_POST, childTxid: 'reply-2', blockHeight: 7 })
|
||||
level.postChildrenDb.put(`${REVERSED_POST}:reply-1`, { parentTxid: REVERSED_POST, childTxid: 'reply-1' })
|
||||
level.postChildrenDb.put(`${DISPLAY_POST}:reply-2`, { parentTxid: DISPLAY_POST, childTxid: 'reply-2' })
|
||||
|
||||
level.pollOptionsDb.put('option-1', { pollTxid: REVERSED_POLL, option: 'yes', blockHeight: 8 })
|
||||
level.pollOptionsDb.put('option-2', { pollTxid: DISPLAY_POLL, option: 'no', blockHeight: 9 })
|
||||
level.pollVotesDb.put('vote-1', { pollTxid: REVERSED_POLL, comment: 'yes', blockHeight: 10 })
|
||||
level.pollVotesDb.put('vote-2', { pollTxid: DISPLAY_POLL, comment: 'no', blockHeight: 11 })
|
||||
}
|
||||
|
||||
describe('#RepairTxidEncoding', () => {
|
||||
describe('reverseTxid', () => {
|
||||
it('reverses the byte order of a display txid', () => {
|
||||
assert.equal(reverseTxid(DISPLAY_POST), REVERSED_POST)
|
||||
})
|
||||
|
||||
it('returns null for a value that is not a 64-character hex txid', () => {
|
||||
assert.isNull(reverseTxid('not-a-txid'))
|
||||
assert.isNull(reverseTxid(null))
|
||||
assert.isNull(reverseTxid('zz'.repeat(32)))
|
||||
})
|
||||
})
|
||||
|
||||
describe('correctReference', () => {
|
||||
it('keeps a reference whose target exists in display order', async () => {
|
||||
const level = makeLevel()
|
||||
level.postsDb.put(DISPLAY_POST, {})
|
||||
assert.equal(await correctReference(level.postsDb, DISPLAY_POST), DISPLAY_POST)
|
||||
})
|
||||
|
||||
it('reverses a reference whose reversed form is the existing target', async () => {
|
||||
const level = makeLevel()
|
||||
level.postsDb.put(DISPLAY_POST, {})
|
||||
assert.equal(await correctReference(level.postsDb, REVERSED_POST), DISPLAY_POST)
|
||||
})
|
||||
|
||||
it('keeps an unknown reference unchanged', async () => {
|
||||
const level = makeLevel()
|
||||
assert.equal(await correctReference(level.postsDb, UNKNOWN), UNKNOWN)
|
||||
})
|
||||
|
||||
it('propagates an unexpected store error', async () => {
|
||||
const targetDb = {
|
||||
async get () {
|
||||
const err = new Error('io failure')
|
||||
err.code = 'EIO'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
let caught = null
|
||||
try {
|
||||
await correctReference(targetDb, DISPLAY_POST)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
|
||||
assert.isNotNull(caught)
|
||||
assert.equal(caught.code, 'EIO')
|
||||
})
|
||||
})
|
||||
|
||||
describe('repairTxidEncoding', () => {
|
||||
it('corrects a reversed like reference and rebuilds postLikes', async () => {
|
||||
const level = makeLevel()
|
||||
loadReversedFixture(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
assert.equal((await level.likesDb.get('like-1')).postTxid, DISPLAY_POST)
|
||||
assert.include(level.postLikesDb.keys(), `${DISPLAY_POST}:like-1`)
|
||||
assert.notInclude(level.postLikesDb.keys(), `${REVERSED_POST}:like-1`)
|
||||
})
|
||||
|
||||
it('corrects a reversed reply reference and rebuilds postChildren', async () => {
|
||||
const level = makeLevel()
|
||||
loadReversedFixture(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
assert.equal((await level.postParentsDb.get('reply-1')).parentTxid, DISPLAY_POST)
|
||||
assert.include(level.postChildrenDb.keys(), `${DISPLAY_POST}:reply-1`)
|
||||
assert.notInclude(level.postChildrenDb.keys(), `${REVERSED_POST}:reply-1`)
|
||||
})
|
||||
|
||||
it('corrects reversed poll option and vote references', async () => {
|
||||
const level = makeLevel()
|
||||
loadReversedFixture(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
assert.equal((await level.pollOptionsDb.get('option-1')).pollTxid, DISPLAY_POLL)
|
||||
assert.equal((await level.pollVotesDb.get('vote-1')).pollTxid, DISPLAY_POLL)
|
||||
})
|
||||
|
||||
it('leaves correctly-encoded references unchanged', async () => {
|
||||
const level = makeLevel()
|
||||
loadReversedFixture(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
assert.equal((await level.likesDb.get('like-2')).postTxid, DISPLAY_POST)
|
||||
assert.equal((await level.postParentsDb.get('reply-2')).parentTxid, DISPLAY_POST)
|
||||
assert.equal((await level.pollOptionsDb.get('option-2')).pollTxid, DISPLAY_POLL)
|
||||
assert.equal((await level.pollVotesDb.get('vote-2')).pollTxid, DISPLAY_POLL)
|
||||
})
|
||||
|
||||
it('leaves an unknown reference unchanged', async () => {
|
||||
const level = makeLevel()
|
||||
loadReversedFixture(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
assert.equal((await level.likesDb.get('like-3')).postTxid, UNKNOWN)
|
||||
})
|
||||
|
||||
it('skips records without a reference field', async () => {
|
||||
const level = makeLevel()
|
||||
level.likesDb.put('like-empty', { addr: 'addr' })
|
||||
level.postParentsDb.put('reply-empty', { childTxid: 'reply-empty' })
|
||||
level.pollOptionsDb.put('option-empty', { option: 'x' })
|
||||
level.pollVotesDb.put('vote-empty', { comment: 'x' })
|
||||
|
||||
const summary = await repairTxidEncoding(level)
|
||||
|
||||
assert.deepEqual(summary, { likes: 0, replies: 0, pollOptions: 0, pollVotes: 0 })
|
||||
})
|
||||
|
||||
it('is idempotent', async () => {
|
||||
const level = makeLevel()
|
||||
loadReversedFixture(level)
|
||||
|
||||
await repairTxidEncoding(level)
|
||||
await repairTxidEncoding(level)
|
||||
|
||||
const postLikes = level.postLikesDb.keys()
|
||||
assert.include(postLikes, `${DISPLAY_POST}:like-1`)
|
||||
assert.notInclude(postLikes, `${REVERSED_POST}:like-1`)
|
||||
const postChildren = level.postChildrenDb.keys()
|
||||
assert.include(postChildren, `${DISPLAY_POST}:reply-1`)
|
||||
assert.notInclude(postChildren, `${REVERSED_POST}:reply-1`)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
Utility: repair byte-reversed txid references in an existing psf-memo-db and
|
||||
rebuild the affected secondary indexes.
|
||||
|
||||
Older psf-memo-client broadcasts embedded a referenced txid in big-endian
|
||||
display order. The indexer expected little-endian wire order, so it stored a
|
||||
reference that is the reverse of the display txid. This utility rewrites
|
||||
those references to display order for likes, replies, poll options, and poll
|
||||
votes, and rebuilds the postLikes and postChildren indexes.
|
||||
|
||||
Run from the psf-memo-db repo root on the host that owns the LevelDB files:
|
||||
|
||||
node util/txid/repair-txid-encoding.js
|
||||
|
||||
The repair is idempotent: re-running it makes no further changes. Progress
|
||||
and a summary are printed to stderr.
|
||||
|
||||
WARNING:
|
||||
- This script opens the LevelDB files directly. psf-memo-db must NOT be
|
||||
running, or another process must not hold the database locks.
|
||||
- Make a backup of leveldb/current before running on a production server:
|
||||
cp -r leveldb/current leveldb/current-pre-txid-repair-backup
|
||||
*/
|
||||
|
||||
import level from 'level'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as url from 'url'
|
||||
import { repairTxidEncoding } from '../../src/lib/repair-txid-encoding.js'
|
||||
|
||||
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
const DATA_DIR = process.env.PSF_MEMO_DB_DATA_DIR
|
||||
? path.resolve(process.env.PSF_MEMO_DB_DATA_DIR)
|
||||
: path.resolve(__dirname, '../../leveldb/current')
|
||||
|
||||
// Stores that hold records to scan. They must already exist.
|
||||
const RECORD_STORES = ['posts', 'polls', 'likes', 'postParents', 'pollOptions', 'pollVotes']
|
||||
// Secondary indexes rebuilt by the repair. Create them when missing.
|
||||
const INDEX_STORES = ['postLikes', 'postChildren']
|
||||
|
||||
function requiredStorePath (dir, name) {
|
||||
const storePath = path.join(dir, name)
|
||||
if (!fs.existsSync(storePath)) {
|
||||
throw new Error(`Required LevelDB store not found: ${storePath}. Set PSF_MEMO_DB_DATA_DIR to the directory containing the psf-memo-db stores.`)
|
||||
}
|
||||
return storePath
|
||||
}
|
||||
|
||||
async function main () {
|
||||
console.error(`Using LevelDB data directory: ${DATA_DIR}`)
|
||||
|
||||
const paths = {}
|
||||
for (const name of RECORD_STORES) {
|
||||
paths[name] = requiredStorePath(DATA_DIR, name)
|
||||
}
|
||||
for (const name of INDEX_STORES) {
|
||||
paths[name] = path.join(DATA_DIR, name)
|
||||
}
|
||||
|
||||
console.error('Opening LevelDB stores...')
|
||||
const dbs = {
|
||||
postsDb: level(paths.posts, { valueEncoding: 'json' }),
|
||||
pollsDb: level(paths.polls, { valueEncoding: 'json' }),
|
||||
likesDb: level(paths.likes, { valueEncoding: 'json' }),
|
||||
postParentsDb: level(paths.postParents, { valueEncoding: 'json' }),
|
||||
pollOptionsDb: level(paths.pollOptions, { valueEncoding: 'json' }),
|
||||
pollVotesDb: level(paths.pollVotes, { valueEncoding: 'json' }),
|
||||
postLikesDb: level(paths.postLikes, { valueEncoding: 'json', createIfMissing: true }),
|
||||
postChildrenDb: level(paths.postChildren, { valueEncoding: 'json', createIfMissing: true })
|
||||
}
|
||||
|
||||
try {
|
||||
console.error('Repairing reversed txid references...')
|
||||
const summary = await repairTxidEncoding(dbs)
|
||||
|
||||
console.error('\nTxid encoding repair complete.')
|
||||
console.error(` likes corrected: ${summary.likes}`)
|
||||
console.error(` replies corrected: ${summary.replies}`)
|
||||
console.error(` poll options corrected: ${summary.pollOptions}`)
|
||||
console.error(` poll votes corrected: ${summary.pollVotes}`)
|
||||
} catch (err) {
|
||||
console.error('\nTxid encoding repair failed:', err.message)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
for (const db of Object.values(dbs)) {
|
||||
await db.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
Reference in New Issue
Block a user