Repository navigation
feat!: session-isolated SQLite persistence for OpenCode V2 (v2.0.0 release candidate) - #1
Conversation
Storage moves from project-level JSON to SQLite, and every checkpoint, snapshot, counter and dedup hash is now scoped by session id. Two sessions in one project can no longer overwrite or inherit each other's state. - storage: node:sqlite, which is built into Node >= 22.5 and Bun >= 1.4 (the runtime OpenCode uses), so there is no native module and no install-time build. Schema is versioned in PRAGMA user_version and migrated in transactions; WAL, busy timeout, enforced foreign keys, 0600 file permissions. - isolation: composite (session_id, project_id) foreign keys make a checkpoint that disagrees with its session impossible; a malformed session id throws instead of binding NULL and silently matching nothing. - service: read-merge-write happens in one transaction, so concurrent writers cannot lose each other's fields; unchanged content is never rewritten and never moves updatedAt, so periodic saves cannot look like progress. - snapshots: per session, collision-free automatic names, and pruning always retains the newest pre-restore undo point. - legacy: JSON import is transactional, idempotent, copies (never deletes) the originals, and lands in a legacy::<projectID> namespace rather than in a live session. Moving it into a session is an explicit opt-in. - sdk: @opencode-ai/plugin (V1) -> @opencode/plugin (2.0.21), Plugin.define with tools registered through ctx.tool.transform, session hooks for compaction and prompt admission, ctx.event.subscribe for session.compacted, and cleanup on unload. - handoff.md is a derived per-session export, never read back as state. BREAKING CHANGE: storage format, plugin API and the configuration key (plugin -> plugins) all change. See docs/MIGRATION.md. The 1.0.1 line is unaffected.
The pre-2.0 suite was 979 assertions against a copy of the logic: none of the three files imported the plugin. They are preserved under tests/legacy/ for reference, and the default suite now imports dist/ and drives the real V2 entry point. 98 tests cover the SQLite driver, schema, transactions, savepoints, crash recovery (SIGKILL mid-transaction), four-process writes, lost-update protection for counters, session isolation (2, 5 and 25 sessions, multiple projects, child sessions, two connections writing one session, restart), the session-scoped service, legacy migration and adoption, and the plugin wiring: tool registration, tool execution end to end, compaction and prompt hooks, the event subscription, cleanup, option validation, and inert behaviour on an unusable database path. Also adds the driver conformance probe used to choose node:sqlite, the fixtures the process-level tests need, and an integration script that packs the tarball, installs it into a clean project and drives a real OpenCode runtime.
- docs/ARCHITECTURE.md: layer map, the driver decision with its evidence, the schema, the invariants the code is built around, concurrency and durability notes, and measured operation costs. - docs/MIGRATION.md: the config change, how legacy JSON is imported, explicit adoption, and how to return to 1.x. - docs/COMPATIBILITY.md: runtime and API matrix, plus why V1 support is intentionally dropped rather than shimmed. - docs/RUNTIME-VERIFICATION.md: what was verified against a real OpenCode 2.0.21 / Bun 1.4.2 runtime, verbatim tool and database output, and the host behaviours that affect plugin loading. - README rewritten for the V2 configuration, session isolation and options; CHANGELOG records the 2.0.0-dev.0 line and the previously missing 1.0.1 entry. - .gitignore excludes checkpoint runtime state so user data cannot be committed.
node:sqlite requires Node >= 22.5, so the 18/20/22 matrix no longer applies. Adds a job that packs the tarball, installs it into a clean project, imports the plugin there, and asserts the tarball ships no database, tests or node_modules.
This file existed only as an untracked file in the local checkout of the 1.0.x line. It now runs the full verification gate and checks that the release tag matches package.json before publishing.
…wn data Independence audit findings, fixed within this plugin's own responsibility. Handoffs and migration backups wrote directly into the shared .opencode/state/ directory, which a co-installed plugin may also use. Both now live in this plugin's own namespace: - derived handoffs: .opencode/state/checkpoint-orchestrator/handoff-<sessionId>.md - migration backups: .opencode/state/checkpoint-orchestrator/legacy-backup/ - handoffDir default updated; an empty relative override falls back to it The legacy import accepted any JSON at .opencode/state/checkpoint.json. Since normalizeData never throws, a same-named file owned by another tool was normalised into an empty checkpoint, imported, and the ledger row then blocked the real import. Payloads are now shape-gated: anything that is not this plugin's data is reported as unrecognized-legacy-state, left byte-for-byte untouched, not imported, and does not block a genuine file from importing later. The read-only handoff export now carries the marker `checkpoint-orchestrator/handoff@1` so an external consumer can detect a format change. BREAKING CHANGE: handoff export paths and the handoffDir default move one directory deeper. Nothing else changes in the storage contract.
…plugin The other two toolkit plugins are not available in this environment, so these tests use a stand-in plugin that exercises the same host contracts: its own tools, its own hooks, its own storage. tests/interop.test.js (12 tests) asserts: works alone; runtime dependencies are the SDK only and no shipped artifact imports another @gitdamnit package; needs no host domain beyond the documented six, enforced with a throwing proxy; coexists in either registration order with both compaction contributions surviving and no hook throwing; is unaffected by a companion that throws, under both an isolating and a stop-at-first-error host; is unaffected by a companion that fails to load; keeps storage ownership disjoint with the companion's file byte-identical; keeps five simultaneous sessions isolated; survives cleanup, reload and double loading; refuses a tool call with no session; loads with absent options. The fake host moved to tests/helpers/fake-host.mjs and is now shared with the wiring suite. Suite totals: 113 tests, all passing.
Adds docs/INDEPENDENCE.md: responsibility boundary, configuration and storage namespaces, the hooks this plugin registers and why correctness never depends on hook or load order, behaviour when a companion is absent, disabled, failing or outdated, the optional versioned read-only interoperability contract, the concurrency story, the integration matrix, and the Docker Plugin Test Lab requirements. It states plainly that Git That Shit and Stranger Danger were not available here and have not been tested together with this plugin. Updates README (independence section, namespaced paths, corrected options), ARCHITECTURE (namespacing and the legacy shape gate), MIGRATION (backup location, gated import) and COMPATIBILITY (toolkit independence, no claim of sibling testing).
Read-only inspection of the sibling checkouts: both are still on the V1 plugin SDK, so neither can load in an OpenCode V2 host as it stands, and neither references this plugin in any dependency or source file. Records the reason plainly instead of implying the combinations were exercised.
A case deliberately builds a plugin context with no options, which makes the plugin fall back to its platform default database path. That created ~/.local/share/opencode/checkpoint-orchestrator/checkpoints.db as a side effect of running the tests. Both wiring suites now point CHECKPOINT_ORCHESTRATOR_DB at a temporary directory for the whole process, and the no-options case asserts through it explicitly, so no test can write outside its own temporary storage. Verified by running the full suite and confirming the real data directory is not created.
Final persistence audit of the v2 line. Storage-layer only: the eight tools,
the session-isolation contract, the storage format and the handoff export are
unchanged.
- Schema creation was unsynchronised. `applyMigrations` read `PRAGMA
user_version` before `BEGIN IMMEDIATE`, so N processes opening a brand-new
database at the same instant all saw version 0 and all replayed `CREATE
TABLE`. The losers died with "table projects already exists" and that plugin
instance stayed inert ("Checkpoint storage unavailable"). The whole upgrade now
runs in one transaction that re-reads the version after taking the write lock,
which also makes a multi-step upgrade all-or-nothing.
- `projects.directory` was UNIQUE. A host reporting a second project id for one
canonical directory (a checkout plus a worktree of it, or a moved repository)
turned every write into "UNIQUE constraint failed: projects.directory".
Migration 2 replaces the unique index with a plain one; project identity is the
id and the directory is metadata.
- `ensureSessionIfMissing` recorded `projectDirectory = projectID`, and the
upsert then rewrote the real directory with the project id string. The
placeholder path no longer touches `directory`/`canonical`, and a later
authoritative `ensureSession` fills in the real value.
- `importLegacyState` checked its ledger before the write transaction, so two
processes starting together could both import and duplicate the legacy
snapshots. The check is now repeated inside the transaction.
Regression tests (119 total, up from 113) include two six-process concurrency
tests (concurrent first-open, concurrent legacy import), migration from a real
v1 schema with existing rows, duplicate project ids for one directory, write-path
directory preservation, and a schema-version/migration-list drift guard.
Also: `npm run test:all`; backup/recovery instructions; corrected handoff paths,
Node floor and test-count claims; and a clear note that the 979 "legacy" tests
re-implement V1 logic rather than importing the shipped plugin.
BREAKING CHANGE: `engines.node` is raised from >=22.5.0 to >=22.13.0, because
`node:sqlite` is only available without `--experimental-sqlite` from Node 22.13.0
and 23.4.0. The previous floor claimed support for runtimes that cannot execute
the storage layer.
Storage-security coverage for the release audit: a damaged or foreign file at the database path must surface as an error the plugin reports and stays inert on, never as an empty store that silently answers "no checkpoint". Payload-level corruption was already covered; this covers file-level corruption. 120 source-level tests.
A losing racer in a concurrent legacy import created a redundant backup copy of files it did not import, because the backup ran before the in-transaction ledger re-check. Move the backup after the check: the winning process copies the originals aside, the loser writes nothing and reports already-imported. The concurrent-import regression test (six processes, exactly one import) still passes.
The lockfile still carried version 1.0.1, no engines field, and the whole @opencode-ai/plugin (V1) dependency tree that package.json no longer declares. Regenerated with `npm install --package-lock-only`; only the V1 tree (@opencode-ai/plugin, @opencode-ai/sdk, effect, uuid, yaml, toml, msgpackr and friends) is removed. `npm ci --dry-run` succeeds.
Finalize 2.0.0 from the qualified release candidate 65bf698. No functional change: the diff is version metadata, release documentation and the publish workflow. - package.json, package-lock.json and PLUGIN_VERSION -> 2.0.0; dist rebuilt. - CHANGELOG gains the [2.0.0] entry consolidating the dev line. - docs/releases/v2.0.0.md freezes the tested commit, the lab environment (opencode 2.0.21 / Node 22.23.3), the image id, the 145/145 result with its evidence paths, and the known limitations. - The independence documentation now matches the lab run that actually happened: all three plugins together at 65bf698 / 6069f91 / 9cfd813. - README: released status, uninstall guidance, lab evidence at the release commit, and the flakiness of the model-output integration checks. - publish.yml is manual-only (workflow_dispatch, tag-guarded). Creating the GitHub release must not publish to npm, so publication stays an explicit, approved action. Verified at this commit: lint, typecheck, build, 120 source tests, 979 legacy reference assertions, and the real-runtime integration suite (22/22 on opencode v2.0.21).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c643b7d6bc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| node -e ' | ||
| const { DatabaseSync } = require("node:sqlite") | ||
| const src = new DatabaseSync(process.env.HOME + "/.local/share/opencode/checkpoint-orchestrator/checkpoints.db") | ||
| src.backup("checkpoints.backup.db").then(() => { src.close(); console.log("backup written") }) |
There was a problem hiding this comment.
Use the module-level SQLite backup function
The documented live-backup command always fails because DatabaseSync instances do not have a backup() method in the supported Node versions; backup is a named node:sqlite export that receives the source database. Users following this recovery procedure get TypeError: src.backup is not a function and no backup. The duplicate command in docs/MIGRATION.md needs the same correction.
Useful? React with 👍 / 👎.
| const cached = this.cache.get(sessionID) | ||
| if (cached) return cached |
There was a problem hiding this comment.
Refresh cached session metadata after moves
Once a session is cached, every later operation skips ctx.session.get, and forget() is never called anywhere. Because OpenCode's session API allows a session to move between projects, saves after a move continue using the old projectID and directory, causing legacy lookup and handoff export to target the old repository and leaving checkpoint metadata associated with the wrong project. Invalidate this cache on session movement/update or re-resolve metadata before project-sensitive writes.
Useful? React with 👍 / 👎.
| /** Legacy JSON is imported into its own namespace, once per project. */ | ||
| const ensureLegacyImported = (projectID: string, projectDirectory: string): void => { | ||
| if (!service || importedProjects.has(projectID)) return | ||
| importedProjects.add(projectID) |
There was a problem hiding this comment.
Cache only terminal legacy-import outcomes
The project is marked as handled before importLegacy reports whether anything was imported. If startup finds no legacy files, an unreadable file, or a same-named foreign file, repairing or adding the real files and invoking checkpoint_migrate in the same OpenCode process cannot retry the namespace import because this guard returns early, even though no migration ledger entry exists. Add the project to the cache only after an imported or already-imported result, or remove it for retryable outcomes.
Useful? React with 👍 / 👎.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Checkpoint Orchestrator v2.0.0 — SQLite session-isolated rewrite (release candidate)
Draft. Do not merge or publish without review. No tag, no npm publish, no
production OpenCode configuration change is made by this PR.
This PR carries the completed OpenCode V2 migration (project-level JSON →
session-isolated SQLite) together with the final persistence-integrity hardening
found by the release-readiness audit.
What is in the branch
Breaking change, deliberately:
main)@opencode/plugin2.0.21)pluginplugins.opencode/state/*.json, project-levelcheckpoints.db, SQLite, session-scopedsession_id.opencode/state/handoff.md.opencode/state/checkpoint-orchestrator/handoff-<sessionId>.mdcheckpoint_migrate>=22.13.0(node:sqliteunflagged)The published
1.0.1release line is untouched and remains installable.Storage-integrity fixes in this PR (the audit's outcome)
applyMigrationsreadPRAGMA user_versionbeforeBEGIN IMMEDIATE, so several processes opening abrand-new database all saw version 0 and all replayed
CREATE TABLE. Thelosers died with
table projects already existsand stayed inert(
Checkpoint storage unavailable). The whole upgrade now runs in onetransaction that re-reads the version after taking the write lock.
Reproduced with 6 simultaneous processes; now migrates exactly once.
UNIQUE(projects.directory)raisedUNIQUE constraint failed: projects.directoryon every write path, which a checkout-plus-worktree hostcan trigger. Migration 2 replaces the unique index with a plain one.
ensureSessionIfMissingrecordedprojectDirectory = projectIDand theupsert then rewrote the real
directory. The placeholder path no longertouches
directory/canonical.outside the write transaction. It is now repeated inside it; 6 concurrent
importers import exactly once.
Schema migration 1 → 2 was verified against a real v1 database containing rows:
data preserved,
integrity_checkok, unique constraint gone.Validation performed
npm run lint,typecheck,build— clean.npm test— 119/119 (was 113; six new regression tests, including twosix-process concurrency tests and a real v1→v2 migration test).
npm run test:legacy— 979/979 (see the note below on what these are).npm run test:opencode— 22/22 checks against realopencode v2.0.21(Bun), exercising
checkpoint_savethrough a real model turn, SQLiteisolation between two sessions, restart durability,
user_version=2,0600/0700permissions, andintegrity_check.npm pack— tarball ships onlydist/, docs and images; no database,node_modulesor tests.Note on test counts
npm test(119) is coverage of this code: every suite imports the builtdist/output or drives the real plugin entry point. The historical 979 V1assertions re-implement the old JSON logic inside the test files and never import
the plugin, so they are kept as a V1 reference under
npm run test:legacy, notas evidence about this release.
npm run test:allruns both so the combinedfigure is reproducible rather than implied.
Excluded suites
tests/plugin.test.jsandtests/interop.test.jsare not excluded fromthis repository: both run in
npm test(18 + 12 tests) and pass. They were onlyskipped inside the outside Docker Plugin Test Lab, because they resolve
@opencode/pluginfrom a full workspace; the lab covers the same subject matterwith its runtime/matrix/trigger suites. The lab also ran 83 of this repository's
other suites against the built artifact.
Independence
No plugin-to-plugin imports, no shared framework, no plugin manager, no required
load order. The Docker Plugin Test Lab registered all eight checkpoint tools
alone, with either sibling plugin, and with all three installed together, in all
six load orders plus a duplicate registration, with no errors; failure-isolation
configurations (companion failing first / mid-order / last) left this plugin's
tools independently registered. The migration never deletes legacy JSON and
never attaches it to a live session automatically.
Reviewer notes
2.0.0; this branch carries2.0.0-dev.2so thatnothing is published or tagged by accident. Bump to
2.0.0(inpackage.jsonandPLUGIN_VERSION) as the final release step.dist/is committed, as on the 1.0.x line.README.mdanddocs/INDEPENDENCE.md(e.g. a compaction aftercheckpoint_clearrecords aclearly-marked, non-injected synthesized placeholder).