Skip to content

feat: copy a stack whole between adapters, history included - #470

Open
cuibonobo wants to merge 15 commits into
mainfrom
claude/portable-export-426
Open

cuibonobo wants to merge 15 commits into
mainfrom
claude/portable-export-426

Conversation

@cuibonobo

@cuibonobo cuibonobo commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Closes #426. A stack can now leave its storage whole, authorship and history included.

  • copyStack(from, create) in @haverstack/core/adapter copies a whole stack into a new store that create makes, and returns that store open. It carries every Record (soft-deleted and unlisted included) with its versions and journal, every Type, and every blob. Blobs go before the Records that name them. Each is read as the store enumerates it (listTypes, listBlobs, queryRecords, and getRecord('_config')), never by what a Record says, so nothing is filtered on the way. Export, import and moving between storage engines are all this one call with a different source and a different kind of store to create.
  • _config travels like every Record. A store is created from a StoreSeed: a _config record and its history. copyStack hands create the source's own seed, so the target's _config arrives with its versions and journal, and its identity is what the source's record holds now, not what the source adapter read when it was opened.
    • Every adapter that creates stores takes seed in place of ownerEntityId and timezone. Native takes it only with create: 'exclusive'.
    • newStoreSeed() makes a fresh one, so a new store's _config is written through the same path as any imported Record.
    • MemoryAdapter now holds a real _config record.
  • What each side needs:
    • Both ends must have listBlobs(), so a copy never lands a stack where it can't be copied out again.
    • Source: must hold _config, and must not be written during the copy. Nothing checks the latter, and the spec and doc comment both say so.
    • Target: a store as its adapter creates one. Its owner is checked against the seed's with the same OwnerMismatchError open() throws. It must hold nothing else yet (no other Record, no Type, no blob) and have importRecord(). So a store already opened with Stack.open() is refused, since it holds the system Types.
    • Anything else is refused with InvalidAdapterError before anything is written.
  • The target is the caller's. On success it comes back open for the caller to close. On a failure, copyStack closes it (it holds the only handle) and the caller removes it. The original error is the one rethrown.
  • StackRecordAdapter.importRecord(record, { versions, journal }) is a new optional method. It writes a Record exactly as recorded, history included, in one atomic write, appends no entry of its own, and returns the StackRecord as createRecord() does. RecordHistory is two sets, keyed by version and seq: every snapshot below record.version, and journal seq values dense from 1, in any order. It's implemented by MemoryAdapter, the shared SQLite logic (both SQLite engines), adapter-local and combineAdapters. APIAdapter has none, since a server is the only writer of its own history tiers. Both journal writes in the SQLite logic run the same INSERT with only bound values.
  • saveVersion() is removed from the adapter contract. Only tooling and tests used it, and importRecord() is now the one standalone history write.
  • Stores are written whole, and opened one way.
    • writeNewStore(exec, seed) writes a new store's tables, its format row and its _config (with history) in one transaction. Opening an existing store runs no DDL, only the per-connection pragmas.
    • So the format table alone says whether a database holds a store. A store in another layout, or naming none, throws FormatMismatchError; that's the one refusal for a store that isn't whole in this layout.
    • Both SQLite engines open through one openStore() in sqlite-shared. It reads the store once, judges the create mode, checks the owner, and creates from a seed or a fresh one. So the two engines can't drift; the DO adapter now refuses an empty owner, as Node does.
    • A failed Node open() removes a file it created.
    • These rules are in adapters.md § Construction, beside OwnerMismatchError.
  • The portable format is a LocalAdapter store: the SQLite database with attachments/<fileId> beside it.
    • portability.md lists every table and column, and gives the reason for each NULL exception.
    • Every SQLite store names its layout in a one-row format table, currently version 1. A layout belongs to a store that persists, so MemoryAdapter has none.
    • The derived index tables are part of the layout too, so an indexing change is a layout change like any other. The copy rebuilds them as it writes.
    • It's a table rather than PRAGMA user_version because Durable Object storage refuses that pragma.
    • A test compares the spec's tables and the format version with the schema a new store is created with.
    • adapter-json stays planned and would become one more kind of store to copy into.
  • One name for the owner: a field is ownerEntityId when it can only hold the owner, and entityId when it may hold any entity. So _config's content ({ ownerEntityId, timezone }), ConfigContent, the discovery response and its fixtures now say ownerEntityId. Relationship targets and grantees keep entityId. This is a wire change: DiscoveryResponse.entityId is now ownerEntityId.
  • Error messages: OwnerMismatchError and FormatMismatchError read <where> is owned by "X", not "Y" and <where> is format version N, not 1, so they fit both open() and copyStack().

Spec

  • New docs/spec/portability.md: copying a stack (_config included, as the seed), writing history, the portable format (tables, encoding rules, columns, format version), tokens and installs. Listed in docs/spec.md.
  • adapters.md § Construction: every store is created holding _config, from a fresh identity or a seed; stores are written whole; the format table says whether one is there; the FormatMismatchError rule, beside OwnerMismatchError.
  • spec.md § The _config record: the owner-naming rule, and ownerEntityId as _config's immutable field.
  • wire-format.md § Discovery and § Identity is trusted on transport: ownerEntityId.
  • identity.md: _config.ownerEntityId.
  • adapters.md: importRecord() among the optional capabilities; listBlobs() required of both ends of a copy; runCopyConformance() under Conformance.
  • versioning.md, journal.md, wire-format.md: saveVersion() references replaced.
  • data-model.md § Backdating on import and access-control.md link to the new page.

Verification

All six checks pass locally: format:check, check:refs, lint, test, build, typecheck.

  • Record conformance suite: new importRecord() checks (exact round trip of record, versions and journal; returns the record; no entry of its own; conflict on an existing id; queryable after import; later writes continue the history), run against MemoryAdapter and NativeSQLiteRecordAdapter.
  • runCopyConformance({ create, source? }): writes a stack with a past into a source store, _config's own patch included, then copies it into one create makes from the seed. Every Record (_config included), version, journal entry, Type and blob must arrive unchanged. The blobs include bytes a file-ref names with no metadata record. It runs against MemoryAdapter, and against LocalAdapter three ways: Local→Local, Memory→Local and Local→Memory.
  • Core unit tests:
    • _config arrives with its history, and a timezone patched after the source was opened reaches the target;
    • paging past one page;
    • every listed blob is copied;
    • a source with no listBlobs() is refused before create is called;
    • each way a target can fail the rule is refused, and the target closed: no importRecord(), no listBlobs(), another owner (OwnerMismatchError), a Record, the system Types Stack.open() seeds, a blob.
  • sqlite-shared (config.test.ts, on an in-memory node:sqlite database):
    • a database holding no store reads as missing;
    • a created store's identity reads back, an absent timezone included;
    • a store naming another version, or none, is refused, with no table written;
    • writeNewStore() writes the whole store or none of it, tables included.
  • record-adapter-sqlite:
    • portable-format.test.ts compares the spec's column table with PRAGMA table_info on a new store, and checks its format row reads version 1;
    • a store naming another version, or none, fails open() with FormatMismatchError and releases the lock;
    • a store in another layout is refused before anything is written to it;
    • a create whose lazy owner throws leaves no file and no lock, and a later ifMissing or exclusive open creates the store.
  • A Durable Object test for importRecord(), including that a write failing partway inside transactionSync leaves no row behind. The DO suite also creates stores through openStore(), which runs writeNewStore()'s DDL inside transactionSync.

Notes for reviewers

  • APIAdapter can't be a copy source yet. It has no listBlobs(), because the wire lists no blobs, so copying out of a server-backed stack is refused. Test copying a stack out of an APIAdapter #471 now covers the wire endpoint and APIAdapter.listBlobs() as well as the round-trip test.
  • The adapter's cached identity. Every adapter still reads ownerEntityId and timezone once at open(), so adapter.timezone, and stack.timezone, go stale after a _config patch until the store is reopened. That predates this PR. The copy no longer depends on it, since it reads _config itself.
  • Wire version: the discovery field rename is a breaking wire change, which WIRE_PROTOCOL_VERSION's rule would make a major bump. It stays 1.0, as it has through earlier breaking changes, because nothing is installed yet.
  • A database another program made holds no format table, so open() treats it as missing, and ifMissing or exclusive would add a store's tables to it. Telling such a file apart would need a rule about tables the stack doesn't own, which this PR doesn't add.
  • Durable Objects: the DO adapter isn't run through runCopyConformance(), because it sits behind RPC. Its own risk, that importRecord runs inside transactionSync, has a dedicated test. Everything else is the SQLite code Local's copy suite already runs.
  • Deliberate out-of-contract input: the SQLite and DO rollback tests pass two snapshots at the same version. It's the only way to make the engine fail after the record row is written; a comment on each test says so.
  • History read orders: getVersions() returns newest first and getJournal() oldest first. That predates this PR and nothing here depends on it, since RecordHistory is keyed. Whether the two should match is Decide whether version history and the journal read in one order #472.
  • The issue's third action (the "leaving never needs permission" design rule, and moving hosts in the trust model) belongs to Add trust, defaults, reversibility and formats to the existing design rules #421 and Spec: a trust model page #418, which create those documents; it's left for them once this lands.
  • Changesets: portable-copy (minor for core, both SQLite record adapters, adapter-local, adapter-api and adapter-conformance) and owner-entity-id (minor for core, wire-types, conformance-fixtures, adapter-api and both SQLite record adapters).

🤖 Generated with Claude Code

https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz

Moving hosts must be lossless without the host's cooperation, and a copy
through the API loses authorship and history. One primitive covers export,
import and moves between storage engines: copyStack(from, to) reads a
stack through methods every adapter has and writes it through the new
optional StackRecordAdapter.importRecord(), which writes a Record exactly
as recorded beside its version snapshots and journal entries in one
atomic write.

The portable format is a LocalAdapter store (a SQLite database with
attachments/ beside it), specified in docs/spec/portability.md, so an
export is a working stack rather than a second on-disk format; the
planned adapter-json becomes one more copy target once it exists.
saveVersion() is removed: importRecord() is the contract's one
standalone history write.

adapter-conformance checks importRecord() in the record suite and adds
runCopyConformance(), a round trip through a full adapter, which
adapter-local runs against itself.

Closes #426

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 817b6eb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
@haverstack/core Minor
@haverstack/wire-types Minor
@haverstack/conformance-fixtures Minor
@haverstack/adapter-api Minor
@haverstack/record-adapter-sqlite Minor
@haverstack/record-adapter-do-sqlite Minor
@haverstack/adapter-local Minor
@haverstack/adapter-conformance Minor
@haverstack/blob-adapter-disk Patch
@haverstack/blob-adapter-s3 Patch
@haverstack/commons Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

- copyStack() checks only the target's owner, as open() checks a store's;
  timezone is passthrough metadata a store takes at creation, so the
  target is created with the source's. OwnerMismatchError says "Cannot
  use", which fits both callers.
- importRecord() states its input: snapshots below record.version,
  distinct seqs, any order. MemoryAdapter orders both lists the same way,
  and tests that imported an orphan snapshot now import real history; the
  orphan-healing tests plant their row through a connection of their own.
- portability.md names the attachment bytes it copies by what garbage
  collection weighs, not as references.
- The copy suite matches attachment records with baseIdOf(), now exported
  from @haverstack/core/adapter.
- pnpm-lock.yaml regenerated with pnpm 11, as CI runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
Two rules kept resurfacing in review because copyStack judged a store it
did not create, and chose which bytes counted as reachable on its own.

- copyStack(from, create) asks create() for a new store with the
  source's identity and returns it open. Owner and timezone travel by
  construction, and what create() returns is held to one rule with one
  error (InvalidAdapterError): that identity, no Record, no Type but the
  system ones, an adapter with importRecord(). A refusal or a failure
  part-way closes the target. OwnerMismatchError is open()'s alone again.
- It copies every blob: listBlobs() when the source has it, otherwise
  every file a Record names, by the reference rule now shared with
  deleteAttachment() and the sweep as referencedFileIds(). What is
  garbage is the target's own collection to decide.
- importRecord() returns the StackRecord, as createRecord() does.
- runCopyConformance() takes the same create(): it writes a stack into
  one store of the adapter under test and copies it into a second, so it
  no longer imports @haverstack/core/testing, and the vitest aliases that
  required come out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
Two rules kept resurfacing because each was patched rather than defined.

- attachments.md now defines the blobs a stack holds once: every blob
  its store lists and every file a Record names. Garbage collection
  weighs that set and copyStack() carries it, with no branch on
  listBlobs().
- A copy target is a store as its adapter creates one: the source's
  identity, holding nothing. The exception for system Types and for
  Stack.open()'s ownerProfile goes with it; the copy brings the source's
  own system Types.
- importRecord()'s journal input runs dense from 1, as every log does,
  and MemoryAdapter allocates the next seq from the log's maximum.
- portability.md shows importRecord() answering with the StackRecord.
- CONTRIBUTING's comment example no longer names saveVersion().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
Which blobs to copy came back in every review because copyStack tried to
make up for a source that cannot list its blobs by reading what Records
name. Records and Types are copied as the store enumerates them; blobs
now are too.

- copyStack() requires from.listBlobs(), as it requires
  to.importRecord(), and refuses a source without it before create() is
  called. Every blob adapter has it; APIAdapter does not, since the wire
  lists no blobs, so a server-backed stack cannot yet be copied out.
- The Record-derived blob set, its attachments.md section and the
  referencedFileIds() extraction are gone; garbage collection is as it
  was.
- The target rule is no Record and no Type: a blob already there is
  content-addressed and conflicts with nothing.
- portability.md lists every column in one table, and a test compares it
  with the schema a new SQLite store is created with. The format states
  that it carries no version marker, and why.
- "No adapter has a format-conversion method of its own" no longer reads
  against importRecord().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
- StackIdentity is Pick<StackRecordAdapter, 'ownerEntityId' | 'timezone'>,
  so it cannot drift from what an adapter reports.
- portability.md names the owner of whatever create() makes: the caller,
  who closes it on success and removes it on failure; copyStack() closes
  it on failure, holding the only handle.
- The encoding rules give the reason for each NULL exception, so the
  format states them as decisions.
- runCopyConformance() takes an optional source factory; adapter-local
  runs it Local to Local, Memory to Local and Local to Memory.
- adapters.md lists copyStack() among listBlobs()'s callers.
- A version collision on its own reads neutrally; the snapshot path keeps
  its racing-writer message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
An export is a file that outlives the code that wrote it, so every store
names its layout from the start rather than leaving an unmarked file as
a special case. Durable Object storage refuses PRAGMA user_version, so
the marker is a one-row `format` table in the shared schema, which both
SQLite engines create alike. portability.md documents it beside the
other tables, and the format test checks it holds version 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
…e format

- A copy target holds no Record; what is named by what it is may already
  be there if it is the same. A blob is named by its bytes and a Type by
  its id, checked by schemaHash, so a Type under another schema is
  refused and one under the same schema, the system Types Stack.open()
  seeds included, is no conflict.
- open() on either SQLite engine refuses an existing store whose format
  row is not FORMAT_VERSION with the new FormatMismatchError, shaped as
  OwnerMismatchError. The row is written only when a store is created,
  and `id INTEGER PRIMARY KEY CHECK (id = 1)` keeps it to one.
- portability.md says the source is not written during a copy, and what
  happens if it is.
- Blobs are copied before the Records that name them; a close() failing
  on the failure path no longer hides the original error; a snapshot
  collision rethrows the original, neutral StackConflictError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
- applyRecordSchema() refuses an existing store in another layout before
  any statement writes to it: it reads sqlite_master, and a store holding
  `records` must name FORMAT_VERSION in its `format` row. Both SQLite
  engines open through it in the same order, so neither adapter checks
  separately or reads _config first.
- The shared version-row insert reports a collision neutrally; the
  snapshot path on both engines reports its own situation, a writer that
  raced past the version.
- portability.md defines RecordHistory as two keyed sets rather than as
  what the two reads return, and states that the derived index tables are
  part of the layout, changed like any other part, by a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
The format row had become a second "this store exists" marker beside the
_config record, and the two could disagree: an interrupted create left
tables a later open refused, or a format row that blocked every create.

- A store exists exactly when it holds _config. readStoredIdentity()
  reads that record and the format row before anything is written, and
  refuses an existing store in another layout; a store with tables and
  no _config is new. Both SQLite engines open through it, so the owner
  and layout checks come from one read.
- insertConfigRecord() writes the format row and _config in one
  transaction, so neither exists without the other.
- sqlite-shared uses core's StackIdentity in place of its own StackConfig.
- portability.md says "every SQLite store", and states existence and the
  check order; a duplicate sentence on RecordHistory order is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
- NativeSQLiteRecordAdapter.open() decides never, ifMissing and
  exclusive by whether a stack is there, a file holding _config, as
  readStoredIdentity() and the Durable Object adapter already do. No
  file is still judged before the lock; a file with tables and no
  _config is a stack not yet created, its lazy owner resolved under the
  lock the failure path releases.
- Both journal writes run one INSERT built from JOURNAL_COLUMNS, with
  seq supplied as a bound value or as the log's next number, and one
  values builder.
- spec.md § The _config record states the naming rule: a stored or wire
  field names the kind it references (entityId), an API property the
  role (ownerEntityId). portability.md points to it.
- copyStack()'s doc comment states the unchecked precondition that the
  source is not written during the copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
… why

- adapters.md § Construction now states when a store is present (it
  holds _config) and that a SQLite store in another layout is refused
  with FormatMismatchError before anything is written, beside
  OwnerMismatchError. portability.md and the code comments link there.
- portability.md says why the copy checks both owner and timezone while
  open() checks the owner alone: _config is the one thing the copy
  cannot write itself, so it confirms create() lost nothing; open()
  tests a claim, and timezone is consulted only at creation.
- A Type already in the target is the same Type by id and schemaHash, as
  defineType() holds, and the source's definition replaces it, as a
  later defineType() renaming it would.
- sqlite-shared's insertConfigRecord() is writeNewStore(), beside
  readStoredIdentity(): it writes everything a new store is made with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
…hing

Rules here kept swinging because each round argued them from scratch.
Each now rests on what the system already does.

- copyStack() checks the target's owner, the one identity claim the
  system checks anywhere, and hands the timezone to create() as any new
  store is given one: timezone is passthrough metadata, as spec.md says.
- The target holds no Record and no Type, as an adapter creates a store;
  the same-schema allowance for Types, which only a store opened through
  Stack.open() needed, is gone. Blobs stay unchecked: not every store
  lists them, and writing the same bytes twice is harmless.
- importRecord is captured once on the target, as listBlobs is on the
  source, so copyRecords() needs no non-null assertion.
- spec.md's owner-naming sentence states only what holds: _config and
  discovery say entityId, adapters ownerEntityId.
- adapters.md notes a layout belongs to a store that persists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
The same rules kept coming back because the code made states the rules
then had to tolerate. This removes the states.

- The owner is ownerEntityId wherever a field can only hold the owner:
  _config's content, ConfigContent, the discovery response and its
  fixtures. Fields that may hold any entity keep entityId. The mapping
  in sqlite-shared and the naming exception in spec.md go away.
- writeNewStore() writes a new store's tables, its format row and
  _config in one transaction; opening an existing store runs no DDL,
  only the per-connection pragmas. A store is whole or missing, so the
  interrupted-create rule and its tests go. Node resolves a lazy owner in
  one place, under the lock, and a failed open removes a file it made.
- Both ends of a copy must list their blobs, and the target must hold
  nothing yet, blobs included, so a copy cannot land a stack where it
  cannot be copied out again.
- OwnerMismatchError's message no longer assumes open(); copyStack()
  throws it for the same owner check open() makes.
- appendJournal() reads the next seq inside its transaction and binds
  it, so both journal writes run the same INSERT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
The copy carried every Record but _config, and took the target's
identity from the source adapter's values cached at open, so a timezone
patched since was lost. Identity lived in two places; the copy now reads
it from the one the store holds.

- A store is created from a StoreSeed: a _config record and its history.
  newStoreSeed() makes a fresh one; copyStack() hands create() the
  source's own, so _config arrives with its versions and journal and the
  target's identity is what the source's record holds now.
- Every adapter that creates stores takes `seed` in place of
  ownerEntityId and timezone, and writes it as importRecord() writes any
  Record. MemoryAdapter now holds a real _config record.
- Both SQLite engines open through one openStore() in sqlite-shared, so
  their open rules cannot drift; the DO adapter now refuses an empty
  owner as Node does.
- Stores are written whole, so the format table alone says whether one
  is there; a store not in this layout is one FormatMismatchError, whose
  message no longer assumes open().
- identity.md names _config.ownerEntityId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz
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.

Portable export and full-fidelity import

2 participants