Repository navigation
Conversation
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 detectedLatest commit: 817b6eb The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
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
2 tasks
- 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
2 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #426. A stack can now leave its storage whole, authorship and history included.
copyStack(from, create)in@haverstack/core/adaptercopies a whole stack into a new store thatcreatemakes, 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, andgetRecord('_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._configtravels like every Record. A store is created from aStoreSeed: a_configrecord and its history.copyStackhandscreatethe source's own seed, so the target's_configarrives 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.seedin place ofownerEntityIdandtimezone. Native takes it only withcreate: 'exclusive'.newStoreSeed()makes a fresh one, so a new store's_configis written through the same path as any imported Record.MemoryAdapternow holds a real_configrecord.listBlobs(), so a copy never lands a stack where it can't be copied out again._config, and must not be written during the copy. Nothing checks the latter, and the spec and doc comment both say so.OwnerMismatchErroropen()throws. It must hold nothing else yet (no other Record, no Type, no blob) and haveimportRecord(). So a store already opened withStack.open()is refused, since it holds the system Types.InvalidAdapterErrorbefore anything is written.copyStackcloses 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 theStackRecordascreateRecord()does.RecordHistoryis two sets, keyed byversionandseq: every snapshot belowrecord.version, and journalseqvalues dense from 1, in any order. It's implemented by MemoryAdapter, the shared SQLite logic (both SQLite engines),adapter-localandcombineAdapters.APIAdapterhas 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, andimportRecord()is now the one standalone history write.writeNewStore(exec, seed)writes a new store's tables, itsformatrow and its_config(with history) in one transaction. Opening an existing store runs no DDL, only the per-connection pragmas.formattable alone says whether a database holds a store. A store in another layout, or naming none, throwsFormatMismatchError; that's the one refusal for a store that isn't whole in this layout.openStore()insqlite-shared. It reads the store once, judges thecreatemode, 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.open()removes a file it created.adapters.md § Construction, besideOwnerMismatchError.LocalAdapterstore: the SQLite database withattachments/<fileId>beside it.portability.mdlists every table and column, and gives the reason for each NULL exception.formattable, currently version 1. A layout belongs to a store that persists, so MemoryAdapter has none.PRAGMA user_versionbecause Durable Object storage refuses that pragma.adapter-jsonstays planned and would become one more kind of store to copy into.ownerEntityIdwhen it can only hold the owner, andentityIdwhen it may hold any entity. So_config's content ({ ownerEntityId, timezone }),ConfigContent, the discovery response and its fixtures now sayownerEntityId. Relationship targets and grantees keepentityId. This is a wire change:DiscoveryResponse.entityIdis nowownerEntityId.OwnerMismatchErrorandFormatMismatchErrorread<where> is owned by "X", not "Y"and<where> is format version N, not 1, so they fit bothopen()andcopyStack().Spec
docs/spec/portability.md: copying a stack (_configincluded, as the seed), writing history, the portable format (tables, encoding rules, columns, format version), tokens and installs. Listed indocs/spec.md.adapters.md § Construction: every store is created holding_config, from a fresh identity or aseed; stores are written whole; theformattable says whether one is there; theFormatMismatchErrorrule, besideOwnerMismatchError.spec.md § The _config record: the owner-naming rule, andownerEntityIdas_config's immutable field.wire-format.md § Discoveryand§ 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 importandaccess-control.mdlink to the new page.Verification
All six checks pass locally:
format:check,check:refs,lint,test,build,typecheck.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 onecreatemakes from the seed. Every Record (_configincluded), version, journal entry, Type and blob must arrive unchanged. The blobs include bytes afile-refnames with no metadata record. It runs against MemoryAdapter, and against LocalAdapter three ways: Local→Local, Memory→Local and Local→Memory._configarrives with its history, and a timezone patched after the source was opened reaches the target;listBlobs()is refused beforecreateis called;importRecord(), nolistBlobs(), another owner (OwnerMismatchError), a Record, the system TypesStack.open()seeds, a blob.sqlite-shared(config.test.ts, on an in-memorynode:sqlitedatabase):writeNewStore()writes the whole store or none of it, tables included.record-adapter-sqlite:portable-format.test.tscompares the spec's column table withPRAGMA table_infoon a new store, and checks itsformatrow reads version 1;open()withFormatMismatchErrorand releases the lock;ifMissingorexclusiveopen creates the store.importRecord(), including that a write failing partway insidetransactionSyncleaves no row behind. The DO suite also creates stores throughopenStore(), which runswriteNewStore()'s DDL insidetransactionSync.Notes for reviewers
APIAdaptercan't be a copy source yet. It has nolistBlobs(), because the wire lists no blobs, so copying out of a server-backed stack is refused. Test copying a stack out of anAPIAdapter#471 now covers the wire endpoint andAPIAdapter.listBlobs()as well as the round-trip test.ownerEntityIdandtimezoneonce atopen(), soadapter.timezone, andstack.timezone, go stale after a_configpatch until the store is reopened. That predates this PR. The copy no longer depends on it, since it reads_configitself.WIRE_PROTOCOL_VERSION's rule would make a major bump. It stays1.0, as it has through earlier breaking changes, because nothing is installed yet.formattable, soopen()treats it as missing, andifMissingorexclusivewould 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.runCopyConformance(), because it sits behind RPC. Its own risk, thatimportRecordruns insidetransactionSync, has a dedicated test. Everything else is the SQLite code Local's copy suite already runs.getVersions()returns newest first andgetJournal()oldest first. That predates this PR and nothing here depends on it, sinceRecordHistoryis keyed. Whether the two should match is Decide whether version history and the journal read in one order #472.portable-copy(minorfor core, both SQLite record adapters, adapter-local, adapter-api and adapter-conformance) andowner-entity-id(minorfor core, wire-types, conformance-fixtures, adapter-api and both SQLite record adapters).🤖 Generated with Claude Code
https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz