Skip to content

feat!: session-isolated SQLite persistence for OpenCode V2 (v2.0.0 release candidate) - #1

Merged
gitdamnit merged 17 commits into
mainfrom
feat/sqlite-opencode-v2
Oct 17, 2026
Merged

gitdamnit merged 17 commits into
mainfrom
feat/sqlite-opencode-v2

Conversation

@gitdamnit

Copy link
Copy Markdown
Owner

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:

1.0.1 (main) this branch
OpenCode V1 V2 (@opencode/plugin 2.0.21)
Config key plugin plugins
Storage .opencode/state/*.json, project-level checkpoints.db, SQLite, session-scoped
Session isolation none — one checkpoint per project one checkpoint per session_id
Handoff .opencode/state/handoff.md .opencode/state/checkpoint-orchestrator/handoff-<sessionId>.md
Tools 7 7 preserved + checkpoint_migrate
Node floor — >=22.13.0 (node:sqlite unflagged)

The published 1.0.1 release line is untouched and remains installable.

Storage-integrity fixes in this PR (the audit's outcome)

  1. Concurrent first-open crashed. applyMigrations read PRAGMA user_version before BEGIN IMMEDIATE, so several processes opening a
    brand-new database all saw version 0 and all replayed CREATE TABLE. The
    losers died with table projects already exists and stayed inert
    (Checkpoint storage unavailable). The whole upgrade now runs in one
    transaction that re-reads the version after taking the write lock.
    Reproduced with 6 simultaneous processes; now migrates exactly once.
  2. One directory, two project ids made storage unwritable.
    UNIQUE(projects.directory) raised UNIQUE constraint failed: projects.directory on every write path, which a checkout-plus-worktree host
    can trigger. Migration 2 replaces the unique index with a plain one.
  3. A write path overwrote the authoritative project directory.
    ensureSessionIfMissing recorded projectDirectory = projectID and the
    upsert then rewrote the real directory. The placeholder path no longer
    touches directory/canonical.
  4. Concurrent legacy imports could duplicate snapshots. The ledger check sat
    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_check ok, unique constraint gone.

Validation performed

  • npm run lint, typecheck, build — clean.
  • npm test — 119/119 (was 113; six new regression tests, including two
    six-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 real opencode v2.0.21
    (Bun), exercising checkpoint_save through a real model turn, SQLite
    isolation between two sessions, restart durability, user_version=2,
    0600/0700 permissions, and integrity_check.
  • npm pack — tarball ships only dist/, docs and images; no database,
    node_modules or tests.

Note on test counts

npm test (119) is coverage of this code: every suite imports the built
dist/ output or drives the real plugin entry point. The historical 979 V1
assertions 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, not
as evidence about this release. npm run test:all runs both so the combined
figure is reproducible rather than implied.

Excluded suites

tests/plugin.test.js and tests/interop.test.js are not excluded from
this repository: both run in npm test (18 + 12 tests) and pass. They were only
skipped inside the outside Docker Plugin Test Lab, because they resolve
@opencode/plugin from a full workspace; the lab covers the same subject matter
with 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

  • The proposed GA version is 2.0.0; this branch carries 2.0.0-dev.2 so that
    nothing is published or tagged by accident. Bump to 2.0.0 (in
    package.json and PLUGIN_VERSION) as the final release step.
  • dist/ is committed, as on the 1.0.x line.
  • Remaining limitations are recorded in README.md and
    docs/INDEPENDENCE.md (e.g. a compaction after checkpoint_clear records a
    clearly-marked, non-injected synthesized placeholder).

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).
@gitdamnit
gitdamnit marked this pull request as ready for review October 17, 2026 00:51
@gitdamnit
gitdamnit merged commit aff5db6 into main Oct 17, 2026
6 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread README.md
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") })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment on lines +135 to +136
const cached = this.cache.get(sessionID)
if (cached) return cached

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-17T00:55:29.710464Z c643b7d Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant