From c47469904740ad509c109e1836b19533638ad925 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Mon, 27 Jul 2026 14:42:59 +0300 Subject: [PATCH 1/3] docs: contribution guidelines, PR template, changelog policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the policy set deployed to connectome-host (PR #49) to this repo, codifying norms already practiced here rather than introducing new ones: merge-commit-only history, comment-based evidence review, declared AI authorship, companion-PR merge-order declarations. Conventional-commit titles are written down as recommended-not-required, which is what this repo's history actually shows. Adds the changelog discipline this repo has never had — 7 releases, no changelog — with enforcement layered cheapest-first: an entry-with-the-change rule binding direct pushes as well as PRs, a soft PR check (src/ touched => CHANGELOG.md touched, `no-changelog` label escape), and a tag-time publish guard that refuses to release a tag with no matching section. `npm version` now cuts the Unreleased section via scripts/release-changelog.mjs, which refuses to run on more than one `## Unreleased` heading (only the first is ever cut, so later ones strand entries) and splices by match index rather than `String.replace(substring)`. Both hardenings are over the connectome-host original, whose CHANGELOG.md accumulated six such headings with one stranding ~60 lines of entries that reached no release. Two things are specific to this repo: - The `publish` job gains `if: startsWith(github.ref, 'refs/tags/v')`, which its siblings already had and it lacked. Without it a workflow_dispatch against a branch would publish whatever version package.json carried, bypassing the changelog guard entirely. Gating on the ref rather than the event keeps manual re-dispatch against an existing tag working. - `github-release` deliberately takes no `needs:`. Unlike the sibling repos, the prerequisite here is a five-target cross-compile matrix, and gating the release notes on the flakiest part of the pipeline would defeat the reason the job is independent in the first place — github-clone consumers need notes even when the cross-build or the publish fails. Being a persistence layer, the PR template adds a Compatibility section: does this touch the record log, blob layout, snapshot encoding or wire format, and do existing stores still open. The audience-scoped breaking-entry format calls out that same question as the one every reader asks first. Verified: release script refuses the empty Unreleased (exit 1); full ritual exercised on a copy — entry -> cut -> tag guard pass/refuse -> notes extraction; all three workflows parse. No Rust touched. Co-Authored-By: Claude Opus 5 --- .github/PULL_REQUEST_TEMPLATE.md | 35 +++++++ .github/workflows/changelog.yml | 32 ++++++ .github/workflows/publish.yml | 50 ++++++++++ CHANGELOG.md | 11 +++ CONTRIBUTING.md | 161 +++++++++++++++++++++++++++++++ package.json | 3 +- scripts/release-changelog.mjs | 58 +++++++++++ 7 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/changelog.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 scripts/release-changelog.mjs diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4515e51 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +## Problem + + + +## Changes + + + +## Tests + + + +## Compatibility + + + +## Not verified + + + +--- + +- [ ] `CHANGELOG.md` updated under `## Unreleased` — or this change is + internal-only / test-only / docs-only (apply the `no-changelog` label). + + diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..5fe3aba --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,32 @@ +name: Changelog + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +jobs: + changelog-entry: + name: Changelog entry present + runs-on: ubuntu-latest + if: "!contains(github.event.pull_request.labels.*.name, 'no-changelog')" + + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Require CHANGELOG.md update when src/ changes + run: | + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + changed=$(git diff --name-only "$base...$head") + echo "Changed files:" + echo "$changed" + if echo "$changed" | grep -q '^src/' && ! echo "$changed" | grep -qx 'CHANGELOG.md'; then + echo "::error::This PR touches src/ but not CHANGELOG.md. Add an entry under 'Unreleased' (see CONTRIBUTING.md), or apply the 'no-changelog' label if the change is internal-only." + exit 1 + fi + echo "OK" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 51c6ea4..a97220b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -80,6 +80,11 @@ jobs: name: Publish to npm runs-on: ubuntu-latest needs: build + # Gated on the ref, not the event, so a manual re-dispatch against an + # existing tag still publishes — but a dispatch against a branch cannot, + # which would otherwise publish whatever version package.json happened to + # carry, bypassing the changelog guard below. + if: startsWith(github.ref, 'refs/tags/v') # OIDC trusted publishing: GitHub mints a short-lived id-token, npm CLI # exchanges it for a publish credential. No long-lived NPM_TOKEN secret. @@ -92,6 +97,15 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Require changelog section for this release + run: | + ver="${GITHUB_REF_NAME#v}" + esc=$(printf '%s' "$ver" | sed 's/[.]/\\./g') + if ! grep -Eq "^## ${esc}([^0-9]|$)" CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## ${ver}' section for tag ${GITHUB_REF_NAME}. Retitle the Unreleased section before tagging (see CONTRIBUTING.md)." + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -130,3 +144,39 @@ jobs: - name: Publish run: npm publish --access public --provenance + + github-release: + name: GitHub release notes + runs-on: ubuntu-latest + # Deliberately independent of the npm publish job — and of the native + # build matrix it needs: some consumers run github-clone checkouts, and + # release notes must exist even when the cross-build or publish fails. + if: startsWith(github.ref, 'refs/tags/v') + + permissions: + contents: write + + steps: + - uses: actions/checkout@v4 + + - name: Mirror changelog section into release notes + env: + GH_TOKEN: ${{ github.token }} + run: | + ver="${GITHUB_REF_NAME#v}" + # Section header is '## X.Y.Z — YYYY-MM-DD'; match the version + # field exactly (string compare, no regex escaping needed). + awk -v ver="$ver" ' + /^## / { if (in_section) exit; if ($2 == ver) { in_section = 1; next } } + in_section { print } + ' CHANGELOG.md | sed '/./,$!d' > notes.md + if ! [ -s notes.md ]; then + echo "::error::No CHANGELOG.md section found for ${ver}." + exit 1 + fi + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release edit "$GITHUB_REF_NAME" --notes-file notes.md + else + gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" \ + --notes-file notes.md --verify-tag + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b14b82c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +Notable changes to `@animalabs/chronicle`, loosely following +[Keep a Changelog](https://keepachangelog.com/). Entries land with the change +that causes them — see [CONTRIBUTING.md](CONTRIBUTING.md#changelog). + +Releases up to and including 0.2.7 predate this file; for their contents see +`git log` and the +[releases page](https://github.com/anima-research/chronicle/releases). + +## Unreleased diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cf23e76 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,161 @@ +# Contributing to chronicle + +chronicle is the storage layer of the Connectome ecosystem +([agent-framework](https://github.com/anima-research/agent-framework), +[membrane](https://github.com/antra-tess/membrane), +[context-manager](https://github.com/anima-research/context-manager), +[connectome-host](https://github.com/anima-research/connectome-host)). These +conventions describe how work actually lands here — they codify existing +practice rather than aspiration. When in doubt, recent merged PRs are the best +reference. + +Everything below applies to every change however it lands — external PR or +maintainer direct push — and to human and AI authors identically. There is +no separate rulebook for either. + +## How changes land + +- External contributions come as PRs against `main`, from a fork or a repo + branch. Maintainers also land small changes directly on `main`; don't be + surprised by history that never saw a PR. +- Branch names: `feat/`, `fix/`, `docs/`, `chore/`. + Descriptive bare names (`fix-branch-at-state`) are also common here. +- PRs are merged as **true merge commits** — no squash, no rebase-merge. + Because nothing is squashed, keep individual commits coherent. +- To update a stale branch, rebase onto `main` or merge `main` in; both are + accepted. +- Stacked PRs and cross-repo companion PRs are fine, but **declare them** in + the body with merge-order guidance ("stacked on #7 — review that first"; + "safe to merge in either order because …"). Everything in the ecosystem + sits on this package, so a change here can require companion work in + context-manager or agent-framework; say which side is safe to land first. + +## What a PR should contain + +Body shape (the PR template mirrors this): **Problem / Changes / Tests**, +plus, when applicable, **Not verified**, **Out of scope**, and +**Companion PRs**. The conventions that matter: + +- **Evidence over assertion.** State the test baseline numerically: + "`cargo test`: N passed / 0 failed; `npm test` smoke green." A claim like + "all tests pass" without the count will be re-verified anyway, so save the + reviewer the trip. +- **Say what you did NOT verify.** This is a persistence layer: the failures + that matter are the ones that only appear on real stores, across a crash, + or on another platform. Be explicit about what you exercised — store size, + whether recovery/torn-tail paths were hit, which targets built — and what + you did not. +- **Format and on-disk compatibility is the sharp edge.** If a change alters + the record log, blob layout, snapshot encoding, or wire format, say + whether existing stores still open, and whether a store written by the new + code still opens on the old. Tests accompany behavior changes, and review + scrutinizes test substance, not mere presence — a test that can't fail on + the unfixed code will be called out. +- **Changelog entry** under `## Unreleased` for anything behavior-affecting + (see below). + +Conventional-commit-style titles (`feat(state): …`, `fix(blobs): …`) are +recommended but not required — much of this repo's history predates the +habit, and plain descriptive titles are perfectly normal here. + +## Review process — what to expect + +- Review arrives as **ordinary PR comments**, not GitHub review approvals — + the comment thread is the gate. Reviews are frequently AI-generated and + explicitly labeled as such, with a severity verdict and itemized findings. +- The reviewer will typically **run your branch** (`cargo test`, the napi + build, the node smoke test, sometimes opening a real store with the + inspector tools) and paste transcripts. Claims are checked, not trusted. +- Respond by pushing fix commits and replying per finding — "Addressed in + ``" — rather than force-pushing a rewritten branch. A re-review then + flips the verdict. +- Maintainers may push small review fixes **directly to your branch** to keep + things moving. Say so in the PR body if you'd rather they didn't. +- PRs are never closed silently: a closed PR gets a one-line disposition + comment (usually supersession by another PR). + +## AI-assisted contributions + +AI-written code is the norm in this ecosystem, welcome from everyone, and +held to exactly the same evidence standards as anything else. Declare it the +way we do: + +- the `🤖 Generated with [Claude Code](https://claude.com/claude-code)` + footer (or equivalent for your tooling) in the PR body, and +- a `Co-Authored-By:` trailer naming the model in commits. + +What earns an automated contribution a changes-requested review is not being +AI-generated — it's arriving without the suite having been run, with tests +that don't fail on unfixed code, or with claims the branch itself disproves. + +## Changelog + +`CHANGELOG.md` keeps a standing `## Unreleased` section with +`### Breaking` / `### Added` / `### Changed` / `### Fixed` subsections +(loosely [Keep a Changelog](https://keepachangelog.com/)). + +- **The entry lands with the change** — same commit, or at least the same + PR. This binds direct pushes to `main` just as much as PRs. On PRs, CI + enforces it softly: touching `src/` without touching `CHANGELOG.md` fails + the `changelog` check unless the `no-changelog` label is applied. +- **What needs an entry:** anything a consumer would notice — the napi + surface, store/branch/state semantics, on-disk or wire format, recovery + behavior, performance characteristics that change how callers should use + it, packaging (which platform binaries ship), defaults. Internal refactors, + test-only, and docs-only changes don't. +- **Breaking entries are audience-scoped.** Name the audience in the heading + (`### Breaking (on-disk format)`) and cover: **who needs to act**, + **migration**, and **unchanged** (what readers might fear broke but + didn't). For this package that last line carries real weight: say plainly + whether existing stores keep opening, because that is the first thing + every reader wants to know. +- **Keep one `## Unreleased` heading.** Add entries under the existing one; + don't open a second. Only the first is cut at release time, so entries + filed under a later heading are silently never released — the release + script refuses to run if it finds more than one. +- **Releases** (maintainers): `npm version ` does the + whole cut — the `version` hook retitles `Unreleased` to + `## X.Y.Z — YYYY-MM-DD` (keeping a fresh `Unreleased` above it, and + refusing to release when there are no entries), then npm commits and tags. + `git push --follow-tags` triggers CI, which cross-builds the native module + for all five targets, refuses a tag with no matching changelog section, + publishes `@animalabs/chronicle` to npm, and creates the GitHub release + with that section as its notes. The two release jobs are independent: some + consumers run github-clone checkouts, so release notes must exist even + when npm publish fails. Version bumps are a maintainer release-time + action, not part of feature PRs. + +## Building and testing + +```bash +npm ci # strict lockfile install +cargo test # Rust suite +npx napi build --platform --features napi-bindings # native module (debug) +npm test # node smoke test (test.mjs) +npm run build # release build of the above +``` + +Two things about the test setup are easy to trip over: + +- **`cargo test` runs without the `napi-bindings` feature.** napi symbols + only resolve inside a Node process, so test executables can't link against + them — only the cdylib can. The napi surface is therefore covered by the + napi build plus the node smoke test, not by `cargo test`. +- **`npm test` loads the built `.node`**, so build before testing and after + switching branches, or you will be testing the previous artifact. + +`Cargo.lock` is deliberately gitignored; `package-lock.json` is committed and +CI installs it with `npm ci`, which unlike `npm install` fails loudly on a +lock that is broken or out of sync. + +Push-time CI (`ci.yml`) runs the Rust suite, the debug napi build and the node +smoke test on every push and PR (ubuntu only — the cross-target matrix runs at +release time). + +Binaries ship inside the single published tarball. The platform packages that +`napi prepublish` would name were never published, and a manifest declaring +them as `optionalDependencies` breaks `npm ci` for every consumer on npm >= 11, +so the release workflow fails if they reappear — don't add them back. + +Stores are binary; use the inspector tooling in `tools/` and `ui/` rather than +reading them by hand. `docs/loom-of-looms.md` is the algebraic spec. diff --git a/package.json b/package.json index cbd296b..ed9ae8d 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "scripts": { "build": "napi build --platform --release --features napi-bindings", "build:debug": "napi build --platform --features napi-bindings", - "test": "node test.mjs" + "test": "node test.mjs", + "version": "node scripts/release-changelog.mjs && git add CHANGELOG.md" }, "devDependencies": { "@napi-rs/cli": "^2.18.0" diff --git a/scripts/release-changelog.mjs b/scripts/release-changelog.mjs new file mode 100644 index 0000000..6c299bc --- /dev/null +++ b/scripts/release-changelog.mjs @@ -0,0 +1,58 @@ +// Runs as npm's `version` lifecycle hook (see package.json): at that point +// package.json already carries the new version, and files staged here are +// included in the release commit that `npm version` then creates and tags. +// +// Cuts the standing `## Unreleased` section into `## X.Y.Z — YYYY-MM-DD` and +// leaves a fresh empty `## Unreleased` above it. Refuses to release when +// there is nothing to release, or when the file's shape is ambiguous. +import { readFileSync, writeFileSync } from "node:fs"; + +const path = "CHANGELOG.md"; +const { version } = JSON.parse(readFileSync("package.json", "utf8")); +const text = readFileSync(path, "utf8"); + +const fail = (msg) => { + console.error(`CHANGELOG.md: ${msg}`); + process.exit(1); +}; + +// Exactly one Unreleased heading. A second one silently strands entries: +// only the first is ever cut, so anything filed under a later heading is +// never released and never reaches the GitHub release notes. +const headings = [...text.matchAll(/^## Unreleased[ \t]*$/gm)]; +if (headings.length === 0) { + fail("no '## Unreleased' section — add one before releasing."); +} +if (headings.length > 1) { + const lines = headings.map((m) => text.slice(0, m.index).split("\n").length); + fail( + `${headings.length} '## Unreleased' headings (lines ${lines.join(", ")}). ` + + "Only the first is released; fold them into one before releasing.", + ); +} +const [header] = headings; + +const escaped = version.replace(/[.]/g, "\\."); +if (new RegExp(`^## ${escaped}([^0-9]|$)`, "m").test(text)) { + fail(`a '## ${version}' section already exists.`); +} + +const afterHeader = text.slice(header.index + header[0].length); +const nextSection = afterHeader.search(/^## /m); +const body = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection); +if (!/^[ \t]*[-*] /m.test(body)) { + fail(`'## Unreleased' has no entries — nothing to release as ${version}.`); +} + +// Spliced by index rather than string-replaced: `text.replace("## Unreleased", …)` +// would hit the first *substring* occurrence, which is not necessarily the +// heading the regex matched (an inline mention of `## Unreleased` in prose +// comes first) and would inject the version heading into the wrong place. +const date = new Date().toISOString().slice(0, 10); +writeFileSync( + path, + text.slice(0, header.index) + + `## Unreleased\n\n## ${version} — ${date}` + + text.slice(header.index + header[0].length), +); +console.log(`CHANGELOG.md: cut Unreleased into '## ${version} — ${date}'.`); From dca22c627b996918a4286783c6636d4620d47873 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Fri, 7 Aug 2026 14:09:34 +0300 Subject: [PATCH 2/3] docs(changelog): document 0.3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The policy this branch introduces binds direct pushes as much as PRs, so the file it adds should not land already behind main. 0.3.0 was tagged 2026-08-01 — after this branch was cut — and is the first release the changelog is expected to cover. Entries reconstructed from the ten commits in v0.2.7..v0.3.0, with the MessagePack state_update encoding filed as a Breaking (on-disk format) entry: it is forward-only, which is the one thing every reader of this package's changelog wants stated plainly. Verified against the tooling this branch ships: - publish.yml tag guard greps '## 0.3.0' — matches. - github-release awk extracts a 107-line, 6,646-byte notes body that starts at '### Breaking' and ends at the section's last line. - scripts/release-changelog.mjs on a copy: refuses the empty Unreleased, cuts cleanly to '## 0.3.1 — 2026-08-07' once an entry exists, and refuses the duplicate on re-run. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b14b82c..f0cadc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,3 +9,113 @@ Releases up to and including 0.2.7 predate this file; for their contents see [releases page](https://github.com/anima-research/chronicle/releases). ## Unreleased + +## 0.3.0 — 2026-08-01 + +### Breaking (on-disk format) + +- **`state_update` records are now MessagePack-encoded, and a store that has + been written by 0.3.0 cannot be read by earlier chronicle.** New records are + written with `to_vec_named` and `serde_bytes` on the value fields, so + `StateOperation` payloads travel as raw binary instead of arrays of JSON + integers. + - **Who needs to act:** anyone who may need to roll a store back to a + pre-0.3.0 chronicle. The format change is forward-only — take a copy + before upgrading if rollback has to stay open. + - **Migration:** none. Nothing rewrites existing records; the new encoding + applies to records written from here on. + - **Unchanged:** existing stores open and read normally. `decode` gates on + the record's encoding tag and historical JSON payloads (written as + `RecordInput::raw`) decode through the fallback arm, pinned by a + handwritten-legacy-payload test. Mixed JSON-history/MessagePack-tail + stores were verified against a copy of a live 6 GB store. The JSON wire + format itself is unchanged. + - JS consumers that previously reached into record payloads with + `JSON.parse` should move to the new `getStateUpdateJson(id)`, which is + encoding-agnostic. + +### Added + +- **`update_state_strategy`** (napi: `updateStateStrategy(registration)`) — the + explicit upsert leg for snapshot cadence. Registrations persist in + `state.bin`, so consumers that re-register on boot could never change cadence + on an existing store: `register_state` errors with `StateExists` and the + first-registration values won forever. Restricted to the same strategy kind, + because changing the kind under a live chain would change reconstruction + semantics for records already on disk; cadence fields steer future snapshot + scheduling only. `initialValue` is ignored, and `register_state` deliberately + stays non-upserting. +- **`getStateUpdateJson(id)`** — encoding-agnostic `state_update` reader for JS + consumers. +- **`materialize_operations`** re-exported at `lib.rs` beside + `apply_operation`. +- **Scaling gates as CI tests** — `tests/disk_scaling.rs` (on-disk bytes vs op + count), `tests/reconstruction_scaling.rs` (cold-read latency at worst-phase + sizes), and `tests/materialize_equivalence.rs` (differential + fold-vs-single-pass equivalence, including a 2000-case fuzz). Plus manual + probes: `tests/perf_probe.rs` for fixed-size per-op numbers and + `examples/store_probe.rs` for measuring against a store copy. +- The criterion bench suite compiles again — it had imported the crate under + its pre-rename name (`record_store`) since the initial release — and now + covers point reads, JSON appends and tree ops. + +### Changed + +- **Full-snapshot spacing is size-aware** (#11). Fulls fired on a fixed + `delta × full` op interval and embed the whole state, so growing AppendLog + and Tree states paid O(N²/2K) disk and O(N/K) append latency. The interval + now grows with the state — a full fires once the ops covered since the last + full reach the item count at that full — with the configured interval as a + floor, so small states keep their configured cadence exactly. Total snapshot + bytes telescope to ≤ 2× final state size. JSON appends measured 77 µs → + 1,709 µs per op across 1k → 50k accumulated entries under the old cadence; + they are now flat at ~20 µs regardless of accumulated size. +- **Reconstruction is single-pass.** Reopen, cache miss, branch switch and time + travel folded `apply_operation` over the op chain, re-parsing and + re-serializing the entire state per op — O(tail × N). `materialize_operations` + decodes the base once, applies the tail in place, and encodes once — + O(state + ops), with semantics preserved exactly. Cold reads went from + 22.4 → 10.7 ms and 259.7 → 44.2 ms at 3.9k/15.9k appends (11.59× → 4.13× + per 4× N: linear). +- **Edits no longer force early full snapshots.** With tails cheap to traverse, + the `has_non_append` forced-full is retired: a non-Append op still blocks + delta snapshots, but the raw tail now rides until the size-aware full fires. + The old behavior cost O(N²/D) disk on any growing log taking regular edits — + the summary-merge pattern measured 439 MB of store for 16k logical ops, + now 18.2 MB (24×). +- **Point lookups of the last item are O(1).** Every state write pops the LRU + caches, and a write-through consumer's lookup of the item it just appended + re-materialized the whole state inside every append. When the head record is + an Append and the caller asks for the last index, the item is now served from + the head record alone. Against a copy of a live store (19,767 messages, 33 MB + state, 6 GB log), append+lookup went 421 ms → 94 µs. +- **Tree point reads are cached in decoded form.** `tree_get`/`tree_list` + deserialized the entire path→entry map per call — 16.7 µs → 2.28 ms across + 100 → 10k entries. The state manager now caches the decoded `TreeState` per + `head_offset` (same key scheme and staleness discipline as the per-item + cache); `tree_get` is flat at ~150 ns at all measured sizes. The trade is + that the decoded-tree LRU is bounded by entries, not bytes. +- Tree op counts are tracked as an upper bound (overwrites count as inserts, + corrected at each full snapshot) — sufficient for spacing, and it only ever + spaces fulls further apart. + +### Fixed + +- **Snapshot-strategy states no longer walk the entire chain on cold reads.** + `reconstruct_from_disk` / `get_state_at` / `find_chain_info_at` broke the + backward walk only on `Snapshot` records; `Set` fell through to the catch-all + and the walk continued to sequence 0. Snapshot-strategy states are written + via `Set` and never get a periodic `Snapshot` record, so cold reads + deserialized every full-state `Set` back to the beginning and discarded all + but the last — chains up to 3,928 deep across an 11.7 GB log, measured at + 6–8 minutes to boot. `Set` replaces the whole state exactly like `Snapshot`, + so the newest one is a valid terminal; these reads are now O(1). +- `CompactionStats::ops_since_last_full_snapshot` counted ops plus delta count + instead of ops (deltas × `delta_snapshot_every` + raw ops), undercounting + roughly 100×. +- Interval-floor arithmetic in both strategy arms uses `saturating_mul`; an + overflowing config previously wrapped. +- Legacy indexes deserialize the new head fields to 0 and keep the configured + cadence until the first full snapshot stamps a baseline. `StateChainHead`'s + positional wire format is now documented and guarded against both the v2 + (pre-#11) and v1 (pre-`item_count`) layouts. From 1d5ba49dbb08cc0faa0dca8248214c87bce36168 Mon Sep 17 00:00:00 2001 From: Anarchid Date: Fri, 7 Aug 2026 14:43:34 +0300 Subject: [PATCH 3/3] ci: pin every action to an immutable SHA; stop persisting checkout credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile flagged the new github-release job (contents: write) for running actions/checkout at a mutable major-version ref: retargeting that tag would change the code running with permission to create and edit releases. Valid, and it undersells the exposure — that job is the LEAST privileged of the write-capable ones. Fixed across every workflow rather than just the flagged line, because a half-pinned repo invites the same finding next time: - 14 actions pinned across changelog.yml, ci.yml and publish.yml, including three third-party ones: dtolnay/rust-toolchain, Swatinem/rust-cache and the artifact actions that carry the native binaries into the publish job. - dtolnay/rust-toolchain@stable was a BRANCH ref — the most mutable of the lot, and it feeds the .node binaries that the OIDC publish job ships. The channel is now named explicitly in `with:`, since pinning to a SHA would otherwise take it from whichever branch that SHA was reached from. Two things the review did not name: - The npm publish job holds `id-token: write` for OIDC trusted publishing. A swapped action there can reach a live publish credential, which is a worse outcome than editing release notes. - checkout defaults to persist-credentials: true, writing the job token into .git/config where every later step in the job can read it. Nothing here pushes over git — publish uses OIDC, the release job uses gh with GH_TOKEN — so all 4 checkouts now set it false. Version tags are kept as trailing comments so the pins stay readable and Dependabot can still bump them. Co-Authored-By: Claude Opus 5 --- .github/workflows/changelog.yml | 2 +- .github/workflows/ci.yml | 12 ++++++++---- .github/workflows/publish.yml | 28 +++++++++++++++++++--------- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index 5fe3aba..c8be472 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -14,7 +14,7 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'no-changelog')" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1921c34..71b9303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,18 +15,22 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable + with: + toolchain: stable - name: Cache Rust build - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 # npm ci installs the lock verbatim: npm >= 11 'npm install' silently # re-resolves platform packages missing from the lock, so only npm ci diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a97220b..c3d3bdf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,16 +31,22 @@ jobs: name: Build - ${{ matrix.target }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 20 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: + # Named explicitly: `stable` was a BRANCH ref, so the channel used to + # come from the ref itself. Pinned to a SHA, it would otherwise come + # from whichever branch's action.yml that SHA carries. + toolchain: stable targets: ${{ matrix.target }} - name: Install cross-compilation tools (aarch64-linux) @@ -60,7 +66,7 @@ jobs: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc - name: Upload native module - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: bindings-${{ matrix.target }} path: '*.node' @@ -68,7 +74,7 @@ jobs: - name: Upload JS bindings (once) if: matrix.target == 'x86_64-unknown-linux-gnu' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: js-bindings path: | @@ -95,7 +101,9 @@ jobs: id-token: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Require changelog section for this release run: | @@ -107,7 +115,7 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: # node 24 ships npm >= 11.5.1, required for OIDC publishing. node-version: 24 @@ -117,7 +125,7 @@ jobs: run: npm ci - name: Download all artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: path: artifacts @@ -157,7 +165,9 @@ jobs: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - name: Mirror changelog section into release notes env: