From b02e69c4675e7397d2fca76c314261a9744053bf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 19:46:53 +0000 Subject: [PATCH 01/16] feat: copy a stack whole between adapters, history included 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 15 + README.md | 2 + docs/spec.md | 1 + docs/spec/access-control.md | 2 +- docs/spec/adapters.md | 4 +- docs/spec/data-model.md | 1 + docs/spec/journal.md | 2 +- docs/spec/portability.md | 115 +++++++ docs/spec/versioning.md | 4 +- docs/spec/wire-format.md | 2 +- packages/adapter-api/src/adapter.ts | 6 - packages/adapter-api/tests/api.test.ts | 19 +- packages/adapter-conformance/src/copy.ts | 148 +++++++++ packages/adapter-conformance/src/helpers.ts | 14 +- packages/adapter-conformance/src/index.ts | 2 + packages/adapter-conformance/src/record.ts | 217 +++++++++++--- .../tests/copy-memory.test.ts | 11 + packages/adapter-local/package.json | 1 + packages/adapter-local/src/index.ts | 9 +- packages/adapter-local/tests/copy.test.ts | 30 ++ packages/adapter-local/vitest.config.ts | 2 + packages/blob-adapter-disk/vitest.config.ts | 1 + packages/core/src/adapter/combine.ts | 7 +- packages/core/src/adapter/copy.ts | 75 +++++ packages/core/src/adapter/index.ts | 2 + packages/core/src/stack/stack.ts | 4 +- packages/core/src/testing.ts | 36 +-- packages/core/src/types/adapter.ts | 26 +- packages/core/tests/combine.test.ts | 25 +- packages/core/tests/copy.test.ts | 76 +++++ packages/core/tests/scoped-stack.test.ts | 282 ++++++++++-------- packages/core/tests/stack.test.ts | 19 +- .../tests/record.test.ts | 51 +++- .../tests/support/test-worker.ts | 10 +- .../tests/record.test.ts | 228 ++++++++------ .../record-adapter-sqlite/vitest.config.ts | 1 + packages/sqlite-shared/src/record-adapter.ts | 9 +- packages/sqlite-shared/src/record-logic.ts | 154 ++++++---- pnpm-lock.yaml | 134 ++++----- 39 files changed, 1275 insertions(+), 472 deletions(-) create mode 100644 .changeset/portable-copy.md create mode 100644 docs/spec/portability.md create mode 100644 packages/adapter-conformance/src/copy.ts create mode 100644 packages/adapter-conformance/tests/copy-memory.test.ts create mode 100644 packages/adapter-local/tests/copy.test.ts create mode 100644 packages/core/src/adapter/copy.ts create mode 100644 packages/core/tests/copy.test.ts diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md new file mode 100644 index 00000000..d80289b5 --- /dev/null +++ b/.changeset/portable-copy.md @@ -0,0 +1,15 @@ +--- +'@haverstack/core': minor +'@haverstack/record-adapter-sqlite': minor +'@haverstack/record-adapter-do-sqlite': minor +'@haverstack/adapter-local': minor +'@haverstack/adapter-api': minor +'@haverstack/adapter-conformance': minor +--- + +A stack can be copied whole between adapters, authorship and history included. + +- `copyStack(from, to)` in `@haverstack/core/adapter` carries every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and the bytes its attachment records name. Copying into a `LocalAdapter` store is an export; copying out of one is an import. +- It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. +- `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. +- `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, a round trip through a full adapter. diff --git a/README.md b/README.md index 5ec6146f..f0dc413c 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,8 @@ The adapter interface is split into `StackRecordAdapter` (structured records) an Use `combineAdapters({ record, blob })` from `@haverstack/core/adapter` to compose a record adapter with a different blob backend — for example, `NativeSQLiteRecordAdapter` with `S3BlobAdapter`. `adapter-local` wraps this pattern for the common case. +To move a stack between backends or hosts, `copyStack(from, to)` from the same entry point copies it whole — soft-deleted and unlisted Records, authorship, version history, journal, Types and attachments. An export is a `LocalAdapter` store: copy into one to export, out of one to import. See [Portability](./docs/spec/portability.md). + --- ## Development diff --git a/docs/spec.md b/docs/spec.md index c9eda826..6381e2f8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -21,6 +21,7 @@ The spec is split into focused documents: | [Attachments](./spec/attachments.md) | Content-addressed binary storage, metadata records, garbage collection | | [Change events](./spec/events.md) | Subscribing to record changes, event shape, permission scoping, delivery | | [Adapters](./spec/adapters.md) | Adapter contract, backends, capabilities, concurrency & storage ownership | +| [Portability](./spec/portability.md) | Copying a stack whole, history included, and the portable export format | | [Wire format](./spec/wire-format.md) | The HTTP API stack servers implement and `adapter-api` consumes | | [Change feed](./spec/change-feed.md) | The wire encoding of the change events above — `GET /changes` over SSE | diff --git a/docs/spec/access-control.md b/docs/spec/access-control.md index f02251b7..5aaa61eb 100644 --- a/docs/spec/access-control.md +++ b/docs/spec/access-control.md @@ -6,7 +6,7 @@ Two questions next to this one have their own documents: whether a Record is _en ## Refused at the write, and again at evaluation -`grantType()` and the access verbs are not the only ways authority-bearing state reaches storage. An unscoped `Stack`, a JSON import, a server mapping a request body, an adapter write and a foreign server's response all produce permission elements and `_grant` Records that no helper vetted and no compiler saw. +`grantType()` and the access verbs are not the only ways authority-bearing state reaches storage. An unscoped `Stack`, a [copied stack](./portability.md#copying-a-stack), a server mapping a request body, an adapter write and a foreign server's response all produce permission elements and `_grant` Records that no helper vetted and no compiler saw. So every rule below that decides reach is enforced twice: **refused at the write**, and **read as conferring nothing at evaluation**. A rule held only at the writing helper would hold only for what went through it, and what it was meant to refuse would instead store, answer 200, and deny forever — a share that looks like it worked and never did. Where a rule below says it is enforced "again at evaluation", this is what it means; the argument is not restated at each one. diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index 946ad4ad..325909f4 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -30,7 +30,7 @@ type StackAdapter = StackRecordAdapter & }; ``` -**Optional capabilities** follow one pattern throughout: an optional interface method, checked for truthiness at the call site, with a described fallback when absent. `StackRecordAdapter.deleteUnreferencedAttachmentRecords()` (atomic reference check — see [Attachments](./attachments.md#deleting-attachments)), `StackRecordAdapter.queryReadable()` (a query among the Records one requester may read; a stack is not scoped without it — see [below](#querying-within-a-reach)), `StackBlobAdapter.listBlobs()` (blob enumeration, used by [garbage collection](./attachments.md#garbage-collection) to find bare-bytes orphans), `StackRecordAdapter.subscribeChanges()` (relaying a feed that originates elsewhere — see [Change events § Where events come from](./events.md#where-events-come-from)), and `StackAdapter.putAttachmentWithMetadata()` (atomic upload, below) are all this shape — no boolean flag in `capabilities`, just an optional method a caller checks for before using. `combineAdapters()` (below) preserves this: it forwards an optional method only when the underlying part actually implements it, never as a wrapper around a missing one. +**Optional capabilities** follow one pattern throughout: an optional interface method, checked for truthiness at the call site, with a described fallback when absent. `StackRecordAdapter.deleteUnreferencedAttachmentRecords()` (atomic reference check — see [Attachments](./attachments.md#deleting-attachments)), `StackRecordAdapter.queryReadable()` (a query among the Records one requester may read; a stack is not scoped without it — see [below](#querying-within-a-reach)), `StackRecordAdapter.importRecord()` (a Record written with its whole history; a stack cannot be copied into without it — see [Portability § Writing history](./portability.md#writing-history)), `StackBlobAdapter.listBlobs()` (blob enumeration, used by [garbage collection](./attachments.md#garbage-collection) to find bare-bytes orphans), `StackRecordAdapter.subscribeChanges()` (relaying a feed that originates elsewhere — see [Change events § Where events come from](./events.md#where-events-come-from)), and `StackAdapter.putAttachmentWithMetadata()` (atomic upload, below) are all this shape — no boolean flag in `capabilities`, just an optional method a caller checks for before using. `combineAdapters()` (below) preserves this: it forwards an optional method only when the underlying part actually implements it, never as a wrapper around a missing one. `StackAdapter.putAttachmentWithMetadata(data, { mimeType, filename?, appId? })` stores bytes and creates the accompanying `_attachment@1` record as **one atomic operation**, returning the created record. It is declared on the composed `StackAdapter` type rather than on either half, because neither half can ever have it: "bytes + record in one operation" is a property only a whole adapter can offer. Exactly one adapter does: the API adapter, backed by a single `POST /attachments` request the server fulfills atomically. Local storage adapters don't implement it, and `combineAdapters()` never synthesizes it from parts (a record backend and a blob backend glued together have no shared transaction). `Stack.putAttachment()` checks for it — present means delegate the whole operation and trust the returned record as backend-authoritative; absent means the bytes-then-`create()` fallback sequence. See [Wire format § Attachments](./wire-format.md#attachments) for why the atomic form is a correctness requirement, not an efficiency optimization. @@ -142,7 +142,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s ## Conformance -The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents. +The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents. `runCopyConformance()` takes an `open()` for a full adapter and checks the [round trip](./portability.md#copying-a-stack) a move between hosts makes through it; `adapter-local` runs it against itself. ## SQLite-backed adapters diff --git a/docs/spec/data-model.md b/docs/spec/data-model.md index f519289d..91a0bfc5 100644 --- a/docs/spec/data-model.md +++ b/docs/spec/data-model.md @@ -93,6 +93,7 @@ The same rules are enforced locally, so a client-minted ID behaves identically w - **`id` and `createdAt` must agree.** Omit `id` and it's derived from `createdAt`'s timestamp, so the two can't diverge. Supply both, and they're checked against each other using the same `idTimestampSkewMs` tolerance the `id`-vs-current-time check above uses (default 24 hours; `null` disables this check too) — disagreement beyond that tolerance throws `StackValidationError` rather than silently diverging. Supplying `id` alone, with no `createdAt`, is unaffected: it stays a pure position choice — including for the owner, whose plain `id`-only creates through `ScopedStack` still get the ordinary `id`-vs-current-time check, not this one. - **`updatedAt` defaults to `createdAt`**, not to the actual current time, so a plain import doesn't fabricate a fake edit and inflate version history. Supplying an `updatedAt` earlier than `createdAt` is a validation error — including when `createdAt` was left to default to now. - **Both fields must be valid, representable Dates.** An `Invalid Date` (what `new Date()` yields for a malformed date string, a common shape for a bad row in an imported corpus) is a `StackValidationError`, not a record: its `getTime()` is `NaN`, and every comparison against `NaN` is false, so an unchecked one would switch off the ordering and skew checks above rather than fail them. The representable range is the range a record ID's 9-character timestamp prefix can encode — `1970-01-01T00:00:00.000Z` through `3084-12-12T12:41:28.831Z` — since a `createdAt` outside it has no ID that can agree with it. Content genuinely dated outside that window belongs in the record's own content fields, not in `createdAt`. +- **Backdating is for a corpus from outside the stack.** Moving a stack itself, with its authorship and history, is [Portability § Copying a stack](./portability.md#copying-a-stack). - **Backdated records are invisible to an `updatedAt` cursor.** A backdated record's `updatedAt` predates its import by construction, so a consumer syncing incrementally by `filter.updatedAt.after` never sees it arrive — which is the point (an import is not a recent edit), but it means an import is picked up by a full corpus read, not by a change cursor. ## Mutations diff --git a/docs/spec/journal.md b/docs/spec/journal.md index bd261048..d6a6bd23 100644 --- a/docs/spec/journal.md +++ b/docs/spec/journal.md @@ -76,7 +76,7 @@ A permission element's delta rides in that same list, on the same terms as a tag It is one rule rather than two lists because the two halves are built from one object: `Stack` names a change once — its ops, its kind, its actor, what it moved — and hands the durable half to the adapter and the live half to the emitter. A verb cannot journal one thing and announce another, and the [purge exception](#a-purge-destroys-the-journal) below is a property of that object rather than a rule each call site remembers. -**`putAttachment()` over an adapter that writes bytes and metadata together is the one emission with no local entry**, exactly as [`saveVersion()` is the one snapshot `APIAdapter` does not write](./versioning.md#snapshot-atomicity): the record was created on the far side, so the entry was appended there, in that same write. The division is the same one the whole adapter draws — a server is the only writer of its own durable tiers — and no adapter that stores records locally has a case like it. +**`putAttachment()` over an adapter that writes bytes and metadata together is the one emission with no local entry**, exactly as [`APIAdapter` writes no snapshot](./versioning.md#snapshot-atomicity): the record was created on the far side, so the entry was appended there, in that same write. The division is the same one the whole adapter draws — a server is the only writer of its own durable tiers — and no adapter that stores records locally has a case like it. ## Ordering diff --git a/docs/spec/portability.md b/docs/spec/portability.md new file mode 100644 index 00000000..a805e3f8 --- /dev/null +++ b/docs/spec/portability.md @@ -0,0 +1,115 @@ +# Portability + +**A stack can leave its host whole.** Every Record, soft-deleted and unlisted ones included, keeps its authorship, its version history and its journal, and every Type and attachment comes with it. Moving needs nothing from the host beyond what it already serves its owner. Identity moves with the stack and the address does not: the new host serves the same owner DID, which a client confirms with `APIAdapter`'s `ownerEntityId` option ([Wire format § Identity is trusted on transport](./wire-format.md#identity-is-trusted-on-transport)). References to the old `stackUrl`, a Group's or a relationship target's, are updated by hand. + +## Copying a stack + +```ts +import { copyStack } from '@haverstack/core/adapter'; + +await copyStack(from, to); +``` + +**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it to another; which pair of adapters it is given is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has an export or import method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. + +What it carries: + +- **Every Type**, through `listTypes()` and `saveType()`. +- **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and the target was created holding the source's identity. +- **The bytes every [`_attachment` family](./attachments.md#finding-a-fileids-metadata-records) record names**, soft-deleted and unlisted ones included, for the reason those count as references. Bytes no record names are [garbage](./attachments.md#garbage-collection) and stay behind. + +Nothing a purge destroyed is carried, because the source no longer holds it. + +**It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. + +**The target is a store newly created with the source's identity.** `copyStack()` refuses one that cannot hold history, has another owner, has another timezone or already holds a Record, before writing anything: + +| Target | Refusal | +| --------------------------------------- | ------------------------------------------------ | +| No `importRecord()` | `InvalidAdapterError` | +| Another `ownerEntityId` | `OwnerMismatchError`, `where: 'the copy target'` | +| Another `timezone` | `InvalidAdapterError` | +| Holds a Record, deleted or unlisted too | `StackConflictError` | + +A store `Stack.open()` has opened holds the system Types and no Record, so it is a valid target unless `ownerProfile` created the owner's card in it. Types are saved over, which is why a target may hold them: the only ones a new store holds are the system Types, which every stack defines alike. + +**It is not atomic.** A failure part-way leaves the target partly written; it is discarded and the copy run again into a new one, never resumed. + +**The source is read only through methods every adapter has**, so it may be an `APIAdapter` opened as the owner, who reads every Record, every version and every journal entry in full. + +## Writing history + +```ts +importRecord?(record: StackRecord, history: RecordHistory): Promise; + +type RecordHistory = { + versions: RecordVersion[]; + journal: RecordJournalEntry[]; +}; +``` + +`StackRecordAdapter.importRecord()` is `createRecord()` for a Record that already has a past, and the adapter contract's only write of a whole history. It writes the Record exactly as given — `version`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, `deletedAt`, `unlistedAt`, its associations and its permissions — beside each version snapshot and each journal entry as recorded, `seq` and `at` included. + +- **One atomic write**, under [the rule every mutating record method follows](./adapters.md#interface-split): a refused import leaves no row behind. +- **`StackConflictError` when `record.id` exists**, as `createRecord()` throws. +- **It appends no journal entry of its own.** The entries it is handed are the log. +- **Writes after it continue the history it wrote.** The next bump snapshots the version the import left and moves past it, and the next entry's `seq` is [allocated from the log's maximum](./journal.md#ordering) as always. + +`RecordHistory` is what `getVersions()` and `getJournal()` return, so an adapter reads a Record's history out in the shape another writes it in. + +**Optional, and every local adapter implements it.** It is optional only so `APIAdapter` can leave it out, as [`queryReadable()`](./adapters.md#querying-within-a-reach) is. A server is the only writer of its own durable tiers ([Versioning § Snapshot atomicity](./versioning.md#snapshot-atomicity)), and the wire carries no endpoint writing either one, so a stack behind an `APIAdapter` cannot be copied into. The [conformance suite](./adapters.md#conformance) holds an adapter declaring it to every rule above, and `runCopyConformance()` holds a full adapter to the round trip: a stack with a past copied in and back out, unchanged at both hops. + +## The portable format + +**An export is a `LocalAdapter` store: a SQLite database, with an `attachments/` directory beside it.** It is a working stack, not an archive to unpack — `Stack.open()` reads it as it reads any local stack. + +```ts +// Export: the host's stack, into a new portable store. +const exported = await LocalAdapter.open({ + path: './my-stack.db', + create: 'exclusive', + ownerEntityId: host.ownerEntityId, + timezone: host.timezone, +}); +await copyStack(host, exported); +await exported.close(); + +// Import: the portable store, into a new host's empty one. +await copyStack(await LocalAdapter.open({ path: './my-stack.db' }), newHost); +``` + +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change. + +**`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. + +**The database** holds six tables that are sources of truth and two that are [derived](./adapters.md#sqlite-backed-adapters): + +| Table | One row per | Key | +| --------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `records` | Record, `_config` included | `id` | +| `associations` | data association | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack` | +| `permissions` | permission element | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | +| `versions` | version snapshot | `record_id`, `version` | +| `journal` | journal entry | `record_id`, `seq` | +| `types` | Type | `id` | +| `content_index` | derived: top-level scalar field | `record_id`, `field` | +| `records_fts` | derived: full-text index entry | `rowid` of the `records` row | + +Their columns follow four encoding rules: + +- **Times are integers**, milliseconds since the Unix epoch. +- **Structured values are JSON text**: `records.content`, `versions.content`, `types.schema`, `journal.ops`, `journal.actor` (a `ChangeActor`) and `journal.associations` (an `AssociationChange[]`). +- **SQL `NULL` is an absent optional field**, except in the two element tables, where every column is `NOT NULL` and `''` marks one that does not apply to the element; and in `journal.previous_parent_id`, where `NULL` is an entry naming no origin and `''` the root, as [the entry](./journal.md#the-entry) distinguishes them. +- **An actor is two columns**, a subject and a principal: `createdBy` is `entity_id` and `principal_id`, and `updatedBy` is `updated_by` and `updated_via`, on both `records` and `versions`. + +The remaining columns are the fields they name: `records` holds `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `app_id`, `deleted_at` and `unlisted_at`; `versions` holds `type_id`, `content` and `updated_at`; `journal` holds `at`, `kind`, `version`, `type_id` and `parent_id`; `types` holds `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from` and `created_at`. An association's `attachment_record_id` sits outside its key, as [Attachments § Naming the upload a reference came from](./attachments.md#naming-the-upload-a-reference-came-from) requires. + +The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is opened with and [`copyStack()` checks](#copying-a-stack). + +`content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. + +## Tokens and installs + +**Tokens.** A bearer token, and the delegation it names, is a credential the host issued; `LocalAdapter` keeps them in a sibling file (`.tokens`) that is never part of the stack. The new host issues its own, and a key that earned one before earns one there the same way: a `did:key` names its public key, so nothing about it is tied to a host. The owner asserts each delegation again, [out of band](./identity.md#authentication-challengeresponse) as before. + +**Installs are Records.** `_install`, `_app` and `_grant` Records are copied as recorded, so an installed app keeps its approved versions and grants under the same key. Whether it can reach the new host is the app's configuration — it is told the new URL — not the stack's. diff --git a/docs/spec/versioning.md b/docs/spec/versioning.md index dfad7dab..90f41008 100644 --- a/docs/spec/versioning.md +++ b/docs/spec/versioning.md @@ -47,7 +47,7 @@ type RecordVersion = { ## Snapshot atomicity -**A snapshot is written as part of the same atomic write as the mutation it precedes**, never as a separate call before it. Adapters accept the snapshot as an option on every mutating method and fold the insert into their own transaction; `Stack` builds it from the record it has already read and passes it through. A standalone `saveVersion()` exists for tooling, but no mutation path uses it — and it is a deliberate no-op over `APIAdapter`, where the server is the only snapshot writer. +**A snapshot is written as part of the same atomic write as the mutation it precedes**, never as a separate call before it. Adapters accept the snapshot as an option on every mutating method and fold the insert into their own transaction; `Stack` builds it from the record it has already read and passes it through. The one other write of a snapshot is [`importRecord()`](./portability.md#writing-history), which writes a Record and its whole history together and which `APIAdapter` does not have: the server is the only snapshot writer there. The reason is recoverability of the history mechanism itself. Were the snapshot a separate call preceding the mutation, a crash in between would leave a `versions` row at the record's own current version — a version number no legitimate snapshot can carry, since a snapshot is written in the same breath as the bump past it. That orphan would then collide with every future mutation's snapshot attempt, permanently blocking writes to the record. @@ -136,7 +136,7 @@ This is not a precondition a caller can hold. `version` is unchanged by these wr - JSON _(planned)_: sibling files `{id}.versions.json` and `{id}.journal.json` - SQLite: `versions` and `journal` tables -- API: **the server is the only writer of both tiers** — `saveVersion()` is a deliberate no-op over `APIAdapter` and a journal entry travels on no request, so a server implementing anything less than the full list of version-bumping endpoints silently loses rollback history for the ones it skipped. Both tiers are read back over the wire: [Wire format § Versions](./wire-format.md#versions) for that list, and [§ Journal](./wire-format.md#journal) for the endpoint serving this one. +- API: **the server is the only writer of both tiers** — no request writes a snapshot or carries a journal entry, and `APIAdapter` has no [`importRecord()`](./portability.md#writing-history), so a server implementing anything less than the full list of version-bumping endpoints silently loses rollback history for the ones it skipped. Both tiers are read back over the wire: [Wire format § Versions](./wire-format.md#versions) for that list, and [§ Journal](./wire-format.md#journal) for the endpoint serving this one. ## Deletion diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index eaae44c9..41694518 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -464,7 +464,7 @@ A move that would make the record its own ancestor answers **409** (code `confli ## Versions -**The server snapshots prior state automatically on every mutating endpoint that bumps `version`** — there is no client-initiated endpoint to write a version directly. The list is exhaustive on purpose: `PATCH /records/:id/content`, `DELETE` (soft), `POST .../undelete`, `POST .../migrate`, and `POST .../restore/:version` itself (restore always creates a new version). The parent, unlisted, association and permission endpoints are the deliberate exception: none of them bumps `version`, so none snapshots either. `saveVersion()` is a deliberate no-op over `APIAdapter` — the server is the only snapshot writer for this adapter — so a server that implements anything less than every endpoint above silently loses rollback history for that endpoint's mutations. +**The server snapshots prior state automatically on every mutating endpoint that bumps `version`** — there is no client-initiated endpoint to write a version directly. The list is exhaustive on purpose: `PATCH /records/:id/content`, `DELETE` (soft), `POST .../undelete`, `POST .../migrate`, and `POST .../restore/:version` itself (restore always creates a new version). The parent, unlisted, association and permission endpoints are the deliberate exception: none of them bumps `version`, so none snapshots either. `APIAdapter` has no [`importRecord()`](./portability.md#writing-history) — the server is the only snapshot writer for this adapter — so a server that implements anything less than every endpoint above silently loses rollback history for that endpoint's mutations. ``` GET /records/:id/versions — list all versions (newest first) diff --git a/packages/adapter-api/src/adapter.ts b/packages/adapter-api/src/adapter.ts index 70e2d9a0..380f1b2c 100644 --- a/packages/adapter-api/src/adapter.ts +++ b/packages/adapter-api/src/adapter.ts @@ -735,12 +735,6 @@ export class APIAdapter implements StackAdapter { return raw ? parseVersion(raw) : null; } - async saveVersion(_id: RecordId, _version: RecordVersion): Promise { - // The server snapshots versions automatically as a side effect of every - // mutating endpoint. There is no client-initiated saveVersion endpoint - // in the wire protocol. - } - /** * Never restores `associations` — no snapshot carries them, so there is * nothing the server could restore associations *from*. See diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index a10883d7..97c10058 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -24,10 +24,11 @@ import { useFetchMock, } from './helpers.js'; import { OwnerMismatchError, resolveQuery } from '@haverstack/core/adapter'; +import type { StackAdapter } from '@haverstack/core/adapter'; import { buildAuthChallengePayload } from '@haverstack/core/wire'; import { WIRE_PROTOCOL_VERSION } from '@haverstack/wire-types'; import type { DiscoveryCapabilities } from '@haverstack/wire-types'; -import type { StackRecord, StackType, RecordVersion, DataAssociation } from '@haverstack/core'; +import type { StackRecord, StackType, DataAssociation } from '@haverstack/core'; import { StackPermissionError, StackNotFoundError, @@ -1581,18 +1582,10 @@ describe('open() reading a success body that is empty or not JSON', () => { }); }); -describe('saveVersion', () => { - test('is a no-op — does not make any HTTP requests', async () => { - const adapter = await openAdapter(); - const callsBefore = mockFetch.mock.calls.length; - const v: RecordVersion = { - version: 1, - typeId: 'com.example/note@1', - content: { text: 'v1' }, - updatedAt: new Date(), - }; - await expect(adapter.saveVersion('rec-abc123', v)).resolves.toBeUndefined(); - expect(mockFetch.mock.calls.length).toBe(callsBefore); +describe('importRecord', () => { + test('is absent: a server is the only writer of its own history tiers', async () => { + const adapter: StackAdapter = await openAdapter(); + expect(adapter.importRecord).toBeUndefined(); }); }); diff --git a/packages/adapter-conformance/src/copy.ts b/packages/adapter-conformance/src/copy.ts new file mode 100644 index 00000000..1bb33592 --- /dev/null +++ b/packages/adapter-conformance/src/copy.ts @@ -0,0 +1,148 @@ +/** + * Stack copy conformance suite + * ------------------------------------------------------- + * The round trip a move between hosts makes: a stack with a past is + * written through Stack, copied into the adapter under test with + * copyStack(), and copied back out into a fresh MemoryAdapter. Every + * Record, version snapshot, journal entry, Type and blob must come back + * as it went in, at both hops. Point it at a full StackAdapter that + * implements importRecord(). See docs/spec/portability.md. + */ + +import { describe, test, expect } from 'vitest'; +import { Stack, SYSTEM_TYPES } from '@haverstack/core'; +import type { StackRecord } from '@haverstack/core'; +import { copyStack, resolveQuery } from '@haverstack/core/adapter'; +import type { StackAdapter } from '@haverstack/core/adapter'; +import { MemoryAdapter } from '@haverstack/core/testing'; + +/** The identity a copy target is created with: the source stack's own. */ +export type CopyTargetIdentity = { ownerEntityId: string; timezone: string | undefined }; + +export interface CopyConformanceOptions { + /** Used in describe() titles, so pick something that reads well there. */ + name: string; + /** Produce a fresh, empty adapter created with this identity. */ + open: (identity: CopyTargetIdentity) => Promise | StackAdapter; + /** Release what `open()` acquired. Called after every test. */ + close?: (adapter: StackAdapter) => Promise | void; +} + +const OWNER = 'did:key:conformance-owner'; +const ALICE = { subjectId: 'did:key:conformance-alice' }; +const BOB = { subjectId: 'did:key:conformance-bob', principalId: 'did:key:conformance-app' }; +const NOTE = 'org.haverstack.conformance/copied-note@1'; + +/** Set order aside: an element set has no order to preserve. */ +const bySerialized = (list: T[] | undefined): T[] | undefined => + list && [...list].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + +/** Everything a copy must carry, in a shape that compares with toEqual(). */ +async function contentsOf(adapter: StackAdapter) { + const records: StackRecord[] = []; + let cursor: string | undefined; + do { + const page = await adapter.queryRecords( + resolveQuery({ + filter: { includeDeleted: true, includeUnlisted: true }, + cursor, + limit: 100, + }), + ); + records.push(...page.records); + cursor = page.cursor ?? undefined; + } while (cursor); + records.sort((a, b) => a.id.localeCompare(b.id)); + + const blobs: Record = {}; + for (const record of records) { + if (record.typeId.startsWith(`${SYSTEM_TYPES.ATTACHMENT}@`)) { + const fileId = (record.content as { fileId: string }).fileId; + blobs[fileId] = await adapter.getBlob(fileId); + } + } + + return { + types: (await adapter.listTypes()).sort((a, b) => a.id.localeCompare(b.id)), + records: await Promise.all( + records.map(async (record) => ({ + record: { + ...record, + associations: bySerialized(record.associations), + permissions: bySerialized(record.permissions), + }, + versions: await adapter.getVersions(record.id), + journal: await adapter.getJournal(record.id), + })), + ), + blobs, + }; +} + +/** + * A stack touched by every kind of write: foreign authorship, edits by an + * app acting for someone, containment, tags, sharing, listing, an + * attachment, a soft delete and a purge. + */ +async function stackWithAPast(): Promise { + const adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'Europe/Paris' }); + const stack = await Stack.open(adapter, { ownerProfile: { name: 'Conformance Owner' } }); + await stack.defineType({ id: NOTE, name: 'Note', schema: { title: { kind: 'text' } } }); + + const box = await stack.create(NOTE, { title: 'box' }); + const note = await stack.create(NOTE, { title: 'v1' }, { createdBy: ALICE }); + await stack.patchContent(note.id, { title: 'v2' }, { actor: BOB }); + await stack.setParent(note.id, box.id, { actor: BOB }); + await stack.editAssociations(note.id, [ + { op: 'add', association: { kind: 'tag', label: 'pinned' } }, + ]); + await stack.editAccess(note.id, [{ op: 'add', association: { kind: 'anyone', label: 'read' } }]); + await stack.setUnlisted(note.id, true); + + await stack.putAttachment(new TextEncoder().encode('attached bytes'), { + mimeType: 'text/plain', + filename: 'notes.txt', + }); + + const removed = await stack.create(NOTE, { title: 'removed' }, { createdBy: ALICE }); + await stack.delete(removed.id); + const purged = await stack.create(NOTE, { title: 'purged' }); + await stack.delete(purged.id, { purge: true }); + return adapter; +} + +export function runCopyConformance(options: CopyConformanceOptions): void { + const { name, open, close } = options; + + describe(`stack copy conformance: ${name}`, () => { + const identity = { ownerEntityId: OWNER, timezone: 'Europe/Paris' }; + + test('implements importRecord()', async () => { + const target = await open(identity); + try { + expect(target.importRecord).toBeTypeOf('function'); + } finally { + await close?.(target); + } + }); + + test('a copy in and back out carries every Record, version, journal entry, Type and blob unchanged', async () => { + const source = await stackWithAPast(); + const expected = await contentsOf(source); + expect(expected.records.length).toBeGreaterThan(3); + expect(Object.keys(expected.blobs)).toHaveLength(1); + + const target = await open(identity); + try { + await copyStack(source, target); + expect(await contentsOf(target)).toEqual(expected); + + const back = await MemoryAdapter.open(identity); + await copyStack(target, back); + expect(await contentsOf(back)).toEqual(expected); + } finally { + await close?.(target); + } + }); + }); +} diff --git a/packages/adapter-conformance/src/helpers.ts b/packages/adapter-conformance/src/helpers.ts index 0bd3f997..fbd33794 100644 --- a/packages/adapter-conformance/src/helpers.ts +++ b/packages/adapter-conformance/src/helpers.ts @@ -7,7 +7,7 @@ * MemoryAdapter resolves through a registered type's schema). */ -import type { StackRecord, StackType } from '@haverstack/core'; +import type { RecordVersion, StackRecord, StackType } from '@haverstack/core'; let counter = 0; @@ -46,3 +46,15 @@ export function makeRecord(overrides: Partial = {}): StackRecord { ...overrides, }; } + +/** The snapshot a version-bumping write takes of `record` as it stands. */ +export function snapshotOf(record: StackRecord): RecordVersion { + return { + version: record.version, + typeId: record.typeId, + content: record.content, + updatedAt: record.updatedAt, + ...(record.createdBy && { createdBy: record.createdBy }), + ...(record.updatedBy && { updatedBy: record.updatedBy }), + }; +} diff --git a/packages/adapter-conformance/src/index.ts b/packages/adapter-conformance/src/index.ts index 08600f40..1bac0025 100644 --- a/packages/adapter-conformance/src/index.ts +++ b/packages/adapter-conformance/src/index.ts @@ -3,3 +3,5 @@ export type { RecordAdapterConformanceOptions } from './record.js'; export { runBlobAdapterConformance } from './blob.js'; export type { BlobAdapterConformanceOptions } from './blob.js'; export { expectStackErrorCode } from './errors.js'; +export { runCopyConformance } from './copy.js'; +export type { CopyConformanceOptions, CopyTargetIdentity } from './copy.js'; diff --git a/packages/adapter-conformance/src/record.ts b/packages/adapter-conformance/src/record.ts index 63cd4ec1..35f7b9a8 100644 --- a/packages/adapter-conformance/src/record.ts +++ b/packages/adapter-conformance/src/record.ts @@ -26,6 +26,7 @@ import type { AuthorityAssociation, DataAssociation, RecordFilter, + RecordJournalEntry, StackCapabilities, StackQuery, StackRecord, @@ -35,7 +36,13 @@ import type { Reach, StackRecordAdapter } from '@haverstack/core/adapter'; type PermissionElement = Extract; import { expectStackErrorCode } from './errors.js'; -import { CONFORMANCE_TYPE_ID, conformanceType, makeRecord, uniqueId } from './helpers.js'; +import { + CONFORMANCE_TYPE_ID, + conformanceType, + makeRecord, + snapshotOf, + uniqueId, +} from './helpers.js'; export interface RecordAdapterConformanceOptions { /** Used in describe() titles, so pick something that reads well there. */ @@ -76,6 +83,22 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt await close?.(adapter); }); + /** + * Patch `record` `count` times, each patch snapshotting the version it + * replaces, as Stack does: versions 1 through `count` end up stored. + */ + const patchWithSnapshots = async (record: StackRecord, count: number) => { + let current = record; + for (let i = 0; i < count; i++) { + current = await adapter.patchContent( + current.id, + { title: `v${current.version + 1}` }, + { snapshot: snapshotOf(current) }, + ); + } + return current; + }; + // ----------------------------------------------------------------- // Capabilities // ----------------------------------------------------------------- @@ -264,28 +287,14 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt test('getVersions returns snapshots newest first', async () => { const record = makeRecord(); await adapter.createRecord(record); - for (const version of [1, 2, 3]) { - await adapter.saveVersion(record.id, { - version, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, - }); - } + await patchWithSnapshots(record, 3); expect((await adapter.getVersions(record.id)).map((v) => v.version)).toEqual([3, 2, 1]); }); test('getVersions pages walk every version exactly once, newest first', async () => { const record = makeRecord(); await adapter.createRecord(record); - for (const version of [1, 2, 3, 4, 5]) { - await adapter.saveVersion(record.id, { - version, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, - }); - } + await patchWithSnapshots(record, 5); const seen: number[] = []; let beforeVersion: number | undefined; for (;;) { @@ -301,14 +310,7 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt test('getVersions beforeVersion is exclusive and limit bounds the result', async () => { const record = makeRecord(); await adapter.createRecord(record); - for (const version of [1, 2, 3]) { - await adapter.saveVersion(record.id, { - version, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, - }); - } + await patchWithSnapshots(record, 3); const versionsOf = async (query: { beforeVersion?: number; limit?: number }) => (await adapter.getVersions(record.id, query)).map((v) => v.version); expect(await versionsOf({ beforeVersion: 3 })).toEqual([2, 1]); @@ -321,12 +323,7 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt test('purging deleteRecord removes the record and its version history', async () => { const record = makeRecord(); await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: record.version, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, - }); + await patchWithSnapshots(record, 1); await adapter.deleteRecord(record.id, { purge: true }); expect(await adapter.getRecord(record.id)).toBeNull(); @@ -648,15 +645,10 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt // Versions — snapshots and restore // ----------------------------------------------------------------- describe('versions', () => { - test('saveVersion and getVersion roundtrip', async () => { + test("a patch's snapshot reads back through getVersion", async () => { const record = makeRecord(); await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: record.version, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, - }); + await patchWithSnapshots(record, 1); const version = await adapter.getVersion(record.id, record.version); expect(version?.content).toEqual(record.content); }); @@ -666,13 +658,7 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt await adapter.createRecord(parent); const record = makeRecord({ content: { title: 'v1' } }); await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: record.version, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, - }); - await adapter.patchContent(record.id, { title: 'v2' }); + await adapter.patchContent(record.id, { title: 'v2' }, { snapshot: snapshotOf(record) }); await adapter.setParent(record.id, parent.id); await adapter.editAssociations(record.id, [ { op: 'add', association: { kind: 'tag', label: 'keep-me' } }, @@ -692,6 +678,147 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt }); }); + // ----------------------------------------------------------------- + // Importing a record — optional, so each test asks whether the adapter + // has importRecord() at all. See docs/spec/portability.md § Writing + // history. + // ----------------------------------------------------------------- + describe('importing a record', () => { + const AUTHOR = { subjectId: 'did:key:conformance-author' }; + const EDITOR = { subjectId: 'did:key:conformance-editor', principalId: 'did:key:app' }; + const at = (day: number) => new Date(Date.UTC(2024, 0, day)); + const pinned: DataAssociation = { kind: 'tag', label: 'pinned' }; + + /** A record with a past: two versions, four journal entries, every optional field set. */ + const withPast = (parentId: string) => { + const record = makeRecord({ + content: { title: 'v2' }, + createdAt: at(1), + updatedAt: at(2), + version: 2, + parentId, + appId: 'org.haverstack.conformance', + createdBy: AUTHOR, + updatedBy: EDITOR, + unlistedAt: at(3), + associations: [pinned], + permissions: [{ kind: 'anyone', label: 'read' }], + }); + const versions = [ + { + version: 1, + typeId: record.typeId, + content: { title: 'v1' }, + updatedAt: at(1), + createdBy: AUTHOR, + updatedBy: AUTHOR, + }, + ]; + const journal: RecordJournalEntry[] = [ + { + seq: 1, + at: at(1), + kind: 'created', + ops: ['create'], + version: 1, + typeId: record.typeId, + actor: AUTHOR, + }, + { + seq: 2, + at: at(2), + kind: 'changed', + ops: ['patch'], + version: 2, + typeId: record.typeId, + actor: EDITOR, + }, + { + seq: 3, + at: at(3), + kind: 'changed', + ops: ['reparent'], + version: 2, + typeId: record.typeId, + parentId, + previousParentId: null, + actor: EDITOR, + }, + { + seq: 4, + at: at(3), + kind: 'changed', + ops: ['associate', 'unlist'], + version: 2, + typeId: record.typeId, + parentId, + associations: [{ op: 'add', association: pinned }], + }, + ]; + return { record, versions, journal }; + }; + + test('writes the record, its versions and its journal exactly as recorded', async () => { + if (!adapter.importRecord) return; + const parent = makeRecord(); + await adapter.createRecord(parent); + const { record, versions, journal } = withPast(parent.id); + await adapter.importRecord(record, { versions, journal }); + + expect(await adapter.getRecord(record.id)).toEqual(record); + expect(await adapter.getVersions(record.id)).toEqual(versions); + expect(await adapter.getJournal(record.id)).toEqual(journal); + }); + + test('appends no journal entry of its own', async () => { + if (!adapter.importRecord) return; + const record = makeRecord(); + await adapter.importRecord(record, { versions: [], journal: [] }); + expect(await adapter.getJournal(record.id)).toEqual([]); + }); + + test('refuses an id that already exists', async () => { + if (!adapter.importRecord) return; + const record = makeRecord(); + await adapter.createRecord(record); + await expectStackErrorCode( + adapter.importRecord(record, { versions: [], journal: [] }), + 'conflict', + ); + }); + + test('an imported record answers queries as a created one does', async () => { + if (!adapter.importRecord) return; + const record = makeRecord({ content: { title: 'imported' }, associations: [pinned] }); + await adapter.importRecord(record, { versions: [], journal: [] }); + const byTag = await adapter.queryRecords(resolveQuery({ filter: { tags: ['pinned'] } })); + expect(byTag.records.map((r) => r.id)).toEqual([record.id]); + if (capabilities.filter.content !== 'none') { + const byContent = await adapter.queryRecords( + resolveQuery({ filter: { content: { title: 'imported' } } }), + ); + expect(byContent.records.map((r) => r.id)).toEqual([record.id]); + } + }); + + test('a write after an import continues its history', async () => { + if (!adapter.importRecord) return; + const parent = makeRecord(); + await adapter.createRecord(parent); + const { record, versions, journal } = withPast(parent.id); + await adapter.importRecord(record, { versions, journal }); + + const patched = await adapter.patchContent( + record.id, + { title: 'v3' }, + { snapshot: snapshotOf(record), journal: { ops: ['patch'], kind: 'changed' } }, + ); + expect(patched.version).toBe(3); + expect((await adapter.getVersions(record.id)).map((v) => v.version)).toEqual([2, 1]); + expect((await adapter.getJournal(record.id)).map((e) => e.seq)).toEqual([1, 2, 3, 4, 5]); + }); + }); + // ----------------------------------------------------------------- // Change journal — the second durable tier // ----------------------------------------------------------------- diff --git a/packages/adapter-conformance/tests/copy-memory.test.ts b/packages/adapter-conformance/tests/copy-memory.test.ts new file mode 100644 index 00000000..d81849cf --- /dev/null +++ b/packages/adapter-conformance/tests/copy-memory.test.ts @@ -0,0 +1,11 @@ +/** + * Runs the stack copy suite with @haverstack/core's own MemoryAdapter as + * the target, so the suite's own round trip is checked where it is fastest. + */ +import { MemoryAdapter } from '@haverstack/core/testing'; +import { runCopyConformance } from '../src/copy.js'; + +runCopyConformance({ + name: 'MemoryAdapter', + open: ({ ownerEntityId, timezone }) => MemoryAdapter.open({ ownerEntityId, timezone }), +}); diff --git a/packages/adapter-local/package.json b/packages/adapter-local/package.json index b06c1767..c2ab3c9b 100644 --- a/packages/adapter-local/package.json +++ b/packages/adapter-local/package.json @@ -44,6 +44,7 @@ "@haverstack/blob-adapter-disk": "workspace:^" }, "devDependencies": { + "@haverstack/adapter-conformance": "workspace:*", "@types/node": "^22.0.0", "typescript": "^5.5.0", "vitest": "^4.1.11" diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index f60ba3ff..8273e61a 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -42,6 +42,7 @@ import type { JournalOptions, MutateOptions, Reach, + RecordHistory, } from '@haverstack/core/adapter'; import type { TokenSession, TokenInfo } from '@haverstack/core/wire'; import { @@ -135,6 +136,10 @@ export class LocalAdapter implements StackAdapter { return this.record.createRecord(record, opts); } + async importRecord(record: StackRecord, history: RecordHistory): Promise { + return this.record.importRecord(record, history); + } + async getRecord(id: RecordId): Promise { return this.record.getRecord(id); } @@ -233,10 +238,6 @@ export class LocalAdapter implements StackAdapter { return this.record.getVersion(id, version); } - async saveVersion(id: RecordId, version: RecordVersion): Promise { - return this.record.saveVersion(id, version); - } - async restoreVersion(id: RecordId, version: number, opts?: MutateOptions): Promise { return this.record.restoreVersion(id, version, opts); } diff --git a/packages/adapter-local/tests/copy.test.ts b/packages/adapter-local/tests/copy.test.ts new file mode 100644 index 00000000..f0f807b8 --- /dev/null +++ b/packages/adapter-local/tests/copy.test.ts @@ -0,0 +1,30 @@ +/** + * Runs @haverstack/adapter-conformance's stack copy suite with a + * LocalAdapter as the target: the portable form a stack is exported to + * and imported from. See docs/spec/portability.md. + */ +import { mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { runCopyConformance } from '@haverstack/adapter-conformance'; +import { LocalAdapter } from '../src/index.js'; + +let currentDir: string | undefined; + +runCopyConformance({ + name: 'LocalAdapter', + open: ({ ownerEntityId, timezone }) => { + currentDir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(currentDir, { recursive: true }); + return LocalAdapter.open({ + create: 'exclusive', + path: join(currentDir, 'stack.db'), + ownerEntityId, + timezone, + }); + }, + close: async (adapter) => { + await adapter.close?.(); + if (currentDir) rmSync(currentDir, { recursive: true, force: true }); + }, +}); diff --git a/packages/adapter-local/vitest.config.ts b/packages/adapter-local/vitest.config.ts index a5f07381..ef7842b4 100644 --- a/packages/adapter-local/vitest.config.ts +++ b/packages/adapter-local/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core/wire': resolve(__dirname, '../core/src/wire/index.ts'), + '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/record-adapter-sqlite': resolve( @@ -12,6 +13,7 @@ export default defineConfig({ '../record-adapter-sqlite/src/index.ts', ), '@haverstack/sqlite-shared': resolve(__dirname, '../sqlite-shared/src/index.ts'), + '@haverstack/adapter-conformance': resolve(__dirname, '../adapter-conformance/src/index.ts'), '@haverstack/blob-adapter-disk': resolve(__dirname, '../blob-adapter-disk/src/index.ts'), }, }, diff --git a/packages/blob-adapter-disk/vitest.config.ts b/packages/blob-adapter-disk/vitest.config.ts index f8327695..0bbbaf16 100644 --- a/packages/blob-adapter-disk/vitest.config.ts +++ b/packages/blob-adapter-disk/vitest.config.ts @@ -4,6 +4,7 @@ import { resolve } from 'path'; export default defineConfig({ resolve: { alias: { + '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/adapter-conformance': resolve(__dirname, '../adapter-conformance/src/index.ts'), diff --git a/packages/core/src/adapter/combine.ts b/packages/core/src/adapter/combine.ts index 2ca7efbd..a857b9be 100644 --- a/packages/core/src/adapter/combine.ts +++ b/packages/core/src/adapter/combine.ts @@ -9,6 +9,8 @@ import type { Reach, RecordChange, + RecordHistory, + StackRecord, StackAdapter, StackRecordAdapter, StackBlobAdapter, @@ -54,7 +56,6 @@ export function combineAdapters(parts: { getVersions: (id, query) => parts.record.getVersions(id, query), getVersion: (id, v) => parts.record.getVersion(id, v), - saveVersion: (id, v) => parts.record.saveVersion(id, v), getJournal: (id, query) => parts.record.getJournal(id, query), restoreVersion: (id, v, opts) => parts.record.restoreVersion(id, v, opts), commitMigration: (id, toTypeId, content, opts) => @@ -71,6 +72,10 @@ export function combineAdapters(parts: { deleteUnreferencedAttachmentRecords: (fileId: string, metadataBaseId: string) => parts.record.deleteUnreferencedAttachmentRecords!(fileId, metadataBaseId), }), + ...(parts.record.importRecord && { + importRecord: (r: StackRecord, history: RecordHistory) => + parts.record.importRecord!(r, history), + }), ...(parts.record.queryReadable && { queryReadable: (q: AdapterQuery, reach: Reach) => parts.record.queryReadable!(q, reach), }), diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts new file mode 100644 index 00000000..4b133132 --- /dev/null +++ b/packages/core/src/adapter/copy.ts @@ -0,0 +1,75 @@ +/** + * Copying a stack + * ------------------------------------------------------- + * One stack, read in full from one adapter and written in full to another: + * every Record (soft-deleted and unlisted included) with its version + * snapshots and journal, every Type, and the bytes its attachment records + * name. Export, import and moving between storage engines are all this one + * call, with a different pair of adapters. Full trust, like any direct + * adapter access: authorship and history land as recorded, which no write + * through Stack may do. See docs/spec/portability.md. + */ + +import { InvalidAdapterError, OwnerMismatchError, StackConflictError } from '../errors.js'; +import { baseIdOf } from '../schema.js'; +import { resolveQuery } from '../query-validation.js'; +import type { FileId, PlainQuery, StackAdapter } from '../types/index.js'; +import { SYSTEM_TYPES } from '../types/index.js'; + +/** Records read per page; any size works, since every page is written before the next is read. */ +const PAGE_SIZE = 100; + +/** Every Record a store holds but `_config`, which no query reaches and each store was created with. */ +const EVERYTHING: PlainQuery = { + filter: { includeDeleted: true, includeUnlisted: true }, + sort: { field: 'createdAt', direction: 'asc' }, + limit: PAGE_SIZE, +}; + +/** + * Copy everything `from` holds into `to`, a store newly created with the + * same `ownerEntityId` and `timezone`. Not atomic: a failure part-way + * leaves `to` partly written, to be discarded rather than resumed. + * See docs/spec/portability.md § Copying a stack. + */ +export async function copyStack(from: StackAdapter, to: StackAdapter): Promise { + if (!to.importRecord) { + throw new InvalidAdapterError( + 'Cannot copy into this adapter: it has no importRecord(), so it cannot write history.', + ); + } + if (to.ownerEntityId !== from.ownerEntityId) { + throw new OwnerMismatchError(from.ownerEntityId, to.ownerEntityId, 'the copy target'); + } + if (to.timezone !== from.timezone) { + throw new InvalidAdapterError( + `Cannot copy into this adapter: its timezone is ${JSON.stringify(to.timezone)}, ` + + `but the stack being copied has ${JSON.stringify(from.timezone)}.`, + ); + } + const occupied = await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 })); + if (occupied.records.length) { + throw new StackConflictError('Cannot copy into a store that already holds records.'); + } + + for (const type of await from.listTypes()) await to.saveType(type); + + const fileIds = new Set(); + let cursor: string | undefined; + do { + const page = await from.queryRecords(resolveQuery({ ...EVERYTHING, cursor })); + for (const record of page.records) { + const [versions, journal] = await Promise.all([ + from.getVersions(record.id), + from.getJournal(record.id), + ]); + await to.importRecord(record, { versions, journal }); + if (baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT) { + fileIds.add((record.content as { fileId: FileId }).fileId); + } + } + cursor = page.cursor ?? undefined; + } while (cursor); + + for (const fileId of fileIds) await to.putBlob(await from.getBlob(fileId)); +} diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index eccc0c4e..28e98af4 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -23,10 +23,12 @@ export type { AspectWriteOptions, ElementWriteOptions, Reach, + RecordHistory, } from '../types/index.js'; export { sameAssociationSet } from '../associations/identity.js'; export { ElementSetMovedError, OwnerMismatchError, softDeletedRefusal } from '../errors.js'; export { combineAdapters } from './combine.js'; +export { copyStack } from './copy.js'; export { inReach } from './reach.js'; export type { ReachedRecord } from './reach.js'; export { diff --git a/packages/core/src/stack/stack.ts b/packages/core/src/stack/stack.ts index ebf7c8ed..053ad23d 100644 --- a/packages/core/src/stack/stack.ts +++ b/packages/core/src/stack/stack.ts @@ -1438,8 +1438,8 @@ export class Stack implements StackClient { // never sees it and this is the only place it can be announced. const record = await this.adapter.putAttachmentWithMetadata(data, opts); // The one emission with no journal half of its own: the far side - // wrote the record, so it appended the entry in that same write — - // the same division saveVersion() follows over this adapter. See + // wrote the record, so it appended the entry in that same write: a + // server is the only writer of its own durable tiers. See // docs/spec/journal.md § The entry set is the event set. this.announce(new PendingChange('create'), record); return record as StackRecord & { content: AttachmentContent }; diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 395cc115..ffa27b68 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -35,6 +35,7 @@ import type { StackCapabilities, BlobInfo, Reach, + RecordHistory, } from './types/index.js'; import { SYSTEM_TYPES } from './types/index.js'; import { applyMergePatch } from './merge.js'; @@ -122,6 +123,16 @@ export class MemoryAdapter implements StackAdapter { return stored; } + async importRecord(record: StackRecord, history: RecordHistory) { + await this.createRecord(record); + this.versions.set(record.id, [...history.versions]); + // Held oldest first, as appendJournal() leaves it, whatever order it came in. + this.journals.set( + record.id, + [...history.journal].sort((a, b) => a.seq - b.seq), + ); + } + async getRecord(id: string) { return this.records.get(id) ?? null; } @@ -536,31 +547,6 @@ export class MemoryAdapter implements StackAdapter { return (this.versions.get(id) ?? []).find((v) => v.version === version) ?? null; } - /** - * Loud on a (record, version) collision, mirroring the real adapters' - * UNIQUE constraint. - */ - private insertVersionRow(id: string, version: RecordVersion): void { - const existing = this.versions.get(id) ?? []; - if (existing.some((v) => v.version === version.version)) { - throw new StackConflictError( - `Version ${version.version} already exists for record "${id}" — a concurrent ` + - `writer raced past this version. Use ifVersion to detect this before it happens.`, - ); - } - this.versions.set(id, [...existing, version]); - } - - /** - * Standalone snapshot write for tooling and tests — loud on any - * collision, since nothing here is about to bump the record's version. - * Mutating methods take a `snapshot` option instead; see - * snapshotBeforeMutation. - */ - async saveVersion(id: string, version: RecordVersion) { - this.insertVersionRow(id, version); - } - /** * The snapshot half of a mutating method's atomic snapshot-then-mutate * step. Mirrors the real adapters' snapshotBeforeMutation (see diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 8f3af875..94bb66f1 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -119,6 +119,15 @@ export type Reach = { }; }; +/** + * A record's two durable history tiers as an adapter reads them back: + * every version snapshot, and every journal entry with its `seq` and `at`. + */ +export type RecordHistory = { + versions: RecordVersion[]; + journal: RecordJournalEntry[]; +}; + /** The metadata an upload's `_attachment@1` record carries beside its bytes. */ export type PutAttachmentOptions = { mimeType: string; @@ -152,6 +161,16 @@ export interface StackRecordAdapter { */ createRecord(record: StackRecord, opts?: JournalOptions): Promise; getRecord(id: RecordId): Promise; + /** + * createRecord() for a record that already has a past: writes it exactly + * as given — authorship, dates and version included — beside its version + * snapshots and journal entries as recorded, in one atomic write, and + * appends no entry of its own. StackConflictError if `record.id` exists. + * Optional — a stack whose adapter lacks it cannot be copied into, as a + * server-backed one cannot. See docs/spec/portability.md § Writing history. + */ + importRecord?(record: StackRecord, history: RecordHistory): Promise; + /** * Merge a content patch, one level deep, into the content as it stands * when the write lands. Bumps `version` and snapshots. Never touches @@ -233,13 +252,6 @@ export interface StackRecordAdapter { */ getVersions(id: RecordId, query?: VersionsQuery): Promise; getVersion(id: RecordId, version: number): Promise; - /** - * Standalone snapshot write, outside of a mutation's own atomic path. - * Mutating methods above take a `snapshot` option instead, so the - * snapshot and the mutation land in one write — see SnapshotOptions. - */ - saveVersion(id: RecordId, version: RecordVersion): Promise; - /** * Read a record's change journal, oldest first — required, on the same * footing as getVersions(). A recovery mechanism an app cannot rely on is diff --git a/packages/core/tests/combine.test.ts b/packages/core/tests/combine.test.ts index f1cb67c5..f9021486 100644 --- a/packages/core/tests/combine.test.ts +++ b/packages/core/tests/combine.test.ts @@ -10,6 +10,7 @@ import type { TypeId, BaseId, SubscribeOptions, + RecordHistory, } from '../src/types/index.js'; const purgedRecord: StackRecord = { @@ -80,7 +81,6 @@ function makeRecordAdapter(overrides: Partial = {}): StackRe }, getVersions: async () => [], getVersion: async () => null, - saveVersion: async () => {}, restoreVersion: async () => { throw new Error('not implemented'); }, @@ -279,6 +279,29 @@ describe('combineAdapters', () => { expect(adapter.deleteUnreferencedAttachmentRecords).toBeUndefined(); }); + test('importRecord is present when the record adapter implements it', async () => { + let calledWith: [StackRecord, RecordHistory] | undefined; + const adapter = combineAdapters({ + record: makeRecordAdapter({ + importRecord: async (record, history) => { + calledWith = [record, history]; + }, + }), + blob: makeBlobAdapter(), + }); + const history: RecordHistory = { versions: [], journal: [] }; + await adapter.importRecord!(purgedRecord, history); + expect(calledWith).toEqual([purgedRecord, history]); + }); + + test('importRecord is absent when the record adapter does not implement it', () => { + const adapter = combineAdapters({ + record: makeRecordAdapter(), + blob: makeBlobAdapter(), + }); + expect(adapter.importRecord).toBeUndefined(); + }); + test('listBlobs is present when the blob adapter implements it', async () => { const files: BlobInfo[] = [{ fileId: 'f1', size: 3, modifiedAt: new Date() }]; const adapter = combineAdapters({ diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts new file mode 100644 index 00000000..2416c280 --- /dev/null +++ b/packages/core/tests/copy.test.ts @@ -0,0 +1,76 @@ +import { describe, test, expect } from 'vitest'; +import { copyStack } from '../src/adapter/copy.js'; +import { Stack } from '../src/stack/stack.js'; +import { InvalidAdapterError, OwnerMismatchError, StackConflictError } from '../src/errors.js'; +import { MemoryAdapter } from '../src/testing.js'; +import type { StackAdapter } from '../src/types/index.js'; + +const OWNER = 'did:key:owner'; +const NOTE = 'com.example.test/note@1'; + +const fresh = (ownerEntityId = OWNER, timezone: string | undefined = 'UTC') => + MemoryAdapter.open({ ownerEntityId, timezone }); + +async function sourceWith(notes: number): Promise { + const adapter = await fresh(); + const stack = await Stack.open(adapter); + await stack.defineType({ id: NOTE, name: 'Note', schema: { text: { kind: 'text' } } }); + for (let i = 0; i < notes; i++) await stack.create(NOTE, { text: `note ${i}` }); + return adapter; +} + +describe('copyStack', () => { + test('carries every page of records, not just the first', async () => { + const source = await sourceWith(250); + const target = await fresh(); + await copyStack(source, target); + expect([...target.records.keys()].sort()).toEqual([...source.records.keys()].sort()); + }); + + test('carries the bytes attachment records name, and not bytes no record names', async () => { + const source = await sourceWith(0); + const stack = await Stack.open(source); + const named = await stack.putAttachment(new Uint8Array([1, 2, 3]), { mimeType: 'image/png' }); + const orphan = await source.putBlob(new Uint8Array([9, 9, 9])); + const target = await fresh(); + await copyStack(source, target); + expect(await target.getBlob(named.content.fileId)).toEqual(new Uint8Array([1, 2, 3])); + expect(target.blobs.has(orphan)).toBe(false); + }); + + test('refuses a target with no importRecord(), writing nothing', async () => { + const source = await sourceWith(1); + const target: StackAdapter = await fresh(); + Object.defineProperty(target, 'importRecord', { value: undefined }); + await expect(copyStack(source, target)).rejects.toThrow(InvalidAdapterError); + expect(await target.listTypes()).toEqual([]); + }); + + test('refuses a target owned by someone else', async () => { + const source = await sourceWith(1); + await expect(copyStack(source, await fresh('did:key:someone-else'))).rejects.toThrow( + OwnerMismatchError, + ); + }); + + test('refuses a target with another timezone', async () => { + const source = await sourceWith(1); + await expect(copyStack(source, await fresh(OWNER, 'Europe/Paris'))).rejects.toThrow( + InvalidAdapterError, + ); + }); + + test('refuses a target that already holds records', async () => { + const source = await sourceWith(1); + const target = await sourceWith(1); + await expect(copyStack(source, target)).rejects.toThrow(StackConflictError); + }); + + test('accepts a target Stack.open() has seeded with system types alone', async () => { + const source = await sourceWith(1); + const target = await fresh(); + await Stack.open(target); + await copyStack(source, target); + expect(target.records.size).toBe(source.records.size); + }); +}); diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 0e92ddc7..40268974 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -885,13 +885,18 @@ describe('ScopedStack — versions', () => { test('restoreVersion enforces write access', async () => { // version: 2 — the live record already moved past the v1 snapshot // below (simulating a prior edit), so restoreVersion()'s own - // saveVersion(existing) snapshots version 2, not a colliding version 1. - const record = await adapter.createRecord(makeRecord({ version: 2 })); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: { text: 'old' }, - updatedAt: new Date(), + // snapshot of the live record is version 2, not a colliding version 1. + const record = makeRecord({ version: 2 }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: { text: 'old' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await expect(stack.asEntity(STRANGER).restoreVersion(record.id, 1)).rejects.toThrow( StackNotFoundError, @@ -904,18 +909,21 @@ describe('ScopedStack — versions', () => { // plain reader (read access but no write) must be denied, matching // patchContent()/editAssociations()'s gate exactly. test('getVersions/getVersion deny a read-only requester', async () => { - const record = await adapter.createRecord( - makeRecord({ - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: {}, - updatedAt: new Date(), + const record = makeRecord({ + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: {}, + updatedAt: new Date(), + }, + ], + journal: [], }); await expect(stack.asEntity(MEMBER).getVersions(record.id)).rejects.toThrow( StackPermissionError, @@ -926,39 +934,45 @@ describe('ScopedStack — versions', () => { }); test('getVersions/getVersion allow a write-holder', async () => { - const record = await adapter.createRecord( - makeRecord({ - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: {}, - updatedAt: new Date(), + const record = makeRecord({ + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: {}, + updatedAt: new Date(), + }, + ], + journal: [], }); await expect(stack.asEntity(MEMBER).getVersions(record.id)).resolves.toHaveLength(1); await expect(stack.asEntity(MEMBER).getVersion(record.id, 1)).resolves.not.toBeNull(); }); test('getVersions/getVersion serve the same rows to a write-holder and to the owner', async () => { - const record = await adapter.createRecord( - makeRecord({ - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: { title: 'snapshotted' }, - updatedAt: new Date(0), - createdBy: { subjectId: 'someone-else' }, + const record = makeRecord({ + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: { title: 'snapshotted' }, + updatedAt: new Date(0), + createdBy: { subjectId: 'someone-else' }, + }, + ], + journal: [], }); const [memberList, memberSingle] = await Promise.all([ @@ -977,20 +991,23 @@ describe('ScopedStack — versions', () => { test('getVersions/getVersion on a group require admin, not just membership', async () => { const ADMIN = 'group-version-admin'; - const group = await adapter.createRecord( - makeRecord({ - typeId: '_group@1', - associations: [ - { kind: 'relationship', label: 'admin', target: { kind: 'entity', entityId: ADMIN } }, - { kind: 'relationship', label: 'member', target: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(group.id, { - version: 1, + const group = makeRecord({ typeId: '_group@1', - content: {}, - updatedAt: new Date(), + associations: [ + { kind: 'relationship', label: 'admin', target: { kind: 'entity', entityId: ADMIN } }, + { kind: 'relationship', label: 'member', target: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(group, { + versions: [ + { + version: 1, + typeId: '_group@1', + content: {}, + updatedAt: new Date(), + }, + ], + journal: [], }); await expect(stack.asEntity(MEMBER).getVersions(group.id)).rejects.toThrow(StackNotFoundError); await expect(stack.asEntity(ADMIN).getVersions(group.id)).resolves.toHaveLength(1); @@ -1016,21 +1033,24 @@ describe('ScopedStack — versions', () => { actions: ['read-any', 'update-any'], grantee: { kind: 'entity', entityId: MEMBER }, }); - const record = await adapter.createRecord( - makeRecord({ - typeId: PHOTO_NOTE, - version: 2, - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, + const record = makeRecord({ typeId: PHOTO_NOTE, - content: { coverFileId: 'unreachable-file' }, - updatedAt: new Date(), + version: 2, + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: PHOTO_NOTE, + content: { coverFileId: 'unreachable-file' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await expect(stack.asEntity(MEMBER).restoreVersion(record.id, 1)).rejects.toThrow( StackPermissionError, @@ -1053,21 +1073,24 @@ describe('ScopedStack — versions', () => { actions: ['read-any', 'update-any'], grantee: { kind: 'entity', entityId: MEMBER }, }); - const record = await adapter.createRecord( - makeRecord({ - typeId: PHOTO_NOTE, - version: 2, - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, + const record = makeRecord({ typeId: PHOTO_NOTE, - content: { coverFileId: UNREACHABLE_FILE }, - updatedAt: new Date(), + version: 2, + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: PHOTO_NOTE, + content: { coverFileId: UNREACHABLE_FILE }, + updatedAt: new Date(), + }, + ], + journal: [], }); return record; }; @@ -1093,21 +1116,24 @@ describe('ScopedStack — versions', () => { // back. See docs/spec/versioning.md § Restore semantics. test('containment is not gated, whatever container the record sits in', async () => { const box = await adapter.createRecord(makeRecord()); - const record = await adapter.createRecord( - makeRecord({ - version: 2, - parentId: box.id, - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: { text: 'older' }, - updatedAt: new Date(), + const record = makeRecord({ + version: 2, + parentId: box.id, + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: { text: 'older' }, + updatedAt: new Date(), + }, + ], + journal: [], }); const restored = await stack.asEntity(MEMBER).restoreVersion(record.id, 1); expect(restored.content).toEqual({ text: 'older' }); @@ -1118,21 +1144,24 @@ describe('ScopedStack — versions', () => { // either: the record keeps its dangling parent and its rollback. test('a purged container blocks no restore', async () => { const box = await adapter.createRecord(makeRecord()); - const record = await adapter.createRecord( - makeRecord({ - version: 2, - parentId: box.id, - permissions: [ - { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, - { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, - ], - }), - ); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: { text: 'older' }, - updatedAt: new Date(), + const record = makeRecord({ + version: 2, + parentId: box.id, + permissions: [ + { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, + { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, + ], + }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: { text: 'older' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await stack.delete(box.id, { purge: true }); @@ -2292,14 +2321,17 @@ describe('ScopedStack — grant-based update/delete', () => { describe('ScopedStack — write implies read', () => { /** A record with one prior version, whose permissions bypass validation. */ async function recordWithHistory(permissions: AuthorityAssociation[]): Promise { - const record = await adapter.createRecord( - makeRecord({ version: 2, content: { text: 'current' }, permissions }), - ); - await adapter.saveVersion(record.id, { - version: 1, - typeId: NOTE, - content: { text: 'draft-secret' }, - updatedAt: new Date(), + const record = makeRecord({ version: 2, content: { text: 'current' }, permissions }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: NOTE, + content: { text: 'draft-secret' }, + updatedAt: new Date(), + }, + ], + journal: [], }); return record; } diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index e35cb4fc..14460d1f 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -23,7 +23,7 @@ import { MAX_ID_TIMESTAMP, IdGenerationError, } from '../src/id.js'; -import type { TypeSchema } from '../src/types/index.js'; +import type { RecordVersion, TypeSchema } from '../src/types/index.js'; import { baseIdOf } from '../src/schema.js'; import { migration, typeHandle } from '../src/type-handle.js'; import { add, derive, drop } from '../src/migration-steps.js'; @@ -86,6 +86,13 @@ const EntityV1 = typeHandle({ }); let adapter: MemoryAdapter; + +/** + * A version row written behind Stack's back: the state an interrupted + * write or a hand-edited store leaves. + */ +const plantVersion = (id: string, version: RecordVersion) => + adapter.versions.set(id, [...(adapter.versions.get(id) ?? []), version]); let stack: Stack; /** Replace `stack` with one over the same adapter that knows `migrations`. */ @@ -2253,7 +2260,7 @@ describe('migrateAll', () => { const record = await stack.create(NOTE_V1, { text: 'original' }); // v1 // Simulate a migrateAll() interrupted between its snapshot and its // commitMigration() call, leaving an orphan row at v1. - await adapter.saveVersion(record.id, { + plantVersion(record.id, { version: 1, typeId: NOTE_V1, content: { text: 'original' }, @@ -3007,7 +3014,7 @@ describe('restoreVersion — typeId and validation', () => { const record = await stack.create(NOTE_V1, { text: 'hello' }); // Simulate schema drift or adapter corruption: a stored snapshot whose // content no longer satisfies its own claimed type's schema. - await adapter.saveVersion(record.id, { + plantVersion(record.id, { version: 1, typeId: NOTE_V1, content: {}, // missing required "text" @@ -3337,7 +3344,7 @@ describe('orphan version row recovery', () => { // Simulate an interrupted write: the v1 snapshot committed, but the // mutation that should have bumped past it never did — an orphan row // sitting at the record's own current version. - await adapter.saveVersion(record.id, { + plantVersion(record.id, { version: 1, typeId: NOTE_V1, content: { text: 'hello' }, @@ -3358,7 +3365,7 @@ describe('orphan version row recovery', () => { const record = await stack.create(NOTE_V1, { text: 'hello' }); // v1 await stack.patchContent(record.id, { text: 'v2' }); // v2, snapshots v1 - await adapter.saveVersion(record.id, { + plantVersion(record.id, { version: 2, typeId: NOTE_V1, content: { text: 'v2' }, @@ -3639,7 +3646,7 @@ describe('_config protections', () => { // Simulates a snapshot that predates this guard, or a bypassed // direct-adapter write — either way, a stored version whose entityId // disagrees with the live record's must not be restorable. - await adapter.saveVersion(CONFIG_ID, { + plantVersion(CONFIG_ID, { version: 1, typeId: CONFIG_TYPE, content: { entityId: 'someone-else', timezone: 'UTC' }, diff --git a/packages/record-adapter-do-sqlite/tests/record.test.ts b/packages/record-adapter-do-sqlite/tests/record.test.ts index e1a75c02..4ba61566 100644 --- a/packages/record-adapter-do-sqlite/tests/record.test.ts +++ b/packages/record-adapter-do-sqlite/tests/record.test.ts @@ -19,10 +19,11 @@ import type { AssociationEdit, DataAssociation, RecordVersion, + RecordJournalEntry, StackType, StackCapabilities, } from '@haverstack/core'; -import type { AdapterQuery, Reach } from '@haverstack/core/adapter'; +import type { AdapterQuery, Reach, RecordHistory } from '@haverstack/core/adapter'; import { resolveQuery } from '@haverstack/core/adapter'; /** @@ -68,6 +69,8 @@ type TestStub = { queryRecords(query: AdapterQuery): Promise; queryReadable(query: AdapterQuery, reach: Reach): Promise; getVersions(id: string): Promise; + getJournal(id: string): Promise; + importRecord(record: StackRecord, history: RecordHistory): Promise; editAssociations( id: string, edits: AssociationEdit[], @@ -704,3 +707,49 @@ test('content conditions far past the statement limits execute', async () => { const result = await stub.queryRecords(resolveQuery({ filter: { content } })); expect(result.records.map((r) => r.id)).toEqual(['r1']); }); + +// importRecord() runs inside transactionSync() like every other write here, +// so a refused import leaves nothing behind. See docs/spec/portability.md +// § Writing history. +describe('importRecord', () => { + test('writes the record and its history as recorded', async () => { + const stub = getStub(); + const record = makeRecord({ version: 2, createdBy: { subjectId: 'did:key:author' } }); + const versions = [ + { version: 1, typeId: record.typeId, content: { text: 'v1' }, updatedAt: new Date(0) }, + ]; + const journal: RecordJournalEntry[] = [ + { + seq: 1, + at: new Date(0), + kind: 'created', + ops: ['create'], + version: 1, + typeId: record.typeId, + }, + { + seq: 2, + at: new Date(1), + kind: 'changed', + ops: ['patch'], + version: 2, + typeId: record.typeId, + }, + ]; + await stub.importRecord(record, { versions, journal }); + expect(await stub.getRecord(record.id)).toEqual(record); + expect(await stub.getVersions(record.id)).toEqual(versions); + expect(await stub.getJournal(record.id)).toEqual(journal); + }); + + test('a refused import writes nothing', async () => { + const stub = getStub(); + const record = makeRecord(); + const v = { version: 1, typeId: record.typeId, content: {}, updatedAt: new Date(0) }; + const err = await stub + .importRecord(record, { versions: [v, v], journal: [] }) + .catch((e: unknown) => e); + expect((err as { code?: string }).code).toBe('conflict'); + expect(await stub.getRecord(record.id)).toBeNull(); + }); +}); diff --git a/packages/record-adapter-do-sqlite/tests/support/test-worker.ts b/packages/record-adapter-do-sqlite/tests/support/test-worker.ts index 26a0c02f..75333d5e 100644 --- a/packages/record-adapter-do-sqlite/tests/support/test-worker.ts +++ b/packages/record-adapter-do-sqlite/tests/support/test-worker.ts @@ -12,6 +12,7 @@ import type { ElementWriteOptions, OwnerMismatchError, Reach, + RecordHistory, } from '@haverstack/core/adapter'; import { DoSQLiteRecordAdapter } from '../../src/index.js'; import type { @@ -154,9 +155,14 @@ export class TestRecordAdapterDO extends DurableObject { return this.adapter.getVersion(id, version); } - async saveVersion(id: string, version: RecordVersion) { + async getJournal(id: string) { await this.ready; - return this.adapter.saveVersion(id, version); + return this.adapter.getJournal(id); + } + + async importRecord(record: StackRecord, history: RecordHistory) { + await this.ready; + return this.adapter.importRecord(record, history); } async saveType(type: StackType) { diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 2e7db4cc..c70c230c 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -437,12 +437,16 @@ describe('records — CRUD', () => { test('purging deleteRecord removes version history', async () => { const adapter = await initAdapter(); const record = makeRecord(); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: record.content, - updatedAt: record.updatedAt, + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: record.content, + updatedAt: record.updatedAt, + }, + ], + journal: [], }); await adapter.deleteRecord(record.id, { purge: true }); expect(await adapter.getVersions(record.id)).toEqual([]); @@ -637,12 +641,17 @@ describe('ifVersion', () => { test('restoreVersion enforces ifVersion', async () => { const adapter = await initAdapter(); - const record = await adapter.createRecord(makeRecord({ content: { text: 'original' } })); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: record.updatedAt, + const record = makeRecord({ content: { text: 'original' } }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: record.updatedAt, + }, + ], + journal: [], }); await adapter.patchContent(record.id, { text: 'v2' }); // -> v2 @@ -676,12 +685,17 @@ describe('ifVersion', () => { describe('ifVersion holds between concurrent writes', () => { test('restoreVersion: one of two racing restores wins', async () => { const adapter = await initAdapter(); - const record = await adapter.createRecord(makeRecord({ content: { text: 'original' } })); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: record.updatedAt, + const record = makeRecord({ content: { text: 'original' } }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: record.updatedAt, + }, + ], + journal: [], }); await adapter.patchContent(record.id, { text: 'v2' }); // -> v2 @@ -2161,10 +2175,9 @@ describe('setParent', () => { // ------------------------------------------------------- describe('versions', () => { - test('saveVersion and getVersion roundtrip', async () => { + test('an imported version reads back through getVersion, actors included', async () => { const adapter = await initAdapter(); const record = makeRecord(); - await adapter.createRecord(record); const version = { version: 1, typeId: record.typeId, @@ -2172,7 +2185,7 @@ describe('versions', () => { updatedAt: new Date('2024-01-01'), createdBy: { subjectId: 'entity-123' }, }; - await adapter.saveVersion(record.id, version); + await adapter.importRecord(record, { versions: [version], journal: [] }); const retrieved = await adapter.getVersion(record.id, 1); expect(retrieved?.content).toEqual({ text: 'original' }); expect(retrieved?.createdBy?.subjectId).toBe('entity-123'); @@ -2184,13 +2197,13 @@ describe('versions', () => { test('a snapshot carries no containment, however one is handed in', async () => { const adapter = await initAdapter(); const record = makeRecord(); - await adapter.createRecord(record); const base = { typeId: record.typeId, content: {}, updatedAt: new Date('2024-01-01') }; - await adapter.saveVersion(record.id, { ...base, version: 1 }); - await adapter.saveVersion(record.id, { - ...base, - version: 2, - ...({ parentId: 'box-1' } as Record), + await adapter.importRecord(record, { + versions: [ + { ...base, version: 1 }, + { ...base, version: 2, ...({ parentId: 'box-1' } as Record) }, + ], + journal: [], }); expect('parentId' in (await adapter.getVersion(record.id, 1))!).toBe(false); @@ -2202,12 +2215,16 @@ describe('versions', () => { const box = makeRecord(); await adapter.createRecord(box); const record = makeRecord({ version: 2, parentId: box.id }); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: new Date('2024-01-01'), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: new Date('2024-01-01'), + }, + ], + journal: [], }); const restored = await adapter.restoreVersion(record.id, 1); expect(restored.content).toEqual({ text: 'original' }); @@ -2222,12 +2239,16 @@ describe('versions', () => { const box = makeRecord(); await adapter.createRecord(box); const record = makeRecord({ version: 2 }); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: new Date('2024-01-01'), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: new Date('2024-01-01'), + }, + ], + journal: [], }); const restored = await adapter.restoreVersion(record.id, 1); expect(restored.parentId).toBeUndefined(); @@ -2236,20 +2257,19 @@ describe('versions', () => { ).toHaveLength(2); }); - test('saveVersion throws on a (record, version) collision instead of silently dropping the snapshot', async () => { + test('an import naming one version twice is refused and writes nothing', async () => { const adapter = await initAdapter(); const record = makeRecord(); - await adapter.createRecord(record); const v = { version: 1, typeId: record.typeId, content: { text: 'original' }, updatedAt: new Date(), }; - await adapter.saveVersion(record.id, v); - await expect(adapter.saveVersion(record.id, v)).rejects.toThrow(StackConflictError); - const versions = await adapter.getVersions(record.id); - expect(versions.length).toBe(1); + await expect(adapter.importRecord(record, { versions: [v, v], journal: [] })).rejects.toThrow( + StackConflictError, + ); + expect(await adapter.getRecord(record.id)).toBeNull(); }); // A versions row at the record's own current version is an orphan: no @@ -2261,14 +2281,18 @@ describe('versions', () => { test("a mutating call's snapshot heals a pre-existing orphan at the record's current version instead of colliding with it forever", async () => { const adapter = await initAdapter(); const record = makeRecord({ version: 1, content: { text: 'original' } }); - await adapter.createRecord(record); - // Simulate an interrupted write: the v1 snapshot committed, but the - // mutation that should have bumped past it never did. - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: record.updatedAt, + // The state an interrupted write leaves: the v1 snapshot committed, + // but the mutation that should have bumped past it never did. + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: record.updatedAt, + }, + ], + journal: [], }); const updated = await adapter.patchContent( @@ -2297,14 +2321,18 @@ describe('versions', () => { test('healing an orphan replaces every snapshot field, not just content', async () => { const adapter = await initAdapter(); const record = makeRecord({ version: 1, content: { text: 'original' } }); - await adapter.createRecord(record); // The orphan carries an actor trail of its own alongside its content. - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'stale' }, - updatedAt: record.updatedAt, - updatedBy: { subjectId: 'entity-stale', principalId: 'app-stale' }, + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'stale' }, + updatedAt: record.updatedAt, + updatedBy: { subjectId: 'entity-stale', principalId: 'app-stale' }, + }, + ], + journal: [], }); await adapter.patchContent( @@ -2381,12 +2409,16 @@ describe('restoreVersion', () => { test('restores content and bumps version', async () => { const adapter = await initAdapter(); const record = makeRecord(); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: new Date(), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await adapter.patchContent(record.id, { text: 'changed' }); @@ -2398,12 +2430,16 @@ describe('restoreVersion', () => { test('restored content is searchable and the pre-restore content is not', async () => { const adapter = await initAdapter(); const record = makeRecord({ content: { text: 'original searchable text' } }); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original searchable text' }, - updatedAt: new Date(), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original searchable text' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await adapter.patchContent(record.id, { text: 'changed unrelated text' }); @@ -2434,12 +2470,16 @@ describe('restoreVersion', () => { test('restoreVersion never touches associations — the current set survives untouched', async () => { const adapter = await initAdapter(); const record = makeRecord(); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: record.content, - updatedAt: new Date(), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: record.content, + updatedAt: new Date(), + }, + ], + journal: [], }); await adapter.setAssociations(record.id, [{ kind: 'tag', label: 'current' }]); @@ -2453,12 +2493,16 @@ describe('restoreVersion', () => { test('rolls content back while leaving associations in place', async () => { const adapter = await initAdapter(); const record = makeRecord({ content: { text: 'original' } }); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: new Date(), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await adapter.patchContent(record.id, { text: 'changed' }); await adapter.setAssociations(record.id, [{ kind: 'tag', label: 'current' }]); @@ -2471,12 +2515,16 @@ describe('restoreVersion', () => { test('restores typeId from the snapshot, even when it differs from the record’s current typeId', async () => { const adapter = await initAdapter(); const record = makeRecord({ typeId: 'com.example.test/note@1' }); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: 'com.example.test/note@1', - content: { text: 'original' }, - updatedAt: new Date(), + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: 'com.example.test/note@1', + content: { text: 'original' }, + updatedAt: new Date(), + }, + ], + journal: [], }); await adapter.commitMigration(record.id, 'com.example.test/note@2', { text: 'original', diff --git a/packages/record-adapter-sqlite/vitest.config.ts b/packages/record-adapter-sqlite/vitest.config.ts index a009d005..3b620a1f 100644 --- a/packages/record-adapter-sqlite/vitest.config.ts +++ b/packages/record-adapter-sqlite/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core/wire': resolve(__dirname, '../core/src/wire/index.ts'), + '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/sqlite-shared': resolve(__dirname, '../sqlite-shared/src/index.ts'), diff --git a/packages/sqlite-shared/src/record-adapter.ts b/packages/sqlite-shared/src/record-adapter.ts index cab1efcc..f1e1c081 100644 --- a/packages/sqlite-shared/src/record-adapter.ts +++ b/packages/sqlite-shared/src/record-adapter.ts @@ -39,6 +39,7 @@ import type { JournalOptions, MutateOptions, Reach, + RecordHistory, } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; import type { StackConfig } from './config.js'; @@ -90,6 +91,10 @@ export abstract class SharedSqlRecordAdapter implements StackRecordAdapter { return this.record.createRecord(record, opts); } + importRecord(record: StackRecord, history: RecordHistory): Promise { + return this.record.importRecord(record, history); + } + getRecord(id: string): Promise { return this.record.getRecord(id); } @@ -161,10 +166,6 @@ export abstract class SharedSqlRecordAdapter implements StackRecordAdapter { return this.record.getVersion(id, version); } - saveVersion(id: string, version: RecordVersion): Promise { - return this.record.saveVersion(id, version); - } - // ------------------------------------------------------- // Change journal // ------------------------------------------------------- diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index c2bb784c..6258d6ac 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -46,6 +46,7 @@ import type { JournalOptions, MutateOptions, Reach, + RecordHistory, } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; import { isForeignKeyViolation, isUniqueConstraintViolation } from './executor.js'; @@ -122,6 +123,34 @@ const versionRowValues = (version: RecordVersion): unknown[] => [ version.updatedBy?.principalId ?? null, ]; +/** + * The `journal` columns in the order both of its INSERTs bind them: key + * and timestamp, then journalInputValues(), then what the row reports. + */ +const JOURNAL_COLUMNS = [ + 'record_id', + 'seq', + 'at', + 'kind', + 'ops', + 'previous_parent_id', + 'actor', + 'associations', + 'version', + 'type_id', + 'parent_id', +] as const; + +/** The columns a JournalEntryInput fills, as JOURNAL_COLUMNS orders them. */ +const journalInputValues = (entry: JournalEntryInput): unknown[] => [ + entry.kind, + JSON.stringify(entry.ops), + // '' is the root; NULL is an entry that names no origin at all. + entry.previousParentId === undefined ? null : (entry.previousParentId ?? ''), + entry.actor ? JSON.stringify(entry.actor) : null, + entry.associations?.length ? JSON.stringify(entry.associations) : null, +]; + export type SharedSqlRecordLogicDeps = { exec: SqlExecutor; }; @@ -244,46 +273,68 @@ export class SharedSqlRecordLogic { */ async createRecord(record: StackRecord, opts: JournalOptions = {}): Promise { this.exec.transaction(() => { - try { - this.exec.run( - `INSERT INTO records + this.insertRecord(record); + this.appendJournal(record.id, opts.journal); + }); + + return record; + } + + /** + * createRecord() carrying the record's history as recorded: its version + * rows and journal entries, `seq` and `at` included, in the same + * transaction. Appends no entry of its own. See + * docs/spec/portability.md § Writing history. + */ + async importRecord(record: StackRecord, history: RecordHistory): Promise { + this.exec.transaction(() => { + this.insertRecord(record); + for (const version of history.versions) this.insertVersionRow(record.id, version); + for (const entry of history.journal) this.insertJournalRow(record.id, entry); + }); + } + + /** + * The record row and every table describing it, for a caller already + * inside a transaction. A duplicate id maps to StackConflictError. + */ + private insertRecord(record: StackRecord): void { + try { + this.exec.run( + `INSERT INTO records (id, type_id, created_at, updated_at, content, version, parent_id, entity_id, app_id, principal_id, updated_by, updated_via, deleted_at, unlisted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - record.id, - record.typeId, - toMs(record.createdAt), - toMs(record.updatedAt), - JSON.stringify(record.content), - record.version, - record.parentId ?? null, - record.createdBy?.subjectId ?? null, - record.appId ?? null, - record.createdBy?.principalId ?? null, - record.updatedBy?.subjectId ?? null, - record.updatedBy?.principalId ?? null, - record.deletedAt ? toMs(record.deletedAt) : null, - record.unlistedAt ? toMs(record.unlistedAt) : null, - ], - ); - } catch (err) { - if (isUniqueConstraintViolation(err)) { - throw new StackConflictError(`Record already exists: "${record.id}"`); - } - throw err; + [ + record.id, + record.typeId, + toMs(record.createdAt), + toMs(record.updatedAt), + JSON.stringify(record.content), + record.version, + record.parentId ?? null, + record.createdBy?.subjectId ?? null, + record.appId ?? null, + record.createdBy?.principalId ?? null, + record.updatedBy?.subjectId ?? null, + record.updatedBy?.principalId ?? null, + record.deletedAt ? toMs(record.deletedAt) : null, + record.unlistedAt ? toMs(record.unlistedAt) : null, + ], + ); + } catch (err) { + if (isUniqueConstraintViolation(err)) { + throw new StackConflictError(`Record already exists: "${record.id}"`); } + throw err; + } - if (record.associations?.length) this.insertAssociations(record.id, record.associations); - if (record.permissions?.length) this.insertPermissions(record.id, record.permissions); - - this.searchIndex.insert(record.id, JSON.stringify(record.content)); - this.contentIndex.sync(record.id, record.typeId, record.content); - this.appendJournal(record.id, opts.journal); - }); + if (record.associations?.length) this.insertAssociations(record.id, record.associations); + if (record.permissions?.length) this.insertPermissions(record.id, record.permissions); - return record; + this.searchIndex.insert(record.id, JSON.stringify(record.content)); + this.contentIndex.sync(record.id, record.typeId, record.content); } async getRecord(id: string): Promise { @@ -648,15 +699,6 @@ export class SharedSqlRecordLogic { ); } - /** - * Standalone snapshot write for tooling and tests — loud on any - * collision, since nothing here is about to bump the version. Mutating - * methods take a `snapshot` option instead; see snapshotBeforeMutation. - */ - async saveVersion(id: string, version: RecordVersion): Promise { - this.insertVersionRow(id, version); - } - /** * The snapshot half of a mutating method's atomic snapshot-then-mutate * transaction. A collision is a racing-writer conflict, except an orphan @@ -699,9 +741,7 @@ export class SharedSqlRecordLogic { if (!row) throw recordNotFound(recordId); this.exec.run( - `INSERT INTO journal - (record_id, seq, at, kind, ops, version, type_id, parent_id, - previous_parent_id, actor, associations) + `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) VALUES ( ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?), @@ -710,15 +750,27 @@ export class SharedSqlRecordLogic { recordId, recordId, toMs(new Date()), - entry.kind, - JSON.stringify(entry.ops), + ...journalInputValues(entry), row.version, row.type_id, row.parent_id, - // '' is the root; NULL is an entry that names no origin at all. - entry.previousParentId === undefined ? null : (entry.previousParentId ?? ''), - entry.actor ? JSON.stringify(entry.actor) : null, - entry.associations?.length ? JSON.stringify(entry.associations) : null, + ], + ); + } + + /** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */ + private insertJournalRow(recordId: string, entry: RecordJournalEntry): void { + this.exec.run( + `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) + VALUES (${JOURNAL_COLUMNS.map(() => '?').join(', ')})`, + [ + recordId, + entry.seq, + toMs(entry.at), + ...journalInputValues(entry), + entry.version, + entry.typeId, + entry.parentId ?? null, ], ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ebd291..405095df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,16 +24,16 @@ importers: version: 1.0.0 '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.2.1(supports-color@10.2.2)) + version: 10.0.1(eslint@10.2.1) '@types/node': specifier: ^22.0.0 version: 22.19.17 eslint: specifier: ^10.2.1 - version: 10.2.1(supports-color@10.2.2) + version: 10.2.1 eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1(supports-color@10.2.2)) + version: 10.1.8(eslint@10.2.1) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -42,7 +42,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.59.1 - version: 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + version: 8.59.1(eslint@10.2.1)(typescript@5.9.3) vitest: specifier: ^4.1.11 version: 4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) @@ -97,6 +97,9 @@ importers: specifier: workspace:^ version: link:../record-adapter-sqlite devDependencies: + '@haverstack/adapter-conformance': + specifier: workspace:* + version: link:../adapter-conformance '@types/node': specifier: ^22.0.0 version: 22.19.17 @@ -209,7 +212,7 @@ importers: version: link:../sqlite-shared tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.28)(supports-color@10.2.2)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -240,7 +243,7 @@ importers: version: 22.19.17 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.28)(supports-color@10.2.2)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -772,105 +775,89 @@ packages: resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.4': resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.4': resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.4': resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.4': resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.4': resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.3.4': resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.4': resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.35.5': resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.35.5': resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.35.5': resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.35.5': resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.35.5': resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.35.5': resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.35.5': resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.35.5': resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.35.5': resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} @@ -974,79 +961,66 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -2452,17 +2426,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(supports-color@10.2.2))': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': dependencies: - eslint: 10.2.1(supports-color@10.2.2) + eslint: 10.2.1 eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5(supports-color@10.2.2)': + '@eslint/config-array@0.23.5': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -2475,9 +2449,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.2.1(supports-color@10.2.2))': + '@eslint/js@10.0.1(eslint@10.2.1)': optionalDependencies: - eslint: 10.2.1(supports-color@10.2.2) + eslint: 10.2.1 '@eslint/object-schema@3.0.5': {} @@ -2810,15 +2784,15 @@ snapshots: '@types/sinonjs__fake-timers@15.0.1': {} - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@5.9.3))(eslint@10.2.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.1 - eslint: 10.2.1(supports-color@10.2.2) + eslint: 10.2.1 ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2826,23 +2800,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.1 - debug: 4.4.3(supports-color@10.2.2) - eslint: 10.2.1(supports-color@10.2.2) + debug: 4.4.3 + eslint: 10.2.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.1(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) '@typescript-eslint/types': 8.59.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2856,13 +2830,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - debug: 4.4.3(supports-color@10.2.2) - eslint: 10.2.1(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.2.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2870,13 +2844,13 @@ snapshots: '@typescript-eslint/types@8.59.1': {} - '@typescript-eslint/typescript-estree@8.59.1(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) '@typescript-eslint/types': 8.59.1 '@typescript-eslint/visitor-keys': 8.59.1 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 @@ -2885,13 +2859,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.1(eslint@10.2.1)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) '@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) - eslint: 10.2.1(supports-color@10.2.2) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + eslint: 10.2.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -3010,11 +2984,9 @@ snapshots: dataloader@2.2.3: {} - debug@4.4.3(supports-color@10.2.2): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 10.2.2 deep-is@0.1.4: {} @@ -3057,9 +3029,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.2.1(supports-color@10.2.2)): + eslint-config-prettier@10.1.8(eslint@10.2.1): dependencies: - eslint: 10.2.1(supports-color@10.2.2) + eslint: 10.2.1 eslint-scope@9.1.2: dependencies: @@ -3072,11 +3044,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.1(supports-color@10.2.2): + eslint@10.2.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(supports-color@10.2.2)) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5(supports-color@10.2.2) + '@eslint/config-array': 0.23.5 '@eslint/config-helpers': 0.5.5 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.1 @@ -3086,7 +3058,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -3512,13 +3484,13 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.28)(supports-color@10.2.2)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.2) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3(supports-color@10.2.2) + debug: 4.4.3 esbuild: 0.28.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -3548,13 +3520,13 @@ snapshots: type-detect@4.1.0: {} - typescript-eslint@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): + typescript-eslint@8.59.1(eslint@10.2.1)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) - eslint: 10.2.1(supports-color@10.2.2) + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@5.9.3))(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) + eslint: 10.2.1 typescript: 5.9.3 transitivePeerDependencies: - supports-color From b95ebbbe9657add8dcfba662bfc888941a7b5be6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 21:49:02 +0000 Subject: [PATCH 02/16] fix: one identity rule for a copy target, and history a Record can have - 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 1 + docs/spec/portability.md | 9 +- packages/adapter-conformance/src/copy.ts | 4 +- packages/core/src/adapter/copy.ts | 6 - packages/core/src/adapter/index.ts | 1 + packages/core/src/errors.ts | 15 +- packages/core/src/testing.ts | 7 +- packages/core/src/types/adapter.ts | 10 +- packages/core/tests/copy.test.ts | 7 - packages/core/tests/scoped-stack.test.ts | 4 + .../tests/record.test.ts | 6 +- .../tests/record.test.ts | 99 ++++++------- pnpm-lock.yaml | 131 +++++++++++------- 13 files changed, 167 insertions(+), 133 deletions(-) diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index d80289b5..ca5c8b0e 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -11,5 +11,6 @@ A stack can be copied whole between adapters, authorship and history included. - `copyStack(from, to)` in `@haverstack/core/adapter` carries every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and the bytes its attachment records name. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. +- `baseIdOf()` is exported from `@haverstack/core/adapter`, and `OwnerMismatchError` reads "Cannot use …" since `copyStack()` throws it too. - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. - `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, a round trip through a full adapter. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index a805e3f8..f90ed74d 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -16,19 +16,18 @@ What it carries: - **Every Type**, through `listTypes()` and `saveType()`. - **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and the target was created holding the source's identity. -- **The bytes every [`_attachment` family](./attachments.md#finding-a-fileids-metadata-records) record names**, soft-deleted and unlisted ones included, for the reason those count as references. Bytes no record names are [garbage](./attachments.md#garbage-collection) and stay behind. +- **The bytes every [`_attachment` family](./attachments.md#finding-a-fileids-metadata-records) record names**, soft-deleted and unlisted ones included: the files [garbage collection](./attachments.md#garbage-collection) weighs, so a collection on the target reaches the decision one on the source would have. Bare bytes no record names are garbage already, and stay behind. Nothing a purge destroyed is carried, because the source no longer holds it. **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is a store newly created with the source's identity.** `copyStack()` refuses one that cannot hold history, has another owner, has another timezone or already holds a Record, before writing anything: +**The target is a store newly created with the source's identity.** Its owner is checked as `open()` checks a store's ([Adapters § Construction](./adapters.md#construction)). Its `timezone` is not: it is passthrough metadata a store takes only when it is created, so the target is created with `from.timezone`, as any new store is created with the timezone it should hold. `copyStack()` refuses a target before writing anything: | Target | Refusal | | --------------------------------------- | ------------------------------------------------ | | No `importRecord()` | `InvalidAdapterError` | | Another `ownerEntityId` | `OwnerMismatchError`, `where: 'the copy target'` | -| Another `timezone` | `InvalidAdapterError` | | Holds a Record, deleted or unlisted too | `StackConflictError` | A store `Stack.open()` has opened holds the system Types and no Record, so it is a valid target unless `ownerProfile` created the owner's card in it. Types are saved over, which is why a target may hold them: the only ones a new store holds are the system Types, which every stack defines alike. @@ -55,6 +54,8 @@ type RecordHistory = { - **It appends no journal entry of its own.** The entries it is handed are the log. - **Writes after it continue the history it wrote.** The next bump snapshots the version the import left and moves past it, and the next entry's `seq` is [allocated from the log's maximum](./journal.md#ordering) as always. +**It is handed history a Record can have**, as every adapter method is held only to what its caller produces ([Adapters § Interface split](./adapters.md#interface-split)): every snapshot's `version` is below `record.version` — one at the record's own version is [an orphan](./versioning.md#snapshot-atomicity) — and every entry's `seq` is distinct. What an adapter does with anything else is unspecified. Order is not part of the input: snapshots are keyed by `version` and entries by `seq`, and reading them back orders them as `getVersions()` and `getJournal()` always do. + `RecordHistory` is what `getVersions()` and `getJournal()` return, so an adapter reads a Record's history out in the shape another writes it in. **Optional, and every local adapter implements it.** It is optional only so `APIAdapter` can leave it out, as [`queryReadable()`](./adapters.md#querying-within-a-reach) is. A server is the only writer of its own durable tiers ([Versioning § Snapshot atomicity](./versioning.md#snapshot-atomicity)), and the wire carries no endpoint writing either one, so a stack behind an `APIAdapter` cannot be copied into. The [conformance suite](./adapters.md#conformance) holds an adapter declaring it to every rule above, and `runCopyConformance()` holds a full adapter to the round trip: a stack with a past copied in and back out, unchanged at both hops. @@ -104,7 +105,7 @@ Their columns follow four encoding rules: The remaining columns are the fields they name: `records` holds `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `app_id`, `deleted_at` and `unlisted_at`; `versions` holds `type_id`, `content` and `updated_at`; `journal` holds `at`, `kind`, `version`, `type_id` and `parent_id`; `types` holds `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from` and `created_at`. An association's `attachment_record_id` sits outside its key, as [Attachments § Naming the upload a reference came from](./attachments.md#naming-the-upload-a-reference-came-from) requires. -The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is opened with and [`copyStack()` checks](#copying-a-stack). +The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with. `content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. diff --git a/packages/adapter-conformance/src/copy.ts b/packages/adapter-conformance/src/copy.ts index 1bb33592..f3e3b7ed 100644 --- a/packages/adapter-conformance/src/copy.ts +++ b/packages/adapter-conformance/src/copy.ts @@ -12,7 +12,7 @@ import { describe, test, expect } from 'vitest'; import { Stack, SYSTEM_TYPES } from '@haverstack/core'; import type { StackRecord } from '@haverstack/core'; -import { copyStack, resolveQuery } from '@haverstack/core/adapter'; +import { baseIdOf, copyStack, resolveQuery } from '@haverstack/core/adapter'; import type { StackAdapter } from '@haverstack/core/adapter'; import { MemoryAdapter } from '@haverstack/core/testing'; @@ -56,7 +56,7 @@ async function contentsOf(adapter: StackAdapter) { const blobs: Record = {}; for (const record of records) { - if (record.typeId.startsWith(`${SYSTEM_TYPES.ATTACHMENT}@`)) { + if (baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT) { const fileId = (record.content as { fileId: string }).fileId; blobs[fileId] = await adapter.getBlob(fileId); } diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index 4b133132..6c4c6b2f 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -41,12 +41,6 @@ export async function copyStack(from: StackAdapter, to: StackAdapter): Promise a.version - b.version), + ); this.journals.set( record.id, [...history.journal].sort((a, b) => a.seq - b.seq), diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 94bb66f1..8088a27b 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -162,12 +162,10 @@ export interface StackRecordAdapter { createRecord(record: StackRecord, opts?: JournalOptions): Promise; getRecord(id: RecordId): Promise; /** - * createRecord() for a record that already has a past: writes it exactly - * as given — authorship, dates and version included — beside its version - * snapshots and journal entries as recorded, in one atomic write, and - * appends no entry of its own. StackConflictError if `record.id` exists. - * Optional — a stack whose adapter lacks it cannot be copied into, as a - * server-backed one cannot. See docs/spec/portability.md § Writing history. + * createRecord() for a record with a past: written exactly as given, beside + * its snapshots and journal entries as recorded, in one atomic write that + * appends no entry of its own. Optional: a stack whose adapter lacks it + * cannot be copied into. See docs/spec/portability.md § Writing history. */ importRecord?(record: StackRecord, history: RecordHistory): Promise; diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index 2416c280..dec648aa 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -53,13 +53,6 @@ describe('copyStack', () => { ); }); - test('refuses a target with another timezone', async () => { - const source = await sourceWith(1); - await expect(copyStack(source, await fresh(OWNER, 'Europe/Paris'))).rejects.toThrow( - InvalidAdapterError, - ); - }); - test('refuses a target that already holds records', async () => { const source = await sourceWith(1); const target = await sourceWith(1); diff --git a/packages/core/tests/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 40268974..139cdd2f 100644 --- a/packages/core/tests/scoped-stack.test.ts +++ b/packages/core/tests/scoped-stack.test.ts @@ -910,6 +910,7 @@ describe('ScopedStack — versions', () => { // patchContent()/editAssociations()'s gate exactly. test('getVersions/getVersion deny a read-only requester', async () => { const record = makeRecord({ + version: 2, permissions: [ { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, ], @@ -935,6 +936,7 @@ describe('ScopedStack — versions', () => { test('getVersions/getVersion allow a write-holder', async () => { const record = makeRecord({ + version: 2, permissions: [ { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, @@ -957,6 +959,7 @@ describe('ScopedStack — versions', () => { test('getVersions/getVersion serve the same rows to a write-holder and to the owner', async () => { const record = makeRecord({ + version: 2, permissions: [ { kind: 'permission', label: 'read', grantee: { kind: 'entity', entityId: MEMBER } }, { kind: 'permission', label: 'write', grantee: { kind: 'entity', entityId: MEMBER } }, @@ -992,6 +995,7 @@ describe('ScopedStack — versions', () => { test('getVersions/getVersion on a group require admin, not just membership', async () => { const ADMIN = 'group-version-admin'; const group = makeRecord({ + version: 2, typeId: '_group@1', associations: [ { kind: 'relationship', label: 'admin', target: { kind: 'entity', entityId: ADMIN } }, diff --git a/packages/record-adapter-do-sqlite/tests/record.test.ts b/packages/record-adapter-do-sqlite/tests/record.test.ts index 4ba61566..d5498ab9 100644 --- a/packages/record-adapter-do-sqlite/tests/record.test.ts +++ b/packages/record-adapter-do-sqlite/tests/record.test.ts @@ -742,9 +742,11 @@ describe('importRecord', () => { expect(await stub.getJournal(record.id)).toEqual(journal); }); - test('a refused import writes nothing', async () => { + // Two snapshots at one version are outside the contract; they are here + // only to make the engine fail after the record row is written. + test('an import that fails part-way leaves no row behind', async () => { const stub = getStub(); - const record = makeRecord(); + const record = makeRecord({ version: 2 }); const v = { version: 1, typeId: record.typeId, content: {}, updatedAt: new Date(0) }; const err = await stub .importRecord(record, { versions: [v, v], journal: [] }) diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index c70c230c..fc913c06 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -11,7 +11,7 @@ import { StackBadRequestError, } from '@haverstack/core'; import { OwnerMismatchError, resolveQuery } from '@haverstack/core/adapter'; -import type { AuthorityAssociation, StackRecord } from '@haverstack/core'; +import type { AuthorityAssociation, RecordVersion, StackRecord } from '@haverstack/core'; // ------------------------------------------------------- // Test helpers @@ -30,6 +30,27 @@ afterEach(() => { rmSync(testDir, { recursive: true, force: true }); }); +/** + * A versions row written behind the adapter's back, through a connection of + * its own: the state an interrupted write leaves, which no adapter call can. + */ +const plantVersionRow = (recordId: string, v: RecordVersion): void => { + const db = new DatabaseSync(dbPath); + db.prepare( + `INSERT INTO versions (record_id, version, type_id, content, updated_at, updated_by, updated_via) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run( + recordId, + v.version, + v.typeId, + JSON.stringify(v.content), + v.updatedAt.getTime(), + v.updatedBy?.subjectId ?? null, + v.updatedBy?.principalId ?? null, + ); + db.close(); +}; + const initAdapter = (opts?: { timezone?: string; ownerEntityId?: string }) => NativeSQLiteRecordAdapter.open({ create: 'exclusive', @@ -436,7 +457,7 @@ describe('records — CRUD', () => { test('purging deleteRecord removes version history', async () => { const adapter = await initAdapter(); - const record = makeRecord(); + const record = makeRecord({ version: 2 }); await adapter.importRecord(record, { versions: [ { @@ -641,7 +662,7 @@ describe('ifVersion', () => { test('restoreVersion enforces ifVersion', async () => { const adapter = await initAdapter(); - const record = makeRecord({ content: { text: 'original' } }); + const record = makeRecord({ version: 2, content: { text: 'v2' } }); await adapter.importRecord(record, { versions: [ { @@ -653,7 +674,6 @@ describe('ifVersion', () => { ], journal: [], }); - await adapter.patchContent(record.id, { text: 'v2' }); // -> v2 await expect(adapter.restoreVersion(record.id, 1, { ifVersion: 1 })).rejects.toBeInstanceOf( StackVersionConflictError, @@ -685,7 +705,7 @@ describe('ifVersion', () => { describe('ifVersion holds between concurrent writes', () => { test('restoreVersion: one of two racing restores wins', async () => { const adapter = await initAdapter(); - const record = makeRecord({ content: { text: 'original' } }); + const record = makeRecord({ version: 2, content: { text: 'v2' } }); await adapter.importRecord(record, { versions: [ { @@ -697,7 +717,6 @@ describe('ifVersion holds between concurrent writes', () => { ], journal: [], }); - await adapter.patchContent(record.id, { text: 'v2' }); // -> v2 const results = await Promise.allSettled([ adapter.restoreVersion(record.id, 1, { ifVersion: 2 }), @@ -2177,7 +2196,7 @@ describe('setParent', () => { describe('versions', () => { test('an imported version reads back through getVersion, actors included', async () => { const adapter = await initAdapter(); - const record = makeRecord(); + const record = makeRecord({ version: 2 }); const version = { version: 1, typeId: record.typeId, @@ -2196,7 +2215,7 @@ describe('versions', () => { // and docs/spec/versioning.md § Version history. test('a snapshot carries no containment, however one is handed in', async () => { const adapter = await initAdapter(); - const record = makeRecord(); + const record = makeRecord({ version: 3 }); const base = { typeId: record.typeId, content: {}, updatedAt: new Date('2024-01-01') }; await adapter.importRecord(record, { versions: [ @@ -2257,15 +2276,12 @@ describe('versions', () => { ).toHaveLength(2); }); - test('an import naming one version twice is refused and writes nothing', async () => { + // Two snapshots at one version are outside the contract; they are here + // only to make the engine fail after the record row is written. + test('an import that fails part-way leaves no row behind', async () => { const adapter = await initAdapter(); - const record = makeRecord(); - const v = { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: new Date(), - }; + const record = makeRecord({ version: 2 }); + const v = { version: 1, typeId: record.typeId, content: {}, updatedAt: new Date() }; await expect(adapter.importRecord(record, { versions: [v, v], journal: [] })).rejects.toThrow( StackConflictError, ); @@ -2281,18 +2297,14 @@ describe('versions', () => { test("a mutating call's snapshot heals a pre-existing orphan at the record's current version instead of colliding with it forever", async () => { const adapter = await initAdapter(); const record = makeRecord({ version: 1, content: { text: 'original' } }); - // The state an interrupted write leaves: the v1 snapshot committed, - // but the mutation that should have bumped past it never did. - await adapter.importRecord(record, { - versions: [ - { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: record.updatedAt, - }, - ], - journal: [], + await adapter.createRecord(record); + // Simulate an interrupted write: the v1 snapshot committed, but the + // mutation that should have bumped past it never did. + plantVersionRow(record.id, { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: record.updatedAt, }); const updated = await adapter.patchContent( @@ -2321,18 +2333,14 @@ describe('versions', () => { test('healing an orphan replaces every snapshot field, not just content', async () => { const adapter = await initAdapter(); const record = makeRecord({ version: 1, content: { text: 'original' } }); + await adapter.createRecord(record); // The orphan carries an actor trail of its own alongside its content. - await adapter.importRecord(record, { - versions: [ - { - version: 1, - typeId: record.typeId, - content: { text: 'stale' }, - updatedAt: record.updatedAt, - updatedBy: { subjectId: 'entity-stale', principalId: 'app-stale' }, - }, - ], - journal: [], + plantVersionRow(record.id, { + version: 1, + typeId: record.typeId, + content: { text: 'stale' }, + updatedAt: record.updatedAt, + updatedBy: { subjectId: 'entity-stale', principalId: 'app-stale' }, }); await adapter.patchContent( @@ -2408,7 +2416,7 @@ describe('versions', () => { describe('restoreVersion', () => { test('restores content and bumps version', async () => { const adapter = await initAdapter(); - const record = makeRecord(); + const record = makeRecord({ version: 2, content: { text: 'changed' } }); await adapter.importRecord(record, { versions: [ { @@ -2420,7 +2428,6 @@ describe('restoreVersion', () => { ], journal: [], }); - await adapter.patchContent(record.id, { text: 'changed' }); const restored = await adapter.restoreVersion(record.id, 1); expect(restored.content).toEqual({ text: 'original' }); @@ -2429,7 +2436,7 @@ describe('restoreVersion', () => { test('restored content is searchable and the pre-restore content is not', async () => { const adapter = await initAdapter(); - const record = makeRecord({ content: { text: 'original searchable text' } }); + const record = makeRecord({ version: 2, content: { text: 'changed unrelated text' } }); await adapter.importRecord(record, { versions: [ { @@ -2441,7 +2448,6 @@ describe('restoreVersion', () => { ], journal: [], }); - await adapter.patchContent(record.id, { text: 'changed unrelated text' }); await adapter.restoreVersion(record.id, 1); @@ -2469,7 +2475,7 @@ describe('restoreVersion', () => { // § Version history. test('restoreVersion never touches associations — the current set survives untouched', async () => { const adapter = await initAdapter(); - const record = makeRecord(); + const record = makeRecord({ version: 2 }); await adapter.importRecord(record, { versions: [ { @@ -2492,7 +2498,7 @@ describe('restoreVersion', () => { // it never touches associations for any record. test('rolls content back while leaving associations in place', async () => { const adapter = await initAdapter(); - const record = makeRecord({ content: { text: 'original' } }); + const record = makeRecord({ version: 2, content: { text: 'changed' } }); await adapter.importRecord(record, { versions: [ { @@ -2504,7 +2510,6 @@ describe('restoreVersion', () => { ], journal: [], }); - await adapter.patchContent(record.id, { text: 'changed' }); await adapter.setAssociations(record.id, [{ kind: 'tag', label: 'current' }]); const restored = await adapter.restoreVersion(record.id, 1); @@ -2514,7 +2519,7 @@ describe('restoreVersion', () => { test('restores typeId from the snapshot, even when it differs from the record’s current typeId', async () => { const adapter = await initAdapter(); - const record = makeRecord({ typeId: 'com.example.test/note@1' }); + const record = makeRecord({ typeId: 'com.example.test/note@1', version: 2 }); await adapter.importRecord(record, { versions: [ { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 405095df..572a401e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,16 +24,16 @@ importers: version: 1.0.0 '@eslint/js': specifier: ^10.0.1 - version: 10.0.1(eslint@10.2.1) + version: 10.0.1(eslint@10.2.1(supports-color@10.2.2)) '@types/node': specifier: ^22.0.0 version: 22.19.17 eslint: specifier: ^10.2.1 - version: 10.2.1 + version: 10.2.1(supports-color@10.2.2) eslint-config-prettier: specifier: ^10.1.8 - version: 10.1.8(eslint@10.2.1) + version: 10.1.8(eslint@10.2.1(supports-color@10.2.2)) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -42,7 +42,7 @@ importers: version: 5.9.3 typescript-eslint: specifier: ^8.59.1 - version: 8.59.1(eslint@10.2.1)(typescript@5.9.3) + version: 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) vitest: specifier: ^4.1.11 version: 4.1.11(@types/node@22.19.17)(vite@7.3.6(@types/node@22.19.17)(yaml@2.9.0)) @@ -212,7 +212,7 @@ importers: version: link:../sqlite-shared tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(postcss@8.5.28)(supports-color@10.2.2)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -243,7 +243,7 @@ importers: version: 22.19.17 tsup: specifier: ^8.5.1 - version: 8.5.1(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(postcss@8.5.28)(supports-color@10.2.2)(typescript@5.9.3)(yaml@2.9.0) typescript: specifier: ^5.5.0 version: 5.9.3 @@ -775,89 +775,105 @@ packages: resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.3.4': resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.3.4': resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.3.4': resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.3.4': resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.3.4': resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.3.4': resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.3.4': resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.35.5': resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.35.5': resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.35.5': resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.35.5': resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.35.5': resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.35.5': resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.35.5': resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.35.5': resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.35.5': resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} @@ -961,66 +977,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -2426,17 +2455,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1)': + '@eslint-community/eslint-utils@4.9.1(eslint@10.2.1(supports-color@10.2.2))': dependencies: - eslint: 10.2.1 + eslint: 10.2.1(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.23.5': + '@eslint/config-array@0.23.5(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 3.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 transitivePeerDependencies: - supports-color @@ -2449,9 +2478,9 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.2.1)': + '@eslint/js@10.0.1(eslint@10.2.1(supports-color@10.2.2))': optionalDependencies: - eslint: 10.2.1 + eslint: 10.2.1(supports-color@10.2.2) '@eslint/object-schema@3.0.5': {} @@ -2784,15 +2813,15 @@ snapshots: '@types/sinonjs__fake-timers@15.0.1': {} - '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@5.9.3))(eslint@10.2.1)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.1(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.59.1 - '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.1 - eslint: 10.2.1 + eslint: 10.2.1(supports-color@10.2.2) ignore: 7.0.5 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2800,23 +2829,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.1 - debug: 4.4.3 - eslint: 10.2.1 + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.2.1(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.1(typescript@5.9.3)': + '@typescript-eslint/project-service@8.59.1(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) '@typescript-eslint/types': 8.59.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2830,13 +2859,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) - debug: 4.4.3 - eslint: 10.2.1 + '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 10.2.1(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -2844,13 +2873,13 @@ snapshots: '@typescript-eslint/types@8.59.1': {} - '@typescript-eslint/typescript-estree@8.59.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.59.1(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.59.1(typescript@5.9.3) + '@typescript-eslint/project-service': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.59.1(typescript@5.9.3) '@typescript-eslint/types': 8.59.1 '@typescript-eslint/visitor-keys': 8.59.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.5 semver: 7.7.4 tinyglobby: 0.2.16 @@ -2859,13 +2888,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.59.1(eslint@10.2.1)(typescript@5.9.3)': + '@typescript-eslint/utils@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.59.1 '@typescript-eslint/types': 8.59.1 - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - eslint: 10.2.1 + '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) + eslint: 10.2.1(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2984,9 +3013,11 @@ snapshots: dataloader@2.2.3: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 deep-is@0.1.4: {} @@ -3029,9 +3060,9 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.8(eslint@10.2.1): + eslint-config-prettier@10.1.8(eslint@10.2.1(supports-color@10.2.2)): dependencies: - eslint: 10.2.1 + eslint: 10.2.1(supports-color@10.2.2) eslint-scope@9.1.2: dependencies: @@ -3044,11 +3075,11 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@10.2.1: + eslint@10.2.1(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1) + '@eslint-community/eslint-utils': 4.9.1(eslint@10.2.1(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 + '@eslint/config-array': 0.23.5(supports-color@10.2.2) '@eslint/config-helpers': 0.5.5 '@eslint/core': 1.2.1 '@eslint/plugin-kit': 0.7.1 @@ -3058,7 +3089,7 @@ snapshots: '@types/estree': 1.0.8 ajv: 6.15.0 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 9.1.2 eslint-visitor-keys: 5.0.1 @@ -3484,13 +3515,13 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(postcss@8.5.28)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(postcss@8.5.28)(supports-color@10.2.2)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.28.2) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) esbuild: 0.28.2 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 @@ -3520,13 +3551,13 @@ snapshots: type-detect@4.1.0: {} - typescript-eslint@8.59.1(eslint@10.2.1)(typescript@5.9.3): + typescript-eslint@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1)(typescript@5.9.3))(eslint@10.2.1)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.1(eslint@10.2.1)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.59.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.59.1(eslint@10.2.1)(typescript@5.9.3) - eslint: 10.2.1 + '@typescript-eslint/eslint-plugin': 8.59.1(@typescript-eslint/parser@8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.1(eslint@10.2.1(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 10.2.1(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color From 3c9f6203a7721b4f92e3c952a373bf0fe833ee65 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 14:12:07 +0000 Subject: [PATCH 03/16] refactor: copyStack creates its target, and copies every blob 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 6 +- README.md | 2 +- docs/spec/adapters.md | 2 +- docs/spec/portability.md | 46 +++--- packages/adapter-conformance/src/copy.ts | 91 ++++++----- packages/adapter-conformance/src/index.ts | 2 +- packages/adapter-conformance/src/record.ts | 3 +- .../tests/copy-memory.test.ts | 6 +- packages/adapter-local/src/index.ts | 2 +- packages/adapter-local/tests/copy.test.ts | 24 +-- packages/adapter-local/vitest.config.ts | 1 - packages/blob-adapter-disk/vitest.config.ts | 1 - packages/core/src/adapter/copy.ts | 105 +++++++++---- packages/core/src/adapter/index.ts | 2 +- packages/core/src/errors.ts | 15 +- packages/core/src/schema.ts | 18 ++- packages/core/src/stack/stack.ts | 21 +-- packages/core/src/testing.ts | 3 +- packages/core/src/types/adapter.ts | 2 +- packages/core/tests/combine.test.ts | 3 +- packages/core/tests/copy.test.ts | 141 ++++++++++++++---- .../tests/record.test.ts | 2 +- .../record-adapter-sqlite/vitest.config.ts | 1 - packages/sqlite-shared/src/record-adapter.ts | 2 +- packages/sqlite-shared/src/record-logic.ts | 4 +- 25 files changed, 314 insertions(+), 191 deletions(-) diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index ca5c8b0e..6047dc2d 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -9,8 +9,8 @@ A stack can be copied whole between adapters, authorship and history included. -- `copyStack(from, to)` in `@haverstack/core/adapter` carries every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and the bytes its attachment records name. Copying into a `LocalAdapter` store is an export; copying out of one is an import. +- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. -- `baseIdOf()` is exported from `@haverstack/core/adapter`, and `OwnerMismatchError` reads "Cannot use …" since `copyStack()` throws it too. +- `StackIdentity` and `CreateStore` are exported beside it. - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. -- `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, a round trip through a full adapter. +- `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, which holds a full adapter to both halves of a copy. diff --git a/README.md b/README.md index f0dc413c..8c84d5ba 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,7 @@ The adapter interface is split into `StackRecordAdapter` (structured records) an Use `combineAdapters({ record, blob })` from `@haverstack/core/adapter` to compose a record adapter with a different blob backend — for example, `NativeSQLiteRecordAdapter` with `S3BlobAdapter`. `adapter-local` wraps this pattern for the common case. -To move a stack between backends or hosts, `copyStack(from, to)` from the same entry point copies it whole — soft-deleted and unlisted Records, authorship, version history, journal, Types and attachments. An export is a `LocalAdapter` store: copy into one to export, out of one to import. See [Portability](./docs/spec/portability.md). +To move a stack between backends or hosts, `copyStack(from, create)` from the same entry point copies it whole — soft-deleted and unlisted Records, authorship, version history, journal, Types and blobs — into a new store `create` makes with the source's identity. An export is a `LocalAdapter` store: copy into one to export, out of one to import. See [Portability](./docs/spec/portability.md). --- diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index 325909f4..9864b9ae 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -142,7 +142,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s ## Conformance -The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents. `runCopyConformance()` takes an `open()` for a full adapter and checks the [round trip](./portability.md#copying-a-stack) a move between hosts makes through it; `adapter-local` runs it against itself. +The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents. `runCopyConformance()` takes a `create()` for a full adapter and checks the [copy](./portability.md#copying-a-stack) a move between hosts makes, reading from one of its stores and writing into another; `adapter-local` runs it against itself. ## SQLite-backed adapters diff --git a/docs/spec/portability.md b/docs/spec/portability.md index f90ed74d..2db4eac3 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -7,32 +7,26 @@ ```ts import { copyStack } from '@haverstack/core/adapter'; -await copyStack(from, to); +const target = await copyStack(from, (identity) => createStore(identity)); + +type StackIdentity = { ownerEntityId: EntityId; timezone: string | undefined }; ``` -**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it to another; which pair of adapters it is given is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has an export or import method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. +**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it into a new store, which it asks `create` to make with the source's identity and returns open. Which source it is given, and what kind of store `create` makes, is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has an export or import method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. -What it carries: +What it carries is everything the source holds: - **Every Type**, through `listTypes()` and `saveType()`. -- **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and the target was created holding the source's identity. -- **The bytes every [`_attachment` family](./attachments.md#finding-a-fileids-metadata-records) record names**, soft-deleted and unlisted ones included: the files [garbage collection](./attachments.md#garbage-collection) weighs, so a collection on the target reaches the decision one on the source would have. Bare bytes no record names are garbage already, and stay behind. +- **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and `create` made the target with what it holds. +- **Every blob**, through `listBlobs()`. A source without it enumerates its blobs the one way left: every file a Record names, by an `_attachment` family record's `fileId` or by a [reference](./attachments.md#garbage-collection). A reference whose bytes are gone stays dangling, as it was. -Nothing a purge destroyed is carried, because the source no longer holds it. +Nothing is filtered on the way: deciding what is garbage is the target's own [collection](./attachments.md#garbage-collection), run when its owner chooses, and nothing a purge destroyed is carried, because the source no longer holds it. **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is a store newly created with the source's identity.** Its owner is checked as `open()` checks a store's ([Adapters § Construction](./adapters.md#construction)). Its `timezone` is not: it is passthrough metadata a store takes only when it is created, so the target is created with `from.timezone`, as any new store is created with the timezone it should hold. `copyStack()` refuses a target before writing anything: - -| Target | Refusal | -| --------------------------------------- | ------------------------------------------------ | -| No `importRecord()` | `InvalidAdapterError` | -| Another `ownerEntityId` | `OwnerMismatchError`, `where: 'the copy target'` | -| Holds a Record, deleted or unlisted too | `StackConflictError` | +**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is what creating a store with that identity produces — that owner and timezone, no Record, no Type but the [system ones](./data-model.md#system-types) — on an adapter with `importRecord()`. A store `Stack.open()` has opened holds the system Types alone, so it qualifies unless `ownerProfile` created the owner's card in it. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. -A store `Stack.open()` has opened holds the system Types and no Record, so it is a valid target unless `ownerProfile` created the owner's card in it. Types are saved over, which is why a target may hold them: the only ones a new store holds are the system Types, which every stack defines alike. - -**It is not atomic.** A failure part-way leaves the target partly written; it is discarded and the copy run again into a new one, never resumed. +**It is not atomic.** A failure part-way, the refusal included, closes the target; it is discarded and the copy run again into a new one, never resumed. **The source is read only through methods every adapter has**, so it may be an `APIAdapter` opened as the owner, who reads every Record, every version and every journal entry in full. @@ -58,25 +52,23 @@ type RecordHistory = { `RecordHistory` is what `getVersions()` and `getJournal()` return, so an adapter reads a Record's history out in the shape another writes it in. -**Optional, and every local adapter implements it.** It is optional only so `APIAdapter` can leave it out, as [`queryReadable()`](./adapters.md#querying-within-a-reach) is. A server is the only writer of its own durable tiers ([Versioning § Snapshot atomicity](./versioning.md#snapshot-atomicity)), and the wire carries no endpoint writing either one, so a stack behind an `APIAdapter` cannot be copied into. The [conformance suite](./adapters.md#conformance) holds an adapter declaring it to every rule above, and `runCopyConformance()` holds a full adapter to the round trip: a stack with a past copied in and back out, unchanged at both hops. +**Optional, and every local adapter implements it.** It is optional only so `APIAdapter` can leave it out, as [`queryReadable()`](./adapters.md#querying-within-a-reach) is. A server is the only writer of its own durable tiers ([Versioning § Snapshot atomicity](./versioning.md#snapshot-atomicity)), and the wire carries no endpoint writing either one, so a stack behind an `APIAdapter` cannot be copied into. The [conformance suite](./adapters.md#conformance) holds an adapter declaring it to every rule above, and `runCopyConformance()` holds a full adapter to both halves of a copy: a stack with a past, written into one store it creates and copied into a second, arrives unchanged. ## The portable format **An export is a `LocalAdapter` store: a SQLite database, with an `attachments/` directory beside it.** It is a working stack, not an archive to unpack — `Stack.open()` reads it as it reads any local stack. ```ts +const portable = (path: string) => (identity: StackIdentity) => + LocalAdapter.open({ path, create: 'exclusive', ...identity }); + // Export: the host's stack, into a new portable store. -const exported = await LocalAdapter.open({ - path: './my-stack.db', - create: 'exclusive', - ownerEntityId: host.ownerEntityId, - timezone: host.timezone, -}); -await copyStack(host, exported); +const exported = await copyStack(host, portable('./my-stack.db')); await exported.close(); -// Import: the portable store, into a new host's empty one. -await copyStack(await LocalAdapter.open({ path: './my-stack.db' }), newHost); +// Import: the portable store, into a store created however the new host creates one. +const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); +await copyStack(exportedStack, createHostStore); ``` The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change. @@ -105,7 +97,7 @@ Their columns follow four encoding rules: The remaining columns are the fields they name: `records` holds `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `app_id`, `deleted_at` and `unlisted_at`; `versions` holds `type_id`, `content` and `updated_at`; `journal` holds `at`, `kind`, `version`, `type_id` and `parent_id`; `types` holds `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from` and `created_at`. An association's `attachment_record_id` sits outside its key, as [Attachments § Naming the upload a reference came from](./attachments.md#naming-the-upload-a-reference-came-from) requires. -The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with. +The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. `content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. diff --git a/packages/adapter-conformance/src/copy.ts b/packages/adapter-conformance/src/copy.ts index f3e3b7ed..9740f74a 100644 --- a/packages/adapter-conformance/src/copy.ts +++ b/packages/adapter-conformance/src/copy.ts @@ -1,30 +1,26 @@ /** * Stack copy conformance suite * ------------------------------------------------------- - * The round trip a move between hosts makes: a stack with a past is - * written through Stack, copied into the adapter under test with - * copyStack(), and copied back out into a fresh MemoryAdapter. Every - * Record, version snapshot, journal entry, Type and blob must come back - * as it went in, at both hops. Point it at a full StackAdapter that - * implements importRecord(). See docs/spec/portability.md. + * The copy a move between hosts makes: a stack with a past is written + * through Stack into one store the adapter under test creates, then copied + * with copyStack() into a second. Every Record, version snapshot, journal + * entry, Type and blob must arrive as it was, so the adapter is held to + * both halves of a copy, the read and the write. Point it at a full + * StackAdapter that implements importRecord(). See docs/spec/portability.md. */ import { describe, test, expect } from 'vitest'; -import { Stack, SYSTEM_TYPES } from '@haverstack/core'; -import type { StackRecord } from '@haverstack/core'; -import { baseIdOf, copyStack, resolveQuery } from '@haverstack/core/adapter'; -import type { StackAdapter } from '@haverstack/core/adapter'; -import { MemoryAdapter } from '@haverstack/core/testing'; - -/** The identity a copy target is created with: the source stack's own. */ -export type CopyTargetIdentity = { ownerEntityId: string; timezone: string | undefined }; +import { Stack } from '@haverstack/core'; +import type { FileId, StackRecord } from '@haverstack/core'; +import { copyStack, resolveQuery } from '@haverstack/core/adapter'; +import type { CreateStore, StackAdapter } from '@haverstack/core/adapter'; export interface CopyConformanceOptions { /** Used in describe() titles, so pick something that reads well there. */ name: string; - /** Produce a fresh, empty adapter created with this identity. */ - open: (identity: CopyTargetIdentity) => Promise | StackAdapter; - /** Release what `open()` acquired. Called after every test. */ + /** Create a new, empty store with this identity; called twice per copy. */ + create: CreateStore; + /** Release what `create()` acquired. Called on every store it made. */ close?: (adapter: StackAdapter) => Promise | void; } @@ -32,13 +28,14 @@ const OWNER = 'did:key:conformance-owner'; const ALICE = { subjectId: 'did:key:conformance-alice' }; const BOB = { subjectId: 'did:key:conformance-bob', principalId: 'did:key:conformance-app' }; const NOTE = 'org.haverstack.conformance/copied-note@1'; +const IDENTITY = { ownerEntityId: OWNER, timezone: 'Europe/Paris' }; /** Set order aside: an element set has no order to preserve. */ const bySerialized = (list: T[] | undefined): T[] | undefined => list && [...list].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); /** Everything a copy must carry, in a shape that compares with toEqual(). */ -async function contentsOf(adapter: StackAdapter) { +async function contentsOf(adapter: StackAdapter, fileIds: FileId[]) { const records: StackRecord[] = []; let cursor: string | undefined; do { @@ -55,12 +52,7 @@ async function contentsOf(adapter: StackAdapter) { records.sort((a, b) => a.id.localeCompare(b.id)); const blobs: Record = {}; - for (const record of records) { - if (baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT) { - const fileId = (record.content as { fileId: string }).fileId; - blobs[fileId] = await adapter.getBlob(fileId); - } - } + for (const fileId of fileIds) blobs[fileId] = await adapter.getBlob(fileId); return { types: (await adapter.listTypes()).sort((a, b) => a.id.localeCompare(b.id)), @@ -82,12 +74,16 @@ async function contentsOf(adapter: StackAdapter) { /** * A stack touched by every kind of write: foreign authorship, edits by an * app acting for someone, containment, tags, sharing, listing, an - * attachment, a soft delete and a purge. + * attachment, bytes a content field names with no metadata record, a soft + * delete and a purge. Answers with the blobs it wrote. */ -async function stackWithAPast(): Promise { - const adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'Europe/Paris' }); +async function writePast(adapter: StackAdapter): Promise { const stack = await Stack.open(adapter, { ownerProfile: { name: 'Conformance Owner' } }); - await stack.defineType({ id: NOTE, name: 'Note', schema: { title: { kind: 'text' } } }); + await stack.defineType({ + id: NOTE, + name: 'Note', + schema: { title: { kind: 'text' }, cover: { kind: 'file-ref' } }, + }); const box = await stack.create(NOTE, { title: 'box' }); const note = await stack.create(NOTE, { title: 'v1' }, { createdBy: ALICE }); @@ -99,49 +95,46 @@ async function stackWithAPast(): Promise { await stack.editAccess(note.id, [{ op: 'add', association: { kind: 'anyone', label: 'read' } }]); await stack.setUnlisted(note.id, true); - await stack.putAttachment(new TextEncoder().encode('attached bytes'), { + const attached = await stack.putAttachment(new TextEncoder().encode('attached bytes'), { mimeType: 'text/plain', filename: 'notes.txt', }); + const bare = await adapter.putBlob(new TextEncoder().encode('bytes with no metadata')); + await stack.create(NOTE, { title: 'covered', cover: bare }); const removed = await stack.create(NOTE, { title: 'removed' }, { createdBy: ALICE }); await stack.delete(removed.id); const purged = await stack.create(NOTE, { title: 'purged' }); await stack.delete(purged.id, { purge: true }); - return adapter; + return [attached.content.fileId, bare]; } export function runCopyConformance(options: CopyConformanceOptions): void { - const { name, open, close } = options; + const { name, create, close } = options; describe(`stack copy conformance: ${name}`, () => { - const identity = { ownerEntityId: OWNER, timezone: 'Europe/Paris' }; - test('implements importRecord()', async () => { - const target = await open(identity); + const store = await create(IDENTITY); try { - expect(target.importRecord).toBeTypeOf('function'); + expect(store.importRecord).toBeTypeOf('function'); } finally { - await close?.(target); + await close?.(store); } }); - test('a copy in and back out carries every Record, version, journal entry, Type and blob unchanged', async () => { - const source = await stackWithAPast(); - const expected = await contentsOf(source); - expect(expected.records.length).toBeGreaterThan(3); - expect(Object.keys(expected.blobs)).toHaveLength(1); - - const target = await open(identity); + test('a copy carries every Record, version, journal entry, Type and blob unchanged', async () => { + const source = await create(IDENTITY); + let target: StackAdapter | undefined; try { - await copyStack(source, target); - expect(await contentsOf(target)).toEqual(expected); + const fileIds = await writePast(source); + const expected = await contentsOf(source, fileIds); + expect(expected.records.length).toBeGreaterThan(3); - const back = await MemoryAdapter.open(identity); - await copyStack(target, back); - expect(await contentsOf(back)).toEqual(expected); + target = await copyStack(source, create); + expect(await contentsOf(target, fileIds)).toEqual(expected); } finally { - await close?.(target); + await close?.(source); + if (target) await close?.(target); } }); }); diff --git a/packages/adapter-conformance/src/index.ts b/packages/adapter-conformance/src/index.ts index 1bac0025..a34678fc 100644 --- a/packages/adapter-conformance/src/index.ts +++ b/packages/adapter-conformance/src/index.ts @@ -4,4 +4,4 @@ export { runBlobAdapterConformance } from './blob.js'; export type { BlobAdapterConformanceOptions } from './blob.js'; export { expectStackErrorCode } from './errors.js'; export { runCopyConformance } from './copy.js'; -export type { CopyConformanceOptions, CopyTargetIdentity } from './copy.js'; +export type { CopyConformanceOptions } from './copy.js'; diff --git a/packages/adapter-conformance/src/record.ts b/packages/adapter-conformance/src/record.ts index 35f7b9a8..0240982d 100644 --- a/packages/adapter-conformance/src/record.ts +++ b/packages/adapter-conformance/src/record.ts @@ -763,8 +763,7 @@ export function runRecordAdapterConformance(options: RecordAdapterConformanceOpt const parent = makeRecord(); await adapter.createRecord(parent); const { record, versions, journal } = withPast(parent.id); - await adapter.importRecord(record, { versions, journal }); - + expect(await adapter.importRecord(record, { versions, journal })).toEqual(record); expect(await adapter.getRecord(record.id)).toEqual(record); expect(await adapter.getVersions(record.id)).toEqual(versions); expect(await adapter.getJournal(record.id)).toEqual(journal); diff --git a/packages/adapter-conformance/tests/copy-memory.test.ts b/packages/adapter-conformance/tests/copy-memory.test.ts index d81849cf..6e22857f 100644 --- a/packages/adapter-conformance/tests/copy-memory.test.ts +++ b/packages/adapter-conformance/tests/copy-memory.test.ts @@ -1,11 +1,11 @@ /** - * Runs the stack copy suite with @haverstack/core's own MemoryAdapter as - * the target, so the suite's own round trip is checked where it is fastest. + * Runs the stack copy suite against @haverstack/core's own MemoryAdapter, + * so the suite itself is checked where it is fastest. */ import { MemoryAdapter } from '@haverstack/core/testing'; import { runCopyConformance } from '../src/copy.js'; runCopyConformance({ name: 'MemoryAdapter', - open: ({ ownerEntityId, timezone }) => MemoryAdapter.open({ ownerEntityId, timezone }), + create: ({ ownerEntityId, timezone }) => MemoryAdapter.open({ ownerEntityId, timezone }), }); diff --git a/packages/adapter-local/src/index.ts b/packages/adapter-local/src/index.ts index 8273e61a..0a6a458d 100644 --- a/packages/adapter-local/src/index.ts +++ b/packages/adapter-local/src/index.ts @@ -136,7 +136,7 @@ export class LocalAdapter implements StackAdapter { return this.record.createRecord(record, opts); } - async importRecord(record: StackRecord, history: RecordHistory): Promise { + async importRecord(record: StackRecord, history: RecordHistory): Promise { return this.record.importRecord(record, history); } diff --git a/packages/adapter-local/tests/copy.test.ts b/packages/adapter-local/tests/copy.test.ts index f0f807b8..1f89b82e 100644 --- a/packages/adapter-local/tests/copy.test.ts +++ b/packages/adapter-local/tests/copy.test.ts @@ -1,30 +1,34 @@ /** - * Runs @haverstack/adapter-conformance's stack copy suite with a - * LocalAdapter as the target: the portable form a stack is exported to - * and imported from. See docs/spec/portability.md. + * Runs @haverstack/adapter-conformance's stack copy suite against + * LocalAdapter: the portable form a stack is exported to and imported + * from. See docs/spec/portability.md. */ import { mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { runCopyConformance } from '@haverstack/adapter-conformance'; +import type { StackAdapter } from '@haverstack/core/adapter'; import { LocalAdapter } from '../src/index.js'; -let currentDir: string | undefined; +const dirs = new Map(); runCopyConformance({ name: 'LocalAdapter', - open: ({ ownerEntityId, timezone }) => { - currentDir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(currentDir, { recursive: true }); - return LocalAdapter.open({ + create: async ({ ownerEntityId, timezone }) => { + const dir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const adapter = await LocalAdapter.open({ create: 'exclusive', - path: join(currentDir, 'stack.db'), + path: join(dir, 'stack.db'), ownerEntityId, timezone, }); + dirs.set(adapter, dir); + return adapter; }, close: async (adapter) => { await adapter.close?.(); - if (currentDir) rmSync(currentDir, { recursive: true, force: true }); + rmSync(dirs.get(adapter)!, { recursive: true, force: true }); + dirs.delete(adapter); }, }); diff --git a/packages/adapter-local/vitest.config.ts b/packages/adapter-local/vitest.config.ts index ef7842b4..c06275e1 100644 --- a/packages/adapter-local/vitest.config.ts +++ b/packages/adapter-local/vitest.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core/wire': resolve(__dirname, '../core/src/wire/index.ts'), - '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/record-adapter-sqlite': resolve( diff --git a/packages/blob-adapter-disk/vitest.config.ts b/packages/blob-adapter-disk/vitest.config.ts index 0bbbaf16..f8327695 100644 --- a/packages/blob-adapter-disk/vitest.config.ts +++ b/packages/blob-adapter-disk/vitest.config.ts @@ -4,7 +4,6 @@ import { resolve } from 'path'; export default defineConfig({ resolve: { alias: { - '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/adapter-conformance': resolve(__dirname, '../adapter-conformance/src/index.ts'), diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index 6c4c6b2f..e6b3f438 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -1,21 +1,28 @@ /** * Copying a stack * ------------------------------------------------------- - * One stack, read in full from one adapter and written in full to another: - * every Record (soft-deleted and unlisted included) with its version - * snapshots and journal, every Type, and the bytes its attachment records - * name. Export, import and moving between storage engines are all this one - * call, with a different pair of adapters. Full trust, like any direct - * adapter access: authorship and history land as recorded, which no write - * through Stack may do. See docs/spec/portability.md. + * One stack, read in full from one adapter and written in full to a new + * store created with its identity: every Record (soft-deleted and unlisted + * included) with its version snapshots and journal, every Type, and every + * blob. Export, import and moving between storage engines are all this one + * call, with a different source and a different kind of store to create. + * Full trust, like any direct adapter access: authorship and history land + * as recorded, which no write through Stack may do. What is garbage is left + * to the target's own collection. See docs/spec/portability.md. */ -import { InvalidAdapterError, OwnerMismatchError, StackConflictError } from '../errors.js'; -import { baseIdOf } from '../schema.js'; +import { InvalidAdapterError, StackNotFoundError } from '../errors.js'; +import { baseIdOf, referencedFileIds } from '../schema.js'; import { resolveQuery } from '../query-validation.js'; -import type { FileId, PlainQuery, StackAdapter } from '../types/index.js'; +import type { EntityId, FileId, PlainQuery, StackAdapter } from '../types/index.js'; import { SYSTEM_TYPES } from '../types/index.js'; +/** What a store is created with: the identity `_config` holds. */ +export type StackIdentity = { ownerEntityId: EntityId; timezone: string | undefined }; + +/** Creates a new, empty store with `identity` — `LocalAdapter.open({ create: 'exclusive', ...identity })`, say. */ +export type CreateStore = (identity: StackIdentity) => Promise | StackAdapter; + /** Records read per page; any size works, since every page is written before the next is read. */ const PAGE_SIZE = 100; @@ -26,29 +33,61 @@ const EVERYTHING: PlainQuery = { limit: PAGE_SIZE, }; +const SYSTEM_BASE_IDS = new Set(Object.values(SYSTEM_TYPES)); + /** - * Copy everything `from` holds into `to`, a store newly created with the - * same `ownerEntityId` and `timezone`. Not atomic: a failure part-way - * leaves `to` partly written, to be discarded rather than resumed. + * Copy everything `from` holds into the store `create` makes with `from`'s + * identity, and return that store, open. Not atomic: a failure closes the + * store part-written, to be discarded rather than resumed. * See docs/spec/portability.md § Copying a stack. */ -export async function copyStack(from: StackAdapter, to: StackAdapter): Promise { - if (!to.importRecord) { - throw new InvalidAdapterError( - 'Cannot copy into this adapter: it has no importRecord(), so it cannot write history.', +export async function copyStack(from: StackAdapter, create: CreateStore): Promise { + const identity = { ownerEntityId: from.ownerEntityId, timezone: from.timezone }; + const to = await create(identity); + try { + await assertNewStore(to, identity); + await copyInto(from, to); + return to; + } catch (err) { + await to.close?.(); + throw err; + } +} + +/** + * The one rule a target is held to: it is what creating a store with + * `identity` produces — that owner and timezone, no Record, no Type but the + * system ones — on an adapter that can write history. + */ +async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promise { + const refuse = (why: string) => { + throw new InvalidAdapterError(`Cannot copy into the store create() returned: ${why}.`); + }; + if (!to.importRecord) refuse('it has no importRecord(), so it cannot write history'); + if (to.ownerEntityId !== identity.ownerEntityId) { + refuse(`it is owned by "${to.ownerEntityId}", not "${identity.ownerEntityId}"`); + } + if (to.timezone !== identity.timezone) { + refuse( + `its timezone is ${JSON.stringify(to.timezone)}, not ${JSON.stringify(identity.timezone)}`, ); } - if (to.ownerEntityId !== from.ownerEntityId) { - throw new OwnerMismatchError(from.ownerEntityId, to.ownerEntityId, 'the copy target'); + if ((await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 }))).records.length) { + refuse('it already holds Records'); } - const occupied = await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 })); - if (occupied.records.length) { - throw new StackConflictError('Cannot copy into a store that already holds records.'); + if ((await to.listTypes()).some((type) => !SYSTEM_BASE_IDS.has(type.baseId))) { + refuse('it already holds Types of its own'); } +} - for (const type of await from.listTypes()) await to.saveType(type); +async function copyInto(from: StackAdapter, to: StackAdapter): Promise { + const types = await from.listTypes(); + for (const type of types) await to.saveType(type); + const schemas = new Map(types.map((type) => [type.id, type.schema])); - const fileIds = new Set(); + // Without listBlobs(), the blobs a store holds are the ones its Records + // name, as far as anything can enumerate them. + const named = new Set(); let cursor: string | undefined; do { const page = await from.queryRecords(resolveQuery({ ...EVERYTHING, cursor })); @@ -57,13 +96,25 @@ export async function copyStack(from: StackAdapter, to: StackAdapter): Promise blob.fileId) : named; + for (const fileId of fileIds) { + try { + await to.putBlob(await from.getBlob(fileId)); + } catch (err) { + // A reference whose bytes are gone stays dangling, as it was. + if (!(err instanceof StackNotFoundError)) throw err; + } + } } diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index 04f10885..3ad5a4d3 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -29,8 +29,8 @@ export { sameAssociationSet } from '../associations/identity.js'; export { ElementSetMovedError, OwnerMismatchError, softDeletedRefusal } from '../errors.js'; export { combineAdapters } from './combine.js'; export { copyStack } from './copy.js'; +export type { CreateStore, StackIdentity } from './copy.js'; export { inReach } from './reach.js'; -export { baseIdOf } from '../schema.js'; export type { ReachedRecord } from './reach.js'; export { assertQueryCapabilities, diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 21309b77..aa1bc4ed 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -243,12 +243,11 @@ export class InvalidAdapterError extends Error { } /** - * Thrown when a store's owner is not the one expected: by an adapter's open() - * when a plain-string `ownerEntityId` disagrees with the store's, and by - * copyStack() when the target's disagrees with the source's. `where` names - * the store; `actual` is undefined when a server reports no owner at all. - * Outside the StackError taxonomy for the same reason as InvalidAdapterError. - * See docs/spec/adapters.md § Construction. + * Thrown by an adapter's open() when a plain-string `ownerEntityId` disagrees + * with the owner the store already has. `where` names the path, URL or + * Durable Object that was opened; `actual` is undefined when a server reports + * no owner at all. Outside the StackError taxonomy for the same reason as + * InvalidAdapterError. See docs/spec/adapters.md § Construction. */ export class OwnerMismatchError extends Error { constructor( @@ -258,8 +257,8 @@ export class OwnerMismatchError extends Error { ) { super( actual - ? `Cannot use ${where}: it is owned by "${actual}", but ownerEntityId "${expected}" was given.` - : `Cannot use ${where}: it reports no owner, but ownerEntityId "${expected}" was given.`, + ? `Cannot open ${where}: it is owned by "${actual}", but ownerEntityId "${expected}" was given.` + : `Cannot open ${where}: it reports no owner, but ownerEntityId "${expected}" was given.`, ); this.name = 'OwnerMismatchError'; } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index d75f9ab9..e65ef534 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -12,7 +12,7 @@ * the namespaced ID controlled by the app author. */ -import type { TypeSchema, FieldDef, ScalarFieldKind } from './types/index.js'; +import type { TypeSchema, FieldDef, ScalarFieldKind, FileId, StackRecord } from './types/index.js'; import type { ReadonlyTypeSchema } from './type-handle.js'; import { formatEnumValues } from './validate.js'; @@ -320,6 +320,22 @@ export const fileRefFields = (schema: ReadonlyTypeSchema): string[] => .filter(([, def]) => def.kind === 'file-ref') .map(([field]) => field); +/** + * The files a record references, by the one definition deleteAttachment(), + * the garbage sweep and copyStack() share: attachment associations and + * top-level `file-ref` fields under `schema`, the record's own Type's. + */ +export const referencedFileIds = (record: StackRecord, schema: ReadonlyTypeSchema): FileId[] => { + const fromAssociations = (record.associations ?? []).flatMap((a) => + a.kind === 'attachment' ? [a.fileId] : [], + ); + const content = record.content as Record; + const fromContent = fileRefFields(schema).flatMap((field) => + typeof content[field] === 'string' ? [content[field]] : [], + ); + return [...new Set([...fromAssociations, ...fromContent])]; +}; + // ------------------------------------------------------- // Type ID parsing // ------------------------------------------------------- diff --git a/packages/core/src/stack/stack.ts b/packages/core/src/stack/stack.ts index 053ad23d..59760f2c 100644 --- a/packages/core/src/stack/stack.ts +++ b/packages/core/src/stack/stack.ts @@ -24,8 +24,8 @@ import { parseTypeId, baseIdOf, diffSchemas, - fileRefFields, lineageProblem, + referencedFileIds, } from '../schema.js'; import { dropAbsentFields, @@ -1152,22 +1152,13 @@ export class Stack implements StackClient { } /** - * The files a record references, by the same definition - * deleteAttachment() and the garbage sweep use: attachment associations - * and top-level `file-ref` content fields. An `_attachment` record's own - * `fileId` is a plain string by design and is not one, which is why - * purging a metadata record reports nothing — see systemTypeDefinitions(). + * The files a record references, under its own Type. An `_attachment` + * record's own `fileId` is a plain string by design and is not one, which + * is why purging a metadata record reports nothing — see + * systemTypeDefinitions(). */ private async referencedFileIds(record: StackRecord): Promise { - const fromAssociations = (record.associations ?? []).flatMap((a) => - a.kind === 'attachment' ? [a.fileId] : [], - ); - const type = await this.getType(record.typeId); - const content = record.content as Record; - const fromContent = fileRefFields(type?.schema ?? {}).flatMap((field) => - typeof content[field] === 'string' ? [content[field]] : [], - ); - return [...new Set([...fromAssociations, ...fromContent])]; + return referencedFileIds(record, (await this.getType(record.typeId))?.schema ?? {}); } /** diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index dc323718..2be45b7f 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -124,7 +124,7 @@ export class MemoryAdapter implements StackAdapter { } async importRecord(record: StackRecord, history: RecordHistory) { - await this.createRecord(record); + const stored = await this.createRecord(record); // Held oldest first, as the writes leave them, whatever order they came in. this.versions.set( record.id, @@ -134,6 +134,7 @@ export class MemoryAdapter implements StackAdapter { record.id, [...history.journal].sort((a, b) => a.seq - b.seq), ); + return stored; } async getRecord(id: string) { diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 8088a27b..680a91ca 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -167,7 +167,7 @@ export interface StackRecordAdapter { * appends no entry of its own. Optional: a stack whose adapter lacks it * cannot be copied into. See docs/spec/portability.md § Writing history. */ - importRecord?(record: StackRecord, history: RecordHistory): Promise; + importRecord?(record: StackRecord, history: RecordHistory): Promise; /** * Merge a content patch, one level deep, into the content as it stands diff --git a/packages/core/tests/combine.test.ts b/packages/core/tests/combine.test.ts index f9021486..a33d156e 100644 --- a/packages/core/tests/combine.test.ts +++ b/packages/core/tests/combine.test.ts @@ -285,12 +285,13 @@ describe('combineAdapters', () => { record: makeRecordAdapter({ importRecord: async (record, history) => { calledWith = [record, history]; + return record; }, }), blob: makeBlobAdapter(), }); const history: RecordHistory = { versions: [], journal: [] }; - await adapter.importRecord!(purgedRecord, history); + expect(await adapter.importRecord!(purgedRecord, history)).toBe(purgedRecord); expect(calledWith).toEqual([purgedRecord, history]); }); diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index dec648aa..b68ab69c 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -1,69 +1,146 @@ import { describe, test, expect } from 'vitest'; import { copyStack } from '../src/adapter/copy.js'; +import type { StackIdentity } from '../src/adapter/copy.js'; import { Stack } from '../src/stack/stack.js'; -import { InvalidAdapterError, OwnerMismatchError, StackConflictError } from '../src/errors.js'; +import { InvalidAdapterError } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; import type { StackAdapter } from '../src/types/index.js'; const OWNER = 'did:key:owner'; const NOTE = 'com.example.test/note@1'; -const fresh = (ownerEntityId = OWNER, timezone: string | undefined = 'UTC') => +const create = ({ ownerEntityId, timezone }: StackIdentity) => MemoryAdapter.open({ ownerEntityId, timezone }); async function sourceWith(notes: number): Promise { - const adapter = await fresh(); + const adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }); const stack = await Stack.open(adapter); - await stack.defineType({ id: NOTE, name: 'Note', schema: { text: { kind: 'text' } } }); + await stack.defineType({ + id: NOTE, + name: 'Note', + schema: { text: { kind: 'text' }, cover: { kind: 'file-ref' } }, + }); for (let i = 0; i < notes; i++) await stack.create(NOTE, { text: `note ${i}` }); return adapter; } +/** A create() whose store fails the target rule in one way, and records whether it was closed. */ +function badStore(make: () => Promise) { + const made: { closed: boolean } = { closed: false }; + return { + made, + create: async () => { + const store = await make(); + store.close = async () => void (made.closed = true); + return store; + }, + }; +} + describe('copyStack', () => { + test('creates the target with the source identity, timezone included', async () => { + const source = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'Europe/Paris' }); + const target = await copyStack(source, create); + expect([target.ownerEntityId, target.timezone]).toEqual([OWNER, 'Europe/Paris']); + }); + test('carries every page of records, not just the first', async () => { const source = await sourceWith(250); - const target = await fresh(); - await copyStack(source, target); + const target = (await copyStack(source, create)) as MemoryAdapter; expect([...target.records.keys()].sort()).toEqual([...source.records.keys()].sort()); }); - test('carries the bytes attachment records name, and not bytes no record names', async () => { + test('carries every blob the source lists, referenced or not', async () => { const source = await sourceWith(0); const stack = await Stack.open(source); const named = await stack.putAttachment(new Uint8Array([1, 2, 3]), { mimeType: 'image/png' }); - const orphan = await source.putBlob(new Uint8Array([9, 9, 9])); - const target = await fresh(); - await copyStack(source, target); - expect(await target.getBlob(named.content.fileId)).toEqual(new Uint8Array([1, 2, 3])); - expect(target.blobs.has(orphan)).toBe(false); + const bare = await source.putBlob(new Uint8Array([9, 9, 9])); + const target = (await copyStack(source, create)) as MemoryAdapter; + expect([...target.blobs.keys()].sort()).toEqual([named.content.fileId, bare].sort()); }); - test('refuses a target with no importRecord(), writing nothing', async () => { - const source = await sourceWith(1); - const target: StackAdapter = await fresh(); - Object.defineProperty(target, 'importRecord', { value: undefined }); - await expect(copyStack(source, target)).rejects.toThrow(InvalidAdapterError); - expect(await target.listTypes()).toEqual([]); - }); + test('without listBlobs(), carries every blob a Record names, by any of the three ways', async () => { + const source = await sourceWith(0); + source.listBlobs = undefined; + const stack = await Stack.open(source); + const meta = await stack.putAttachment(new Uint8Array([1]), { mimeType: 'image/png' }); + const viaField = await source.putBlob(new Uint8Array([2])); + const viaAssociation = await source.putBlob(new Uint8Array([3])); + const unnamed = await source.putBlob(new Uint8Array([4])); + await stack.create(NOTE, { text: 'cover', cover: viaField }); + const tagged = await stack.create(NOTE, { text: 'attached' }); + await stack.editAssociations(tagged.id, [ + { op: 'add', association: { kind: 'attachment', label: 'file', fileId: viaAssociation } }, + ]); - test('refuses a target owned by someone else', async () => { - const source = await sourceWith(1); - await expect(copyStack(source, await fresh('did:key:someone-else'))).rejects.toThrow( - OwnerMismatchError, + const target = (await copyStack(source, create)) as MemoryAdapter; + expect([...target.blobs.keys()].sort()).toEqual( + [meta.content.fileId, viaField, viaAssociation].sort(), ); + expect(target.blobs.has(unnamed)).toBe(false); }); - test('refuses a target that already holds records', async () => { - const source = await sourceWith(1); - const target = await sourceWith(1); - await expect(copyStack(source, target)).rejects.toThrow(StackConflictError); + test('a reference whose bytes are gone copies as it stands, dangling', async () => { + const source = await sourceWith(0); + source.listBlobs = undefined; + const gone = await source.putBlob(new Uint8Array([7])); + await (await Stack.open(source)).create(NOTE, { text: 'cover', cover: gone }); + await source.deleteBlob(gone); + const target = (await copyStack(source, create)) as MemoryAdapter; + expect(target.blobs.size).toBe(0); + expect(target.records.size).toBe(source.records.size); }); - test('accepts a target Stack.open() has seeded with system types alone', async () => { + test('accepts a store Stack.open() has seeded with system Types alone', async () => { const source = await sourceWith(1); - const target = await fresh(); - await Stack.open(target); - await copyStack(source, target); - expect(target.records.size).toBe(source.records.size); + const target = await copyStack(source, async (identity) => { + const store = await create(identity); + await Stack.open(store); + return store; + }); + expect((target as MemoryAdapter).records.size).toBe(source.records.size); + }); + + // One rule for every way the store create() returns can differ from a new + // store with the source's identity. See docs/spec/portability.md + // § Copying a stack. + describe('refuses a store that is not a new one with the source identity, and closes it', () => { + const cases: [string, (identity: StackIdentity) => Promise][] = [ + [ + 'no importRecord()', + async (identity) => { + const store = await create(identity); + Object.defineProperty(store, 'importRecord', { value: undefined }); + return store; + }, + ], + [ + 'another owner', + async ({ timezone }) => create({ ownerEntityId: 'did:key:other', timezone }), + ], + [ + 'another timezone', + async ({ ownerEntityId }) => create({ ownerEntityId, timezone: 'Asia/Tokyo' }), + ], + ['a Record already in it', async () => sourceWith(1)], + [ + 'a Type of its own', + async (identity) => { + const store = await create(identity); + await ( + await Stack.open(store) + ).defineType({ id: 'com.example.other/x@1', name: 'X', schema: {} }); + return store; + }, + ], + ]; + + test.each(cases)('%s', async (_name, make) => { + const source = await sourceWith(1); + const identity = { ownerEntityId: OWNER, timezone: 'UTC' }; + const { made, create: bad } = badStore(() => make(identity)); + await expect(copyStack(source, bad)).rejects.toThrow(InvalidAdapterError); + expect(made.closed).toBe(true); + }); }); }); diff --git a/packages/record-adapter-do-sqlite/tests/record.test.ts b/packages/record-adapter-do-sqlite/tests/record.test.ts index d5498ab9..46a8b62a 100644 --- a/packages/record-adapter-do-sqlite/tests/record.test.ts +++ b/packages/record-adapter-do-sqlite/tests/record.test.ts @@ -70,7 +70,7 @@ type TestStub = { queryReadable(query: AdapterQuery, reach: Reach): Promise; getVersions(id: string): Promise; getJournal(id: string): Promise; - importRecord(record: StackRecord, history: RecordHistory): Promise; + importRecord(record: StackRecord, history: RecordHistory): Promise; editAssociations( id: string, edits: AssociationEdit[], diff --git a/packages/record-adapter-sqlite/vitest.config.ts b/packages/record-adapter-sqlite/vitest.config.ts index 3b620a1f..a009d005 100644 --- a/packages/record-adapter-sqlite/vitest.config.ts +++ b/packages/record-adapter-sqlite/vitest.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core/wire': resolve(__dirname, '../core/src/wire/index.ts'), - '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/sqlite-shared': resolve(__dirname, '../sqlite-shared/src/index.ts'), diff --git a/packages/sqlite-shared/src/record-adapter.ts b/packages/sqlite-shared/src/record-adapter.ts index f1e1c081..4988d3f6 100644 --- a/packages/sqlite-shared/src/record-adapter.ts +++ b/packages/sqlite-shared/src/record-adapter.ts @@ -91,7 +91,7 @@ export abstract class SharedSqlRecordAdapter implements StackRecordAdapter { return this.record.createRecord(record, opts); } - importRecord(record: StackRecord, history: RecordHistory): Promise { + importRecord(record: StackRecord, history: RecordHistory): Promise { return this.record.importRecord(record, history); } diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index 6258d6ac..b8f21b27 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -286,12 +286,14 @@ export class SharedSqlRecordLogic { * transaction. Appends no entry of its own. See * docs/spec/portability.md § Writing history. */ - async importRecord(record: StackRecord, history: RecordHistory): Promise { + async importRecord(record: StackRecord, history: RecordHistory): Promise { this.exec.transaction(() => { this.insertRecord(record); for (const version of history.versions) this.insertVersionRow(record.id, version); for (const entry of history.journal) this.insertJournalRow(record.id, entry); }); + + return record; } /** From f176838f4a262d277eddafa9f07e97ef94f53371 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:17:08 +0000 Subject: [PATCH 04/16] fix: one set of blobs a stack holds, and an empty copy target 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- CONTRIBUTING.md | 2 +- docs/spec/attachments.md | 6 +++++- docs/spec/portability.md | 10 +++++----- packages/core/src/adapter/copy.ts | 24 +++++++++--------------- packages/core/src/schema.ts | 7 ++++--- packages/core/src/testing.ts | 4 +++- packages/core/tests/copy.test.ts | 24 +++++++++++------------- 7 files changed, 38 insertions(+), 39 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0a5385c7..3f4b24b7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -173,7 +173,7 @@ The convention this codebase follows, in order of how often it comes up: ```ts // Don't: -// The old two-call design could crash between saveVersion() and the +// The old two-call design could crash between the snapshot write and the // mutation, leaving an orphan row that collided with every future snapshot. // Do: diff --git a/docs/spec/attachments.md b/docs/spec/attachments.md index ea458a2a..18927f8a 100644 --- a/docs/spec/attachments.md +++ b/docs/spec/attachments.md @@ -146,6 +146,10 @@ const { record, referencedFileIds } = await stack.delete(recordId, { purge: true **Until the follow-up `deleteAttachment()` call, the bytes remain reachable** to anyone who can name the hash — the [uploader](#stack-vs-scopedstack-methods) in particular, whose access does not lapse. That is what makes the second step part of the erasure rather than tidying after it. +## The blobs a stack holds + +**A stack holds every blob its blob store lists, and every file one of its Records names.** A Record names a file by an [`_attachment` family](#finding-a-fileids-metadata-records) record's `fileId`, or by a reference: an `attachment` association or a top-level `file-ref` field (below). The halves overlap and neither covers the other: a store's listing is the only way to find bytes nothing names, and `listBlobs()` is [optional](./adapters.md#interface-split), while a name can outlive its bytes. Every operation that walks a stack's blobs walks this one set — [garbage collection](#garbage-collection) weighs it, and [`copyStack()`](./portability.md#copying-a-stack) carries it. + ## Garbage collection Attachment bytes are only ever removed by an explicit `deleteAttachment(fileId)` call — normal app flows delete _records_, and nothing notices when the last record referencing a file goes away. `collectAttachmentGarbage()` is an explicit, owner-invoked sweep that finds and removes those orphans. It is **not** automatic reference-counting: auto-delete on last dissociate/record-delete would couple every record write to blob lifecycle and would race without a transactional adapter. @@ -157,7 +161,7 @@ stack.collectAttachmentGarbage(opts?: { }): Promise<{ deletedFileIds: FileId[]; reclaimedBytes: number }> ``` -**What counts as garbage:** a file is collectable only when _no_ record — live or soft-deleted — references it via an `attachment` Association or a `file-ref` content field. This is the same reference definition `deleteAttachment()` uses, and the same recoverability principle: nothing reachable from a soft-deleted (undelete-able) state gets destroyed. +**What counts as garbage:** the candidates are [the blobs the stack holds](#the-blobs-a-stack-holds), and a file is collectable only when _no_ record — live or soft-deleted — references it via an `attachment` Association or a `file-ref` content field. This is the same reference definition `deleteAttachment()` uses, and the same recoverability principle: nothing reachable from a soft-deleted (undelete-able) state gets destroyed. **Version history is not a reference.** A `file-ref` field sitting in a snapshot does not hold bytes alive, for either the sweep or `deleteAttachment()`: counting it would make a file undeletable for the lifetime of any snapshot that ever mentioned it, which is exactly the intentional delete this model exists to keep possible. The consequence is that [`restoreVersion()`](./versioning.md#restore-semantics) can put back a `file-ref` naming bytes that have since been deleted — a **dangling file reference**, and the one kind of dangle a restore can produce: history is put back, not re-litigated against what is true now. (A [dangling parent](./data-model.md#reparenting) comes from a container being deleted out from under its children, never from a restore, which settles no containment at all.) Consumers handle one the way they handle any `fileId` that resolves to nothing, and `getAttachment()` answers `StackNotFoundError`. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 2db4eac3..27031e89 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -18,13 +18,13 @@ What it carries is everything the source holds: - **Every Type**, through `listTypes()` and `saveType()`. - **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and `create` made the target with what it holds. -- **Every blob**, through `listBlobs()`. A source without it enumerates its blobs the one way left: every file a Record names, by an `_attachment` family record's `fileId` or by a [reference](./attachments.md#garbage-collection). A reference whose bytes are gone stays dangling, as it was. +- **[Every blob the stack holds](./attachments.md#the-blobs-a-stack-holds)**: every one its blob store lists, and every file a Record names. A name whose bytes are gone stays dangling, as it was. Nothing is filtered on the way: deciding what is garbage is the target's own [collection](./attachments.md#garbage-collection), run when its owner chooses, and nothing a purge destroyed is carried, because the source no longer holds it. **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is what creating a store with that identity produces — that owner and timezone, no Record, no Type but the [system ones](./data-model.md#system-types) — on an adapter with `importRecord()`. A store `Stack.open()` has opened holds the system Types alone, so it qualifies unless `ownerProfile` created the owner's card in it. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. +**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is a store as its adapter creates one with that identity — that owner and timezone, holding nothing — on an adapter with `importRecord()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. A store `Stack.open()` has opened is not one: it holds the [system Types](./data-model.md#system-types). The copy brings the source's own, which are part of what it holds, and a later `Stack.open()` of the target meets them as it meets any store's. **It is not atomic.** A failure part-way, the refusal included, closes the target; it is discarded and the copy run again into a new one, never resumed. @@ -33,7 +33,7 @@ Nothing is filtered on the way: deciding what is garbage is the target's own [co ## Writing history ```ts -importRecord?(record: StackRecord, history: RecordHistory): Promise; +importRecord?(record: StackRecord, history: RecordHistory): Promise; type RecordHistory = { versions: RecordVersion[]; @@ -44,11 +44,11 @@ type RecordHistory = { `StackRecordAdapter.importRecord()` is `createRecord()` for a Record that already has a past, and the adapter contract's only write of a whole history. It writes the Record exactly as given — `version`, `createdAt`, `updatedAt`, `createdBy`, `updatedBy`, `deletedAt`, `unlistedAt`, its associations and its permissions — beside each version snapshot and each journal entry as recorded, `seq` and `at` included. - **One atomic write**, under [the rule every mutating record method follows](./adapters.md#interface-split): a refused import leaves no row behind. -- **`StackConflictError` when `record.id` exists**, as `createRecord()` throws. +- **It answers with the Record it wrote**, as `createRecord()` does, and throws `StackConflictError` when `record.id` exists, as `createRecord()` throws. - **It appends no journal entry of its own.** The entries it is handed are the log. - **Writes after it continue the history it wrote.** The next bump snapshots the version the import left and moves past it, and the next entry's `seq` is [allocated from the log's maximum](./journal.md#ordering) as always. -**It is handed history a Record can have**, as every adapter method is held only to what its caller produces ([Adapters § Interface split](./adapters.md#interface-split)): every snapshot's `version` is below `record.version` — one at the record's own version is [an orphan](./versioning.md#snapshot-atomicity) — and every entry's `seq` is distinct. What an adapter does with anything else is unspecified. Order is not part of the input: snapshots are keyed by `version` and entries by `seq`, and reading them back orders them as `getVersions()` and `getJournal()` always do. +**It is handed history a Record can have**, as every adapter method is held only to what its caller produces ([Adapters § Interface split](./adapters.md#interface-split)): every snapshot's `version` is below `record.version` — one at the record's own version is [an orphan](./versioning.md#snapshot-atomicity) — and the entries' `seq` values run [dense from 1](./journal.md#ordering), as every log's do. What an adapter does with anything else is unspecified. Order is not part of the input: snapshots are keyed by `version` and entries by `seq`, and reading them back orders them as `getVersions()` and `getJournal()` always do. `RecordHistory` is what `getVersions()` and `getJournal()` return, so an adapter reads a Record's history out in the shape another writes it in. diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index e6b3f438..8c55821a 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -33,8 +33,6 @@ const EVERYTHING: PlainQuery = { limit: PAGE_SIZE, }; -const SYSTEM_BASE_IDS = new Set(Object.values(SYSTEM_TYPES)); - /** * Copy everything `from` holds into the store `create` makes with `from`'s * identity, and return that store, open. Not atomic: a failure closes the @@ -55,9 +53,9 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis } /** - * The one rule a target is held to: it is what creating a store with - * `identity` produces — that owner and timezone, no Record, no Type but the - * system ones — on an adapter that can write history. + * The one rule a target is held to: it is a store as its adapter creates + * one with `identity` — that owner and timezone, holding nothing — on an + * adapter that can write history. */ async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promise { const refuse = (why: string) => { @@ -75,9 +73,7 @@ async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promis if ((await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 }))).records.length) { refuse('it already holds Records'); } - if ((await to.listTypes()).some((type) => !SYSTEM_BASE_IDS.has(type.baseId))) { - refuse('it already holds Types of its own'); - } + if ((await to.listTypes()).length) refuse('it already holds Types'); } async function copyInto(from: StackAdapter, to: StackAdapter): Promise { @@ -85,9 +81,9 @@ async function copyInto(from: StackAdapter, to: StackAdapter): Promise { for (const type of types) await to.saveType(type); const schemas = new Map(types.map((type) => [type.id, type.schema])); - // Without listBlobs(), the blobs a store holds are the ones its Records - // name, as far as anything can enumerate them. - const named = new Set(); + // The blobs a stack holds: those its blob store lists, and those its + // Records name. See docs/spec/attachments.md § The blobs a stack holds. + const fileIds = new Set((await from.listBlobs?.())?.map((blob) => blob.fileId)); let cursor: string | undefined; do { const page = await from.queryRecords(resolveQuery({ ...EVERYTHING, cursor })); @@ -97,18 +93,16 @@ async function copyInto(from: StackAdapter, to: StackAdapter): Promise { from.getJournal(record.id), ]); await to.importRecord!(record, { versions, journal }); - if (from.listBlobs) continue; if (baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT) { - named.add((record.content as { fileId: FileId }).fileId); + fileIds.add((record.content as { fileId: FileId }).fileId); } for (const fileId of referencedFileIds(record, schemas.get(record.typeId) ?? {})) { - named.add(fileId); + fileIds.add(fileId); } } cursor = page.cursor ?? undefined; } while (cursor); - const fileIds = from.listBlobs ? (await from.listBlobs()).map((blob) => blob.fileId) : named; for (const fileId of fileIds) { try { await to.putBlob(await from.getBlob(fileId)); diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index e65ef534..bcfc7a2b 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -321,9 +321,10 @@ export const fileRefFields = (schema: ReadonlyTypeSchema): string[] => .map(([field]) => field); /** - * The files a record references, by the one definition deleteAttachment(), - * the garbage sweep and copyStack() share: attachment associations and - * top-level `file-ref` fields under `schema`, the record's own Type's. + * The files a record references, by the one definition deleteAttachment() + * and the garbage sweep share: attachment associations and top-level + * `file-ref` fields under `schema`, the record's own Type's. An + * `_attachment` record's own `fileId` is not one. */ export const referencedFileIds = (record: StackRecord, schema: ReadonlyTypeSchema): FileId[] => { const fromAssociations = (record.associations ?? []).flatMap((a) => diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 2be45b7f..82761048 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -630,7 +630,9 @@ export class MemoryAdapter implements StackAdapter { const log = this.journals.get(id) ?? []; log.push({ ...entry, - seq: log.length + 1, + // From the log's maximum, as every adapter allocates it. + // See docs/spec/journal.md § Ordering. + seq: (log.at(-1)?.seq ?? 0) + 1, at: new Date(), version: record.version, typeId: record.typeId, diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index b68ab69c..9db07304 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -50,7 +50,7 @@ describe('copyStack', () => { expect([...target.records.keys()].sort()).toEqual([...source.records.keys()].sort()); }); - test('carries every blob the source lists, referenced or not', async () => { + test('carries every blob the source lists, named by a Record or not', async () => { const source = await sourceWith(0); const stack = await Stack.open(source); const named = await stack.putAttachment(new Uint8Array([1, 2, 3]), { mimeType: 'image/png' }); @@ -59,7 +59,7 @@ describe('copyStack', () => { expect([...target.blobs.keys()].sort()).toEqual([named.content.fileId, bare].sort()); }); - test('without listBlobs(), carries every blob a Record names, by any of the three ways', async () => { + test('carries every blob a Record names, by any of the three ways, with no listing to go on', async () => { const source = await sourceWith(0); source.listBlobs = undefined; const stack = await Stack.open(source); @@ -91,20 +91,10 @@ describe('copyStack', () => { expect(target.records.size).toBe(source.records.size); }); - test('accepts a store Stack.open() has seeded with system Types alone', async () => { - const source = await sourceWith(1); - const target = await copyStack(source, async (identity) => { - const store = await create(identity); - await Stack.open(store); - return store; - }); - expect((target as MemoryAdapter).records.size).toBe(source.records.size); - }); - // One rule for every way the store create() returns can differ from a new // store with the source's identity. See docs/spec/portability.md // § Copying a stack. - describe('refuses a store that is not a new one with the source identity, and closes it', () => { + describe('refuses a store that is not a new, empty one with the source identity, and closes it', () => { const cases: [string, (identity: StackIdentity) => Promise][] = [ [ 'no importRecord()', @@ -123,6 +113,14 @@ describe('copyStack', () => { async ({ ownerEntityId }) => create({ ownerEntityId, timezone: 'Asia/Tokyo' }), ], ['a Record already in it', async () => sourceWith(1)], + [ + 'the system Types Stack.open() seeds', + async (identity) => { + const store = await create(identity); + await Stack.open(store); + return store; + }, + ], [ 'a Type of its own', async (identity) => { From a9ec3aef9f31023621328a16db71ec735ca31c51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 17:37:52 +0000 Subject: [PATCH 05/16] refactor: copy blobs as the store lists them, and pin the format 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 2 +- docs/spec/attachments.md | 6 +- docs/spec/portability.md | 30 ++++++--- packages/core/src/adapter/copy.ts | 58 +++++++----------- packages/core/src/schema.ts | 19 +----- packages/core/src/stack/stack.ts | 21 +++++-- packages/core/tests/copy.test.ts | 44 ++++++------- .../tests/portable-format.test.ts | 61 +++++++++++++++++++ 8 files changed, 141 insertions(+), 100 deletions(-) create mode 100644 packages/record-adapter-sqlite/tests/portable-format.test.ts diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index 6047dc2d..5c03756e 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -9,7 +9,7 @@ A stack can be copied whole between adapters, authorship and history included. -- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. Copying into a `LocalAdapter` store is an export; copying out of one is an import. +- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. The source must list its blobs, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. - `StackIdentity` and `CreateStore` are exported beside it. - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. diff --git a/docs/spec/attachments.md b/docs/spec/attachments.md index 18927f8a..ea458a2a 100644 --- a/docs/spec/attachments.md +++ b/docs/spec/attachments.md @@ -146,10 +146,6 @@ const { record, referencedFileIds } = await stack.delete(recordId, { purge: true **Until the follow-up `deleteAttachment()` call, the bytes remain reachable** to anyone who can name the hash — the [uploader](#stack-vs-scopedstack-methods) in particular, whose access does not lapse. That is what makes the second step part of the erasure rather than tidying after it. -## The blobs a stack holds - -**A stack holds every blob its blob store lists, and every file one of its Records names.** A Record names a file by an [`_attachment` family](#finding-a-fileids-metadata-records) record's `fileId`, or by a reference: an `attachment` association or a top-level `file-ref` field (below). The halves overlap and neither covers the other: a store's listing is the only way to find bytes nothing names, and `listBlobs()` is [optional](./adapters.md#interface-split), while a name can outlive its bytes. Every operation that walks a stack's blobs walks this one set — [garbage collection](#garbage-collection) weighs it, and [`copyStack()`](./portability.md#copying-a-stack) carries it. - ## Garbage collection Attachment bytes are only ever removed by an explicit `deleteAttachment(fileId)` call — normal app flows delete _records_, and nothing notices when the last record referencing a file goes away. `collectAttachmentGarbage()` is an explicit, owner-invoked sweep that finds and removes those orphans. It is **not** automatic reference-counting: auto-delete on last dissociate/record-delete would couple every record write to blob lifecycle and would race without a transactional adapter. @@ -161,7 +157,7 @@ stack.collectAttachmentGarbage(opts?: { }): Promise<{ deletedFileIds: FileId[]; reclaimedBytes: number }> ``` -**What counts as garbage:** the candidates are [the blobs the stack holds](#the-blobs-a-stack-holds), and a file is collectable only when _no_ record — live or soft-deleted — references it via an `attachment` Association or a `file-ref` content field. This is the same reference definition `deleteAttachment()` uses, and the same recoverability principle: nothing reachable from a soft-deleted (undelete-able) state gets destroyed. +**What counts as garbage:** a file is collectable only when _no_ record — live or soft-deleted — references it via an `attachment` Association or a `file-ref` content field. This is the same reference definition `deleteAttachment()` uses, and the same recoverability principle: nothing reachable from a soft-deleted (undelete-able) state gets destroyed. **Version history is not a reference.** A `file-ref` field sitting in a snapshot does not hold bytes alive, for either the sweep or `deleteAttachment()`: counting it would make a file undeletable for the lifetime of any snapshot that ever mentioned it, which is exactly the intentional delete this model exists to keep possible. The consequence is that [`restoreVersion()`](./versioning.md#restore-semantics) can put back a `file-ref` naming bytes that have since been deleted — a **dangling file reference**, and the one kind of dangle a restore can produce: history is put back, not re-litigated against what is true now. (A [dangling parent](./data-model.md#reparenting) comes from a container being deleted out from under its children, never from a restore, which settles no containment at all.) Consumers handle one the way they handle any `fileId` that resolves to nothing, and `getAttachment()` answers `StackNotFoundError`. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 27031e89..80e53b9d 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -1,6 +1,6 @@ # Portability -**A stack can leave its host whole.** Every Record, soft-deleted and unlisted ones included, keeps its authorship, its version history and its journal, and every Type and attachment comes with it. Moving needs nothing from the host beyond what it already serves its owner. Identity moves with the stack and the address does not: the new host serves the same owner DID, which a client confirms with `APIAdapter`'s `ownerEntityId` option ([Wire format § Identity is trusted on transport](./wire-format.md#identity-is-trusted-on-transport)). References to the old `stackUrl`, a Group's or a relationship target's, are updated by hand. +**A stack can leave its host whole.** Every Record, soft-deleted and unlisted ones included, keeps its authorship, its version history and its journal, and every Type and attachment comes with it. Identity moves with the stack and the address does not: the new host serves the same owner DID, which a client confirms with `APIAdapter`'s `ownerEntityId` option ([Wire format § Identity is trusted on transport](./wire-format.md#identity-is-trusted-on-transport)). References to the old `stackUrl`, a Group's or a relationship target's, are updated by hand. ## Copying a stack @@ -12,23 +12,23 @@ const target = await copyStack(from, (identity) => createStore(identity)); type StackIdentity = { ownerEntityId: EntityId; timezone: string | undefined }; ``` -**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it into a new store, which it asks `create` to make with the source's identity and returns open. Which source it is given, and what kind of store `create` makes, is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has an export or import method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. +**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it into a new store, which it asks `create` to make with the source's identity and returns open. Which source it is given, and what kind of store `create` makes, is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has a format-conversion method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. What it carries is everything the source holds: - **Every Type**, through `listTypes()` and `saveType()`. - **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and `create` made the target with what it holds. -- **[Every blob the stack holds](./attachments.md#the-blobs-a-stack-holds)**: every one its blob store lists, and every file a Record names. A name whose bytes are gone stays dangling, as it was. +- **Every blob**, through `listBlobs()`, which a source must have. -Nothing is filtered on the way: deciding what is garbage is the target's own [collection](./attachments.md#garbage-collection), run when its owner chooses, and nothing a purge destroyed is carried, because the source no longer holds it. +**Each is read as the store enumerates it**, never by what a Record says, so nothing is filtered on the way: what is garbage is the target's own [collection](./attachments.md#garbage-collection) to decide, run when its owner chooses. Nothing a purge destroyed is carried, because the source no longer holds it. **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is a store as its adapter creates one with that identity — that owner and timezone, holding nothing — on an adapter with `importRecord()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. A store `Stack.open()` has opened is not one: it holds the [system Types](./data-model.md#system-types). The copy brings the source's own, which are part of what it holds, and a later `Stack.open()` of the target meets them as it meets any store's. +**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is the store its adapter creates with that identity — that owner and timezone, no Record and no Type — on an adapter with `importRecord()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. Blobs are content-addressed, so one already there conflicts with nothing. A store `Stack.open()` has opened does not qualify: it holds the [system Types](./data-model.md#system-types). The copy brings the source's own, which are part of what it holds, and a later `Stack.open()` of the target meets them as it meets any store's. **It is not atomic.** A failure part-way, the refusal included, closes the target; it is discarded and the copy run again into a new one, never resumed. -**The source is read only through methods every adapter has**, so it may be an `APIAdapter` opened as the owner, who reads every Record, every version and every journal entry in full. +**The source needs `listBlobs()`**, as the target needs `importRecord()`, and a source without it is refused with `InvalidAdapterError` before `create` is called. Every blob adapter has it. `APIAdapter` does not, because the wire has no endpoint that lists blobs, so a stack behind one cannot yet be copied out through it. ## Writing history @@ -71,7 +71,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **It carries no version marker**, because only one layout exists: the first change that needs to tell two apart adds one, and an export without it is this layout. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. @@ -95,7 +95,21 @@ Their columns follow four encoding rules: - **SQL `NULL` is an absent optional field**, except in the two element tables, where every column is `NOT NULL` and `''` marks one that does not apply to the element; and in `journal.previous_parent_id`, where `NULL` is an entry naming no origin and `''` the root, as [the entry](./journal.md#the-entry) distinguishes them. - **An actor is two columns**, a subject and a principal: `createdBy` is `entity_id` and `principal_id`, and `updatedBy` is `updated_by` and `updated_via`, on both `records` and `versions`. -The remaining columns are the fields they name: `records` holds `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `app_id`, `deleted_at` and `unlisted_at`; `versions` holds `type_id`, `content` and `updated_at`; `journal` holds `at`, `kind`, `version`, `type_id` and `parent_id`; `types` holds `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from` and `created_at`. An association's `attachment_record_id` sits outside its key, as [Attachments § Naming the upload a reference came from](./attachments.md#naming-the-upload-a-reference-came-from) requires. +An association's `attachment_record_id` sits outside its key, as [Attachments § Naming the upload a reference came from](./attachments.md#naming-the-upload-a-reference-came-from) requires. + +### Columns + +Every column of every table but `records_fts`, an FTS5 index over `records.content` whose own tables are the engine's. Each column is the field it names, under the rules above. + +| Table | Columns | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `records` | `id`, `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `entity_id`, `app_id`, `principal_id`, `updated_by`, `updated_via`, `deleted_at`, `unlisted_at` | +| `associations` | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack`, `attachment_record_id` | +| `permissions` | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | +| `versions` | `record_id`, `version`, `type_id`, `content`, `updated_at`, `entity_id`, `principal_id`, `updated_by`, `updated_via` | +| `journal` | `record_id`, `seq`, `at`, `kind`, `ops`, `version`, `type_id`, `parent_id`, `previous_parent_id`, `actor`, `associations` | +| `types` | `id`, `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from`, `created_at` | +| `content_index` | `record_id`, `field`, `value_rank`, `num_value`, `text_key`, `text_value`, `file_id` | The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index 8c55821a..11118776 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -4,18 +4,17 @@ * One stack, read in full from one adapter and written in full to a new * store created with its identity: every Record (soft-deleted and unlisted * included) with its version snapshots and journal, every Type, and every - * blob. Export, import and moving between storage engines are all this one - * call, with a different source and a different kind of store to create. - * Full trust, like any direct adapter access: authorship and history land - * as recorded, which no write through Stack may do. What is garbage is left - * to the target's own collection. See docs/spec/portability.md. + * blob. Each is read as the store enumerates it, 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. Full trust, like any direct + * adapter access: authorship and history land as recorded, which no write + * through Stack may do. See docs/spec/portability.md. */ -import { InvalidAdapterError, StackNotFoundError } from '../errors.js'; -import { baseIdOf, referencedFileIds } from '../schema.js'; +import { InvalidAdapterError } from '../errors.js'; import { resolveQuery } from '../query-validation.js'; -import type { EntityId, FileId, PlainQuery, StackAdapter } from '../types/index.js'; -import { SYSTEM_TYPES } from '../types/index.js'; +import type { EntityId, PlainQuery, StackAdapter } from '../types/index.js'; /** What a store is created with: the identity `_config` holds. */ export type StackIdentity = { ownerEntityId: EntityId; timezone: string | undefined }; @@ -40,11 +39,19 @@ const EVERYTHING: PlainQuery = { * See docs/spec/portability.md § Copying a stack. */ export async function copyStack(from: StackAdapter, create: CreateStore): Promise { + const listBlobs = from.listBlobs?.bind(from); + if (!listBlobs) { + throw new InvalidAdapterError( + 'Cannot copy from this adapter: it has no listBlobs(), so its blobs cannot be enumerated.', + ); + } const identity = { ownerEntityId: from.ownerEntityId, timezone: from.timezone }; const to = await create(identity); try { await assertNewStore(to, identity); - await copyInto(from, to); + for (const type of await from.listTypes()) await to.saveType(type); + await copyRecords(from, to); + for (const { fileId } of await listBlobs()) await to.putBlob(await from.getBlob(fileId)); return to; } catch (err) { await to.close?.(); @@ -53,9 +60,10 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis } /** - * The one rule a target is held to: it is a store as its adapter creates - * one with `identity` — that owner and timezone, holding nothing — on an - * adapter that can write history. + * The one rule a target is held to: the store its adapter creates with + * `identity` — that owner and timezone, no Record and no Type — on an + * adapter that can write history. Blobs are content-addressed, so one + * already there conflicts with nothing. */ async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promise { const refuse = (why: string) => { @@ -76,14 +84,7 @@ async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promis if ((await to.listTypes()).length) refuse('it already holds Types'); } -async function copyInto(from: StackAdapter, to: StackAdapter): Promise { - const types = await from.listTypes(); - for (const type of types) await to.saveType(type); - const schemas = new Map(types.map((type) => [type.id, type.schema])); - - // The blobs a stack holds: those its blob store lists, and those its - // Records name. See docs/spec/attachments.md § The blobs a stack holds. - const fileIds = new Set((await from.listBlobs?.())?.map((blob) => blob.fileId)); +async function copyRecords(from: StackAdapter, to: StackAdapter): Promise { let cursor: string | undefined; do { const page = await from.queryRecords(resolveQuery({ ...EVERYTHING, cursor })); @@ -93,22 +94,7 @@ async function copyInto(from: StackAdapter, to: StackAdapter): Promise { from.getJournal(record.id), ]); await to.importRecord!(record, { versions, journal }); - if (baseIdOf(record.typeId) === SYSTEM_TYPES.ATTACHMENT) { - fileIds.add((record.content as { fileId: FileId }).fileId); - } - for (const fileId of referencedFileIds(record, schemas.get(record.typeId) ?? {})) { - fileIds.add(fileId); - } } cursor = page.cursor ?? undefined; } while (cursor); - - for (const fileId of fileIds) { - try { - await to.putBlob(await from.getBlob(fileId)); - } catch (err) { - // A reference whose bytes are gone stays dangling, as it was. - if (!(err instanceof StackNotFoundError)) throw err; - } - } } diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index bcfc7a2b..d75f9ab9 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -12,7 +12,7 @@ * the namespaced ID controlled by the app author. */ -import type { TypeSchema, FieldDef, ScalarFieldKind, FileId, StackRecord } from './types/index.js'; +import type { TypeSchema, FieldDef, ScalarFieldKind } from './types/index.js'; import type { ReadonlyTypeSchema } from './type-handle.js'; import { formatEnumValues } from './validate.js'; @@ -320,23 +320,6 @@ export const fileRefFields = (schema: ReadonlyTypeSchema): string[] => .filter(([, def]) => def.kind === 'file-ref') .map(([field]) => field); -/** - * The files a record references, by the one definition deleteAttachment() - * and the garbage sweep share: attachment associations and top-level - * `file-ref` fields under `schema`, the record's own Type's. An - * `_attachment` record's own `fileId` is not one. - */ -export const referencedFileIds = (record: StackRecord, schema: ReadonlyTypeSchema): FileId[] => { - const fromAssociations = (record.associations ?? []).flatMap((a) => - a.kind === 'attachment' ? [a.fileId] : [], - ); - const content = record.content as Record; - const fromContent = fileRefFields(schema).flatMap((field) => - typeof content[field] === 'string' ? [content[field]] : [], - ); - return [...new Set([...fromAssociations, ...fromContent])]; -}; - // ------------------------------------------------------- // Type ID parsing // ------------------------------------------------------- diff --git a/packages/core/src/stack/stack.ts b/packages/core/src/stack/stack.ts index 59760f2c..053ad23d 100644 --- a/packages/core/src/stack/stack.ts +++ b/packages/core/src/stack/stack.ts @@ -24,8 +24,8 @@ import { parseTypeId, baseIdOf, diffSchemas, + fileRefFields, lineageProblem, - referencedFileIds, } from '../schema.js'; import { dropAbsentFields, @@ -1152,13 +1152,22 @@ export class Stack implements StackClient { } /** - * The files a record references, under its own Type. An `_attachment` - * record's own `fileId` is a plain string by design and is not one, which - * is why purging a metadata record reports nothing — see - * systemTypeDefinitions(). + * The files a record references, by the same definition + * deleteAttachment() and the garbage sweep use: attachment associations + * and top-level `file-ref` content fields. An `_attachment` record's own + * `fileId` is a plain string by design and is not one, which is why + * purging a metadata record reports nothing — see systemTypeDefinitions(). */ private async referencedFileIds(record: StackRecord): Promise { - return referencedFileIds(record, (await this.getType(record.typeId))?.schema ?? {}); + const fromAssociations = (record.associations ?? []).flatMap((a) => + a.kind === 'attachment' ? [a.fileId] : [], + ); + const type = await this.getType(record.typeId); + const content = record.content as Record; + const fromContent = fileRefFields(type?.schema ?? {}).flatMap((field) => + typeof content[field] === 'string' ? [content[field]] : [], + ); + return [...new Set([...fromAssociations, ...fromContent])]; } /** diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index 9db07304..8c3842b5 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -59,36 +59,28 @@ describe('copyStack', () => { expect([...target.blobs.keys()].sort()).toEqual([named.content.fileId, bare].sort()); }); - test('carries every blob a Record names, by any of the three ways, with no listing to go on', async () => { - const source = await sourceWith(0); + test('refuses a source with no listBlobs(), before creating anything', async () => { + const source = await sourceWith(1); source.listBlobs = undefined; - const stack = await Stack.open(source); - const meta = await stack.putAttachment(new Uint8Array([1]), { mimeType: 'image/png' }); - const viaField = await source.putBlob(new Uint8Array([2])); - const viaAssociation = await source.putBlob(new Uint8Array([3])); - const unnamed = await source.putBlob(new Uint8Array([4])); - await stack.create(NOTE, { text: 'cover', cover: viaField }); - const tagged = await stack.create(NOTE, { text: 'attached' }); - await stack.editAssociations(tagged.id, [ - { op: 'add', association: { kind: 'attachment', label: 'file', fileId: viaAssociation } }, - ]); - - const target = (await copyStack(source, create)) as MemoryAdapter; - expect([...target.blobs.keys()].sort()).toEqual( - [meta.content.fileId, viaField, viaAssociation].sort(), - ); - expect(target.blobs.has(unnamed)).toBe(false); + let created = false; + await expect( + copyStack(source, (identity) => { + created = true; + return create(identity); + }), + ).rejects.toThrow(InvalidAdapterError); + expect(created).toBe(false); }); - test('a reference whose bytes are gone copies as it stands, dangling', async () => { + test('a blob already in the target conflicts with nothing', async () => { const source = await sourceWith(0); - source.listBlobs = undefined; - const gone = await source.putBlob(new Uint8Array([7])); - await (await Stack.open(source)).create(NOTE, { text: 'cover', cover: gone }); - await source.deleteBlob(gone); - const target = (await copyStack(source, create)) as MemoryAdapter; - expect(target.blobs.size).toBe(0); - expect(target.records.size).toBe(source.records.size); + const fileId = await source.putBlob(new Uint8Array([5])); + const target = (await copyStack(source, async (identity) => { + const store = await create(identity); + await store.putBlob(new Uint8Array([5])); + return store; + })) as MemoryAdapter; + expect([...target.blobs.keys()]).toEqual([fileId]); }); // One rule for every way the store create() returns can differ from a new diff --git a/packages/record-adapter-sqlite/tests/portable-format.test.ts b/packages/record-adapter-sqlite/tests/portable-format.test.ts new file mode 100644 index 00000000..09de3faf --- /dev/null +++ b/packages/record-adapter-sqlite/tests/portable-format.test.ts @@ -0,0 +1,61 @@ +/** + * Pins docs/spec/portability.md § Columns to the schema a new store is + * created with: the portable format is specified so a reader needs no tool, + * which holds only while the spec and the database agree. + */ +import { test, expect, afterEach } from 'vitest'; +import { mkdirSync, readFileSync, rmSync } from 'fs'; +import { join, resolve } from 'path'; +import { tmpdir } from 'os'; +import { DatabaseSync } from '../src/node-sqlite.js'; +import { NativeSQLiteRecordAdapter } from '../src/index.js'; + +const SPEC = resolve(__dirname, '../../../docs/spec/portability.md'); + +/** The `### Columns` table, as table name to column names. */ +function documentedColumns(): Record { + const spec = readFileSync(SPEC, 'utf8'); + const section = spec.slice(spec.indexOf('### Columns')).split(/\n#{2,3} /)[0]; + const tables: Record = {}; + for (const [, table, columns] of section.matchAll(/^\| `(\w+)` +\| (.+?) +\|$/gm)) { + tables[table] = [...columns.matchAll(/`(\w+)`/g)].map(([, column]) => column); + } + return tables; +} + +let dir: string | undefined; +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); +}); + +test('every table and column a new store holds is the one the spec documents', async () => { + dir = join(tmpdir(), `portable-format-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const path = join(dir, 'stack.db'); + const adapter = await NativeSQLiteRecordAdapter.open({ + path, + create: 'exclusive', + ownerEntityId: 'did:key:portable-format', + }); + await adapter.close(); + + const db = new DatabaseSync(path); + // records_fts and its shadow tables belong to FTS5; see § Columns. + const tables = ( + db + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' AND name NOT LIKE 'records_fts%'`, + ) + .all() as { name: string }[] + ).map(({ name }) => name); + const actual = Object.fromEntries( + tables.map((table) => [ + table, + (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name), + ]), + ); + db.close(); + + expect(actual).toEqual(documentedColumns()); +}); From 7542c48ca5c994f8826ed5fa0239802ca86019e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:44:42 +0000 Subject: [PATCH 06/16] fix: one owner for a copy's target, and a copy across engines - StackIdentity is Pick, 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec/adapters.md | 2 +- docs/spec/portability.md | 4 +- packages/adapter-conformance/src/copy.ts | 19 ++++--- packages/adapter-local/tests/copy.test.ts | 62 ++++++++++++++-------- packages/adapter-local/vitest.config.ts | 1 + packages/core/src/adapter/copy.ts | 8 +-- packages/sqlite-shared/src/record-logic.ts | 10 ++-- 7 files changed, 66 insertions(+), 40 deletions(-) diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index 9864b9ae..c72c67a0 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -30,7 +30,7 @@ type StackAdapter = StackRecordAdapter & }; ``` -**Optional capabilities** follow one pattern throughout: an optional interface method, checked for truthiness at the call site, with a described fallback when absent. `StackRecordAdapter.deleteUnreferencedAttachmentRecords()` (atomic reference check — see [Attachments](./attachments.md#deleting-attachments)), `StackRecordAdapter.queryReadable()` (a query among the Records one requester may read; a stack is not scoped without it — see [below](#querying-within-a-reach)), `StackRecordAdapter.importRecord()` (a Record written with its whole history; a stack cannot be copied into without it — see [Portability § Writing history](./portability.md#writing-history)), `StackBlobAdapter.listBlobs()` (blob enumeration, used by [garbage collection](./attachments.md#garbage-collection) to find bare-bytes orphans), `StackRecordAdapter.subscribeChanges()` (relaying a feed that originates elsewhere — see [Change events § Where events come from](./events.md#where-events-come-from)), and `StackAdapter.putAttachmentWithMetadata()` (atomic upload, below) are all this shape — no boolean flag in `capabilities`, just an optional method a caller checks for before using. `combineAdapters()` (below) preserves this: it forwards an optional method only when the underlying part actually implements it, never as a wrapper around a missing one. +**Optional capabilities** follow one pattern throughout: an optional interface method, checked for truthiness at the call site, with a described fallback when absent. `StackRecordAdapter.deleteUnreferencedAttachmentRecords()` (atomic reference check — see [Attachments](./attachments.md#deleting-attachments)), `StackRecordAdapter.queryReadable()` (a query among the Records one requester may read; a stack is not scoped without it — see [below](#querying-within-a-reach)), `StackRecordAdapter.importRecord()` (a Record written with its whole history; a stack cannot be copied into without it — see [Portability § Writing history](./portability.md#writing-history)), `StackBlobAdapter.listBlobs()` (blob enumeration, used by [garbage collection](./attachments.md#garbage-collection) to find bare-bytes orphans, and required of a [copy's source](./portability.md#copying-a-stack)), `StackRecordAdapter.subscribeChanges()` (relaying a feed that originates elsewhere — see [Change events § Where events come from](./events.md#where-events-come-from)), and `StackAdapter.putAttachmentWithMetadata()` (atomic upload, below) are all this shape — no boolean flag in `capabilities`, just an optional method a caller checks for before using. `combineAdapters()` (below) preserves this: it forwards an optional method only when the underlying part actually implements it, never as a wrapper around a missing one. `StackAdapter.putAttachmentWithMetadata(data, { mimeType, filename?, appId? })` stores bytes and creates the accompanying `_attachment@1` record as **one atomic operation**, returning the created record. It is declared on the composed `StackAdapter` type rather than on either half, because neither half can ever have it: "bytes + record in one operation" is a property only a whole adapter can offer. Exactly one adapter does: the API adapter, backed by a single `POST /attachments` request the server fulfills atomically. Local storage adapters don't implement it, and `combineAdapters()` never synthesizes it from parts (a record backend and a blob backend glued together have no shared transaction). `Stack.putAttachment()` checks for it — present means delegate the whole operation and trust the returned record as backend-authoritative; absent means the bytes-then-`create()` fallback sequence. See [Wire format § Attachments](./wire-format.md#attachments) for why the atomic form is a correctness requirement, not an efficiency optimization. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 80e53b9d..8a9a0b3f 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -26,7 +26,7 @@ What it carries is everything the source holds: **The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is the store its adapter creates with that identity — that owner and timezone, no Record and no Type — on an adapter with `importRecord()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. Blobs are content-addressed, so one already there conflicts with nothing. A store `Stack.open()` has opened does not qualify: it holds the [system Types](./data-model.md#system-types). The copy brings the source's own, which are part of what it holds, and a later `Stack.open()` of the target meets them as it meets any store's. -**It is not atomic.** A failure part-way, the refusal included, closes the target; it is discarded and the copy run again into a new one, never resumed. +**It is not atomic, and the target is the caller's.** Whatever `create` makes belongs to whoever passed `create`: on success it comes back open, theirs to close; on a failure part-way, the refusal included, `copyStack()` closes it, holding the only handle, and the caller removes it, having chosen where it lives. The copy is then run again into a new store, never resumed. **The source needs `listBlobs()`**, as the target needs `importRecord()`, and a source without it is refused with `InvalidAdapterError` before `create` is called. Every blob adapter has it. `APIAdapter` does not, because the wire has no endpoint that lists blobs, so a stack behind one cannot yet be copied out through it. @@ -92,7 +92,7 @@ Their columns follow four encoding rules: - **Times are integers**, milliseconds since the Unix epoch. - **Structured values are JSON text**: `records.content`, `versions.content`, `types.schema`, `journal.ops`, `journal.actor` (a `ChangeActor`) and `journal.associations` (an `AssociationChange[]`). -- **SQL `NULL` is an absent optional field**, except in the two element tables, where every column is `NOT NULL` and `''` marks one that does not apply to the element; and in `journal.previous_parent_id`, where `NULL` is an entry naming no origin and `''` the root, as [the entry](./journal.md#the-entry) distinguishes them. +- **SQL `NULL` is an absent optional field**, with two exceptions, each because `NULL` alone cannot carry what the column must. In the two element tables every column is `NOT NULL` and `''` marks one that does not apply to the element: those columns are the primary key, and SQLite holds two keys differing only by a `NULL` distinct, so two identical tags would both be stored. In `journal.previous_parent_id`, `NULL` is an entry naming no origin and `''` the root: the column has three states to carry — no move, off the root, out of a container — as [the entry](./journal.md#the-entry) distinguishes them. - **An actor is two columns**, a subject and a principal: `createdBy` is `entity_id` and `principal_id`, and `updatedBy` is `updated_by` and `updated_via`, on both `records` and `versions`. An association's `attachment_record_id` sits outside its key, as [Attachments § Naming the upload a reference came from](./attachments.md#naming-the-upload-a-reference-came-from) requires. diff --git a/packages/adapter-conformance/src/copy.ts b/packages/adapter-conformance/src/copy.ts index 9740f74a..41df3db8 100644 --- a/packages/adapter-conformance/src/copy.ts +++ b/packages/adapter-conformance/src/copy.ts @@ -2,10 +2,11 @@ * Stack copy conformance suite * ------------------------------------------------------- * The copy a move between hosts makes: a stack with a past is written - * through Stack into one store the adapter under test creates, then copied - * with copyStack() into a second. Every Record, version snapshot, journal - * entry, Type and blob must arrive as it was, so the adapter is held to - * both halves of a copy, the read and the write. Point it at a full + * through Stack into a source store, then copied with copyStack() into one + * the adapter under test creates. Every Record, version snapshot, journal + * entry, Type and blob must arrive as it was. The source is the adapter + * under test too unless `source` names another engine, so a suite can hold + * an adapter to the read half, the write half, or both. Point it at a full * StackAdapter that implements importRecord(). See docs/spec/portability.md. */ @@ -18,9 +19,11 @@ import type { CreateStore, StackAdapter } from '@haverstack/core/adapter'; export interface CopyConformanceOptions { /** Used in describe() titles, so pick something that reads well there. */ name: string; - /** Create a new, empty store with this identity; called twice per copy. */ + /** Create a new, empty store with this identity: the copy's target. */ create: CreateStore; - /** Release what `create()` acquired. Called on every store it made. */ + /** Create the store the stack is written into and copied from. Defaults to `create`. */ + source?: CreateStore; + /** Release what either factory acquired. Called on every store they made. */ close?: (adapter: StackAdapter) => Promise | void; } @@ -110,7 +113,7 @@ async function writePast(adapter: StackAdapter): Promise { } export function runCopyConformance(options: CopyConformanceOptions): void { - const { name, create, close } = options; + const { name, create, source: createSource = create, close } = options; describe(`stack copy conformance: ${name}`, () => { test('implements importRecord()', async () => { @@ -123,7 +126,7 @@ export function runCopyConformance(options: CopyConformanceOptions): void { }); test('a copy carries every Record, version, journal entry, Type and blob unchanged', async () => { - const source = await create(IDENTITY); + const source = await createSource(IDENTITY); let target: StackAdapter | undefined; try { const fileIds = await writePast(source); diff --git a/packages/adapter-local/tests/copy.test.ts b/packages/adapter-local/tests/copy.test.ts index 1f89b82e..6d8d1725 100644 --- a/packages/adapter-local/tests/copy.test.ts +++ b/packages/adapter-local/tests/copy.test.ts @@ -1,34 +1,52 @@ /** - * Runs @haverstack/adapter-conformance's stack copy suite against - * LocalAdapter: the portable form a stack is exported to and imported - * from. See docs/spec/portability.md. + * Runs @haverstack/adapter-conformance's stack copy suite with LocalAdapter, + * the portable form a stack is exported to and imported from, on each side + * of a copy and on both: one primitive across engines, in either direction. + * See docs/spec/portability.md. */ import { mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { runCopyConformance } from '@haverstack/adapter-conformance'; -import type { StackAdapter } from '@haverstack/core/adapter'; +import type { CreateStore, StackAdapter } from '@haverstack/core/adapter'; +import { MemoryAdapter } from '@haverstack/core/testing'; import { LocalAdapter } from '../src/index.js'; const dirs = new Map(); +const local: CreateStore = async ({ ownerEntityId, timezone }) => { + const dir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const adapter = await LocalAdapter.open({ + create: 'exclusive', + path: join(dir, 'stack.db'), + ownerEntityId, + timezone, + }); + dirs.set(adapter, dir); + return adapter; +}; + +const memory: CreateStore = ({ ownerEntityId, timezone }) => + MemoryAdapter.open({ ownerEntityId, timezone }); + +const close = async (adapter: StackAdapter) => { + await adapter.close?.(); + const dir = dirs.get(adapter); + if (dir) rmSync(dir, { recursive: true, force: true }); + dirs.delete(adapter); +}; + +runCopyConformance({ name: 'LocalAdapter', create: local, close }); +runCopyConformance({ + name: 'MemoryAdapter into LocalAdapter', + create: local, + source: memory, + close, +}); runCopyConformance({ - name: 'LocalAdapter', - create: async ({ ownerEntityId, timezone }) => { - const dir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); - mkdirSync(dir, { recursive: true }); - const adapter = await LocalAdapter.open({ - create: 'exclusive', - path: join(dir, 'stack.db'), - ownerEntityId, - timezone, - }); - dirs.set(adapter, dir); - return adapter; - }, - close: async (adapter) => { - await adapter.close?.(); - rmSync(dirs.get(adapter)!, { recursive: true, force: true }); - dirs.delete(adapter); - }, + name: 'LocalAdapter into MemoryAdapter', + create: memory, + source: local, + close, }); diff --git a/packages/adapter-local/vitest.config.ts b/packages/adapter-local/vitest.config.ts index c06275e1..ef7842b4 100644 --- a/packages/adapter-local/vitest.config.ts +++ b/packages/adapter-local/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ resolve: { alias: { '@haverstack/core/wire': resolve(__dirname, '../core/src/wire/index.ts'), + '@haverstack/core/testing': resolve(__dirname, '../core/src/testing.ts'), '@haverstack/core/adapter': resolve(__dirname, '../core/src/adapter/index.ts'), '@haverstack/core': resolve(__dirname, '../core/src/index.ts'), '@haverstack/record-adapter-sqlite': resolve( diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index 11118776..cf698437 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -14,10 +14,10 @@ import { InvalidAdapterError } from '../errors.js'; import { resolveQuery } from '../query-validation.js'; -import type { EntityId, PlainQuery, StackAdapter } from '../types/index.js'; +import type { PlainQuery, StackAdapter, StackRecordAdapter } from '../types/index.js'; -/** What a store is created with: the identity `_config` holds. */ -export type StackIdentity = { ownerEntityId: EntityId; timezone: string | undefined }; +/** What a store is created with: the identity `_config` holds, as an adapter reports it. */ +export type StackIdentity = Pick; /** Creates a new, empty store with `identity` — `LocalAdapter.open({ create: 'exclusive', ...identity })`, say. */ export type CreateStore = (identity: StackIdentity) => Promise | StackAdapter; @@ -35,7 +35,7 @@ const EVERYTHING: PlainQuery = { /** * Copy everything `from` holds into the store `create` makes with `from`'s * identity, and return that store, open. Not atomic: a failure closes the - * store part-written, to be discarded rather than resumed. + * store part-written, and the caller, whose store it is, removes it. * See docs/spec/portability.md § Copying a stack. */ export async function copyStack(from: StackAdapter, create: CreateStore): Promise { diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index b8f21b27..1ccca9c4 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -678,8 +678,7 @@ export class SharedSqlRecordLogic { } catch (err) { if (isUniqueConstraintViolation(err)) { throw new StackConflictError( - `Version ${version.version} already exists for record "${id}" — a concurrent ` + - `writer raced past this version. Use ifVersion to detect this before it happens.`, + `Version ${version.version} already exists for record "${id}"`, ); } throw err; @@ -714,7 +713,12 @@ export class SharedSqlRecordLogic { } catch (err) { if (!(err instanceof StackConflictError)) throw err; - if (this.currentVersion(id) !== version.version) throw err; + if (this.currentVersion(id) !== version.version) { + throw new StackConflictError( + `Version ${version.version} already exists for record "${id}" — a concurrent ` + + `writer raced past this version. Use ifVersion to detect this before it happens.`, + ); + } this.overwriteVersionRow(id, version); } From 19bb0797e0d64e62450163db04a17b2108658b52 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 19:52:08 +0000 Subject: [PATCH 07/16] feat(sqlite-shared): every store names its layout in a format table 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec/portability.md | 26 ++++++++++--------- .../tests/portable-format.test.ts | 4 ++- packages/sqlite-shared/src/schema.ts | 10 +++++++ 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 8a9a0b3f..f02bb7e0 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -71,22 +71,23 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **It carries no version marker**, because only one layout exists: the first change that needs to tell two apart adds one, and an export without it is this layout. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every store names its layout** in the one row of its `format` table: `version` is `1` for the layout below. A change to the layout bumps it, so a reader tells which layout it holds before reading anything else. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. -**The database** holds six tables that are sources of truth and two that are [derived](./adapters.md#sqlite-backed-adapters): +**The database** holds its `format`, six tables that are sources of truth, and two that are [derived](./adapters.md#sqlite-backed-adapters): -| Table | One row per | Key | -| --------------- | ------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `records` | Record, `_config` included | `id` | -| `associations` | data association | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack` | -| `permissions` | permission element | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | -| `versions` | version snapshot | `record_id`, `version` | -| `journal` | journal entry | `record_id`, `seq` | -| `types` | Type | `id` | -| `content_index` | derived: top-level scalar field | `record_id`, `field` | -| `records_fts` | derived: full-text index entry | `rowid` of the `records` row | +| Table | One row per | Key | +| --------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `format` | the store's layout version, one row only | none | +| `records` | Record, `_config` included | `id` | +| `associations` | data association | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack` | +| `permissions` | permission element | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | +| `versions` | version snapshot | `record_id`, `version` | +| `journal` | journal entry | `record_id`, `seq` | +| `types` | Type | `id` | +| `content_index` | derived: top-level scalar field | `record_id`, `field` | +| `records_fts` | derived: full-text index entry | `rowid` of the `records` row | Their columns follow four encoding rules: @@ -103,6 +104,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte | Table | Columns | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `format` | `version` | | `records` | `id`, `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `entity_id`, `app_id`, `principal_id`, `updated_by`, `updated_via`, `deleted_at`, `unlisted_at` | | `associations` | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack`, `attachment_record_id` | | `permissions` | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | diff --git a/packages/record-adapter-sqlite/tests/portable-format.test.ts b/packages/record-adapter-sqlite/tests/portable-format.test.ts index 09de3faf..67feddbc 100644 --- a/packages/record-adapter-sqlite/tests/portable-format.test.ts +++ b/packages/record-adapter-sqlite/tests/portable-format.test.ts @@ -28,7 +28,7 @@ afterEach(() => { if (dir) rmSync(dir, { recursive: true, force: true }); }); -test('every table and column a new store holds is the one the spec documents', async () => { +test('every table and column a new store holds is the one the spec documents, at format version 1', async () => { dir = join(tmpdir(), `portable-format-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); const path = join(dir, 'stack.db'); @@ -55,7 +55,9 @@ test('every table and column a new store holds is the one the spec documents', a (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name), ]), ); + const format = db.prepare('SELECT version FROM format').all(); db.close(); expect(actual).toEqual(documentedColumns()); + expect(format).toEqual([{ version: 1 }]); }); diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index 1b05f1a2..c90cdf92 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -8,7 +8,17 @@ import type { SqlExecutor } from './executor.js'; * (docs/spec/wire-format.md § Authentication). */ +/** The layout version every store's `format` table names. */ +export const FORMAT_VERSION = 1; + export const RECORD_SCHEMA_SQL = ` + -- Which layout this store is, in one row, so an export names the one a + -- reader holds. See docs/spec/portability.md § The portable format. + CREATE TABLE IF NOT EXISTS format ( + version INTEGER NOT NULL + ) STRICT; + INSERT INTO format (version) SELECT ${FORMAT_VERSION} WHERE NOT EXISTS (SELECT 1 FROM format); + CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, type_id TEXT NOT NULL, From 871f842e749f8f63c3f69307e96b0a3746990330 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 20:37:57 +0000 Subject: [PATCH 08/16] fix: Types follow the blob rule in a copy target, and open() reads the 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 1 + docs/spec/portability.md | 10 +++--- packages/core/src/adapter/copy.ts | 35 +++++++++++++------ packages/core/src/adapter/index.ts | 7 +++- packages/core/src/errors.ts | 22 ++++++++++++ packages/core/src/testing.ts | 5 +-- packages/core/tests/copy.test.ts | 31 ++++++++++------ .../record-adapter-do-sqlite/src/index.ts | 2 ++ packages/record-adapter-sqlite/src/index.ts | 2 ++ .../tests/record.test.ts | 22 +++++++++++- packages/sqlite-shared/src/config.ts | 23 +++++++++--- packages/sqlite-shared/src/record-logic.ts | 7 +--- packages/sqlite-shared/src/record.ts | 2 ++ packages/sqlite-shared/src/schema.ts | 9 ++--- 14 files changed, 131 insertions(+), 47 deletions(-) diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index 5c03756e..cf6a88a2 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -12,5 +12,6 @@ A stack can be copied whole between adapters, authorship and history included. - `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. The source must list its blobs, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. - `StackIdentity` and `CreateStore` are exported beside it. +- Every SQLite store names its layout in a one-row `format` table, and opening one in another layout throws the new `FormatMismatchError` (from `@haverstack/core/adapter`). - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. - `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, which holds a full adapter to both halves of a copy. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index f02bb7e0..431bbd34 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -24,7 +24,9 @@ What it carries is everything the source holds: **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: it is the store its adapter creates with that identity — that owner and timezone, no Record and no Type — on an adapter with `importRecord()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. Blobs are content-addressed, so one already there conflicts with nothing. A store `Stack.open()` has opened does not qualify: it holds the [system Types](./data-model.md#system-types). The copy brings the source's own, which are part of what it holds, and a later `Stack.open()` of the target meets them as it meets any store's. +**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: a store with that identity holding no Record, on an adapter with `importRecord()`. 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 its `schemaHash`. So a Type already there under another schema is refused, and one under the same schema — the [system Types](./data-model.md#system-types) a `Stack.open()` seeds, say — is no conflict. Whatever else the target already holds is its own, as a blob no Record names is. Anything the rule refuses is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. + +**The source is not written during the copy.** Records are read a page at a time and each one's history separately, so a write landing partway through yields a target matching no moment of the source. Nothing enforces this: a local source's only writer is the process that owns it, which is the one copying. **It is not atomic, and the target is the caller's.** Whatever `create` makes belongs to whoever passed `create`: on success it comes back open, theirs to close; on a failure part-way, the refusal included, `copyStack()` closes it, holding the only handle, and the caller removes it, having chosen where it lives. The copy is then run again into a new store, never resumed. @@ -71,7 +73,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every store names its layout** in the one row of its `format` table: `version` is `1` for the layout below. A change to the layout bumps it, so a reader tells which layout it holds before reading anything else. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it, so a reader tells which layout it holds before reading anything else, and an adapter opening an existing store refuses one in any layout but its own with `FormatMismatchError` (`expected`, `actual`, `where`), released as an `OwnerMismatchError` is. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. @@ -79,7 +81,7 @@ The layout is specified here so an export stays readable without the tool that w | Table | One row per | Key | | --------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `format` | the store's layout version, one row only | none | +| `format` | the store's layout version, one row only | `id` | | `records` | Record, `_config` included | `id` | | `associations` | data association | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack` | | `permissions` | permission element | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | @@ -104,7 +106,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte | Table | Columns | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `format` | `version` | +| `format` | `id`, `version` | | `records` | `id`, `type_id`, `created_at`, `updated_at`, `content`, `version`, `parent_id`, `entity_id`, `app_id`, `principal_id`, `updated_by`, `updated_via`, `deleted_at`, `unlisted_at` | | `associations` | `record_id`, `kind`, `label`, `file_id`, `related_scope`, `related_id`, `related_ns`, `related_stack`, `attachment_record_id` | | `permissions` | `record_id`, `kind`, `label`, `grantee_kind`, `grantee_id`, `role` | diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index cf698437..752ced3d 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -14,7 +14,7 @@ import { InvalidAdapterError } from '../errors.js'; import { resolveQuery } from '../query-validation.js'; -import type { PlainQuery, StackAdapter, StackRecordAdapter } from '../types/index.js'; +import type { PlainQuery, StackAdapter, StackRecordAdapter, StackType } from '../types/index.js'; /** What a store is created with: the identity `_config` holds, as an adapter reports it. */ export type StackIdentity = Pick; @@ -48,24 +48,31 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis const identity = { ownerEntityId: from.ownerEntityId, timezone: from.timezone }; const to = await create(identity); try { - await assertNewStore(to, identity); - for (const type of await from.listTypes()) await to.saveType(type); - await copyRecords(from, to); + const types = await from.listTypes(); + await assertNewStore(to, identity, types); + for (const type of types) await to.saveType(type); + // Bytes before the Records that name them, as every upload writes them. for (const { fileId } of await listBlobs()) await to.putBlob(await from.getBlob(fileId)); + await copyRecords(from, to); return to; } catch (err) { - await to.close?.(); + // The failure that stopped the copy is the one to report. + await to.close?.().catch(() => {}); throw err; } } /** - * The one rule a target is held to: the store its adapter creates with - * `identity` — that owner and timezone, no Record and no Type — on an - * adapter that can write history. Blobs are content-addressed, so one - * already there conflicts with nothing. + * The one rule a target is held to: a store with `identity` holding no + * Record, on an adapter that can write history. Whatever is named by what + * it is — a blob by its bytes, a Type by its `id` and `schemaHash` — may + * already be there if it is the same; a Type under another schema is refused. */ -async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promise { +async function assertNewStore( + to: StackAdapter, + identity: StackIdentity, + types: StackType[], +): Promise { const refuse = (why: string) => { throw new InvalidAdapterError(`Cannot copy into the store create() returned: ${why}.`); }; @@ -81,7 +88,13 @@ async function assertNewStore(to: StackAdapter, identity: StackIdentity): Promis if ((await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 }))).records.length) { refuse('it already holds Records'); } - if ((await to.listTypes()).length) refuse('it already holds Types'); + const hashes = new Map(types.map((type) => [type.id, type.schemaHash])); + for (const held of await to.listTypes()) { + const hash = hashes.get(held.id); + if (hash !== undefined && hash !== held.schemaHash) { + refuse(`it holds Type "${held.id}" under another schema`); + } + } } async function copyRecords(from: StackAdapter, to: StackAdapter): Promise { diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index 3ad5a4d3..a765461f 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -26,7 +26,12 @@ export type { RecordHistory, } from '../types/index.js'; export { sameAssociationSet } from '../associations/identity.js'; -export { ElementSetMovedError, OwnerMismatchError, softDeletedRefusal } from '../errors.js'; +export { + ElementSetMovedError, + FormatMismatchError, + OwnerMismatchError, + softDeletedRefusal, +} from '../errors.js'; export { combineAdapters } from './combine.js'; export { copyStack } from './copy.js'; export type { CreateStore, StackIdentity } from './copy.js'; diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index aa1bc4ed..f877ef48 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -264,6 +264,28 @@ export class OwnerMismatchError extends Error { } } +/** + * Thrown by an adapter's open() when an existing store names a layout other + * than the one the adapter reads: a store moves to a new layout only by + * being copied into one. `actual` is undefined when the store names none. + * Outside the StackError taxonomy for the same reason as OwnerMismatchError. + * See docs/spec/portability.md § The portable format. + */ +export class FormatMismatchError extends Error { + constructor( + public readonly expected: number, + public readonly actual: number | undefined, + public readonly where: string, + ) { + super( + actual === undefined + ? `Cannot open ${where}: it names no format version, and this adapter reads version ${expected}.` + : `Cannot open ${where}: it is format version ${actual}, and this adapter reads version ${expected}.`, + ); + this.name = 'FormatMismatchError'; + } +} + /** * Thrown by an adapter's element-set write when the stored set no longer * matches the `expectSet` it was handed. Outside the StackError taxonomy: diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 82761048..6783705a 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -568,10 +568,7 @@ export class MemoryAdapter implements StackAdapter { const record = this.records.get(id); if (!record || record.version !== version.version) { - throw new StackConflictError( - `Version ${version.version} already exists for record "${id}" — a concurrent ` + - `writer raced past this version. Use ifVersion to detect this before it happens.`, - ); + throw new StackConflictError(`Version ${version.version} already exists for record "${id}"`); } this.versions.set( diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index 8c3842b5..96a72955 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -83,6 +83,21 @@ describe('copyStack', () => { expect([...target.blobs.keys()]).toEqual([fileId]); }); + test('accepts Types already in the target under the same schema, the system ones included', async () => { + const source = await sourceWith(1); + const target = (await copyStack(source, async (identity) => { + const store = await create(identity); + const stack = await Stack.open(store); + await stack.defineType({ + id: NOTE, + name: 'Note', + schema: { text: { kind: 'text' }, cover: { kind: 'file-ref' } }, + }); + return store; + })) as MemoryAdapter; + expect(target.records.size).toBe(source.records.size); + }); + // One rule for every way the store create() returns can differ from a new // store with the source's identity. See docs/spec/portability.md // § Copying a stack. @@ -106,20 +121,16 @@ describe('copyStack', () => { ], ['a Record already in it', async () => sourceWith(1)], [ - 'the system Types Stack.open() seeds', - async (identity) => { - const store = await create(identity); - await Stack.open(store); - return store; - }, - ], - [ - 'a Type of its own', + 'a Type under another schema', async (identity) => { const store = await create(identity); await ( await Stack.open(store) - ).defineType({ id: 'com.example.other/x@1', name: 'X', schema: {} }); + ).defineType({ + id: NOTE, + name: 'Note', + schema: { text: { kind: 'string' } }, + }); return store; }, ], diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index 5f23c7e0..5b3a09b7 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -24,6 +24,7 @@ import { applyRecordSchema, + assertFormatVersion, insertConfigRecord, tryReadStackConfig, SharedSqlRecordAdapter, @@ -75,6 +76,7 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { applyRecordSchema(exec, { wal: false }); let config = tryReadStackConfig(exec); if (config) { + assertFormatVersion(exec, 'the Durable Object storage'); if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== config.entityId) { throw new OwnerMismatchError( opts.ownerEntityId, diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index adfb6a61..1a7a3d19 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -31,6 +31,7 @@ import { applyRecordSchema, acquireLock, releaseLock, + assertFormatVersion, insertConfigRecord, readStackConfig, SharedSqlRecordAdapter, @@ -143,6 +144,7 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { applyRecordSchema(exec, { wal: true }); let config: StackConfig; if (exists) { + assertFormatVersion(exec, `"${opts.path}"`); config = readStackConfig(exec); if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== config.entityId) { throw new OwnerMismatchError(opts.ownerEntityId, config.entityId, `"${opts.path}"`); diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index fc913c06..4810a043 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -10,7 +10,7 @@ import { StackNotFoundError, StackBadRequestError, } from '@haverstack/core'; -import { OwnerMismatchError, resolveQuery } from '@haverstack/core/adapter'; +import { FormatMismatchError, OwnerMismatchError, resolveQuery } from '@haverstack/core/adapter'; import type { AuthorityAssociation, RecordVersion, StackRecord } from '@haverstack/core'; // ------------------------------------------------------- @@ -242,6 +242,26 @@ describe('open create modes', () => { }, ); + // A store moves to a new layout only by being copied into one. + // See docs/spec/portability.md § The portable format. + test.each([ + ['another version', 'UPDATE format SET version = 2', 2], + ['no version at all', 'DELETE FROM format', undefined], + ] as const)( + 'a store naming %s throws FormatMismatchError and releases the lock', + async (_name, sql, actual) => { + await (await initAdapter()).close(); + const db = new DatabaseSync(dbPath); + db.exec(sql); + db.close(); + const err = await NativeSQLiteRecordAdapter.open({ path: dbPath }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(FormatMismatchError); + expect((err as FormatMismatchError).expected).toBe(1); + expect((err as FormatMismatchError).actual).toBe(actual); + expect(existsSync(`${dbPath}.lock`)).toBe(false); + }, + ); + test('a matching plain-string owner opens an existing store', async () => { await (await initAdapter({ ownerEntityId: 'owner-abc' })).close(); const adapter = await NativeSQLiteRecordAdapter.open({ diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index b544633e..7aa0f719 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,5 +1,9 @@ +import { FormatMismatchError } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; +/** The layout this code reads and writes: what a new store's `format` row names. */ +export const FORMAT_VERSION = 1; + /** timezone is optional passthrough app metadata — no default. */ export type StackConfig = { entityId: string; timezone: string | undefined }; @@ -7,10 +11,10 @@ export type StackConfig = { entityId: string; timezone: string | undefined }; const CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`; /** - * Inserts the singleton _config@1 record and returns what it wrote, so a - * caller that has just created a database reaches the same StackConfig an - * existing one yields without a read-back. Only valid on a freshly-created - * database. + * Writes what a new store is created with — its `format` row and the + * singleton _config@1 record — and returns the config, so a caller that has + * just created a database reaches the same StackConfig an existing one + * yields without a read-back. Only valid on a freshly-created database. */ export const insertConfigRecord = ( exec: SqlExecutor, @@ -18,6 +22,7 @@ export const insertConfigRecord = ( timezone: string | undefined, ): StackConfig => { const now = Date.now(); + exec.run('INSERT INTO format (id, version) VALUES (1, ?)', [FORMAT_VERSION]); exec.run( `INSERT INTO records (id, type_id, created_at, updated_at, content, version) VALUES ('_config', '_config@1', ?, ?, ?, 1)`, @@ -44,3 +49,13 @@ export const readStackConfig = (exec: SqlExecutor): StackConfig => { if (!config) throw new Error('Stack database is missing its config record.'); return config; }; + +/** + * Refuses an existing store in any layout but FORMAT_VERSION: a store moves + * to a new layout only by being copied into one. `where` names the store, + * as OwnerMismatchError's does. + */ +export const assertFormatVersion = (exec: SqlExecutor, where: string): void => { + const version = exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version; + if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); +}; diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index 1ccca9c4..eb6e54f9 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -713,12 +713,7 @@ export class SharedSqlRecordLogic { } catch (err) { if (!(err instanceof StackConflictError)) throw err; - if (this.currentVersion(id) !== version.version) { - throw new StackConflictError( - `Version ${version.version} already exists for record "${id}" — a concurrent ` + - `writer raced past this version. Use ifVersion to detect this before it happens.`, - ); - } + if (this.currentVersion(id) !== version.version) throw err; this.overwriteVersionRow(id, version); } diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index d813b4c4..467cb80b 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -20,6 +20,8 @@ export { applyRecordSchema } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; export { + assertFormatVersion, + FORMAT_VERSION, insertConfigRecord, readStackConfig, tryReadStackConfig, diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index c90cdf92..f82d56d5 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -8,16 +8,13 @@ import type { SqlExecutor } from './executor.js'; * (docs/spec/wire-format.md § Authentication). */ -/** The layout version every store's `format` table names. */ -export const FORMAT_VERSION = 1; - export const RECORD_SCHEMA_SQL = ` - -- Which layout this store is, in one row, so an export names the one a - -- reader holds. See docs/spec/portability.md § The portable format. + -- Which layout this store is, in its one row, so an export names the one + -- a reader holds. See docs/spec/portability.md § The portable format. CREATE TABLE IF NOT EXISTS format ( + id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL ) STRICT; - INSERT INTO format (version) SELECT ${FORMAT_VERSION} WHERE NOT EXISTS (SELECT 1 FROM format); CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, From 6011329529c16b5518b709f1a39405034be199f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 00:43:40 +0000 Subject: [PATCH 09/16] fix(sqlite-shared): check a store's layout before touching it - 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec/portability.md | 6 ++-- packages/core/src/testing.ts | 5 ++- .../record-adapter-do-sqlite/src/index.ts | 4 +-- packages/record-adapter-sqlite/src/index.ts | 4 +-- .../tests/record.test.ts | 17 ++++++++++ packages/sqlite-shared/src/config.ts | 15 +-------- packages/sqlite-shared/src/record-logic.ts | 8 ++++- packages/sqlite-shared/src/record.ts | 4 +-- packages/sqlite-shared/src/schema.ts | 31 +++++++++++++++++-- 9 files changed, 63 insertions(+), 31 deletions(-) diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 431bbd34..1d82b396 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -52,7 +52,7 @@ type RecordHistory = { **It is handed history a Record can have**, as every adapter method is held only to what its caller produces ([Adapters § Interface split](./adapters.md#interface-split)): every snapshot's `version` is below `record.version` — one at the record's own version is [an orphan](./versioning.md#snapshot-atomicity) — and the entries' `seq` values run [dense from 1](./journal.md#ordering), as every log's do. What an adapter does with anything else is unspecified. Order is not part of the input: snapshots are keyed by `version` and entries by `seq`, and reading them back orders them as `getVersions()` and `getJournal()` always do. -`RecordHistory` is what `getVersions()` and `getJournal()` return, so an adapter reads a Record's history out in the shape another writes it in. +`RecordHistory` is two sets, snapshots keyed by `version` and entries by `seq`: whatever order `getVersions()` and `getJournal()` read them out in, an adapter writes them by key. **Optional, and every local adapter implements it.** It is optional only so `APIAdapter` can leave it out, as [`queryReadable()`](./adapters.md#querying-within-a-reach) is. A server is the only writer of its own durable tiers ([Versioning § Snapshot atomicity](./versioning.md#snapshot-atomicity)), and the wire carries no endpoint writing either one, so a stack behind an `APIAdapter` cannot be copied into. The [conformance suite](./adapters.md#conformance) holds an adapter declaring it to every rule above, and `runCopyConformance()` holds a full adapter to both halves of a copy: a stack with a past, written into one store it creates and copied into a second, arrives unchanged. @@ -73,7 +73,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it, so a reader tells which layout it holds before reading anything else, and an adapter opening an existing store refuses one in any layout but its own with `FormatMismatchError` (`expected`, `actual`, `where`), released as an `OwnerMismatchError` is. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it, so a reader tells which layout it holds before reading anything else, and an adapter opening an existing store checks it first, before writing anything, refusing any layout but its own with `FormatMismatchError` (`expected`, `actual`, `where`), released as an `OwnerMismatchError` is. A store holding `records` with no `format` row names no layout, and is refused the same way. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. @@ -117,7 +117,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. -`content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. +`content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. They are part of the layout all the same, so a change to how either is built is a layout change like any other, made by copying into a new store, which rebuilds both as it writes. ## Tokens and installs diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 6783705a..82761048 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -568,7 +568,10 @@ export class MemoryAdapter implements StackAdapter { const record = this.records.get(id); if (!record || record.version !== version.version) { - throw new StackConflictError(`Version ${version.version} already exists for record "${id}"`); + throw new StackConflictError( + `Version ${version.version} already exists for record "${id}" — a concurrent ` + + `writer raced past this version. Use ifVersion to detect this before it happens.`, + ); } this.versions.set( diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index 5b3a09b7..81744da6 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -24,7 +24,6 @@ import { applyRecordSchema, - assertFormatVersion, insertConfigRecord, tryReadStackConfig, SharedSqlRecordAdapter, @@ -73,10 +72,9 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: DoSQLiteRecordAdapterOpenOptions, ): Promise { const exec = new DurableObjectSqliteExecutor(storage); - applyRecordSchema(exec, { wal: false }); + applyRecordSchema(exec, { wal: false, where: 'the Durable Object storage' }); let config = tryReadStackConfig(exec); if (config) { - assertFormatVersion(exec, 'the Durable Object storage'); if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== config.entityId) { throw new OwnerMismatchError( opts.ownerEntityId, diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 1a7a3d19..70550238 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -31,7 +31,6 @@ import { applyRecordSchema, acquireLock, releaseLock, - assertFormatVersion, insertConfigRecord, readStackConfig, SharedSqlRecordAdapter, @@ -141,10 +140,9 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { checkMode(exists); db = new DatabaseSync(opts.path); const exec = new NativeSqliteExecutor(db); - applyRecordSchema(exec, { wal: true }); + applyRecordSchema(exec, { wal: true, where: `"${opts.path}"` }); let config: StackConfig; if (exists) { - assertFormatVersion(exec, `"${opts.path}"`); config = readStackConfig(exec); if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== config.entityId) { throw new OwnerMismatchError(opts.ownerEntityId, config.entityId, `"${opts.path}"`); diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 4810a043..1577281f 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -247,6 +247,7 @@ describe('open create modes', () => { test.each([ ['another version', 'UPDATE format SET version = 2', 2], ['no version at all', 'DELETE FROM format', undefined], + ['no format table', 'DROP TABLE format', undefined], ] as const)( 'a store naming %s throws FormatMismatchError and releases the lock', async (_name, sql, actual) => { @@ -262,6 +263,22 @@ describe('open create modes', () => { }, ); + test('a store in another layout is refused before anything is written to it', async () => { + await (await initAdapter()).close(); + const db = new DatabaseSync(dbPath); + db.exec('UPDATE format SET version = 2; DROP TABLE content_index;'); + db.close(); + await expect(NativeSQLiteRecordAdapter.open({ path: dbPath })).rejects.toThrow( + FormatMismatchError, + ); + const after = new DatabaseSync(dbPath); + const table = after + .prepare(`SELECT name FROM sqlite_master WHERE name = 'content_index'`) + .get(); + after.close(); + expect(table).toBeUndefined(); + }); + test('a matching plain-string owner opens an existing store', async () => { await (await initAdapter({ ownerEntityId: 'owner-abc' })).close(); const adapter = await NativeSQLiteRecordAdapter.open({ diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index 7aa0f719..7ea590a1 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,8 +1,5 @@ -import { FormatMismatchError } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; - -/** The layout this code reads and writes: what a new store's `format` row names. */ -export const FORMAT_VERSION = 1; +import { FORMAT_VERSION } from './schema.js'; /** timezone is optional passthrough app metadata — no default. */ export type StackConfig = { entityId: string; timezone: string | undefined }; @@ -49,13 +46,3 @@ export const readStackConfig = (exec: SqlExecutor): StackConfig => { if (!config) throw new Error('Stack database is missing its config record.'); return config; }; - -/** - * Refuses an existing store in any layout but FORMAT_VERSION: a store moves - * to a new layout only by being copied into one. `where` names the store, - * as OwnerMismatchError's does. - */ -export const assertFormatVersion = (exec: SqlExecutor, where: string): void => { - const version = exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version; - if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); -}; diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index eb6e54f9..3bc20466 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -713,7 +713,13 @@ export class SharedSqlRecordLogic { } catch (err) { if (!(err instanceof StackConflictError)) throw err; - if (this.currentVersion(id) !== version.version) throw err; + // A collision below the current version is a writer that raced past it. + if (this.currentVersion(id) !== version.version) { + throw new StackConflictError( + `Version ${version.version} already exists for record "${id}" — a concurrent ` + + `writer raced past this version. Use ifVersion to detect this before it happens.`, + ); + } this.overwriteVersionRow(id, version); } diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index 467cb80b..291d83f2 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -16,12 +16,10 @@ * for lock.ts's plain functions — so avoiding the import (not just the * unused export) has to happen at this file's level. */ -export { applyRecordSchema } from './schema.js'; +export { applyRecordSchema, FORMAT_VERSION } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; export { - assertFormatVersion, - FORMAT_VERSION, insertConfigRecord, readStackConfig, tryReadStackConfig, diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index f82d56d5..855a06d5 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -1,3 +1,4 @@ +import { FormatMismatchError } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; /** @@ -8,6 +9,9 @@ import type { SqlExecutor } from './executor.js'; * (docs/spec/wire-format.md § Authentication). */ +/** The layout this code reads and writes: what a new store's `format` row names. */ +export const FORMAT_VERSION = 1; + export const RECORD_SCHEMA_SQL = ` -- Which layout this store is, in its one row, so an export names the one -- a reader holds. See docs/spec/portability.md § The portable format. @@ -239,17 +243,38 @@ export type RecordSchemaOptions = { * Object storage does. */ wal: boolean; + /** Names the store in a FormatMismatchError, as OwnerMismatchError's `where` does. */ + where: string; }; /** - * Brings a records database up to the schema every SQLite record adapter + * Refuses an existing store in any layout but FORMAT_VERSION before writing + * a byte to it, then brings it up to the schema every SQLite record adapter * expects: foreign keys on, then the tables and the FTS5 index. Every - * statement is idempotent, so an engine that cannot tell - * whether a store exists can run this on each attach. + * statement is idempotent, so an engine that cannot tell whether a store + * exists can run this on each attach. */ export const applyRecordSchema = (exec: SqlExecutor, opts: RecordSchemaOptions): void => { + assertFormatVersion(exec, opts.where); exec.exec(PRAGMA_FOREIGN_KEYS_ON); if (opts.wal) exec.exec(PRAGMA_JOURNAL_MODE_WAL); exec.exec(RECORD_SCHEMA_SQL); exec.exec(FTS5_SCHEMA_SQL); }; + +/** + * A store holding a `records` table is not new, so it must name this layout; + * a store moves to another only by being copied into one. Reads only. + * See docs/spec/portability.md § The portable format. + */ +const assertFormatVersion = (exec: SqlExecutor, where: string): void => { + const tables = exec.all<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('records', 'format')`, + ); + const names = new Set(tables.map(({ name }) => name)); + if (!names.has('records')) return; + const version = names.has('format') + ? exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version + : undefined; + if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); +}; From 85e7db63dbed563186585da034d0b1166d5e14a7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 13:45:31 +0000 Subject: [PATCH 10/16] fix(sqlite-shared): one marker for whether a store exists 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec/portability.md | 4 +- .../record-adapter-do-sqlite/src/index.ts | 27 ++-- packages/record-adapter-sqlite/src/index.ts | 35 +++-- packages/sqlite-shared/src/config.ts | 63 ++++----- packages/sqlite-shared/src/record-adapter.ts | 8 +- packages/sqlite-shared/src/record.ts | 7 +- packages/sqlite-shared/src/schema.ts | 29 +--- packages/sqlite-shared/tests/config.test.ts | 131 +++++++++++------- 8 files changed, 157 insertions(+), 147 deletions(-) diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 1d82b396..faa75ce3 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -50,7 +50,7 @@ type RecordHistory = { - **It appends no journal entry of its own.** The entries it is handed are the log. - **Writes after it continue the history it wrote.** The next bump snapshots the version the import left and moves past it, and the next entry's `seq` is [allocated from the log's maximum](./journal.md#ordering) as always. -**It is handed history a Record can have**, as every adapter method is held only to what its caller produces ([Adapters § Interface split](./adapters.md#interface-split)): every snapshot's `version` is below `record.version` — one at the record's own version is [an orphan](./versioning.md#snapshot-atomicity) — and the entries' `seq` values run [dense from 1](./journal.md#ordering), as every log's do. What an adapter does with anything else is unspecified. Order is not part of the input: snapshots are keyed by `version` and entries by `seq`, and reading them back orders them as `getVersions()` and `getJournal()` always do. +**It is handed history a Record can have**, as every adapter method is held only to what its caller produces ([Adapters § Interface split](./adapters.md#interface-split)): every snapshot's `version` is below `record.version` — one at the record's own version is [an orphan](./versioning.md#snapshot-atomicity) — and the entries' `seq` values run [dense from 1](./journal.md#ordering), as every log's do. What an adapter does with anything else is unspecified. `RecordHistory` is two sets, snapshots keyed by `version` and entries by `seq`: whatever order `getVersions()` and `getJournal()` read them out in, an adapter writes them by key. @@ -73,7 +73,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it, so a reader tells which layout it holds before reading anything else, and an adapter opening an existing store checks it first, before writing anything, refusing any layout but its own with `FormatMismatchError` (`expected`, `actual`, `where`), released as an `OwnerMismatchError` is. A store holding `records` with no `format` row names no layout, and is refused the same way. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as `_config`, and a store exists exactly when it holds `_config`: one left with tables and neither row by an interrupted create is created afresh. An adapter opening an existing store reads its `format` row before writing anything to it, refusing any layout but its own — or none — with `FormatMismatchError` (`expected`, `actual`, `where`), released as an `OwnerMismatchError` is. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index 81744da6..150a3ef1 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -25,11 +25,11 @@ import { applyRecordSchema, insertConfigRecord, - tryReadStackConfig, + readStoredIdentity, SharedSqlRecordAdapter, - type StackConfig, } from '@haverstack/sqlite-shared/record'; import { OwnerMismatchError } from '@haverstack/core/adapter'; +import type { StackIdentity } from '@haverstack/core/adapter'; import { DurableObjectSqliteExecutor } from './executor.js'; // ------------------------------------------------------- @@ -52,8 +52,8 @@ export type DoSQLiteRecordAdapterOpenOptions = { // ------------------------------------------------------- export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { - private constructor(exec: DurableObjectSqliteExecutor, config: StackConfig) { - super(exec, config); + private constructor(exec: DurableObjectSqliteExecutor, identity: StackIdentity) { + super(exec, identity); } /** @@ -72,22 +72,19 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: DoSQLiteRecordAdapterOpenOptions, ): Promise { const exec = new DurableObjectSqliteExecutor(storage); - applyRecordSchema(exec, { wal: false, where: 'the Durable Object storage' }); - let config = tryReadStackConfig(exec); - if (config) { - if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== config.entityId) { - throw new OwnerMismatchError( - opts.ownerEntityId, - config.entityId, - 'the Durable Object storage', - ); + const where = 'the Durable Object storage'; + let identity = readStoredIdentity(exec, where); + applyRecordSchema(exec, { wal: false }); + if (identity) { + if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== identity.ownerEntityId) { + throw new OwnerMismatchError(opts.ownerEntityId, identity.ownerEntityId, where); } } else { const ownerEntityId = typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - config = insertConfigRecord(exec, ownerEntityId, opts.timezone); + identity = insertConfigRecord(exec, { ownerEntityId, timezone: opts.timezone }); } - return new DoSQLiteRecordAdapter(exec, config); + return new DoSQLiteRecordAdapter(exec, identity); } // ------------------------------------------------------- diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 70550238..6e964311 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -32,11 +32,11 @@ import { acquireLock, releaseLock, insertConfigRecord, - readStackConfig, + readStoredIdentity, SharedSqlRecordAdapter, - type StackConfig, } from '@haverstack/sqlite-shared'; import { OwnerMismatchError } from '@haverstack/core/adapter'; +import type { StackIdentity } from '@haverstack/core/adapter'; import { NativeSqliteExecutor } from './executor.js'; // ------------------------------------------------------- @@ -88,9 +88,9 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { private readonly path: string, private readonly db: DatabaseSync, exec: NativeSqliteExecutor, - config: StackConfig, + identity: StackIdentity, ) { - super(exec, config); + super(exec, identity); } /** @@ -140,18 +140,29 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { checkMode(exists); db = new DatabaseSync(opts.path); const exec = new NativeSqliteExecutor(db); - applyRecordSchema(exec, { wal: true, where: `"${opts.path}"` }); - let config: StackConfig; - if (exists) { - config = readStackConfig(exec); - if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== config.entityId) { - throw new OwnerMismatchError(opts.ownerEntityId, config.entityId, `"${opts.path}"`); + const stored = exists ? readStoredIdentity(exec, `"${opts.path}"`) : null; + if (exists && !stored) { + throw new Error('Stack database is missing its config record.'); + } + applyRecordSchema(exec, { wal: true }); + let identity: StackIdentity; + if (stored) { + identity = stored; + if ( + typeof opts.ownerEntityId === 'string' && + opts.ownerEntityId !== identity.ownerEntityId + ) { + throw new OwnerMismatchError( + opts.ownerEntityId, + identity.ownerEntityId, + `"${opts.path}"`, + ); } } else { if (!newOwner) throw new Error(`Cannot create: "${opts.path}" vanished while opening.`); - config = insertConfigRecord(exec, newOwner, opts.timezone); + identity = insertConfigRecord(exec, { ownerEntityId: newOwner, timezone: opts.timezone }); } - return new NativeSQLiteRecordAdapter(opts.path, db, exec, config); + return new NativeSQLiteRecordAdapter(opts.path, db, exec, identity); } catch (err) { db?.close(); releaseLock(opts.path); diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index 7ea590a1..56996f80 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,48 +1,49 @@ +import { FormatMismatchError } from '@haverstack/core/adapter'; +import type { StackIdentity } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; import { FORMAT_VERSION } from './schema.js'; -/** timezone is optional passthrough app metadata — no default. */ -export type StackConfig = { entityId: string; timezone: string | undefined }; - /** The one place the singleton record's id is spelled. */ const CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`; +const hasTable = (exec: SqlExecutor, name: string): boolean => + exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, [name]) !== undefined; + /** * Writes what a new store is created with — its `format` row and the - * singleton _config@1 record — and returns the config, so a caller that has - * just created a database reaches the same StackConfig an existing one - * yields without a read-back. Only valid on a freshly-created database. + * singleton _config@1 record — in one transaction, so neither exists without + * the other, and returns the identity so a caller that has just created a + * database reaches what an existing one yields without a read-back. Only + * valid on a freshly-created database. */ -export const insertConfigRecord = ( - exec: SqlExecutor, - entityId: string, - timezone: string | undefined, -): StackConfig => { +export const insertConfigRecord = (exec: SqlExecutor, identity: StackIdentity): StackIdentity => { const now = Date.now(); - exec.run('INSERT INTO format (id, version) VALUES (1, ?)', [FORMAT_VERSION]); - exec.run( - `INSERT INTO records (id, type_id, created_at, updated_at, content, version) - VALUES ('_config', '_config@1', ?, ?, ?, 1)`, - [now, now, JSON.stringify({ entityId, timezone })], - ); - return { entityId, timezone }; + exec.transaction(() => { + exec.run('INSERT INTO format (id, version) VALUES (1, ?)', [FORMAT_VERSION]); + exec.run( + `INSERT INTO records (id, type_id, created_at, updated_at, content, version) + VALUES ('_config', '_config@1', ?, ?, ?, 1)`, + [now, now, JSON.stringify({ entityId: identity.ownerEntityId, timezone: identity.timezone })], + ); + }); + return identity; }; /** - * Reads the singleton _config@1 record, or null if this database has none. - * Null is the signal an engine that cannot tell from the outside whether a - * store exists (a Durable Object) uses to decide between reattaching and creating. + * The identity a store holds, or null when it holds none: a store exists + * exactly when it has its _config@1 record, so one left with tables and no + * record is new. An existing store is refused in any layout but + * FORMAT_VERSION, before the schema is applied to it — a store moves to + * another layout only by being copied into one. Reads only. + * See docs/spec/portability.md § The portable format. */ -export const tryReadStackConfig = (exec: SqlExecutor): StackConfig | null => { - const row = exec.get<{ content: string }>(CONFIG_ROW_SQL); +export const readStoredIdentity = (exec: SqlExecutor, where: string): StackIdentity | null => { + const row = hasTable(exec, 'records') ? exec.get<{ content: string }>(CONFIG_ROW_SQL) : undefined; if (!row) return null; + const version = hasTable(exec, 'format') + ? exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version + : undefined; + if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); const content = JSON.parse(row.content) as { entityId: string; timezone?: string }; - return { entityId: content.entityId, timezone: content.timezone }; -}; - -/** Reads the singleton _config@1 record. Throws if the database is missing it. */ -export const readStackConfig = (exec: SqlExecutor): StackConfig => { - const config = tryReadStackConfig(exec); - if (!config) throw new Error('Stack database is missing its config record.'); - return config; + return { ownerEntityId: content.entityId, timezone: content.timezone }; }; diff --git a/packages/sqlite-shared/src/record-adapter.ts b/packages/sqlite-shared/src/record-adapter.ts index 4988d3f6..7d65e602 100644 --- a/packages/sqlite-shared/src/record-adapter.ts +++ b/packages/sqlite-shared/src/record-adapter.ts @@ -40,9 +40,9 @@ import type { MutateOptions, Reach, RecordHistory, + StackIdentity, } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; -import type { StackConfig } from './config.js'; import { SharedSqlRecordLogic } from './record-logic.js'; /** @@ -77,10 +77,10 @@ export abstract class SharedSqlRecordAdapter implements StackRecordAdapter { private readonly record: SharedSqlRecordLogic; - protected constructor(exec: SqlExecutor, config: StackConfig) { + protected constructor(exec: SqlExecutor, identity: StackIdentity) { this.record = new SharedSqlRecordLogic({ exec }); - this.ownerEntityId = config.entityId; - this.timezone = config.timezone; + this.ownerEntityId = identity.ownerEntityId; + this.timezone = identity.timezone; } // ------------------------------------------------------- diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index 291d83f2..736acfef 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -19,10 +19,5 @@ export { applyRecordSchema, FORMAT_VERSION } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; -export { - insertConfigRecord, - readStackConfig, - tryReadStackConfig, - type StackConfig, -} from './config.js'; +export { insertConfigRecord, readStoredIdentity } from './config.js'; export { SharedSqlRecordAdapter, SQLITE_RECORD_CAPABILITIES } from './record-adapter.js'; diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index 855a06d5..b012e979 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -1,4 +1,3 @@ -import { FormatMismatchError } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; /** @@ -243,38 +242,18 @@ export type RecordSchemaOptions = { * Object storage does. */ wal: boolean; - /** Names the store in a FormatMismatchError, as OwnerMismatchError's `where` does. */ - where: string; }; /** - * Refuses an existing store in any layout but FORMAT_VERSION before writing - * a byte to it, then brings it up to the schema every SQLite record adapter + * Brings a records database up to the schema every SQLite record adapter * expects: foreign keys on, then the tables and the FTS5 index. Every - * statement is idempotent, so an engine that cannot tell whether a store - * exists can run this on each attach. + * statement is idempotent, so an engine that cannot tell + * whether a store exists can run this on each attach. Run it after + * readStoredIdentity(), which refuses a store in another layout untouched. */ export const applyRecordSchema = (exec: SqlExecutor, opts: RecordSchemaOptions): void => { - assertFormatVersion(exec, opts.where); exec.exec(PRAGMA_FOREIGN_KEYS_ON); if (opts.wal) exec.exec(PRAGMA_JOURNAL_MODE_WAL); exec.exec(RECORD_SCHEMA_SQL); exec.exec(FTS5_SCHEMA_SQL); }; - -/** - * A store holding a `records` table is not new, so it must name this layout; - * a store moves to another only by being copied into one. Reads only. - * See docs/spec/portability.md § The portable format. - */ -const assertFormatVersion = (exec: SqlExecutor, where: string): void => { - const tables = exec.all<{ name: string }>( - `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('records', 'format')`, - ); - const names = new Set(tables.map(({ name }) => name)); - if (!names.has('records')) return; - const version = names.has('format') - ? exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version - : undefined; - if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); -}; diff --git a/packages/sqlite-shared/tests/config.test.ts b/packages/sqlite-shared/tests/config.test.ts index 88d49948..97a89a9b 100644 --- a/packages/sqlite-shared/tests/config.test.ts +++ b/packages/sqlite-shared/tests/config.test.ts @@ -1,70 +1,97 @@ import { describe, test, expect } from 'vitest'; -import { - insertConfigRecord, - readStackConfig, - tryReadStackConfig, - type StackConfig, -} from '../src/config.js'; +import { DatabaseSync } from 'node:sqlite'; +import { FormatMismatchError } from '@haverstack/core/adapter'; +import { insertConfigRecord, readStoredIdentity } from '../src/config.js'; import type { SqlExecutor } from '../src/executor.js'; +import { applyRecordSchema, FORMAT_VERSION } from '../src/schema.js'; -/** - * A SqlExecutor over a single nullable row, which is all of storage the - * config functions touch: one INSERT and one SELECT of the `_config` - * record's content. Keeping it this narrow lets these tests run with no - * SQLite binding at all — the engines' own suites cover the real SQL. - */ -const stubExecutor = (): SqlExecutor & { row: { content: string } | undefined } => ({ - row: undefined as { content: string } | undefined, - exec() {}, - run(_sql, params = []) { - this.row = { content: params[2] as string }; - return 1; - }, - get() { - return this.row as T | undefined; - }, - all() { - return [] as T[]; - }, - transaction(fn: () => T) { - return fn(); +const executor = (db: DatabaseSync): SqlExecutor => ({ + exec: (sql) => db.exec(sql), + run: (sql, params = []) => Number(db.prepare(sql).run(...(params as never[])).changes), + get: (sql: string, params: readonly unknown[] = []) => + db.prepare(sql).get(...(params as never[])) as T | undefined, + all: (sql: string, params: readonly unknown[] = []) => + db.prepare(sql).all(...(params as never[])) as T[], + transaction(fn: () => T): T { + db.exec('BEGIN'); + try { + const result = fn(); + db.exec('COMMIT'); + return result; + } catch (err) { + db.exec('ROLLBACK'); + throw err; + } }, }); -describe('tryReadStackConfig', () => { - test('returns null when the database has no config record', () => { - expect(tryReadStackConfig(stubExecutor())).toBeNull(); +/** A schema-applied, empty database: what a store looks like before it is created. */ +const blank = () => { + const exec = executor(new DatabaseSync(':memory:')); + applyRecordSchema(exec, { wal: false }); + return exec; +}; + +const tables = (exec: SqlExecutor) => + exec.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE type = 'table'`).length; + +describe('readStoredIdentity', () => { + test('is null for a database with no tables at all', () => { + expect(readStoredIdentity(executor(new DatabaseSync(':memory:')), 'here')).toBeNull(); + }); + + // A store exists exactly when it has its _config record, so one left with + // tables and no record by an interrupted create is created afresh. + test('is null for a store with tables but no config record', () => { + expect(readStoredIdentity(blank(), 'here')).toBeNull(); }); - test('returns the stored config once one is written', () => { - const exec = stubExecutor(); - insertConfigRecord(exec, 'did:key:owner', 'America/New_York'); - expect(tryReadStackConfig(exec)).toEqual({ - entityId: 'did:key:owner', - timezone: 'America/New_York', - }); + test('reads back the identity a create wrote, an absent timezone kept as a key', () => { + const exec = blank(); + insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: undefined }); + const identity = readStoredIdentity(exec, 'here'); + expect(identity).toEqual({ ownerEntityId: 'did:key:owner', timezone: undefined }); + expect('timezone' in identity!).toBe(true); }); - test('reports an absent timezone as undefined rather than omitting it', () => { - const exec = stubExecutor(); - insertConfigRecord(exec, 'did:key:owner', undefined); - const config = tryReadStackConfig(exec) as StackConfig; - expect(config.timezone).toBeUndefined(); - expect('timezone' in config).toBe(true); + test.each([ + ['another version', 'UPDATE format SET version = 2', 2], + ['no version', 'DELETE FROM format', undefined], + ['no format table', 'DROP TABLE format', undefined], + ] as const)('refuses a store naming %s, writing nothing', (_name, sql, actual) => { + const exec = blank(); + insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + exec.exec(sql); + const before = tables(exec); + const err = (() => { + try { + return readStoredIdentity(exec, 'here'); + } catch (e) { + return e; + } + })(); + expect(err).toBeInstanceOf(FormatMismatchError); + expect(err).toMatchObject({ expected: FORMAT_VERSION, actual, where: 'here' }); + expect(tables(exec)).toBe(before); }); }); describe('insertConfigRecord', () => { - test('returns the config it wrote, matching what a later read yields', () => { - const exec = stubExecutor(); - const written = insertConfigRecord(exec, 'did:key:owner', 'UTC'); - expect(written).toEqual({ entityId: 'did:key:owner', timezone: 'UTC' }); - expect(written).toEqual(tryReadStackConfig(exec)); + test('returns the identity it wrote, matching what a later read yields', () => { + const exec = blank(); + const written = insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + expect(written).toEqual(readStoredIdentity(exec, 'here')); }); -}); -describe('readStackConfig', () => { - test('throws when the database has no config record', () => { - expect(() => readStackConfig(stubExecutor())).toThrow(/missing its config record/); + test('writes the format row and the config record together, or neither', () => { + const exec = blank(); + exec.run( + `INSERT INTO records (id, type_id, created_at, updated_at, content, version) + VALUES ('_config', '_config@1', 0, 0, '{}', 1)`, + ); + expect(() => + insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }), + ).toThrow(); + expect(exec.get('SELECT 1 FROM format')).toBeUndefined(); }); }); From e65bbbe8bd49cad387d5c8742711e10fb26a4f9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:42:10 +0000 Subject: [PATCH 11/16] fix: Native judges create modes by the one store marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec.md | 2 + docs/spec/portability.md | 2 +- packages/core/src/adapter/copy.ts | 3 +- packages/record-adapter-sqlite/src/index.ts | 37 ++++++----- .../tests/record.test.ts | 36 ++++++++++ packages/sqlite-shared/src/record-logic.ts | 66 +++++++++++-------- 6 files changed, 99 insertions(+), 47 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 6381e2f8..dcf81e7c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -95,6 +95,8 @@ A plain-string `ownerEntityId` is also checked against the owner of an existing **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. For the API adapter, they are sourced from the discovery endpoint (`GET /.well-known/stack`) when the adapter is opened and cached for the session. +**The owner is `entityId` where it is stored or sent, and `ownerEntityId` where it is an API.** A stored or wire field names the kind of thing it references, so `_config`'s content and the discovery response carry `entityId`, as a relationship target and a grantee do. An API property names the role the entity plays, so the adapter, `Stack` and every `open()` call it `ownerEntityId`. The adapters that read `_config` or discovery are where one becomes the other. + **`timezone` is optional, passthrough app metadata — nothing in core reads it for behavior.** It's a presentation concern that lives at the data layer because `_config` is the natural place to store one fact per stack. There is no default: an absent `timezone` stays `undefined` end to end (`ConfigContent.timezone`, `adapter.timezone`, discovery's `timezone` field) rather than being defaulted to `'UTC'` — a default would assert knowledge the stack was never actually given. Apps that want a display default apply it themselves, explicitly, at the point they format something. **`_config` is protected**, by `Stack` itself rather than any one adapter — the same layering as schema validation, so every adapter and `ScopedStack` (which delegates to `Stack`) inherit it automatically: diff --git a/docs/spec/portability.md b/docs/spec/portability.md index faa75ce3..57a1c94f 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -115,7 +115,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte | `types` | `id`, `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from`, `created_at` | | `content_index` | `record_id`, `field`, `value_rank`, `num_value`, `text_key`, `text_value`, `file_id` | -The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. +The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. The owner is `entityId` here and `ownerEntityId` in the API, as [the `_config` record](../spec.md#the-_config-record) names it in each. `content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. They are part of the layout all the same, so a change to how either is built is a layout change like any other, made by copying into a new store, which rebuilds both as it writes. diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index 752ced3d..e744360b 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -35,7 +35,8 @@ const EVERYTHING: PlainQuery = { /** * Copy everything `from` holds into the store `create` makes with `from`'s * identity, and return that store, open. Not atomic: a failure closes the - * store part-written, and the caller, whose store it is, removes it. + * store part-written, and the caller, whose store it is, removes it. The + * source must not be written meanwhile; nothing here can check that. * See docs/spec/portability.md § Copying a stack. */ export async function copyStack(from: StackAdapter, create: CreateStore): Promise { diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 6e964311..1c2f732e 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -103,6 +103,8 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: NativeSQLiteRecordAdapterOpenOptions, ): Promise { const mode: StoreCreateMode = opts.create ?? 'never'; + // `exists` is whether a stack is there: a file holding its _config + // record. See docs/spec/portability.md § The portable format. const checkMode = (exists: boolean): void => { if (!exists && mode === 'never') { throw new Error( @@ -117,33 +119,34 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { ); } }; - const existedBeforeLock = existsSync(opts.path); - checkMode(existedBeforeLock); - - // Resolved before the lock is taken so a failing provider leaves nothing held. - let newOwner: string | undefined; - if (!existedBeforeLock) { - newOwner = + const resolveOwner = async (): Promise => { + const owner = typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - if (!newOwner) { + if (!owner) { throw new Error( `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`, ); } + return owner; + }; + + // No file is certainly no stack, so that case is judged before the lock, + // and its provider resolved there so a failing one leaves nothing held. + let newOwner: string | undefined; + if (!existsSync(opts.path)) { + checkMode(false); + newOwner = await resolveOwner(); } acquireLock(opts.path, opts.force); let db: DatabaseSync | undefined; try { - // Re-checked under the lock: another process may have created the file meanwhile. - const exists = existsSync(opts.path); - checkMode(exists); + // Re-read under the lock: another process may have created it meanwhile. + const file = existsSync(opts.path); db = new DatabaseSync(opts.path); const exec = new NativeSqliteExecutor(db); - const stored = exists ? readStoredIdentity(exec, `"${opts.path}"`) : null; - if (exists && !stored) { - throw new Error('Stack database is missing its config record.'); - } + const stored = file ? readStoredIdentity(exec, `"${opts.path}"`) : null; + checkMode(stored !== null); applyRecordSchema(exec, { wal: true }); let identity: StackIdentity; if (stored) { @@ -159,7 +162,9 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { ); } } else { - if (!newOwner) throw new Error(`Cannot create: "${opts.path}" vanished while opening.`); + // A file with no _config is a stack not yet created, so its owner is + // resolved here, under the lock the catch below releases. + newOwner ??= await resolveOwner(); identity = insertConfigRecord(exec, { ownerEntityId: newOwner, timezone: opts.timezone }); } return new NativeSQLiteRecordAdapter(opts.path, db, exec, identity); diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 1577281f..b20ba6e5 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -279,6 +279,42 @@ describe('open create modes', () => { expect(table).toBeUndefined(); }); + // A stack exists exactly when its file holds _config, so a file an + // interrupted create left without one is a stack not yet created. + // See docs/spec/portability.md § The portable format. + describe('a file holding tables but no config record', () => { + const halfMade = async () => { + await (await initAdapter()).close(); + const db = new DatabaseSync(dbPath); + db.exec(`DELETE FROM records WHERE id = '_config'; DELETE FROM format;`); + db.close(); + }; + + test.each(['ifMissing', 'exclusive'] as const)("is created under '%s'", async (create) => { + await halfMade(); + const provider = vi.fn(() => 'owner-new'); + const adapter = await NativeSQLiteRecordAdapter.open({ + path: dbPath, + create, + ownerEntityId: provider, + }); + expect(adapter.ownerEntityId).toBe('owner-new'); + expect(provider).toHaveBeenCalledOnce(); + await adapter.close(); + expect((await NativeSQLiteRecordAdapter.open({ path: dbPath })).ownerEntityId).toBe( + 'owner-new', + ); + }); + + test("is refused as missing under 'never', and releases the lock", async () => { + await halfMade(); + await expect(NativeSQLiteRecordAdapter.open({ path: dbPath })).rejects.toThrow( + /no database found/, + ); + expect(existsSync(`${dbPath}.lock`)).toBe(false); + }); + }); + test('a matching plain-string owner opens an existing store', async () => { await (await initAdapter({ ownerEntityId: 'owner-abc' })).close(); const adapter = await NativeSQLiteRecordAdapter.open({ diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index 3bc20466..fc74f134 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -124,8 +124,9 @@ const versionRowValues = (version: RecordVersion): unknown[] => [ ]; /** - * The `journal` columns in the order both of its INSERTs bind them: key - * and timestamp, then journalInputValues(), then what the row reports. + * The `journal` columns, and the one INSERT both journal writes run: every + * column bound in this order, `seq` from `seqSql` — a bound value for an + * entry as recorded, the log's next number for an append. */ const JOURNAL_COLUMNS = [ 'record_id', @@ -141,14 +142,33 @@ const JOURNAL_COLUMNS = [ 'parent_id', ] as const; -/** The columns a JournalEntryInput fills, as JOURNAL_COLUMNS orders them. */ -const journalInputValues = (entry: JournalEntryInput): unknown[] => [ +const insertJournalSql = (seqSql: string): string => + `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) + VALUES (${JOURNAL_COLUMNS.map((column) => (column === 'seq' ? seqSql : '?')).join(', ')})`; + +/** What the record row reports for an entry, beside what the caller supplied. */ +type JournalRowState = { version: number; typeId: string; parentId: string | null }; + +/** The values for insertJournalSql(), in JOURNAL_COLUMNS order. */ +const journalValues = ( + recordId: string, + seq: unknown, + at: Date, + entry: JournalEntryInput, + row: JournalRowState, +): unknown[] => [ + recordId, + seq, + toMs(at), entry.kind, JSON.stringify(entry.ops), // '' is the root; NULL is an entry that names no origin at all. entry.previousParentId === undefined ? null : (entry.previousParentId ?? ''), entry.actor ? JSON.stringify(entry.actor) : null, entry.associations?.length ? JSON.stringify(entry.associations) : null, + row.version, + row.typeId, + row.parentId, ]; export type SharedSqlRecordLogicDeps = { @@ -747,38 +767,26 @@ export class SharedSqlRecordLogic { }>('SELECT version, type_id, parent_id FROM records WHERE id = ?', [recordId]); if (!row) throw recordNotFound(recordId); + // The subselect's one parameter is the record id, bound in seq's place. this.exec.run( - `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) - VALUES ( - ?, - (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?), - ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - recordId, - recordId, - toMs(new Date()), - ...journalInputValues(entry), - row.version, - row.type_id, - row.parent_id, - ], + insertJournalSql('(SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?)'), + journalValues(recordId, recordId, new Date(), entry, { + version: row.version, + typeId: row.type_id, + parentId: row.parent_id, + }), ); } /** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */ private insertJournalRow(recordId: string, entry: RecordJournalEntry): void { this.exec.run( - `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) - VALUES (${JOURNAL_COLUMNS.map(() => '?').join(', ')})`, - [ - recordId, - entry.seq, - toMs(entry.at), - ...journalInputValues(entry), - entry.version, - entry.typeId, - entry.parentId ?? null, - ], + insertJournalSql('?'), + journalValues(recordId, entry.seq, entry.at, entry, { + version: entry.version, + typeId: entry.typeId, + parentId: entry.parentId ?? null, + }), ); } From ff74fb2f0a67e603939dd3d0f246c872de3acfea Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 22:23:25 +0000 Subject: [PATCH 12/16] docs: open-time rules live in Construction, and the copy's checks say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec/adapters.md | 6 +++++- docs/spec/portability.md | 4 ++-- packages/core/src/errors.ts | 2 +- packages/record-adapter-do-sqlite/src/index.ts | 4 ++-- packages/record-adapter-sqlite/src/index.ts | 6 +++--- packages/record-adapter-sqlite/tests/record.test.ts | 4 ++-- packages/sqlite-shared/src/config.ts | 4 ++-- packages/sqlite-shared/src/record.ts | 2 +- packages/sqlite-shared/tests/config.test.ts | 12 ++++++------ 9 files changed, 24 insertions(+), 20 deletions(-) diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index c72c67a0..e62ce2ae 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -126,6 +126,8 @@ An adapter that holds a stack's identity has exactly one entry point, an async s | `APIAdapter` | `open({ url, ownerEntityId?, … })` | none: a client never creates | | `MemoryAdapter`, `IncapableMemoryAdapter` (testing) | `open({ ownerEntityId, timezone? })` | none: always new | +**A store is present exactly when it holds its `_config` record.** That record is written in one transaction with everything else a new store is created with, so a store an interrupted create left without it is missing, whatever else it holds: `create` decides, and creating it starts over. A missing file is missing too, and needs no reading to say so. + `create` names what `open()` does when the store is missing or present, like `O_CREAT`/`O_EXCL`: - `'never'`: open an existing store, fail if missing. @@ -138,7 +140,9 @@ An adapter that holds a stack's identity has exactly one entry point, an async s - **A plain string or a lazy `() => string | Promise`** on every creating path, called only when a store is actually created. - **A plain string is checked against an existing store's owner**, in every mode and on every adapter; a mismatch throws `OwnerMismatchError` (`expected`, `actual`, and a `where` naming the path, URL or Durable Object) and releases anything `open()` had acquired. A lazy provider is never called just to compare. -`OwnerMismatchError` is exported from `@haverstack/core/adapter`, outside the `StackError` taxonomy alongside `InvalidAdapterError`: it reports a setup mistake, not a state a request can be in. +**A SQLite store present in another layout is refused, before anything is written to it.** Its `format` row must name the layout the adapter reads (see [Portability § The portable format](./portability.md#the-portable-format)); a store naming another, or none, throws `FormatMismatchError` (`expected`, `actual` and `where`, as above) and releases what `open()` had acquired. A store moves to another layout only by being copied into one. The layout is read with the same read that answers whether the store is present and who owns it, so the three checks cannot disagree. + +`OwnerMismatchError` and `FormatMismatchError` are exported from `@haverstack/core/adapter`, outside the `StackError` taxonomy alongside `InvalidAdapterError`: each reports a setup mistake, not a state a request can be in. ## Conformance diff --git a/docs/spec/portability.md b/docs/spec/portability.md index 57a1c94f..ef18e9ad 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -24,7 +24,7 @@ What it carries is everything the source holds: **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). What it returns is held to one rule: a store with that identity holding no Record, on an adapter with `importRecord()`. 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 its `schemaHash`. So a Type already there under another schema is refused, and one under the same schema — the [system Types](./data-model.md#system-types) a `Stack.open()` seeds, say — is no conflict. Whatever else the target already holds is its own, as a blob no Record names is. Anything the rule refuses is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. +**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). `_config` is the one thing the copy cannot write itself, so what `create` returns is checked for both: that is the copy confirming it lost nothing, not the claim `open()` checks, which is about the owner alone because `timezone` is consulted only when a store is created. What it returns is held to one rule: a store with that identity holding no Record, on an adapter with `importRecord()`. 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` and `schemaHash`, as [`defineType()`](./data-model.md#types) holds two definitions the same. So a Type already there under another schema is refused, and one under the same schema — the [system Types](./data-model.md#system-types) a `Stack.open()` seeds, say — is no conflict: the source's definition replaces it, as a later `defineType()` [renaming it](./data-model.md#types) would. Whatever else the target already holds is its own, as a blob no Record names is. Anything the rule refuses is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. **The source is not written during the copy.** Records are read a page at a time and each one's history separately, so a write landing partway through yields a target matching no moment of the source. Nothing enforces this: a local source's only writer is the process that owns it, which is the one copying. @@ -73,7 +73,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as `_config`, and a store exists exactly when it holds `_config`: one left with tables and neither row by an interrupted create is created afresh. An adapter opening an existing store reads its `format` row before writing anything to it, refusing any layout but its own — or none — with `FormatMismatchError` (`expected`, `actual`, `where`), released as an `OwnerMismatchError` is. The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as `_config`, and an adapter opening a store refuses one in another layout before writing to it, by the rules in [Adapters § Construction](./adapters.md#construction). The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index f877ef48..df76b243 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -269,7 +269,7 @@ export class OwnerMismatchError extends Error { * than the one the adapter reads: a store moves to a new layout only by * being copied into one. `actual` is undefined when the store names none. * Outside the StackError taxonomy for the same reason as OwnerMismatchError. - * See docs/spec/portability.md § The portable format. + * See docs/spec/adapters.md § Construction. */ export class FormatMismatchError extends Error { constructor( diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index 150a3ef1..43cecf87 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -24,7 +24,7 @@ import { applyRecordSchema, - insertConfigRecord, + writeNewStore, readStoredIdentity, SharedSqlRecordAdapter, } from '@haverstack/sqlite-shared/record'; @@ -82,7 +82,7 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { } else { const ownerEntityId = typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - identity = insertConfigRecord(exec, { ownerEntityId, timezone: opts.timezone }); + identity = writeNewStore(exec, { ownerEntityId, timezone: opts.timezone }); } return new DoSQLiteRecordAdapter(exec, identity); } diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 1c2f732e..a28914e9 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -31,7 +31,7 @@ import { applyRecordSchema, acquireLock, releaseLock, - insertConfigRecord, + writeNewStore, readStoredIdentity, SharedSqlRecordAdapter, } from '@haverstack/sqlite-shared'; @@ -104,7 +104,7 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { ): Promise { const mode: StoreCreateMode = opts.create ?? 'never'; // `exists` is whether a stack is there: a file holding its _config - // record. See docs/spec/portability.md § The portable format. + // record. See docs/spec/adapters.md § Construction. const checkMode = (exists: boolean): void => { if (!exists && mode === 'never') { throw new Error( @@ -165,7 +165,7 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { // A file with no _config is a stack not yet created, so its owner is // resolved here, under the lock the catch below releases. newOwner ??= await resolveOwner(); - identity = insertConfigRecord(exec, { ownerEntityId: newOwner, timezone: opts.timezone }); + identity = writeNewStore(exec, { ownerEntityId: newOwner, timezone: opts.timezone }); } return new NativeSQLiteRecordAdapter(opts.path, db, exec, identity); } catch (err) { diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index b20ba6e5..963fc5da 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -243,7 +243,7 @@ describe('open create modes', () => { ); // A store moves to a new layout only by being copied into one. - // See docs/spec/portability.md § The portable format. + // See docs/spec/adapters.md § Construction. test.each([ ['another version', 'UPDATE format SET version = 2', 2], ['no version at all', 'DELETE FROM format', undefined], @@ -281,7 +281,7 @@ describe('open create modes', () => { // A stack exists exactly when its file holds _config, so a file an // interrupted create left without one is a stack not yet created. - // See docs/spec/portability.md § The portable format. + // See docs/spec/adapters.md § Construction. describe('a file holding tables but no config record', () => { const halfMade = async () => { await (await initAdapter()).close(); diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index 56996f80..e2f71fb7 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -16,7 +16,7 @@ const hasTable = (exec: SqlExecutor, name: string): boolean => * database reaches what an existing one yields without a read-back. Only * valid on a freshly-created database. */ -export const insertConfigRecord = (exec: SqlExecutor, identity: StackIdentity): StackIdentity => { +export const writeNewStore = (exec: SqlExecutor, identity: StackIdentity): StackIdentity => { const now = Date.now(); exec.transaction(() => { exec.run('INSERT INTO format (id, version) VALUES (1, ?)', [FORMAT_VERSION]); @@ -35,7 +35,7 @@ export const insertConfigRecord = (exec: SqlExecutor, identity: StackIdentity): * record is new. An existing store is refused in any layout but * FORMAT_VERSION, before the schema is applied to it — a store moves to * another layout only by being copied into one. Reads only. - * See docs/spec/portability.md § The portable format. + * See docs/spec/adapters.md § Construction. */ export const readStoredIdentity = (exec: SqlExecutor, where: string): StackIdentity | null => { const row = hasTable(exec, 'records') ? exec.get<{ content: string }>(CONFIG_ROW_SQL) : undefined; diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index 736acfef..23159475 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -19,5 +19,5 @@ export { applyRecordSchema, FORMAT_VERSION } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; -export { insertConfigRecord, readStoredIdentity } from './config.js'; +export { writeNewStore, readStoredIdentity } from './config.js'; export { SharedSqlRecordAdapter, SQLITE_RECORD_CAPABILITIES } from './record-adapter.js'; diff --git a/packages/sqlite-shared/tests/config.test.ts b/packages/sqlite-shared/tests/config.test.ts index 97a89a9b..b2568e74 100644 --- a/packages/sqlite-shared/tests/config.test.ts +++ b/packages/sqlite-shared/tests/config.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; import { FormatMismatchError } from '@haverstack/core/adapter'; -import { insertConfigRecord, readStoredIdentity } from '../src/config.js'; +import { writeNewStore, readStoredIdentity } from '../src/config.js'; import type { SqlExecutor } from '../src/executor.js'; import { applyRecordSchema, FORMAT_VERSION } from '../src/schema.js'; @@ -48,7 +48,7 @@ describe('readStoredIdentity', () => { test('reads back the identity a create wrote, an absent timezone kept as a key', () => { const exec = blank(); - insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: undefined }); + writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: undefined }); const identity = readStoredIdentity(exec, 'here'); expect(identity).toEqual({ ownerEntityId: 'did:key:owner', timezone: undefined }); expect('timezone' in identity!).toBe(true); @@ -60,7 +60,7 @@ describe('readStoredIdentity', () => { ['no format table', 'DROP TABLE format', undefined], ] as const)('refuses a store naming %s, writing nothing', (_name, sql, actual) => { const exec = blank(); - insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); exec.exec(sql); const before = tables(exec); const err = (() => { @@ -76,10 +76,10 @@ describe('readStoredIdentity', () => { }); }); -describe('insertConfigRecord', () => { +describe('writeNewStore', () => { test('returns the identity it wrote, matching what a later read yields', () => { const exec = blank(); - const written = insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + const written = writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); expect(written).toEqual(readStoredIdentity(exec, 'here')); }); @@ -90,7 +90,7 @@ describe('insertConfigRecord', () => { VALUES ('_config', '_config@1', 0, 0, '{}', 1)`, ); expect(() => - insertConfigRecord(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }), + writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }), ).toThrow(); expect(exec.get('SELECT 1 FROM format')).toBeUndefined(); }); From f4e5ebebf0234349079ff7d6c73a26abb36746f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 22:33:03 +0000 Subject: [PATCH 13/16] fix: a copy target is checked as open() checks a store, and holds nothing 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- docs/spec.md | 2 +- docs/spec/adapters.md | 2 +- docs/spec/portability.md | 4 +- packages/core/src/adapter/copy.ts | 61 ++++++++++++------------------- packages/core/tests/copy.test.ts | 29 +-------------- 5 files changed, 30 insertions(+), 68 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index dcf81e7c..c2300120 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -95,7 +95,7 @@ A plain-string `ownerEntityId` is also checked against the owner of an existing **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. For the API adapter, they are sourced from the discovery endpoint (`GET /.well-known/stack`) when the adapter is opened and cached for the session. -**The owner is `entityId` where it is stored or sent, and `ownerEntityId` where it is an API.** A stored or wire field names the kind of thing it references, so `_config`'s content and the discovery response carry `entityId`, as a relationship target and a grantee do. An API property names the role the entity plays, so the adapter, `Stack` and every `open()` call it `ownerEntityId`. The adapters that read `_config` or discovery are where one becomes the other. +**The owner is `entityId` in `_config`'s content and in the discovery response, and `ownerEntityId` on an adapter, on `Stack` and in every `open()`.** The adapters that read `_config` or discovery are where one becomes the other. **`timezone` is optional, passthrough app metadata — nothing in core reads it for behavior.** It's a presentation concern that lives at the data layer because `_config` is the natural place to store one fact per stack. There is no default: an absent `timezone` stays `undefined` end to end (`ConfigContent.timezone`, `adapter.timezone`, discovery's `timezone` field) rather than being defaulted to `'UTC'` — a default would assert knowledge the stack was never actually given. Apps that want a display default apply it themselves, explicitly, at the point they format something. diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index e62ce2ae..90111042 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -140,7 +140,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s - **A plain string or a lazy `() => string | Promise`** on every creating path, called only when a store is actually created. - **A plain string is checked against an existing store's owner**, in every mode and on every adapter; a mismatch throws `OwnerMismatchError` (`expected`, `actual`, and a `where` naming the path, URL or Durable Object) and releases anything `open()` had acquired. A lazy provider is never called just to compare. -**A SQLite store present in another layout is refused, before anything is written to it.** Its `format` row must name the layout the adapter reads (see [Portability § The portable format](./portability.md#the-portable-format)); a store naming another, or none, throws `FormatMismatchError` (`expected`, `actual` and `where`, as above) and releases what `open()` had acquired. A store moves to another layout only by being copied into one. The layout is read with the same read that answers whether the store is present and who owns it, so the three checks cannot disagree. +**A SQLite store present in another layout is refused, before anything is written to it.** A layout belongs to a store that persists, so `MemoryAdapter`, which persists nothing, has none. Its `format` row must name the layout the adapter reads (see [Portability § The portable format](./portability.md#the-portable-format)); a store naming another, or none, throws `FormatMismatchError` (`expected`, `actual` and `where`, as above) and releases what `open()` had acquired. A store moves to another layout only by being copied into one. The layout is read with the same read that answers whether the store is present and who owns it, so the three checks cannot disagree. `OwnerMismatchError` and `FormatMismatchError` are exported from `@haverstack/core/adapter`, outside the `StackError` taxonomy alongside `InvalidAdapterError`: each reports a setup mistake, not a state a request can be in. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index ef18e9ad..ca9b260d 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -24,7 +24,7 @@ What it carries is everything the source holds: **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is created with the source's identity, so the identity travels by construction.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)). `_config` is the one thing the copy cannot write itself, so what `create` returns is checked for both: that is the copy confirming it lost nothing, not the claim `open()` checks, which is about the owner alone because `timezone` is consulted only when a store is created. What it returns is held to one rule: a store with that identity holding no Record, on an adapter with `importRecord()`. 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` and `schemaHash`, as [`defineType()`](./data-model.md#types) holds two definitions the same. So a Type already there under another schema is refused, and one under the same schema — the [system Types](./data-model.md#system-types) a `Stack.open()` seeds, say — is no conflict: the source's definition replaces it, as a later `defineType()` [renaming it](./data-model.md#types) would. Whatever else the target already holds is its own, as a blob no Record names is. Anything the rule refuses is a mistake in `create`, refused with `InvalidAdapterError` before anything is written. +**The target is a store as its adapter creates one.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)), and what it returns is checked as `open()` checks a store: its owner is the source's, the one identity claim the system checks anywhere, while `timezone` is passthrough metadata a store takes when it is created. It must also hold no Record and no Type yet, on an adapter with `importRecord()`. Blobs go unchecked: not every store can list them, and writing the same bytes twice is harmless. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written; a store `Stack.open()` has already opened holds the [system Types](./data-model.md#system-types), so it is refused too. **The source is not written during the copy.** Records are read a page at a time and each one's history separately, so a write landing partway through yields a target matching no moment of the source. Nothing enforces this: a local source's only writer is the process that owns it, which is the one copying. @@ -115,7 +115,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte | `types` | `id`, `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from`, `created_at` | | `content_index` | `record_id`, `field`, `value_rank`, `num_value`, `text_key`, `text_value`, `file_id` | -The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. The owner is `entityId` here and `ownerEntityId` in the API, as [the `_config` record](../spec.md#the-_config-record) names it in each. +The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. The owner is `entityId` here and `ownerEntityId` on an adapter, as [the `_config` record](../spec.md#the-_config-record) says. `content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. They are part of the layout all the same, so a change to how either is built is a layout change like any other, made by copying into a new store, which rebuilds both as it writes. diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index e744360b..c4b92a89 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -14,7 +14,7 @@ import { InvalidAdapterError } from '../errors.js'; import { resolveQuery } from '../query-validation.js'; -import type { PlainQuery, StackAdapter, StackRecordAdapter, StackType } from '../types/index.js'; +import type { PlainQuery, StackAdapter, StackRecordAdapter } from '../types/index.js'; /** What a store is created with: the identity `_config` holds, as an adapter reports it. */ export type StackIdentity = Pick; @@ -46,15 +46,15 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis 'Cannot copy from this adapter: it has no listBlobs(), so its blobs cannot be enumerated.', ); } - const identity = { ownerEntityId: from.ownerEntityId, timezone: from.timezone }; - const to = await create(identity); + const to = await create({ ownerEntityId: from.ownerEntityId, timezone: from.timezone }); try { - const types = await from.listTypes(); - await assertNewStore(to, identity, types); - for (const type of types) await to.saveType(type); + const importRecord = to.importRecord?.bind(to); + if (!importRecord) refuseTarget('it has no importRecord(), so it cannot write history'); + await assertNewStore(to, from.ownerEntityId); + for (const type of await from.listTypes()) await to.saveType(type); // Bytes before the Records that name them, as every upload writes them. for (const { fileId } of await listBlobs()) await to.putBlob(await from.getBlob(fileId)); - await copyRecords(from, to); + await copyRecords(from, importRecord); return to; } catch (err) { // The failure that stopped the copy is the one to report. @@ -63,42 +63,29 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis } } +function refuseTarget(why: string): never { + throw new InvalidAdapterError(`Cannot copy into the store create() returned: ${why}.`); +} + /** - * The one rule a target is held to: a store with `identity` holding no - * Record, on an adapter that can write history. Whatever is named by what - * it is — a blob by its bytes, a Type by its `id` and `schemaHash` — may - * already be there if it is the same; a Type under another schema is refused. + * A target is a store as its adapter creates one: the source's owner, checked + * as open() checks it, and no Record or Type yet. Blobs go unchecked: not + * every store can list them, and writing the same bytes twice is harmless. */ -async function assertNewStore( - to: StackAdapter, - identity: StackIdentity, - types: StackType[], -): Promise { - const refuse = (why: string) => { - throw new InvalidAdapterError(`Cannot copy into the store create() returned: ${why}.`); - }; - if (!to.importRecord) refuse('it has no importRecord(), so it cannot write history'); - if (to.ownerEntityId !== identity.ownerEntityId) { - refuse(`it is owned by "${to.ownerEntityId}", not "${identity.ownerEntityId}"`); - } - if (to.timezone !== identity.timezone) { - refuse( - `its timezone is ${JSON.stringify(to.timezone)}, not ${JSON.stringify(identity.timezone)}`, - ); +async function assertNewStore(to: StackAdapter, ownerEntityId: string): Promise { + if (to.ownerEntityId !== ownerEntityId) { + refuseTarget(`it is owned by "${to.ownerEntityId}", not "${ownerEntityId}"`); } if ((await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 }))).records.length) { - refuse('it already holds Records'); - } - const hashes = new Map(types.map((type) => [type.id, type.schemaHash])); - for (const held of await to.listTypes()) { - const hash = hashes.get(held.id); - if (hash !== undefined && hash !== held.schemaHash) { - refuse(`it holds Type "${held.id}" under another schema`); - } + refuseTarget('it already holds Records'); } + if ((await to.listTypes()).length) refuseTarget('it already holds Types'); } -async function copyRecords(from: StackAdapter, to: StackAdapter): Promise { +async function copyRecords( + from: StackAdapter, + importRecord: NonNullable, +): Promise { let cursor: string | undefined; do { const page = await from.queryRecords(resolveQuery({ ...EVERYTHING, cursor })); @@ -107,7 +94,7 @@ async function copyRecords(from: StackAdapter, to: StackAdapter): Promise from.getVersions(record.id), from.getJournal(record.id), ]); - await to.importRecord!(record, { versions, journal }); + await importRecord(record, { versions, journal }); } cursor = page.cursor ?? undefined; } while (cursor); diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index 96a72955..9a0e35bf 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -83,21 +83,6 @@ describe('copyStack', () => { expect([...target.blobs.keys()]).toEqual([fileId]); }); - test('accepts Types already in the target under the same schema, the system ones included', async () => { - const source = await sourceWith(1); - const target = (await copyStack(source, async (identity) => { - const store = await create(identity); - const stack = await Stack.open(store); - await stack.defineType({ - id: NOTE, - name: 'Note', - schema: { text: { kind: 'text' }, cover: { kind: 'file-ref' } }, - }); - return store; - })) as MemoryAdapter; - expect(target.records.size).toBe(source.records.size); - }); - // One rule for every way the store create() returns can differ from a new // store with the source's identity. See docs/spec/portability.md // § Copying a stack. @@ -115,22 +100,12 @@ describe('copyStack', () => { 'another owner', async ({ timezone }) => create({ ownerEntityId: 'did:key:other', timezone }), ], - [ - 'another timezone', - async ({ ownerEntityId }) => create({ ownerEntityId, timezone: 'Asia/Tokyo' }), - ], ['a Record already in it', async () => sourceWith(1)], [ - 'a Type under another schema', + 'the system Types Stack.open() seeds', async (identity) => { const store = await create(identity); - await ( - await Stack.open(store) - ).defineType({ - id: NOTE, - name: 'Note', - schema: { text: { kind: 'string' } }, - }); + await Stack.open(store); return store; }, ], From e0475d2fdd09581310d819d3d2b5effe4191603c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 22:56:01 +0000 Subject: [PATCH 14/16] refactor: one name for the owner, and stores written whole 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/owner-entity-id.md | 14 +++++ .changeset/portable-copy.md | 5 +- docs/spec.md | 6 +- docs/spec/adapters.md | 4 +- docs/spec/portability.md | 10 +-- docs/spec/wire-format.md | 10 +-- packages/adapter-api/src/adapter.ts | 8 +-- packages/adapter-api/tests/api.test.ts | 18 +++--- .../adapter-api/tests/conformance.test.ts | 2 +- packages/adapter-api/tests/helpers.ts | 2 +- .../conformance-fixtures/src/discovery.ts | 12 ++-- packages/conformance-fixtures/src/errors.ts | 12 ++-- packages/core/src/adapter/copy.ts | 22 ++++--- packages/core/src/errors.ts | 13 ++-- packages/core/src/stack/integrity.ts | 11 ++-- packages/core/src/stack/stack.ts | 20 +++--- packages/core/src/stack/system-types.ts | 2 +- packages/core/src/types/system.ts | 2 +- packages/core/tests/change-events.test.ts | 2 +- packages/core/tests/copy.test.ts | 50 ++++++++------- packages/core/tests/stack.test.ts | 42 +++++++------ .../record-adapter-do-sqlite/src/index.ts | 8 +-- packages/record-adapter-sqlite/src/index.ts | 55 ++++++++-------- .../tests/record.test.ts | 55 ++++++++-------- packages/sqlite-shared/src/config.ts | 38 ++++++----- packages/sqlite-shared/src/record-logic.ts | 28 +++++---- packages/sqlite-shared/src/record.ts | 2 +- packages/sqlite-shared/src/schema.ts | 15 ++--- packages/sqlite-shared/tests/config.test.ts | 63 +++++++++---------- packages/wire-types/src/discovery.ts | 2 +- packages/wire-types/tests/changes.test.ts | 2 +- 31 files changed, 279 insertions(+), 256 deletions(-) create mode 100644 .changeset/owner-entity-id.md diff --git a/.changeset/owner-entity-id.md b/.changeset/owner-entity-id.md new file mode 100644 index 00000000..f9f16dcc --- /dev/null +++ b/.changeset/owner-entity-id.md @@ -0,0 +1,14 @@ +--- +'@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 +--- + +The stack owner has one name wherever a field can only hold the owner: `ownerEntityId`. + +- `_config`'s content is `{ ownerEntityId, timezone }`, and `ConfigContent.entityId` is `ConfigContent.ownerEntityId`. +- The discovery response (`GET /.well-known/stack`) carries `ownerEntityId` in place of `entityId`, in `DiscoveryResponse` and the discovery fixtures. The `_config` ownership-conflict fixture is `error-conflict-config-owner-change`. +- Fields that may hold any entity, such as a relationship target or a grantee, keep `entityId`. diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index cf6a88a2..7fcbc4e5 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -9,9 +9,10 @@ A stack can be copied whole between adapters, authorship and history included. -- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. The source must list its blobs, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. +- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. Both ends must list their blobs, and the target must hold nothing yet, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. - `StackIdentity` and `CreateStore` are exported beside it. -- Every SQLite store names its layout in a one-row `format` table, and opening one in another layout throws the new `FormatMismatchError` (from `@haverstack/core/adapter`). +- Every SQLite store names its layout in a one-row `format` table, and opening one in another layout throws the new `FormatMismatchError` (from `@haverstack/core/adapter`). A new store is written whole, tables included, in one transaction, and a failed `NativeSQLiteRecordAdapter.open()` removes a file it created. +- `OwnerMismatchError` reads ` is owned by "X", not "Y"`, and `copyStack()` throws it too. - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. - `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, which holds a full adapter to both halves of a copy. diff --git a/docs/spec.md b/docs/spec.md index c2300120..45b2d228 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -31,7 +31,7 @@ A Stack is opened with the async factory `Stack.open(adapter, opts)`, which read ```ts // First run — generate an identity keypair and create a new database. -// `did` is a "did:key:z6Mk..." string — that's the owner entityId. +// `did` is a "did:key:z6Mk..." string — that's the owner's ownerEntityId. // Persist `privateKey` yourself (OS keychain, encrypted file, ...); the // stack never stores it. See Identity. const { did, privateKey } = await generateDidKeypair(); @@ -95,14 +95,14 @@ A plain-string `ownerEntityId` is also checked against the owner of an existing **Stack identity** (`ownerEntityId`, `timezone`) is stored as a singleton `_config@1` record in the records table. Adapters expose these values as typed readonly properties (`adapter.ownerEntityId`, `adapter.timezone`) rather than as a generic key/value store. For the API adapter, they are sourced from the discovery endpoint (`GET /.well-known/stack`) when the adapter is opened and cached for the session. -**The owner is `entityId` in `_config`'s content and in the discovery response, and `ownerEntityId` on an adapter, on `Stack` and in every `open()`.** The adapters that read `_config` or discovery are where one becomes the other. +**A field is `ownerEntityId` when it can only hold the owner, and `entityId` when it may hold any entity.** So `_config`'s content, the discovery response, every adapter, `Stack` and every `open()` say `ownerEntityId`, and a relationship target or a grantee says `entityId`. The owner has one name wherever it is the owner. **`timezone` is optional, passthrough app metadata — nothing in core reads it for behavior.** It's a presentation concern that lives at the data layer because `_config` is the natural place to store one fact per stack. There is no default: an absent `timezone` stays `undefined` end to end (`ConfigContent.timezone`, `adapter.timezone`, discovery's `timezone` field) rather than being defaulted to `'UTC'` — a default would assert knowledge the stack was never actually given. Apps that want a display default apply it themselves, explicitly, at the point they format something. **`_config` is protected**, by `Stack` itself rather than any one adapter — the same layering as schema validation, so every adapter and `ScopedStack` (which delegates to `Stack`) inherit it automatically: - **Addressable only by ID.** `get('_config')` works; a generic `query()` never returns it, regardless of filter — matching every other reserved-ID system record, but load-bearing here since `_config` is read at open and consulted by every permission check. -- **`entityId` is immutable.** `patchContent('_config', { entityId: '...' })` throws `StackConflictError` — changing it would silently re-anchor stack ownership out from under a running system, and identity is a DID: the field isn't a label, it's the key the whole stack answers to. Other fields (`timezone` today) update normally. `restoreVersion('_config', ...)` inherits the same rule: a snapshot whose `entityId` disagrees with the live record's cannot be restored. Ownership transfer, if ever added, is a deliberate future API with key-custody semantics — not a field write. +- **`ownerEntityId` is immutable.** `patchContent('_config', { ownerEntityId: '...' })` throws `StackConflictError` — changing it would silently re-anchor stack ownership out from under a running system, and identity is a DID: the field isn't a label, it's the key the whole stack answers to. Other fields (`timezone` today) update normally. `restoreVersion('_config', ...)` inherits the same rule: a snapshot whose `ownerEntityId` disagrees with the live record's cannot be restored. Ownership transfer, if ever added, is a deliberate future API with key-custody semantics — not a field write. - **Never deletable**, soft or purged. `delete('_config')` always throws `StackConflictError`: a soft-deleted config is unreadable through normal paths, and a purged one leaves nothing to reopen the stack against. ## Open questions diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index 90111042..c878ef30 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -30,7 +30,7 @@ type StackAdapter = StackRecordAdapter & }; ``` -**Optional capabilities** follow one pattern throughout: an optional interface method, checked for truthiness at the call site, with a described fallback when absent. `StackRecordAdapter.deleteUnreferencedAttachmentRecords()` (atomic reference check — see [Attachments](./attachments.md#deleting-attachments)), `StackRecordAdapter.queryReadable()` (a query among the Records one requester may read; a stack is not scoped without it — see [below](#querying-within-a-reach)), `StackRecordAdapter.importRecord()` (a Record written with its whole history; a stack cannot be copied into without it — see [Portability § Writing history](./portability.md#writing-history)), `StackBlobAdapter.listBlobs()` (blob enumeration, used by [garbage collection](./attachments.md#garbage-collection) to find bare-bytes orphans, and required of a [copy's source](./portability.md#copying-a-stack)), `StackRecordAdapter.subscribeChanges()` (relaying a feed that originates elsewhere — see [Change events § Where events come from](./events.md#where-events-come-from)), and `StackAdapter.putAttachmentWithMetadata()` (atomic upload, below) are all this shape — no boolean flag in `capabilities`, just an optional method a caller checks for before using. `combineAdapters()` (below) preserves this: it forwards an optional method only when the underlying part actually implements it, never as a wrapper around a missing one. +**Optional capabilities** follow one pattern throughout: an optional interface method, checked for truthiness at the call site, with a described fallback when absent. `StackRecordAdapter.deleteUnreferencedAttachmentRecords()` (atomic reference check — see [Attachments](./attachments.md#deleting-attachments)), `StackRecordAdapter.queryReadable()` (a query among the Records one requester may read; a stack is not scoped without it — see [below](#querying-within-a-reach)), `StackRecordAdapter.importRecord()` (a Record written with its whole history; a stack cannot be copied into without it — see [Portability § Writing history](./portability.md#writing-history)), `StackBlobAdapter.listBlobs()` (blob enumeration, used by [garbage collection](./attachments.md#garbage-collection) to find bare-bytes orphans, and required of [both ends of a copy](./portability.md#copying-a-stack)), `StackRecordAdapter.subscribeChanges()` (relaying a feed that originates elsewhere — see [Change events § Where events come from](./events.md#where-events-come-from)), and `StackAdapter.putAttachmentWithMetadata()` (atomic upload, below) are all this shape — no boolean flag in `capabilities`, just an optional method a caller checks for before using. `combineAdapters()` (below) preserves this: it forwards an optional method only when the underlying part actually implements it, never as a wrapper around a missing one. `StackAdapter.putAttachmentWithMetadata(data, { mimeType, filename?, appId? })` stores bytes and creates the accompanying `_attachment@1` record as **one atomic operation**, returning the created record. It is declared on the composed `StackAdapter` type rather than on either half, because neither half can ever have it: "bytes + record in one operation" is a property only a whole adapter can offer. Exactly one adapter does: the API adapter, backed by a single `POST /attachments` request the server fulfills atomically. Local storage adapters don't implement it, and `combineAdapters()` never synthesizes it from parts (a record backend and a blob backend glued together have no shared transaction). `Stack.putAttachment()` checks for it — present means delegate the whole operation and trust the returned record as backend-authoritative; absent means the bytes-then-`create()` fallback sequence. See [Wire format § Attachments](./wire-format.md#attachments) for why the atomic form is a correctness requirement, not an efficiency optimization. @@ -126,7 +126,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s | `APIAdapter` | `open({ url, ownerEntityId?, … })` | none: a client never creates | | `MemoryAdapter`, `IncapableMemoryAdapter` (testing) | `open({ ownerEntityId, timezone? })` | none: always new | -**A store is present exactly when it holds its `_config` record.** That record is written in one transaction with everything else a new store is created with, so a store an interrupted create left without it is missing, whatever else it holds: `create` decides, and creating it starts over. A missing file is missing too, and needs no reading to say so. +**A store is written whole, so it is either present or missing.** A new store's tables, its `format` row and its `_config` record are written in one transaction, and opening an existing one writes none of them. A database holding none of a store's tables is missing; one holding any is a store, present exactly when it is whole. An `open()` that fails leaves things as it found them: `NativeSQLiteRecordAdapter` removes a file it created rather than leaving it for the next open to trip on. `create` names what `open()` does when the store is missing or present, like `O_CREAT`/`O_EXCL`: diff --git a/docs/spec/portability.md b/docs/spec/portability.md index ca9b260d..f31085c2 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -18,19 +18,19 @@ What it carries is everything the source holds: - **Every Type**, through `listTypes()` and `saveType()`. - **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and `create` made the target with what it holds. -- **Every blob**, through `listBlobs()`, which a source must have. +- **Every blob**, through `listBlobs()`, which both ends of a copy must have. **Each is read as the store enumerates it**, never by what a Record says, so nothing is filtered on the way: what is garbage is the target's own [collection](./attachments.md#garbage-collection) to decide, run when its owner chooses. Nothing a purge destroyed is carried, because the source no longer holds it. **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is a store as its adapter creates one.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)), and what it returns is checked as `open()` checks a store: its owner is the source's, the one identity claim the system checks anywhere, while `timezone` is passthrough metadata a store takes when it is created. It must also hold no Record and no Type yet, on an adapter with `importRecord()`. Blobs go unchecked: not every store can list them, and writing the same bytes twice is harmless. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written; a store `Stack.open()` has already opened holds the [system Types](./data-model.md#system-types), so it is refused too. +**The target is a store as its adapter creates one.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)), and what it returns is checked as `open()` checks a store: its owner must be the source's, or the copy throws `OwnerMismatchError`, as `open()` does. `timezone` is passthrough metadata a store takes when it is created, so it is handed over and not checked. The store must also hold nothing yet — no Record, no Type, no blob — on an adapter with `importRecord()` and `listBlobs()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written; a store `Stack.open()` has already opened holds the [system Types](./data-model.md#system-types), so it is refused too. **The source is not written during the copy.** Records are read a page at a time and each one's history separately, so a write landing partway through yields a target matching no moment of the source. Nothing enforces this: a local source's only writer is the process that owns it, which is the one copying. **It is not atomic, and the target is the caller's.** Whatever `create` makes belongs to whoever passed `create`: on success it comes back open, theirs to close; on a failure part-way, the refusal included, `copyStack()` closes it, holding the only handle, and the caller removes it, having chosen where it lives. The copy is then run again into a new store, never resumed. -**The source needs `listBlobs()`**, as the target needs `importRecord()`, and a source without it is refused with `InvalidAdapterError` before `create` is called. Every blob adapter has it. `APIAdapter` does not, because the wire has no endpoint that lists blobs, so a stack behind one cannot yet be copied out through it. +**Both ends list everything they hold.** The source needs `listBlobs()` to be read in full, and the target needs it to be checked empty and to be read in full by a later copy in turn, so a copy never lands a stack where it cannot be copied out again. A source without it is refused with `InvalidAdapterError` before `create` is called. Every blob adapter has it. `APIAdapter` does not, because the wire has no endpoint that lists blobs, so a stack behind one cannot yet be copied out through it. ## Writing history @@ -73,7 +73,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as `_config`, and an adapter opening a store refuses one in another layout before writing to it, by the rules in [Adapters § Construction](./adapters.md#construction). The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as the tables and `_config`, and an adapter opening a store refuses one in another layout before writing to it, by the rules in [Adapters § Construction](./adapters.md#construction). The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. @@ -115,7 +115,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte | `types` | `id`, `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from`, `created_at` | | `content_index` | `record_id`, `field`, `value_rank`, `num_value`, `text_key`, `text_value`, `file_id` | -The `_config` row carries `{ entityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. The owner is `entityId` here and `ownerEntityId` on an adapter, as [the `_config` record](../spec.md#the-_config-record) says. +The `_config` row carries `{ ownerEntityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. `content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. They are part of the layout all the same, so a change to how either is built is a layout change like any other, made by copying into a new store, which rebuilds both as it writes. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md index 41694518..1a4b0164 100644 --- a/docs/spec/wire-format.md +++ b/docs/spec/wire-format.md @@ -4,7 +4,7 @@ The API adapter speaks REST over HTTP with JSON bodies and standard status codes ## Discovery -A client hits this endpoint first to understand the server's identity and capabilities. The response supplies `entityId` and `timezone`, which the `APIAdapter` caches as `ownerEntityId` and `timezone` properties for the session. +A client hits this endpoint first to understand the server's identity and capabilities. The response supplies `ownerEntityId` and `timezone`, which the `APIAdapter` caches as properties of the same names for the session. ``` GET /.well-known/stack @@ -13,7 +13,7 @@ GET /.well-known/stack ```json { "version": "1.0", - "entityId": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "ownerEntityId": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", "timezone": "America/New_York", "capabilities": { "filter": { "content": "path", "contentPresent": true, "contentByType": true, "search": true }, @@ -51,11 +51,11 @@ Every entry in `capabilities` may be absent, and a foreign server may name a `fi ### Identity is trusted on transport -`entityId` in the response above is an **unsigned JSON field**, and nothing here challenges the server to prove it holds any binding to that DID. Structurally it cannot: the server deliberately never holds the owner's key (see [Identity](./identity.md)). So a client's belief about _whose stack it is talking to_ rests on TLS and the URL it was given — not on a signature, unlike every other claim in this system. A hostile or misdirected server can present itself as anyone's stack, and a client that writes private data to it has no cryptographic recourse. +`ownerEntityId` in the response above is an **unsigned JSON field**, and nothing here challenges the server to prove it holds any binding to that DID. Structurally it cannot: the server deliberately never holds the owner's key (see [Identity](./identity.md)). So a client's belief about _whose stack it is talking to_ rests on TLS and the URL it was given — not on a signature, unlike every other claim in this system. A hostile or misdirected server can present itself as anyone's stack, and a client that writes private data to it has no cryptographic recourse. This is inherent to the hosted topology rather than an omission, and it is stated here because the rest of the spec is otherwise scrupulous about naming exactly this kind of asymmetry. Proving a server↔owner binding is deferred alongside [key rotation](./identity.md#deferred-key-rotation): both need an identity that outlives a single key, and neither is foreclosed by anything above. -**A client that already knows which DID it expects can say so.** One following a Group's `stackUrl`, or reconnecting to a stack it has used before, holds that expectation. `APIAdapter`'s `ownerEntityId` option states it, comparing it against discovery's `entityId` and failing `open()` with `OwnerMismatchError` on any difference — including a discovery response carrying no `entityId` at all, which is not a match. The comparison is exact string equality; no DID method is normalized, since core resolves none of them. +**A client that already knows which DID it expects can say so.** One following a Group's `stackUrl`, or reconnecting to a stack it has used before, holds that expectation. `APIAdapter`'s `ownerEntityId` option states it, comparing it against discovery's `ownerEntityId` and failing `open()` with `OwnerMismatchError` on any difference — including a discovery response carrying no `ownerEntityId` at all, which is not a match. The comparison is exact string equality; no DID method is normalized, since core resolves none of them. Two limits, so the option is not read as more than it is. It narrows misdirection to a server that already knows which DID it should be claiming — it does not make discovery identity a proof, and no client-side check can. And it runs on a response that has already been fetched, so a static `token`, if one was configured, reached that server before the check could refuse it: `ownerEntityId` guards what a client goes on to _write_, not what it already sent to a URL it chose to contact. @@ -180,7 +180,7 @@ Empty is not an encoding of anything — nor is a literal `null`. Absence is `40 | **401** | Unauthorized | Missing or invalid bearer token — no verified DID behind the request at all ("who are you?") | | **403** | Forbidden | `StackPermissionError` — the requester's DID verified but they lack access ("your claim is genuine; no"). Reserved for a requester who can **read** the record: telling anyone else that it exists would confirm a guessed or derived ID, so they get 404 instead. See [Refusals and disclosure](./disclosure.md) | | **404** | Not found | `StackNotFoundError` — record or version does not exist, **or** exists and the requester cannot read it. The two are deliberately indistinguishable (previous row) | -| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting a still-referenced attachment, a duplicate client-supplied `id`, deleting `_config` or changing its `entityId`); or `StackSchemaDriftError` (code `schema_drift`) — `POST /types` redefining an existing `id` with a non-additive schema change | +| **409** | Conflict | `StackConflictError` — operation blocked by a constraint violation (e.g. deleting a still-referenced attachment, a duplicate client-supplied `id`, deleting `_config` or changing its `ownerEntityId`); or `StackSchemaDriftError` (code `schema_drift`) — `POST /types` redefining an existing `id` with a non-additive schema change | | **412** | Precondition failed | `StackVersionConflictError` (code `version_conflict`) — an `If-Match` precondition doesn't match the record's current version. A distinct error type and status from `StackConflictError`/409 — the two have different recovery stories | | **413** | Request entity too large | `StackPayloadTooLargeError` (code `payload_too_large`) — an attachment upload, a record body, or a PATCH body exceeds the server's size limit (see [Request size limits](#request-size-limits)). Unambiguous status (no other code shares 413), so status-only reconstruction recovers this class even without a parseable body. `putAttachment()` also pre-checks the declared `limits.attachmentBytes` ceiling client-side and throws the same class before sending, so this status is a backstop, not the only enforcement point | | **422** | Unprocessable entity | `StackValidationError` — request is syntactically valid but content fails schema validation (e.g. a required field has the wrong type) | diff --git a/packages/adapter-api/src/adapter.ts b/packages/adapter-api/src/adapter.ts index 380f1b2c..5086c19b 100644 --- a/packages/adapter-api/src/adapter.ts +++ b/packages/adapter-api/src/adapter.ts @@ -272,11 +272,11 @@ export class APIAdapter implements StackAdapter { } // After version negotiation: a differing major means fields may not mean - // what this client reads them as, so `entityId` isn't worth comparing yet. - if (opts.ownerEntityId !== undefined && discovery.entityId !== opts.ownerEntityId) { + // what this client reads them as, so `ownerEntityId` isn't worth comparing yet. + if (opts.ownerEntityId !== undefined && discovery.ownerEntityId !== opts.ownerEntityId) { throw new OwnerMismatchError( opts.ownerEntityId, - discovery.entityId, + discovery.ownerEntityId, `the server at "${baseUrl}"`, ); } @@ -299,7 +299,7 @@ export class APIAdapter implements StackAdapter { baseUrl, token, opts.credential, - discovery.entityId, + discovery.ownerEntityId, // Passthrough metadata only — no 'UTC' default, which would claim // knowledge the discovery response didn't actually provide. discovery.timezone, diff --git a/packages/adapter-api/tests/api.test.ts b/packages/adapter-api/tests/api.test.ts index 97c10058..e815b874 100644 --- a/packages/adapter-api/tests/api.test.ts +++ b/packages/adapter-api/tests/api.test.ts @@ -302,7 +302,7 @@ describe('open — version negotiation', () => { describe('open — ownerEntityId', () => { const OWNER_DID = 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'; const OTHER_DID = 'did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG'; - const ownedDiscovery = { ...DISCOVERY, entityId: OWNER_DID }; + const ownedDiscovery = { ...DISCOVERY, ownerEntityId: OWNER_DID }; test('opens when discovery reports the expected owner', async () => { mockFetch.mockResolvedValueOnce(jsonResponse(ownedDiscovery)); @@ -315,14 +315,14 @@ describe('open — ownerEntityId', () => { }); test('refuses a server reporting a different owner', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, entityId: OTHER_DID })); + mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, ownerEntityId: OTHER_DID })); await expect( APIAdapter.open({ url: BASE_URL, token: TOKEN, ownerEntityId: OWNER_DID }), ).rejects.toThrow(OwnerMismatchError); }); test('carries both DIDs for a caller that wants to report them', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, entityId: OTHER_DID })); + mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, ownerEntityId: OTHER_DID })); const err = await APIAdapter.open({ url: BASE_URL, token: TOKEN, @@ -334,14 +334,14 @@ describe('open — ownerEntityId', () => { }); test('refuses discovery carrying no owner at all — absence is not a match', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, entityId: undefined })); + mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, ownerEntityId: undefined })); await expect( APIAdapter.open({ url: BASE_URL, token: TOKEN, ownerEntityId: OWNER_DID }), ).rejects.toThrow(OwnerMismatchError); }); test('compares exactly — a DID differing only in case is a mismatch', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, entityId: OWNER_DID })); + mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, ownerEntityId: OWNER_DID })); await expect( APIAdapter.open({ url: BASE_URL, @@ -352,13 +352,13 @@ describe('open — ownerEntityId', () => { }); test('an omitted ownerEntityId opens against whatever owner is reported', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, entityId: OTHER_DID })); + mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, ownerEntityId: OTHER_DID })); const adapter = await APIAdapter.open({ url: BASE_URL, token: TOKEN }); expect(adapter.ownerEntityId).toBe(OTHER_DID); }); test('refuses before sending any other request', async () => { - mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, entityId: OTHER_DID })); + mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, ownerEntityId: OTHER_DID })); await expect( APIAdapter.open({ url: BASE_URL, token: TOKEN, ownerEntityId: OWNER_DID }), ).rejects.toThrow(OwnerMismatchError); @@ -366,10 +366,10 @@ describe('open — ownerEntityId', () => { }); // A differing major means discovery's fields may not mean what this client - // reads them as, so entityId isn't worth comparing yet. + // reads them as, so ownerEntityId isn't worth comparing yet. test('an incompatible protocol major is refused ahead of the owner check', async () => { mockFetch.mockResolvedValueOnce( - jsonResponse({ ...DISCOVERY, version: '2.0', entityId: OTHER_DID }), + jsonResponse({ ...DISCOVERY, version: '2.0', ownerEntityId: OTHER_DID }), ); await expect( APIAdapter.open({ url: BASE_URL, token: TOKEN, ownerEntityId: OWNER_DID }), diff --git a/packages/adapter-api/tests/conformance.test.ts b/packages/adapter-api/tests/conformance.test.ts index cd03a612..c3a3e8c5 100644 --- a/packages/adapter-api/tests/conformance.test.ts +++ b/packages/adapter-api/tests/conformance.test.ts @@ -97,7 +97,7 @@ describe('discovery fixtures', () => { const [url] = mockFetch.mock.lastCall as [string, RequestInit]; expect(url).toBe(`${BASE_URL}${fixture.path}`); - expect(adapter.ownerEntityId).toBe(fixture.responseBody!.entityId); + expect(adapter.ownerEntityId).toBe(fixture.responseBody!.ownerEntityId); expect(adapter.timezone).toBe(fixture.responseBody!.timezone); expect(adapter.capabilities).toEqual(fixture.responseBody!.capabilities); }); diff --git a/packages/adapter-api/tests/helpers.ts b/packages/adapter-api/tests/helpers.ts index 17cf0d39..6c6f2ccf 100644 --- a/packages/adapter-api/tests/helpers.ts +++ b/packages/adapter-api/tests/helpers.ts @@ -50,7 +50,7 @@ export const CHANGE_FEED = discoveryFixtures.find( */ export const DISCOVERY = { version: WIRE_PROTOCOL_VERSION, - entityId: OWNER, + ownerEntityId: OWNER, timezone: 'America/New_York', capabilities: FULL_CAPABILITIES, }; diff --git a/packages/conformance-fixtures/src/discovery.ts b/packages/conformance-fixtures/src/discovery.ts index 986f9a83..1632e548 100644 --- a/packages/conformance-fixtures/src/discovery.ts +++ b/packages/conformance-fixtures/src/discovery.ts @@ -15,7 +15,7 @@ export const discoveryFixtures: ConformanceFixture responseStatus: 200, responseBody: { version: WIRE_PROTOCOL_VERSION, - entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + ownerEntityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', timezone: 'America/New_York', capabilities: { filter: { @@ -47,7 +47,7 @@ export const discoveryFixtures: ConformanceFixture responseStatus: 200, responseBody: { version: WIRE_PROTOCOL_VERSION, - entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + ownerEntityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', capabilities: { filter: { content: 'none', contentPresent: false, contentByType: false, search: false }, sort: { @@ -71,7 +71,7 @@ export const discoveryFixtures: ConformanceFixture responseStatus: 200, responseBody: { version: WIRE_PROTOCOL_VERSION, - entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + ownerEntityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', capabilities: { filter: { content: 'none', @@ -105,7 +105,7 @@ export const discoveryFixtures: ConformanceFixture responseStatus: 200, responseBody: { version: WIRE_PROTOCOL_VERSION, - entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + ownerEntityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', capabilities: { filter: { content: 'path', @@ -139,7 +139,7 @@ export const discoveryFixtures: ConformanceFixture responseStatus: 200, responseBody: { version: WIRE_PROTOCOL_VERSION, - entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + ownerEntityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', capabilities: { filter: { content: 'path', @@ -173,7 +173,7 @@ export const discoveryFixtures: ConformanceFixture responseStatus: 200, responseBody: { version: WIRE_PROTOCOL_VERSION, - entityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', + ownerEntityId: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK', capabilities: { filter: { content: 'none', diff --git a/packages/conformance-fixtures/src/errors.ts b/packages/conformance-fixtures/src/errors.ts index 8bb5d7d5..58c5a6aa 100644 --- a/packages/conformance-fixtures/src/errors.ts +++ b/packages/conformance-fixtures/src/errors.ts @@ -1478,22 +1478,22 @@ export const errorResponseFixtures: ConformanceFixture[] = [ }, }, { - name: 'error-conflict-config-entityid-change', + name: 'error-conflict-config-owner-change', description: - 'PATCH /records/_config/content that would change entityId returns 409 / code ' + - '"conflict" — entityId defines stack ownership, read once at open and consulted by every ' + + 'PATCH /records/_config/content that would change ownerEntityId returns 409 / code ' + + '"conflict" — ownerEntityId defines stack ownership, read once at open and consulted by every ' + 'permission check thereafter; a write that silently re-anchored it would desync every ' + 'already-running owner check. Other _config fields (e.g. timezone) update normally through ' + - 'the same endpoint — only an entityId change is refused.', + 'the same endpoint — only an ownerEntityId change is refused.', method: 'PATCH', path: '/records/_config/content', - requestBody: { entityId: 'did:key:z6MkvVv7EXm3g3XZ8k4hqYqK5zqfSj6pS4KvL5s6cQzYzZq3' }, + requestBody: { ownerEntityId: 'did:key:z6MkvVv7EXm3g3XZ8k4hqYqK5zqfSj6pS4KvL5s6cQzYzZq3' }, responseStatus: 409, responseBody: { error: { code: 'conflict', message: - 'Cannot change _config.entityId: it defines stack ownership. Ownership transfer is ' + + 'Cannot change _config.ownerEntityId: it defines stack ownership. Ownership transfer is ' + 'not a supported operation.', }, }, diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index c4b92a89..b5ef7adc 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -12,7 +12,7 @@ * through Stack may do. See docs/spec/portability.md. */ -import { InvalidAdapterError } from '../errors.js'; +import { InvalidAdapterError, OwnerMismatchError } from '../errors.js'; import { resolveQuery } from '../query-validation.js'; import type { PlainQuery, StackAdapter, StackRecordAdapter } from '../types/index.js'; @@ -50,7 +50,9 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis try { const importRecord = to.importRecord?.bind(to); if (!importRecord) refuseTarget('it has no importRecord(), so it cannot write history'); - await assertNewStore(to, from.ownerEntityId); + const listHeld = to.listBlobs?.bind(to); + if (!listHeld) refuseTarget('it has no listBlobs(), so it could not be copied from in turn'); + await assertNewStore(to, from.ownerEntityId, listHeld); for (const type of await from.listTypes()) await to.saveType(type); // Bytes before the Records that name them, as every upload writes them. for (const { fileId } of await listBlobs()) await to.putBlob(await from.getBlob(fileId)); @@ -68,18 +70,24 @@ function refuseTarget(why: string): never { } /** - * A target is a store as its adapter creates one: the source's owner, checked - * as open() checks it, and no Record or Type yet. Blobs go unchecked: not - * every store can list them, and writing the same bytes twice is harmless. + * A target is a store as its adapter creates one: the source's owner, + * checked as open() checks it, and nothing held yet — no Record, no Type, no + * blob. Every end of a copy lists all it holds, so a target is itself a + * store a later copy can read in full. */ -async function assertNewStore(to: StackAdapter, ownerEntityId: string): Promise { +async function assertNewStore( + to: StackAdapter, + ownerEntityId: string, + listHeld: NonNullable, +): Promise { if (to.ownerEntityId !== ownerEntityId) { - refuseTarget(`it is owned by "${to.ownerEntityId}", not "${ownerEntityId}"`); + throw new OwnerMismatchError(ownerEntityId, to.ownerEntityId, 'the store create() returned'); } if ((await to.queryRecords(resolveQuery({ ...EVERYTHING, limit: 1 }))).records.length) { refuseTarget('it already holds Records'); } if ((await to.listTypes()).length) refuseTarget('it already holds Types'); + if ((await listHeld()).length) refuseTarget('it already holds blobs'); } async function copyRecords( diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index df76b243..d5460913 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -243,10 +243,11 @@ export class InvalidAdapterError extends Error { } /** - * Thrown by an adapter's open() when a plain-string `ownerEntityId` disagrees - * with the owner the store already has. `where` names the path, URL or - * Durable Object that was opened; `actual` is undefined when a server reports - * no owner at all. Outside the StackError taxonomy for the same reason as + * Thrown when a store is not owned by the owner expected of it: by an + * adapter's open() given a plain-string `ownerEntityId`, and by copyStack() + * when the store `create` returned is not the source's owner's. `where` + * names the store; `actual` is undefined when a server reports no owner at + * all. Outside the StackError taxonomy for the same reason as * InvalidAdapterError. See docs/spec/adapters.md § Construction. */ export class OwnerMismatchError extends Error { @@ -257,8 +258,8 @@ export class OwnerMismatchError extends Error { ) { super( actual - ? `Cannot open ${where}: it is owned by "${actual}", but ownerEntityId "${expected}" was given.` - : `Cannot open ${where}: it reports no owner, but ownerEntityId "${expected}" was given.`, + ? `${where} is owned by "${actual}", not "${expected}".` + : `${where} reports no owner, not "${expected}".`, ); this.name = 'OwnerMismatchError'; } diff --git a/packages/core/src/stack/integrity.ts b/packages/core/src/stack/integrity.ts index ad106ffe..b19d5a78 100644 --- a/packages/core/src/stack/integrity.ts +++ b/packages/core/src/stack/integrity.ts @@ -334,18 +334,15 @@ export function assertAttachmentImmutable( // ------------------------------------------------------- /** - * `_config.entityId` defines stack ownership; neither patchContent() nor + * `_config.ownerEntityId` defines stack ownership; neither patchContent() nor * restoreVersion() may change it. A conflict with stack integrity, not a * schema violation — hence StackConflictError. See docs/spec.md § The * `_config` record. */ -export function checkConfigEntityIdUnchanged( - existingEntityId: EntityId, - newEntityId: EntityId, -): void { - if (newEntityId !== existingEntityId) { +export function checkConfigOwnerUnchanged(existingOwner: EntityId, newOwner: EntityId): void { + if (newOwner !== existingOwner) { throw new StackConflictError( - 'Cannot change _config.entityId: it defines stack ownership. ' + + 'Cannot change _config.ownerEntityId: it defines stack ownership. ' + 'Ownership transfer is not a supported operation.', ); } diff --git a/packages/core/src/stack/stack.ts b/packages/core/src/stack/stack.ts index 053ad23d..afd91794 100644 --- a/packages/core/src/stack/stack.ts +++ b/packages/core/src/stack/stack.ts @@ -154,7 +154,7 @@ import { checkBindingsOnCreate, checkBindingsOnMigrate, checkBindingsOnUpdate, - checkConfigEntityIdUnchanged, + checkConfigOwnerUnchanged, } from './integrity.js'; import { systemTypeDefinitions } from './system-types.js'; import type { @@ -822,9 +822,9 @@ export class Stack implements StackClient { await checkBindingsOnUpdate(this, existing.typeId, id, patch, existing.content, merged); if (id === SYSTEM_TYPES.CONFIG) { - checkConfigEntityIdUnchanged( - (existing.content as ConfigContent).entityId, - (merged as ConfigContent).entityId, + checkConfigOwnerUnchanged( + (existing.content as ConfigContent).ownerEntityId, + (merged as ConfigContent).ownerEntityId, ); } return merged; @@ -1285,9 +1285,9 @@ export class Stack implements StackClient { } if (id === SYSTEM_TYPES.CONFIG) { - checkConfigEntityIdUnchanged( - (existing.content as ConfigContent).entityId, - (target.content as ConfigContent).entityId, + checkConfigOwnerUnchanged( + (existing.content as ConfigContent).ownerEntityId, + (target.content as ConfigContent).ownerEntityId, ); } @@ -1399,9 +1399,9 @@ export class Stack implements StackClient { await checkBindingsOnMigrate(this, existing.typeId, toTypeId, id, existingContent, content); if (id === SYSTEM_TYPES.CONFIG) { - checkConfigEntityIdUnchanged( - (existing.content as ConfigContent).entityId, - (content as ConfigContent).entityId, + checkConfigOwnerUnchanged( + (existing.content as ConfigContent).ownerEntityId, + (content as ConfigContent).ownerEntityId, ); } diff --git a/packages/core/src/stack/system-types.ts b/packages/core/src/stack/system-types.ts index 697111c3..1f95c60f 100644 --- a/packages/core/src/stack/system-types.ts +++ b/packages/core/src/stack/system-types.ts @@ -20,7 +20,7 @@ export const systemTypeDefinitions = (): DefineTypeOptions[] => [ id: `${SYSTEM_TYPES.CONFIG}@1`, name: 'Config', schema: { - entityId: { kind: 'string', required: true }, + ownerEntityId: { kind: 'string', required: true }, // Optional passthrough app metadata — see ConfigContent.timezone. timezone: { kind: 'string' }, }, diff --git a/packages/core/src/types/system.ts b/packages/core/src/types/system.ts index a93c0f2c..d09cead4 100644 --- a/packages/core/src/types/system.ts +++ b/packages/core/src/types/system.ts @@ -155,7 +155,7 @@ export type ConfigContent = { * DID of the stack owner. Immutable — see docs/spec.md § The `_config` * record for why ownership transfer isn't a field write. */ - entityId: EntityId; + ownerEntityId: EntityId; /** * IANA timezone string e.g. "America/New_York". Optional passthrough app * metadata — nothing in core reads it for behavior, and there is no diff --git a/packages/core/tests/change-events.test.ts b/packages/core/tests/change-events.test.ts index 9867c416..1c50f027 100644 --- a/packages/core/tests/change-events.test.ts +++ b/packages/core/tests/change-events.test.ts @@ -222,7 +222,7 @@ describe('every record emits, including the ones a query hides', () => { typeId: '_config@1', createdAt: new Date(), updatedAt: new Date(), - content: { entityId: OWNER, timezone: 'UTC' }, + content: { ownerEntityId: OWNER, timezone: 'UTC' }, version: 1, }); const { seen, handler } = collector(); diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index 9a0e35bf..e0100cbd 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from 'vitest'; import { copyStack } from '../src/adapter/copy.js'; import type { StackIdentity } from '../src/adapter/copy.js'; import { Stack } from '../src/stack/stack.js'; -import { InvalidAdapterError } from '../src/errors.js'; +import { InvalidAdapterError, OwnerMismatchError } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; import type { StackAdapter } from '../src/types/index.js'; @@ -72,35 +72,27 @@ describe('copyStack', () => { expect(created).toBe(false); }); - test('a blob already in the target conflicts with nothing', async () => { - const source = await sourceWith(0); - const fileId = await source.putBlob(new Uint8Array([5])); - const target = (await copyStack(source, async (identity) => { - const store = await create(identity); - await store.putBlob(new Uint8Array([5])); - return store; - })) as MemoryAdapter; - expect([...target.blobs.keys()]).toEqual([fileId]); - }); - // One rule for every way the store create() returns can differ from a new // store with the source's identity. See docs/spec/portability.md // § Copying a stack. describe('refuses a store that is not a new, empty one with the source identity, and closes it', () => { - const cases: [string, (identity: StackIdentity) => Promise][] = [ - [ - 'no importRecord()', - async (identity) => { - const store = await create(identity); - Object.defineProperty(store, 'importRecord', { value: undefined }); - return store; - }, - ], + type Make = (identity: StackIdentity) => Promise; + const without = + (method: 'importRecord' | 'listBlobs'): Make => + async (identity) => { + const store = await create(identity); + Object.defineProperty(store, method, { value: undefined }); + return store; + }; + const cases: [string, Make, typeof InvalidAdapterError | typeof OwnerMismatchError][] = [ + ['no importRecord()', without('importRecord'), InvalidAdapterError], + ['no listBlobs()', without('listBlobs'), InvalidAdapterError], [ 'another owner', async ({ timezone }) => create({ ownerEntityId: 'did:key:other', timezone }), + OwnerMismatchError, ], - ['a Record already in it', async () => sourceWith(1)], + ['a Record already in it', async () => sourceWith(1), InvalidAdapterError], [ 'the system Types Stack.open() seeds', async (identity) => { @@ -108,14 +100,24 @@ describe('copyStack', () => { await Stack.open(store); return store; }, + InvalidAdapterError, + ], + [ + 'a blob already in it', + async (identity) => { + const store = await create(identity); + await store.putBlob(new Uint8Array([5])); + return store; + }, + InvalidAdapterError, ], ]; - test.each(cases)('%s', async (_name, make) => { + test.each(cases)('%s', async (_name, make, error) => { const source = await sourceWith(1); const identity = { ownerEntityId: OWNER, timezone: 'UTC' }; const { made, create: bad } = badStore(() => make(identity)); - await expect(copyStack(source, bad)).rejects.toThrow(InvalidAdapterError); + await expect(copyStack(source, bad)).rejects.toThrow(error); expect(made.closed).toBe(true); }); }); diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 14460d1f..2501cc93 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -146,7 +146,7 @@ describe('Stack.open', () => { const schemaB = (await b.getType('_config@1'))!.schema; expect(schemaA).toEqual(schemaB); expect(schemaA).not.toBe(schemaB); - expect(schemaA.entityId).not.toBe(schemaB.entityId); + expect(schemaA.ownerEntityId).not.toBe(schemaB.ownerEntityId); }); test('timezone is undefined when not specified — no default', async () => { @@ -3599,23 +3599,23 @@ describe('_config protections', () => { // MemoryAdapter never materializes a _config record on its own (ownerEntityId // is a plain constructor field) — the guards under test operate on whatever // record exists at id "_config", so tests seed one directly. - async function seedConfig(entityId = 'owner-123', timezone = 'UTC') { + async function seedConfig(ownerEntityId = 'owner-123', timezone = 'UTC') { return adapter.createRecord({ id: CONFIG_ID, typeId: CONFIG_TYPE, createdAt: new Date(), updatedAt: new Date(), - content: { entityId, timezone }, + content: { ownerEntityId, timezone }, version: 1, }); } - test('patchContent() rejects a change to entityId', async () => { + test('patchContent() rejects a change to ownerEntityId', async () => { await seedConfig(); - await expect(stack.patchContent(CONFIG_ID, { entityId: 'someone-else' })).rejects.toThrow( + await expect(stack.patchContent(CONFIG_ID, { ownerEntityId: 'someone-else' })).rejects.toThrow( StackConflictError, ); - expect((await adapter.getRecord(CONFIG_ID))?.content.entityId).toBe('owner-123'); + expect((await adapter.getRecord(CONFIG_ID))?.content.ownerEntityId).toBe('owner-123'); }); test('patchContent() allows changing timezone', async () => { @@ -3624,9 +3624,11 @@ describe('_config protections', () => { expect((updated.content as Record).timezone).toBe('America/New_York'); }); - test('setting entityId to its current value is a no-op, not an error', async () => { + test('setting ownerEntityId to its current value is a no-op, not an error', async () => { await seedConfig('owner-123'); - await expect(stack.patchContent(CONFIG_ID, { entityId: 'owner-123' })).resolves.toBeDefined(); + await expect( + stack.patchContent(CONFIG_ID, { ownerEntityId: 'owner-123' }), + ).resolves.toBeDefined(); }); test('soft delete is rejected', async () => { @@ -3641,21 +3643,21 @@ describe('_config protections', () => { expect(await adapter.getRecord(CONFIG_ID)).not.toBeNull(); }); - test('restoreVersion() rejects a snapshot with a different entityId', async () => { + test('restoreVersion() rejects a snapshot with a different ownerEntityId', async () => { await seedConfig('owner-123'); // Simulates a snapshot that predates this guard, or a bypassed - // direct-adapter write — either way, a stored version whose entityId + // direct-adapter write — either way, a stored version whose ownerEntityId // disagrees with the live record's must not be restorable. plantVersion(CONFIG_ID, { version: 1, typeId: CONFIG_TYPE, - content: { entityId: 'someone-else', timezone: 'UTC' }, + content: { ownerEntityId: 'someone-else', timezone: 'UTC' }, updatedAt: new Date(), }); await expect(stack.restoreVersion(CONFIG_ID, 1)).rejects.toThrow(StackConflictError); }); - test('restoreVersion() allows a snapshot with the same entityId', async () => { + test('restoreVersion() allows a snapshot with the same ownerEntityId', async () => { await seedConfig('owner-123', 'UTC'); await stack.patchContent(CONFIG_ID, { timezone: 'America/New_York' }); const restored = await stack.restoreVersion(CONFIG_ID, 1); @@ -3673,10 +3675,10 @@ describe('_config protections', () => { expect(await stack.get(CONFIG_ID)).not.toBeNull(); }); - test('ScopedStack delegation: the owner cannot change entityId via scoped update either', async () => { + test('ScopedStack delegation: the owner cannot change ownerEntityId via scoped update either', async () => { await seedConfig('owner-123'); await expect( - stack.asEntity('owner-123').patchContent(CONFIG_ID, { entityId: 'someone-else' }), + stack.asEntity('owner-123').patchContent(CONFIG_ID, { ownerEntityId: 'someone-else' }), ).rejects.toThrow(StackConflictError); }); @@ -3685,36 +3687,36 @@ describe('_config protections', () => { await expect(stack.asEntity('owner-123').delete(CONFIG_ID)).rejects.toThrow(StackConflictError); }); - test('commitMigration() rejects a change to entityId', async () => { + test('commitMigration() rejects a change to ownerEntityId', async () => { await seedConfig('owner-123'); await stack.defineType({ id: '_config@2', name: 'Config', schema: { - entityId: { kind: 'string', required: true }, + ownerEntityId: { kind: 'string', required: true }, timezone: { kind: 'string' }, }, }); await expect( - stack.commitMigration(CONFIG_ID, '_config@2', { entityId: 'someone-else' }), + stack.commitMigration(CONFIG_ID, '_config@2', { ownerEntityId: 'someone-else' }), ).rejects.toThrow(StackConflictError); expect((await adapter.getRecord(CONFIG_ID))?.typeId).toBe(CONFIG_TYPE); // never committed }); - test('commitMigration() allows the same entityId', async () => { + test('commitMigration() allows the same ownerEntityId', async () => { await seedConfig('owner-123'); await stack.defineType({ id: '_config@2', name: 'Config', schema: { - entityId: { kind: 'string', required: true }, + ownerEntityId: { kind: 'string', required: true }, timezone: { kind: 'string' }, }, }); const migrated = await stack.commitMigration(CONFIG_ID, '_config@2', { - entityId: 'owner-123', + ownerEntityId: 'owner-123', timezone: 'America/New_York', }); expect(migrated.typeId).toBe('_config@2'); diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index 43cecf87..b13d94d4 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -23,7 +23,7 @@ */ import { - applyRecordSchema, + applyConnectionPragmas, writeNewStore, readStoredIdentity, SharedSqlRecordAdapter, @@ -60,8 +60,8 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { * Open the adapter for a DO instance, creating its storage on first use. * A DO id either already has a config record (reattach) or it doesn't * (first call — opts.ownerEntityId/timezone become the config), so there - * is no `create` option. Schema DDL is `CREATE TABLE IF NOT EXISTS`, so - * running it every call is idempotent and cheap. + * is no `create` option. A new store's tables are written in the same + * transaction as its config record, so storage holds a whole store or none. * * DO SQLite manages its own durability and rejects PRAGMA journal_mode * outright ("not authorized") — verified against the real runtime, not @@ -74,7 +74,7 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { const exec = new DurableObjectSqliteExecutor(storage); const where = 'the Durable Object storage'; let identity = readStoredIdentity(exec, where); - applyRecordSchema(exec, { wal: false }); + applyConnectionPragmas(exec, { wal: false }); if (identity) { if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== identity.ownerEntityId) { throw new OwnerMismatchError(opts.ownerEntityId, identity.ownerEntityId, where); diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index a28914e9..a94a2283 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -26,9 +26,9 @@ */ import { DatabaseSync } from './node-sqlite.js'; -import { existsSync } from 'fs'; +import { existsSync, rmSync } from 'fs'; import { - applyRecordSchema, + applyConnectionPragmas, acquireLock, releaseLock, writeNewStore, @@ -103,8 +103,8 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: NativeSQLiteRecordAdapterOpenOptions, ): Promise { const mode: StoreCreateMode = opts.create ?? 'never'; - // `exists` is whether a stack is there: a file holding its _config - // record. See docs/spec/adapters.md § Construction. + // `exists` is whether the file holds a stack. See docs/spec/adapters.md + // § Construction. const checkMode = (exists: boolean): void => { if (!exists && mode === 'never') { throw new Error( @@ -119,35 +119,20 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { ); } }; - const resolveOwner = async (): Promise => { - const owner = - typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - if (!owner) { - throw new Error( - `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`, - ); - } - return owner; - }; - // No file is certainly no stack, so that case is judged before the lock, - // and its provider resolved there so a failing one leaves nothing held. - let newOwner: string | undefined; - if (!existsSync(opts.path)) { - checkMode(false); - newOwner = await resolveOwner(); - } + // Judged before opening, which would create the file to say it is missing. + if (mode === 'never' && !existsSync(opts.path)) checkMode(false); acquireLock(opts.path, opts.force); + // Under the lock, so no other process can have made the file meanwhile. + const madeFile = !existsSync(opts.path); let db: DatabaseSync | undefined; try { - // Re-read under the lock: another process may have created it meanwhile. - const file = existsSync(opts.path); db = new DatabaseSync(opts.path); const exec = new NativeSqliteExecutor(db); - const stored = file ? readStoredIdentity(exec, `"${opts.path}"`) : null; + const stored = readStoredIdentity(exec, `"${opts.path}"`); checkMode(stored !== null); - applyRecordSchema(exec, { wal: true }); + applyConnectionPragmas(exec, { wal: true }); let identity: StackIdentity; if (stored) { identity = stored; @@ -162,14 +147,26 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { ); } } else { - // A file with no _config is a stack not yet created, so its owner is - // resolved here, under the lock the catch below releases. - newOwner ??= await resolveOwner(); - identity = writeNewStore(exec, { ownerEntityId: newOwner, timezone: opts.timezone }); + // Resolved only now a store is being created, under the lock the catch + // below releases, along with any file this open made. + const owner = + typeof opts.ownerEntityId === 'function' + ? await opts.ownerEntityId() + : opts.ownerEntityId; + if (!owner) { + throw new Error( + `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`, + ); + } + identity = writeNewStore(exec, { ownerEntityId: owner, timezone: opts.timezone }); } return new NativeSQLiteRecordAdapter(opts.path, db, exec, identity); } catch (err) { db?.close(); + // A failed open leaves the filesystem as it found it. + if (madeFile) { + for (const suffix of ['', '-wal', '-shm']) rmSync(`${opts.path}${suffix}`, { force: true }); + } releaseLock(opts.path); throw err; } diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 963fc5da..c781d251 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -279,40 +279,41 @@ describe('open create modes', () => { expect(table).toBeUndefined(); }); - // A stack exists exactly when its file holds _config, so a file an - // interrupted create left without one is a stack not yet created. + // A failed create leaves nothing behind, so a later open finds the store + // missing and creates it. // See docs/spec/adapters.md § Construction. - describe('a file holding tables but no config record', () => { - const halfMade = async () => { - await (await initAdapter()).close(); - const db = new DatabaseSync(dbPath); - db.exec(`DELETE FROM records WHERE id = '_config'; DELETE FROM format;`); - db.close(); - }; - - test.each(['ifMissing', 'exclusive'] as const)("is created under '%s'", async (create) => { - await halfMade(); - const provider = vi.fn(() => 'owner-new'); + test.each(['ifMissing', 'exclusive'] as const)( + "a create whose owner provider fails leaves nothing a later '%s' open trips on", + async (create) => { + await expect( + NativeSQLiteRecordAdapter.open({ + path: dbPath, + create: 'ifMissing', + ownerEntityId: () => { + throw new Error('no key to hand'); + }, + }), + ).rejects.toThrow('no key to hand'); + expect(existsSync(dbPath)).toBe(false); + expect(existsSync(`${dbPath}.lock`)).toBe(false); const adapter = await NativeSQLiteRecordAdapter.open({ path: dbPath, create, - ownerEntityId: provider, + ownerEntityId: 'owner-new', }); expect(adapter.ownerEntityId).toBe('owner-new'); - expect(provider).toHaveBeenCalledOnce(); - await adapter.close(); - expect((await NativeSQLiteRecordAdapter.open({ path: dbPath })).ownerEntityId).toBe( - 'owner-new', - ); - }); + }, + ); - test("is refused as missing under 'never', and releases the lock", async () => { - await halfMade(); - await expect(NativeSQLiteRecordAdapter.open({ path: dbPath })).rejects.toThrow( - /no database found/, - ); - expect(existsSync(`${dbPath}.lock`)).toBe(false); - }); + test('a store holding its tables but no _config record is refused, not created over', async () => { + await (await initAdapter()).close(); + const db = new DatabaseSync(dbPath); + db.exec(`DELETE FROM records WHERE id = '_config'`); + db.close(); + await expect( + NativeSQLiteRecordAdapter.open({ path: dbPath, create: 'ifMissing', ownerEntityId: 'x' }), + ).rejects.toThrow(/no _config record/); + expect(existsSync(`${dbPath}.lock`)).toBe(false); }); test('a matching plain-string owner opens an existing store', async () => { diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index e2f71fb7..12430775 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,7 +1,7 @@ import { FormatMismatchError } from '@haverstack/core/adapter'; import type { StackIdentity } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; -import { FORMAT_VERSION } from './schema.js'; +import { FORMAT_VERSION, FTS5_SCHEMA_SQL, RECORD_SCHEMA_SQL } from './schema.js'; /** The one place the singleton record's id is spelled. */ const CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`; @@ -10,40 +10,44 @@ const hasTable = (exec: SqlExecutor, name: string): boolean => exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, [name]) !== undefined; /** - * Writes what a new store is created with — its `format` row and the - * singleton _config@1 record — in one transaction, so neither exists without - * the other, and returns the identity so a caller that has just created a - * database reaches what an existing one yields without a read-back. Only - * valid on a freshly-created database. + * Writes everything a new store is created with — its tables, its `format` + * row and the singleton _config@1 record — in one transaction, so a store is + * either whole or not there. Returns the identity so a caller that has just + * created a database reaches what an existing one yields without a + * read-back. Only valid on a database readStoredIdentity() found missing. */ export const writeNewStore = (exec: SqlExecutor, identity: StackIdentity): StackIdentity => { const now = Date.now(); exec.transaction(() => { + exec.exec(RECORD_SCHEMA_SQL); + exec.exec(FTS5_SCHEMA_SQL); exec.run('INSERT INTO format (id, version) VALUES (1, ?)', [FORMAT_VERSION]); exec.run( `INSERT INTO records (id, type_id, created_at, updated_at, content, version) VALUES ('_config', '_config@1', ?, ?, ?, 1)`, - [now, now, JSON.stringify({ entityId: identity.ownerEntityId, timezone: identity.timezone })], + [now, now, JSON.stringify(identity)], ); }); return identity; }; /** - * The identity a store holds, or null when it holds none: a store exists - * exactly when it has its _config@1 record, so one left with tables and no - * record is new. An existing store is refused in any layout but - * FORMAT_VERSION, before the schema is applied to it — a store moves to - * another layout only by being copied into one. Reads only. + * The identity a store holds, or null when the database holds no store. + * Stores are written whole, so a database with neither a `records` nor a + * `format` table holds none, and one with either is a store: it must name + * FORMAT_VERSION, or it is refused — a store moves to another layout only by + * being copied into one. Reads only, so a refused store is left untouched. * See docs/spec/adapters.md § Construction. */ export const readStoredIdentity = (exec: SqlExecutor, where: string): StackIdentity | null => { - const row = hasTable(exec, 'records') ? exec.get<{ content: string }>(CONFIG_ROW_SQL) : undefined; - if (!row) return null; - const version = hasTable(exec, 'format') + const hasFormat = hasTable(exec, 'format'); + if (!hasFormat && !hasTable(exec, 'records')) return null; + const version = hasFormat ? exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version : undefined; if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); - const content = JSON.parse(row.content) as { entityId: string; timezone?: string }; - return { ownerEntityId: content.entityId, timezone: content.timezone }; + const row = exec.get<{ content: string }>(CONFIG_ROW_SQL); + if (!row) throw new Error(`Cannot open ${where}: it holds no _config record.`); + const content = JSON.parse(row.content) as StackIdentity; + return { ownerEntityId: content.ownerEntityId, timezone: content.timezone }; }; diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index fc74f134..2bff0c77 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -124,9 +124,9 @@ const versionRowValues = (version: RecordVersion): unknown[] => [ ]; /** - * The `journal` columns, and the one INSERT both journal writes run: every - * column bound in this order, `seq` from `seqSql` — a bound value for an - * entry as recorded, the log's next number for an append. + * The `journal` columns, and the one INSERT both journal writes run, every + * column bound in this order: an entry as recorded and an append differ only + * in where their values come from. */ const JOURNAL_COLUMNS = [ 'record_id', @@ -142,9 +142,8 @@ const JOURNAL_COLUMNS = [ 'parent_id', ] as const; -const insertJournalSql = (seqSql: string): string => - `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) - VALUES (${JOURNAL_COLUMNS.map((column) => (column === 'seq' ? seqSql : '?')).join(', ')})`; +const INSERT_JOURNAL_SQL = `INSERT INTO journal (${JOURNAL_COLUMNS.join(', ')}) + VALUES (${JOURNAL_COLUMNS.map(() => '?').join(', ')})`; /** What the record row reports for an entry, beside what the caller supplied. */ type JournalRowState = { version: number; typeId: string; parentId: string | null }; @@ -751,9 +750,9 @@ export class SharedSqlRecordLogic { /** * Append one entry, stamped from the row as it now stands, so callers run - * this inside their write's transaction, after the write. `seq` is - * allocated in the INSERT itself, so no caller ever holds one it expects - * to be free. See docs/spec/journal.md § Ordering. + * this inside their write's transaction, after the write. `seq` is read + * from the log's maximum in that same transaction, so no caller ever holds + * one it expects to be free. See docs/spec/journal.md § Ordering. */ private appendJournal(recordId: string, entry: JournalEntryInput | undefined): void { if (!entry) return; @@ -767,10 +766,13 @@ export class SharedSqlRecordLogic { }>('SELECT version, type_id, parent_id FROM records WHERE id = ?', [recordId]); if (!row) throw recordNotFound(recordId); - // The subselect's one parameter is the record id, bound in seq's place. + const { seq } = this.exec.get<{ seq: number }>( + 'SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM journal WHERE record_id = ?', + [recordId], + )!; this.exec.run( - insertJournalSql('(SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?)'), - journalValues(recordId, recordId, new Date(), entry, { + INSERT_JOURNAL_SQL, + journalValues(recordId, seq, new Date(), entry, { version: row.version, typeId: row.type_id, parentId: row.parent_id, @@ -781,7 +783,7 @@ export class SharedSqlRecordLogic { /** A whole entry as recorded, `seq` and `at` included — importRecord()'s half of the journal. */ private insertJournalRow(recordId: string, entry: RecordJournalEntry): void { this.exec.run( - insertJournalSql('?'), + INSERT_JOURNAL_SQL, journalValues(recordId, entry.seq, entry.at, entry, { version: entry.version, typeId: entry.typeId, diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index 23159475..7e7f9bc7 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -16,7 +16,7 @@ * for lock.ts's plain functions — so avoiding the import (not just the * unused export) has to happen at this file's level. */ -export { applyRecordSchema, FORMAT_VERSION } from './schema.js'; +export { applyConnectionPragmas, FORMAT_VERSION } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; export { writeNewStore, readStoredIdentity } from './config.js'; diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index b012e979..c9a56d5a 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -235,7 +235,7 @@ export const PRAGMA_FOREIGN_KEYS_ON = `PRAGMA foreign_keys = ON;`; */ export const PRAGMA_JOURNAL_MODE_WAL = `PRAGMA journal_mode = WAL;`; -export type RecordSchemaOptions = { +export type ConnectionOptions = { /** * Whether to put the connection in WAL mode. False for an engine that * owns its own durability and rejects the pragma outright, as Durable @@ -245,15 +245,12 @@ export type RecordSchemaOptions = { }; /** - * Brings a records database up to the schema every SQLite record adapter - * expects: foreign keys on, then the tables and the FTS5 index. Every - * statement is idempotent, so an engine that cannot tell - * whether a store exists can run this on each attach. Run it after - * readStoredIdentity(), which refuses a store in another layout untouched. + * The settings a connection holds rather than the file, so every open + * applies them. They run outside any transaction: `foreign_keys` is a no-op + * inside one, and `journal_mode` cannot change in one. The tables are + * writeNewStore()'s, written once, with the rest of a new store. */ -export const applyRecordSchema = (exec: SqlExecutor, opts: RecordSchemaOptions): void => { +export const applyConnectionPragmas = (exec: SqlExecutor, opts: ConnectionOptions): void => { exec.exec(PRAGMA_FOREIGN_KEYS_ON); if (opts.wal) exec.exec(PRAGMA_JOURNAL_MODE_WAL); - exec.exec(RECORD_SCHEMA_SQL); - exec.exec(FTS5_SCHEMA_SQL); }; diff --git a/packages/sqlite-shared/tests/config.test.ts b/packages/sqlite-shared/tests/config.test.ts index b2568e74..f265eecc 100644 --- a/packages/sqlite-shared/tests/config.test.ts +++ b/packages/sqlite-shared/tests/config.test.ts @@ -3,7 +3,7 @@ import { DatabaseSync } from 'node:sqlite'; import { FormatMismatchError } from '@haverstack/core/adapter'; import { writeNewStore, readStoredIdentity } from '../src/config.js'; import type { SqlExecutor } from '../src/executor.js'; -import { applyRecordSchema, FORMAT_VERSION } from '../src/schema.js'; +import { FORMAT_VERSION } from '../src/schema.js'; const executor = (db: DatabaseSync): SqlExecutor => ({ exec: (sql) => db.exec(sql), @@ -25,31 +25,35 @@ const executor = (db: DatabaseSync): SqlExecutor => ({ }, }); -/** A schema-applied, empty database: what a store looks like before it is created. */ -const blank = () => { - const exec = executor(new DatabaseSync(':memory:')); - applyRecordSchema(exec, { wal: false }); +const empty = () => executor(new DatabaseSync(':memory:')); + +/** A store as a create leaves it. */ +const created = (timezone: string | undefined) => { + const exec = empty(); + writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone }); return exec; }; const tables = (exec: SqlExecutor) => exec.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE type = 'table'`).length; -describe('readStoredIdentity', () => { - test('is null for a database with no tables at all', () => { - expect(readStoredIdentity(executor(new DatabaseSync(':memory:')), 'here')).toBeNull(); - }); +/** The error a call throws, or undefined when it returns. */ +const thrown = (fn: () => unknown): unknown => { + try { + fn(); + return undefined; + } catch (e) { + return e; + } +}; - // A store exists exactly when it has its _config record, so one left with - // tables and no record by an interrupted create is created afresh. - test('is null for a store with tables but no config record', () => { - expect(readStoredIdentity(blank(), 'here')).toBeNull(); +describe('readStoredIdentity', () => { + test('is null for a database holding no store', () => { + expect(readStoredIdentity(empty(), 'here')).toBeNull(); }); test('reads back the identity a create wrote, an absent timezone kept as a key', () => { - const exec = blank(); - writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: undefined }); - const identity = readStoredIdentity(exec, 'here'); + const identity = readStoredIdentity(created(undefined), 'here'); expect(identity).toEqual({ ownerEntityId: 'did:key:owner', timezone: undefined }); expect('timezone' in identity!).toBe(true); }); @@ -59,17 +63,10 @@ describe('readStoredIdentity', () => { ['no version', 'DELETE FROM format', undefined], ['no format table', 'DROP TABLE format', undefined], ] as const)('refuses a store naming %s, writing nothing', (_name, sql, actual) => { - const exec = blank(); - writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + const exec = created('UTC'); exec.exec(sql); const before = tables(exec); - const err = (() => { - try { - return readStoredIdentity(exec, 'here'); - } catch (e) { - return e; - } - })(); + const err = thrown(() => readStoredIdentity(exec, 'here')); expect(err).toBeInstanceOf(FormatMismatchError); expect(err).toMatchObject({ expected: FORMAT_VERSION, actual, where: 'here' }); expect(tables(exec)).toBe(before); @@ -78,20 +75,20 @@ describe('readStoredIdentity', () => { describe('writeNewStore', () => { test('returns the identity it wrote, matching what a later read yields', () => { - const exec = blank(); + const exec = empty(); const written = writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); expect(written).toEqual(readStoredIdentity(exec, 'here')); }); - test('writes the format row and the config record together, or neither', () => { - const exec = blank(); - exec.run( - `INSERT INTO records (id, type_id, created_at, updated_at, content, version) - VALUES ('_config', '_config@1', 0, 0, '{}', 1)`, - ); + // The tables, the format row and _config are one fact: a create failing on + // its format row leaves none of the tables it had already made. + test('writes the whole store or none of it', () => { + const exec = empty(); + exec.exec(`CREATE TABLE format (id INTEGER PRIMARY KEY, version INTEGER NOT NULL)`); + exec.run('INSERT INTO format (id, version) VALUES (1, 1)'); expect(() => writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }), ).toThrow(); - expect(exec.get('SELECT 1 FROM format')).toBeUndefined(); + expect(tables(exec)).toBe(1); }); }); diff --git a/packages/wire-types/src/discovery.ts b/packages/wire-types/src/discovery.ts index 661ac527..f17e9961 100644 --- a/packages/wire-types/src/discovery.ts +++ b/packages/wire-types/src/discovery.ts @@ -24,7 +24,7 @@ export const WIRE_PROTOCOL_VERSION = '1.0'; /** GET /.well-known/stack. See docs/spec/wire-format.md § Discovery. */ export type DiscoveryResponse = { version: string; - entityId: string; + ownerEntityId: string; timezone?: string; capabilities?: DiscoveryCapabilities; auth?: DiscoveryAuth; diff --git a/packages/wire-types/tests/changes.test.ts b/packages/wire-types/tests/changes.test.ts index 83c59c78..7300a14c 100644 --- a/packages/wire-types/tests/changes.test.ts +++ b/packages/wire-types/tests/changes.test.ts @@ -37,7 +37,7 @@ const change = (overrides: Partial = {}): RecordChange => ({ const discovery = (overrides: Partial = {}): DiscoveryResponse => ({ version: WIRE_PROTOCOL_VERSION, - entityId: OWNER, + ownerEntityId: OWNER, capabilities: { filter: { content: 'path', From 817b6ebd33324c44e69454c551b036c2fca48950 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:53:39 +0000 Subject: [PATCH 15/16] feat: _config travels like every Record, and SQLite stores open one way 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 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 4 +- docs/spec/adapters.md | 20 +-- docs/spec/identity.md | 2 +- docs/spec/portability.md | 18 +-- packages/adapter-conformance/src/copy.ts | 20 ++- .../tests/copy-memory.test.ts | 2 +- packages/adapter-local/tests/copy.test.ts | 8 +- packages/core/src/adapter/copy.ts | 40 ++++-- packages/core/src/adapter/index.ts | 5 +- packages/core/src/adapter/seed.ts | 38 ++++++ packages/core/src/errors.ts | 4 +- packages/core/src/testing.ts | 30 +++- packages/core/src/types/adapter.ts | 14 ++ packages/core/tests/change-events.test.ts | 11 -- packages/core/tests/copy.test.ts | 45 +++--- packages/core/tests/stack.test.ts | 28 +--- .../record-adapter-do-sqlite/src/index.ts | 59 ++++---- packages/record-adapter-sqlite/src/index.ts | 82 +++-------- .../tests/record.test.ts | 12 -- packages/sqlite-shared/src/config.ts | 129 +++++++++++++----- packages/sqlite-shared/src/record-logic.ts | 18 ++- packages/sqlite-shared/src/record.ts | 12 +- packages/sqlite-shared/tests/config.test.ts | 12 +- 23 files changed, 353 insertions(+), 260 deletions(-) create mode 100644 packages/core/src/adapter/seed.ts diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index 7fcbc4e5..8a7b4463 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -9,9 +9,9 @@ A stack can be copied whole between adapters, authorship and history included. -- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes with the source's identity, and returns it open. Both ends must list their blobs, and the target must hold nothing yet, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. +- `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes from the source's `_config` record and its history, and returns it open. `_config` travels as every Record does. Both ends must list their blobs, and the target must hold nothing yet, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. -- `StackIdentity` and `CreateStore` are exported beside it. +- `StackIdentity`, `CreateStore`, `StoreSeed`, `newStoreSeed()` and `identityOf()` are exported beside it. Every adapter that creates stores takes a `seed` in place of `ownerEntityId` and `timezone`: the `_config` record, with its history, a new store is created holding. `MemoryAdapter` now holds a real `_config` record. - Every SQLite store names its layout in a one-row `format` table, and opening one in another layout throws the new `FormatMismatchError` (from `@haverstack/core/adapter`). A new store is written whole, tables included, in one transaction, and a failed `NativeSQLiteRecordAdapter.open()` removes a file it created. - `OwnerMismatchError` reads ` is owned by "X", not "Y"`, and `copyStack()` throws it too. - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index c878ef30..fa1eb3c2 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -119,14 +119,16 @@ All adapters support the full Record API. Performance guarantees differ; correct An adapter that holds a stack's identity has exactly one entry point, an async static `open(opts)`, and no public constructor. Whether `open()` may create a new stack is an option, not a separate method. Blob adapters hold no identity and keep their public constructors. `Stack.open(adapter)` stays a separate step, because `combineAdapters()` sits between the two. -| Adapter | Entry point | `create` | -| --------------------------------------------------- | --------------------------------------------- | ------------------------------------------------- | -| `LocalAdapter`, `NativeSQLiteRecordAdapter` | `open({ path, create?, ownerEntityId?, … })` | `'never'` (default), `'ifMissing'`, `'exclusive'` | -| `DoSQLiteRecordAdapter` | `open(storage, { ownerEntityId, timezone? })` | none: always creates if missing | -| `APIAdapter` | `open({ url, ownerEntityId?, … })` | none: a client never creates | -| `MemoryAdapter`, `IncapableMemoryAdapter` (testing) | `open({ ownerEntityId, timezone? })` | none: always new | +| Adapter | Entry point | `create` | +| --------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------- | +| `LocalAdapter`, `NativeSQLiteRecordAdapter` | `open({ path, create?, ownerEntityId? or seed?, … })` | `'never'` (default), `'ifMissing'`, `'exclusive'` | +| `DoSQLiteRecordAdapter` | `open(storage, { ownerEntityId, timezone? } or { seed })` | none: always creates if missing | +| `APIAdapter` | `open({ url, ownerEntityId?, … })` | none: a client never creates | +| `MemoryAdapter`, `IncapableMemoryAdapter` (testing) | `open({ ownerEntityId, timezone? } or { seed })` | none: always new | -**A store is written whole, so it is either present or missing.** A new store's tables, its `format` row and its `_config` record are written in one transaction, and opening an existing one writes none of them. A database holding none of a store's tables is missing; one holding any is a store, present exactly when it is whole. An `open()` that fails leaves things as it found them: `NativeSQLiteRecordAdapter` removes a file it created rather than leaving it for the next open to trip on. +**Every store is created holding one Record, `_config`, which says whose it is.** `open()` takes it in one of two forms: `ownerEntityId` and `timezone`, from which `newStoreSeed()` in `@haverstack/core/adapter` makes a `_config` at version 1 with no past; or a `seed` — a `StoreSeed`, a `_config` record with its history, as [a copy](./portability.md#copying-a-stack) hands over its source's — written as `importRecord()` writes any Record. A seed creates a new store, so `NativeSQLiteRecordAdapter` takes one only with `'exclusive'`; given storage that already holds a store, as the DO adapter can be, its owner is checked against the seed's. `MemoryAdapter` holds its `_config` the same way, so every adapter that stores records holds one. + +**A store is written whole, so it is either present or missing.** A new store's tables, its `format` row and its `_config` record are written in one transaction, and opening an existing one writes none of them. So the `format` table alone says whether a database holds a store: one without it is missing, and one with it holds a whole store. An `open()` that fails leaves things as it found them: `NativeSQLiteRecordAdapter` removes a file it created rather than leaving it for the next open to trip on. `create` names what `open()` does when the store is missing or present, like `O_CREAT`/`O_EXCL`: @@ -136,7 +138,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s `ownerEntityId` follows one rule everywhere it is accepted: -- **Required whenever `open()` may create.** The type makes it required for `'ifMissing'` and `'exclusive'`, on the DO adapter and on `MemoryAdapter`. No adapter defaults it to `''`. +- **Required whenever `open()` may create from it.** The type makes it required for `'ifMissing'` and `'exclusive'`, on the DO adapter and on `MemoryAdapter`, unless a `seed` is given instead. No adapter defaults it to `''`; every SQLite engine opens through one shared path that refuses an empty one. - **A plain string or a lazy `() => string | Promise`** on every creating path, called only when a store is actually created. - **A plain string is checked against an existing store's owner**, in every mode and on every adapter; a mismatch throws `OwnerMismatchError` (`expected`, `actual`, and a `where` naming the path, URL or Durable Object) and releases anything `open()` had acquired. A lazy provider is never called just to compare. @@ -146,7 +148,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s ## Conformance -The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents. `runCopyConformance()` takes a `create()` for a full adapter and checks the [copy](./portability.md#copying-a-stack) a move between hosts makes, reading from one of its stores and writing into another; `adapter-local` runs it against itself. +The invariants above are easy to state in prose and easy to violate silently — FTS5's external-content index going stale, a content path matching an unfiltered superset, a cursor replayed under the wrong sort. **`@haverstack/adapter-conformance`** is the runnable form of this document: `runRecordAdapterConformance()` and `runBlobAdapterConformance()` take an `open()` for your adapter and its declared `capabilities`, and register a vitest suite checking exactly the invariants named here, gated so a capability you don't declare is never penalized for its absence. `record-adapter-sqlite` and `blob-adapter-disk` both run it against themselves (`tests/conformance.test.ts`), alongside `@haverstack/core`'s own `MemoryAdapter` — three independent implementations passing the same suite is the check that the suite describes the contract rather than one adapter's accidents. `runCopyConformance()` takes a `create(seed)` for a full adapter and checks the [copy](./portability.md#copying-a-stack) a move between hosts makes, reading from one of its stores and writing into another; `adapter-local` runs it against itself. ## SQLite-backed adapters diff --git a/docs/spec/identity.md b/docs/spec/identity.md index 3f2be7ec..479ceb82 100644 --- a/docs/spec/identity.md +++ b/docs/spec/identity.md @@ -1,6 +1,6 @@ # Identity -Everywhere the system means "who" — a permission element's `grantee`, `GrantContent.grantee`, group membership associations, a token's session, every [Actor](./data-model.md#actor) (`createdBy`, `updatedBy`, a change's `actor`), `_config.entityId` — the value is a **DID** ([Decentralized Identifier](https://www.w3.org/TR/did-core/)) string, e.g. `did:key:z6Mk...`. +Everywhere the system means "who" — a permission element's `grantee`, `GrantContent.grantee`, group membership associations, a token's session, every [Actor](./data-model.md#actor) (`createdBy`, `updatedBy`, a change's `actor`), `_config.ownerEntityId` — the value is a **DID** ([Decentralized Identifier](https://www.w3.org/TR/did-core/)) string, e.g. `did:key:z6Mk...`. **Why DIDs, why no provider.** Stacks are for individuals and small groups with cohesive identity, not a global directory of principals. Identity must be _verifiable without a provider_, but doesn't need global discovery infrastructure. Once central providers are ruled out and a domain is undesirable as a hard requirement (a domain is rented identity with a renewal-date failure mode), one primitive remains: cryptographic self-certification. An identity is a keypair; claims are signatures; anyone can verify without asking anyone. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index f31085c2..d9e319aa 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -7,24 +7,24 @@ ```ts import { copyStack } from '@haverstack/core/adapter'; -const target = await copyStack(from, (identity) => createStore(identity)); +const target = await copyStack(from, (seed) => createStore(seed)); -type StackIdentity = { ownerEntityId: EntityId; timezone: string | undefined }; +type StoreSeed = { config: StackRecord; history: RecordHistory }; ``` -**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it into a new store, which it asks `create` to make with the source's identity and returns open. Which source it is given, and what kind of store `create` makes, is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has a format-conversion method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. +**One primitive carries export, import and every move between storage engines.** `copyStack()` reads a whole stack from one `StackAdapter` and writes it into a new store, which it asks `create` to make from the source's `_config` record and returns open. Which source it is given, and what kind of store `create` makes, is the only difference between exporting to [the portable format](#the-portable-format), importing from it, and moving a stack from one SQLite engine to another. No adapter has a format-conversion method of its own, so N adapters need N implementations of the [write below](#writing-history), not one converter per pair of formats. What it carries is everything the source holds: - **Every Type**, through `listTypes()` and `saveType()`. -- **Every Record but `_config`**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is the store's own: no query reaches it, and `create` made the target with what it holds. +- **Every Record**, soft-deleted and unlisted ones included, each with every version snapshot and every journal entry, through [`importRecord()`](#writing-history). `_config` is among them: no query reaches it, so it is read by id, and with its history it is the seed `create` makes the target from ([Adapters § Construction](./adapters.md#construction)). The target's identity is therefore what the source's `_config` holds now, whatever its adapter read when it was opened. - **Every blob**, through `listBlobs()`, which both ends of a copy must have. **Each is read as the store enumerates it**, never by what a Record says, so nothing is filtered on the way: what is garbage is the target's own [collection](./attachments.md#garbage-collection) to decide, run when its owner chooses. Nothing a purge destroyed is carried, because the source no longer holds it. **It is a full-trust operation, like any direct adapter access.** It writes authorship and history as recorded, which no write through `Stack` may: a scoped create is stamped with its requester, and nothing outside a write appends a snapshot or a journal entry. It takes adapters rather than stacks because those are where that trust already lives ([Adapters § Concurrency & storage ownership](./adapters.md#concurrency--storage-ownership)). It emits no [change events](./events.md) and appends no journal entry: it reproduces changes that already happened, each of which was announced and journaled when it did. -**The target is a store as its adapter creates one.** `create` receives the source's `ownerEntityId` and `timezone`, as any new store is opened with them ([Adapters § Construction](./adapters.md#construction)), and what it returns is checked as `open()` checks a store: its owner must be the source's, or the copy throws `OwnerMismatchError`, as `open()` does. `timezone` is passthrough metadata a store takes when it is created, so it is handed over and not checked. The store must also hold nothing yet — no Record, no Type, no blob — on an adapter with `importRecord()` and `listBlobs()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written; a store `Stack.open()` has already opened holds the [system Types](./data-model.md#system-types), so it is refused too. +**The target is a store as its adapter creates one.** `create` receives that seed, as any new store is created from one, and what it returns is checked as `open()` checks a store: its owner must be the seed's, or the copy throws `OwnerMismatchError`, as `open()` does. The store must also hold nothing else yet — no other Record, no Type, no blob — on an adapter with `importRecord()` and `listBlobs()`. Anything else is a mistake in `create`, refused with `InvalidAdapterError` before anything is written; a store `Stack.open()` has already opened holds the [system Types](./data-model.md#system-types), so it is refused too. **The source is not written during the copy.** Records are read a page at a time and each one's history separately, so a write landing partway through yields a target matching no moment of the source. Nothing enforces this: a local source's only writer is the process that owns it, which is the one copying. @@ -61,8 +61,8 @@ type RecordHistory = { **An export is a `LocalAdapter` store: a SQLite database, with an `attachments/` directory beside it.** It is a working stack, not an archive to unpack — `Stack.open()` reads it as it reads any local stack. ```ts -const portable = (path: string) => (identity: StackIdentity) => - LocalAdapter.open({ path, create: 'exclusive', ...identity }); +const portable = (path: string) => (seed: StoreSeed) => + LocalAdapter.open({ path, create: 'exclusive', seed }); // Export: the host's stack, into a new portable store. const exported = await copyStack(host, portable('./my-stack.db')); @@ -73,7 +73,7 @@ const exportedStack = await LocalAdapter.open({ path: './my-stack.db' }); await copyStack(exportedStack, createHostStore); ``` -The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as the tables and `_config`, and an adapter opening a store refuses one in another layout before writing to it, by the rules in [Adapters § Construction](./adapters.md#construction). The row is the store's own, like `_config`: a copy writes its target in the target's layout, which is how a stack moves to a newer one. +The layout is specified here so an export stays readable without the tool that wrote it, and a change to it changes this section in the same change; a test compares the tables below with the schema the SQLite adapters create. **Every SQLite store names its layout** in the one row of its `format` table, `id` `1`: `version` is `1` for the layout below. A change to the layout bumps it. The row is written in the same transaction as the tables and `_config`, and an adapter opening a store refuses one in another layout before writing to it, by the rules in [Adapters § Construction](./adapters.md#construction). The row is the store's own: a copy writes its target in the target's layout, which is how a stack moves to a newer one. **`attachments/`** holds a blob's bytes, named by the [64-character lowercase hex SHA-256](./attachments.md) of its contents. @@ -115,7 +115,7 @@ Every column of every table but `records_fts`, an FTS5 index over `records.conte | `types` | `id`, `base_id`, `version`, `name`, `schema`, `schema_hash`, `migrates_from`, `created_at` | | `content_index` | `record_id`, `field`, `value_rank`, `num_value`, `text_key`, `text_value`, `file_id` | -The `_config` row carries `{ ownerEntityId, timezone }` as its content: the stack's owner and timezone, which a store is created with and `copyStack()` hands to `create`. +The `_config` row carries `{ ownerEntityId, timezone }` as its content: the stack's owner and timezone, which every store is created holding. `content_index` and `records_fts` are rebuilt from `records.content`, so a reader may ignore them. A writer may not: a row whose content moved without them answers queries for content it no longer holds. They are part of the layout all the same, so a change to how either is built is a layout change like any other, made by copying into a new store, which rebuilds both as it writes. diff --git a/packages/adapter-conformance/src/copy.ts b/packages/adapter-conformance/src/copy.ts index 41df3db8..50fbf26c 100644 --- a/packages/adapter-conformance/src/copy.ts +++ b/packages/adapter-conformance/src/copy.ts @@ -11,15 +11,15 @@ */ import { describe, test, expect } from 'vitest'; -import { Stack } from '@haverstack/core'; +import { Stack, SYSTEM_TYPES } from '@haverstack/core'; import type { FileId, StackRecord } from '@haverstack/core'; -import { copyStack, resolveQuery } from '@haverstack/core/adapter'; +import { copyStack, newStoreSeed, resolveQuery } from '@haverstack/core/adapter'; import type { CreateStore, StackAdapter } from '@haverstack/core/adapter'; export interface CopyConformanceOptions { /** Used in describe() titles, so pick something that reads well there. */ name: string; - /** Create a new, empty store with this identity: the copy's target. */ + /** Create a new store from this seed: the copy's target. */ create: CreateStore; /** Create the store the stack is written into and copied from. Defaults to `create`. */ source?: CreateStore; @@ -31,7 +31,7 @@ const OWNER = 'did:key:conformance-owner'; const ALICE = { subjectId: 'did:key:conformance-alice' }; const BOB = { subjectId: 'did:key:conformance-bob', principalId: 'did:key:conformance-app' }; const NOTE = 'org.haverstack.conformance/copied-note@1'; -const IDENTITY = { ownerEntityId: OWNER, timezone: 'Europe/Paris' }; +const SEED = newStoreSeed({ ownerEntityId: OWNER, timezone: 'Europe/Paris' }); /** Set order aside: an element set has no order to preserve. */ const bySerialized = (list: T[] | undefined): T[] | undefined => @@ -52,6 +52,9 @@ async function contentsOf(adapter: StackAdapter, fileIds: FileId[]) { records.push(...page.records); cursor = page.cursor ?? undefined; } while (cursor); + // `_config` is no query's, so it is read by id: it travels like the rest. + const config = await adapter.getRecord(SYSTEM_TYPES.CONFIG); + if (config) records.push(config); records.sort((a, b) => a.id.localeCompare(b.id)); const blobs: Record = {}; @@ -78,7 +81,8 @@ async function contentsOf(adapter: StackAdapter, fileIds: FileId[]) { * A stack touched by every kind of write: foreign authorship, edits by an * app acting for someone, containment, tags, sharing, listing, an * attachment, bytes a content field names with no metadata record, a soft - * delete and a purge. Answers with the blobs it wrote. + * delete, a purge, and a `_config` with a past of its own. Answers with the + * blobs it wrote. */ async function writePast(adapter: StackAdapter): Promise { const stack = await Stack.open(adapter, { ownerProfile: { name: 'Conformance Owner' } }); @@ -88,6 +92,8 @@ async function writePast(adapter: StackAdapter): Promise { schema: { title: { kind: 'text' }, cover: { kind: 'file-ref' } }, }); + await stack.patchContent(SYSTEM_TYPES.CONFIG, { timezone: 'Asia/Tokyo' }, { actor: BOB }); + const box = await stack.create(NOTE, { title: 'box' }); const note = await stack.create(NOTE, { title: 'v1' }, { createdBy: ALICE }); await stack.patchContent(note.id, { title: 'v2' }, { actor: BOB }); @@ -117,7 +123,7 @@ export function runCopyConformance(options: CopyConformanceOptions): void { describe(`stack copy conformance: ${name}`, () => { test('implements importRecord()', async () => { - const store = await create(IDENTITY); + const store = await create(SEED); try { expect(store.importRecord).toBeTypeOf('function'); } finally { @@ -126,7 +132,7 @@ export function runCopyConformance(options: CopyConformanceOptions): void { }); test('a copy carries every Record, version, journal entry, Type and blob unchanged', async () => { - const source = await createSource(IDENTITY); + const source = await createSource(SEED); let target: StackAdapter | undefined; try { const fileIds = await writePast(source); diff --git a/packages/adapter-conformance/tests/copy-memory.test.ts b/packages/adapter-conformance/tests/copy-memory.test.ts index 6e22857f..1bc33ac9 100644 --- a/packages/adapter-conformance/tests/copy-memory.test.ts +++ b/packages/adapter-conformance/tests/copy-memory.test.ts @@ -7,5 +7,5 @@ import { runCopyConformance } from '../src/copy.js'; runCopyConformance({ name: 'MemoryAdapter', - create: ({ ownerEntityId, timezone }) => MemoryAdapter.open({ ownerEntityId, timezone }), + create: (seed) => MemoryAdapter.open({ seed }), }); diff --git a/packages/adapter-local/tests/copy.test.ts b/packages/adapter-local/tests/copy.test.ts index 6d8d1725..676b3967 100644 --- a/packages/adapter-local/tests/copy.test.ts +++ b/packages/adapter-local/tests/copy.test.ts @@ -14,21 +14,19 @@ import { LocalAdapter } from '../src/index.js'; const dirs = new Map(); -const local: CreateStore = async ({ ownerEntityId, timezone }) => { +const local: CreateStore = async (seed) => { const dir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); const adapter = await LocalAdapter.open({ create: 'exclusive', path: join(dir, 'stack.db'), - ownerEntityId, - timezone, + seed, }); dirs.set(adapter, dir); return adapter; }; -const memory: CreateStore = ({ ownerEntityId, timezone }) => - MemoryAdapter.open({ ownerEntityId, timezone }); +const memory: CreateStore = (seed) => MemoryAdapter.open({ seed }); const close = async (adapter: StackAdapter) => { await adapter.close?.(); diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index b5ef7adc..2b3b0f5e 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -2,9 +2,9 @@ * Copying a stack * ------------------------------------------------------- * One stack, read in full from one adapter and written in full to a new - * store created with its identity: every Record (soft-deleted and unlisted - * included) with its version snapshots and journal, every Type, and every - * blob. Each is read as the store enumerates it, never by what a Record + * store: every Record (soft-deleted and unlisted included) with its version + * snapshots and journal, `_config` among them as the seed the store is + * created from, every Type, and every blob. Each is read as the store enumerates it, 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. Full trust, like any direct @@ -14,18 +14,22 @@ import { InvalidAdapterError, OwnerMismatchError } from '../errors.js'; import { resolveQuery } from '../query-validation.js'; -import type { PlainQuery, StackAdapter, StackRecordAdapter } from '../types/index.js'; +import type { + ConfigContent, + PlainQuery, + StackAdapter, + StackRecordAdapter, + StoreSeed, +} from '../types/index.js'; +import { SYSTEM_TYPES } from '../types/index.js'; -/** What a store is created with: the identity `_config` holds, as an adapter reports it. */ -export type StackIdentity = Pick; - -/** Creates a new, empty store with `identity` — `LocalAdapter.open({ create: 'exclusive', ...identity })`, say. */ -export type CreateStore = (identity: StackIdentity) => Promise | StackAdapter; +/** Creates a new store from `seed` — `LocalAdapter.open({ path, create: 'exclusive', seed })`, say. */ +export type CreateStore = (seed: StoreSeed) => Promise | StackAdapter; /** Records read per page; any size works, since every page is written before the next is read. */ const PAGE_SIZE = 100; -/** Every Record a store holds but `_config`, which no query reaches and each store was created with. */ +/** Every Record a store holds but `_config`, which no query reaches and every store is created from. */ const EVERYTHING: PlainQuery = { filter: { includeDeleted: true, includeUnlisted: true }, sort: { field: 'createdAt', direction: 'asc' }, @@ -33,8 +37,8 @@ const EVERYTHING: PlainQuery = { }; /** - * Copy everything `from` holds into the store `create` makes with `from`'s - * identity, and return that store, open. Not atomic: a failure closes the + * Copy everything `from` holds into the store `create` makes from its + * `_config` record and that record's history, and return that store, open. Not atomic: a failure closes the * store part-written, and the caller, whose store it is, removes it. The * source must not be written meanwhile; nothing here can check that. * See docs/spec/portability.md § Copying a stack. @@ -46,13 +50,21 @@ export async function copyStack(from: StackAdapter, create: CreateStore): Promis 'Cannot copy from this adapter: it has no listBlobs(), so its blobs cannot be enumerated.', ); } - const to = await create({ ownerEntityId: from.ownerEntityId, timezone: from.timezone }); + const config = await from.getRecord(SYSTEM_TYPES.CONFIG); + if (!config) { + throw new InvalidAdapterError('Cannot copy from this adapter: it holds no _config record.'); + } + const [versions, journal] = await Promise.all([ + from.getVersions(config.id), + from.getJournal(config.id), + ]); + const to = await create({ config, history: { versions, journal } }); try { const importRecord = to.importRecord?.bind(to); if (!importRecord) refuseTarget('it has no importRecord(), so it cannot write history'); const listHeld = to.listBlobs?.bind(to); if (!listHeld) refuseTarget('it has no listBlobs(), so it could not be copied from in turn'); - await assertNewStore(to, from.ownerEntityId, listHeld); + await assertNewStore(to, (config.content as ConfigContent).ownerEntityId, listHeld); for (const type of await from.listTypes()) await to.saveType(type); // Bytes before the Records that name them, as every upload writes them. for (const { fileId } of await listBlobs()) await to.putBlob(await from.getBlob(fileId)); diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index a765461f..00755af8 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -24,6 +24,8 @@ export type { ElementWriteOptions, Reach, RecordHistory, + StackIdentity, + StoreSeed, } from '../types/index.js'; export { sameAssociationSet } from '../associations/identity.js'; export { @@ -34,7 +36,8 @@ export { } from '../errors.js'; export { combineAdapters } from './combine.js'; export { copyStack } from './copy.js'; -export type { CreateStore, StackIdentity } from './copy.js'; +export type { CreateStore } from './copy.js'; +export { identityOf, newStoreSeed } from './seed.js'; export { inReach } from './reach.js'; export type { ReachedRecord } from './reach.js'; export { diff --git a/packages/core/src/adapter/seed.ts b/packages/core/src/adapter/seed.ts new file mode 100644 index 00000000..3afc2bc3 --- /dev/null +++ b/packages/core/src/adapter/seed.ts @@ -0,0 +1,38 @@ +/** + * Store seeds + * ------------------------------------------------------- + * A store is created holding one Record, `_config`, which says whose stack + * it is. A seed is that Record with its history: fresh for a new stack, the + * source's own for a copy, so the one Record every store starts with is + * written the way every other Record is. See docs/spec/adapters.md + * § Construction. + */ + +import type { ConfigContent, StackIdentity, StoreSeed } from '../types/index.js'; +import { SYSTEM_TYPES } from '../types/index.js'; + +/** The seed of a stack that has no past: `_config` at version 1, holding `identity`. */ +export function newStoreSeed(identity: StackIdentity): StoreSeed { + const now = new Date(); + const content: ConfigContent = { + ownerEntityId: identity.ownerEntityId, + ...(identity.timezone !== undefined && { timezone: identity.timezone }), + }; + return { + config: { + id: SYSTEM_TYPES.CONFIG, + typeId: `${SYSTEM_TYPES.CONFIG}@1`, + createdAt: now, + updatedAt: now, + content, + version: 1, + }, + history: { versions: [], journal: [] }, + }; +} + +/** The identity a seed's `_config` holds. */ +export function identityOf(seed: StoreSeed): StackIdentity { + const content = seed.config.content as ConfigContent; + return { ownerEntityId: content.ownerEntityId, timezone: content.timezone }; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index d5460913..4ff4560e 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -280,8 +280,8 @@ export class FormatMismatchError extends Error { ) { super( actual === undefined - ? `Cannot open ${where}: it names no format version, and this adapter reads version ${expected}.` - : `Cannot open ${where}: it is format version ${actual}, and this adapter reads version ${expected}.`, + ? `${where} names no format version, not ${expected}.` + : `${where} is format version ${actual}, not ${expected}.`, ); this.name = 'FormatMismatchError'; } diff --git a/packages/core/src/testing.ts b/packages/core/src/testing.ts index 82761048..d6a92105 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -36,6 +36,7 @@ import type { BlobInfo, Reach, RecordHistory, + StoreSeed, } from './types/index.js'; import { SYSTEM_TYPES } from './types/index.js'; import { applyMergePatch } from './merge.js'; @@ -43,6 +44,7 @@ import { baseIdOf, fileRefFields } from './schema.js'; import { compareSortEntries, constantSortEntry, contentSortEntry } from './adapter/sort.js'; import type { SortEntry } from './adapter/sort.js'; import { inReach } from './adapter/reach.js'; +import { identityOf, newStoreSeed } from './adapter/seed.js'; import { ElementSetMovedError, @@ -63,7 +65,10 @@ const SHA256_HEX_RE = /^[0-9a-f]{64}$/; const withoutUndefined = (content: Record): Record => Object.fromEntries(Object.entries(content).filter(([, v]) => v !== undefined)); -export type MemoryAdapterOpenOptions = { ownerEntityId: string; timezone?: string }; +/** A fresh store's identity, or the seed a copy creates it from — never both. */ +export type MemoryAdapterOpenOptions = + | { ownerEntityId: string; timezone?: string; seed?: never } + | { seed: StoreSeed; ownerEntityId?: never; timezone?: never }; /** * In-memory StackAdapter with keyed cursor pagination. Implements @@ -103,11 +108,16 @@ export class MemoryAdapter implements StackAdapter { // Protected only so IncapableMemoryAdapter can extend; callers go through open(). protected constructor(opts: MemoryAdapterOpenOptions) { - this.ownerEntityId = opts.ownerEntityId; - this.timezone = opts.timezone; + const seed = + opts.seed ?? newStoreSeed({ ownerEntityId: opts.ownerEntityId, timezone: opts.timezone }); + ({ ownerEntityId: this.ownerEntityId, timezone: this.timezone } = identityOf(seed)); + this.writeImported(seed.config, seed.history); } - /** Always a new, empty store; `ownerEntityId` is required so the mistake fails at compile time. */ + /** + * Always a new store, holding only the `_config` record its seed carries; + * `ownerEntityId` or `seed` is required so the mistake fails at compile time. + */ static async open(opts: MemoryAdapterOpenOptions): Promise { return new MemoryAdapter(opts); } @@ -124,7 +134,17 @@ export class MemoryAdapter implements StackAdapter { } async importRecord(record: StackRecord, history: RecordHistory) { - const stored = await this.createRecord(record); + return this.writeImported(record, history); + } + + /** importRecord()'s write, synchronous so the constructor writes a seed the same way. */ + private writeImported(record: StackRecord, history: RecordHistory): StackRecord { + if (this.records.has(record.id)) { + throw new StackConflictError(`Record already exists: "${record.id}"`); + } + const stored = { ...record, content: withoutUndefined(record.content) }; + this.records.set(record.id, stored); + this.order.push(record.id); // Held oldest first, as the writes leave them, whatever order they came in. this.versions.set( record.id, diff --git a/packages/core/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 680a91ca..de22250c 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -128,6 +128,20 @@ export type RecordHistory = { journal: RecordJournalEntry[]; }; +/** What a store is created with: the identity its `_config` record holds, as an adapter reports it. */ +export type StackIdentity = Pick; + +/** + * What a store is created holding: its `_config` record and that record's + * history. A fresh store's is newStoreSeed()'s; a copy's is its source's, so + * `_config` travels as every Record does. See docs/spec/adapters.md + * § Construction. + */ +export type StoreSeed = { + config: StackRecord; + history: RecordHistory; +}; + /** The metadata an upload's `_attachment@1` record carries beside its bytes. */ export type PutAttachmentOptions = { mimeType: string; diff --git a/packages/core/tests/change-events.test.ts b/packages/core/tests/change-events.test.ts index 1c50f027..878aa647 100644 --- a/packages/core/tests/change-events.test.ts +++ b/packages/core/tests/change-events.test.ts @@ -214,17 +214,6 @@ describe('a mutation that changes nothing emits nothing', () => { describe('every record emits, including the ones a query hides', () => { test('_config reports a change despite never being returned by query()', async () => { - // Seeded through the adapter: the reserved id is unreachable from - // create(), and MemoryAdapter carries identity as fields rather than - // as the record the SQL adapters store. - await adapter.createRecord({ - id: '_config', - typeId: '_config@1', - createdAt: new Date(), - updatedAt: new Date(), - content: { ownerEntityId: OWNER, timezone: 'UTC' }, - version: 1, - }); const { seen, handler } = collector(); await stack.subscribe(handler); diff --git a/packages/core/tests/copy.test.ts b/packages/core/tests/copy.test.ts index e0100cbd..44a5e882 100644 --- a/packages/core/tests/copy.test.ts +++ b/packages/core/tests/copy.test.ts @@ -1,16 +1,14 @@ import { describe, test, expect } from 'vitest'; import { copyStack } from '../src/adapter/copy.js'; -import type { StackIdentity } from '../src/adapter/copy.js'; import { Stack } from '../src/stack/stack.js'; import { InvalidAdapterError, OwnerMismatchError } from '../src/errors.js'; import { MemoryAdapter } from '../src/testing.js'; -import type { StackAdapter } from '../src/types/index.js'; +import type { StackAdapter, StoreSeed } from '../src/types/index.js'; const OWNER = 'did:key:owner'; const NOTE = 'com.example.test/note@1'; -const create = ({ ownerEntityId, timezone }: StackIdentity) => - MemoryAdapter.open({ ownerEntityId, timezone }); +const create = (seed: StoreSeed) => MemoryAdapter.open({ seed }); async function sourceWith(notes: number): Promise { const adapter = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }); @@ -25,12 +23,12 @@ async function sourceWith(notes: number): Promise { } /** A create() whose store fails the target rule in one way, and records whether it was closed. */ -function badStore(make: () => Promise) { +function badStore(make: (seed: StoreSeed) => Promise) { const made: { closed: boolean } = { closed: false }; return { made, - create: async () => { - const store = await make(); + create: async (seed: StoreSeed) => { + const store = await make(seed); store.close = async () => void (made.closed = true); return store; }, @@ -38,10 +36,16 @@ function badStore(make: () => Promise) { } describe('copyStack', () => { - test('creates the target with the source identity, timezone included', async () => { - const source = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'Europe/Paris' }); + // _config travels as every Record does, so the target holds what the + // source's record says now, not what its adapter read at open. + test('carries _config with its history, a timezone patched after open included', async () => { + const source = await MemoryAdapter.open({ ownerEntityId: OWNER, timezone: 'UTC' }); + await (await Stack.open(source)).patchContent('_config', { timezone: 'Europe/Paris' }); const target = await copyStack(source, create); expect([target.ownerEntityId, target.timezone]).toEqual([OWNER, 'Europe/Paris']); + expect(await target.getRecord('_config')).toEqual(await source.getRecord('_config')); + expect(await target.getVersions('_config')).toEqual(await source.getVersions('_config')); + expect(await target.getJournal('_config')).toEqual(await source.getJournal('_config')); }); test('carries every page of records, not just the first', async () => { @@ -64,9 +68,9 @@ describe('copyStack', () => { source.listBlobs = undefined; let created = false; await expect( - copyStack(source, (identity) => { + copyStack(source, (seed) => { created = true; - return create(identity); + return create(seed); }), ).rejects.toThrow(InvalidAdapterError); expect(created).toBe(false); @@ -76,11 +80,11 @@ describe('copyStack', () => { // store with the source's identity. See docs/spec/portability.md // § Copying a stack. describe('refuses a store that is not a new, empty one with the source identity, and closes it', () => { - type Make = (identity: StackIdentity) => Promise; + type Make = (seed: StoreSeed) => Promise; const without = (method: 'importRecord' | 'listBlobs'): Make => - async (identity) => { - const store = await create(identity); + async (seed) => { + const store = await create(seed); Object.defineProperty(store, method, { value: undefined }); return store; }; @@ -89,14 +93,14 @@ describe('copyStack', () => { ['no listBlobs()', without('listBlobs'), InvalidAdapterError], [ 'another owner', - async ({ timezone }) => create({ ownerEntityId: 'did:key:other', timezone }), + async () => MemoryAdapter.open({ ownerEntityId: 'did:key:other' }), OwnerMismatchError, ], ['a Record already in it', async () => sourceWith(1), InvalidAdapterError], [ 'the system Types Stack.open() seeds', - async (identity) => { - const store = await create(identity); + async (seed) => { + const store = await create(seed); await Stack.open(store); return store; }, @@ -104,8 +108,8 @@ describe('copyStack', () => { ], [ 'a blob already in it', - async (identity) => { - const store = await create(identity); + async (seed) => { + const store = await create(seed); await store.putBlob(new Uint8Array([5])); return store; }, @@ -115,8 +119,7 @@ describe('copyStack', () => { test.each(cases)('%s', async (_name, make, error) => { const source = await sourceWith(1); - const identity = { ownerEntityId: OWNER, timezone: 'UTC' }; - const { made, create: bad } = badStore(() => make(identity)); + const { made, create: bad } = badStore(make); await expect(copyStack(source, bad)).rejects.toThrow(error); expect(made.closed).toBe(true); }); diff --git a/packages/core/tests/stack.test.ts b/packages/core/tests/stack.test.ts index 2501cc93..2b77ff53 100644 --- a/packages/core/tests/stack.test.ts +++ b/packages/core/tests/stack.test.ts @@ -3596,22 +3596,10 @@ describe('_config protections', () => { const CONFIG_ID = '_config'; const CONFIG_TYPE = '_config@1'; - // MemoryAdapter never materializes a _config record on its own (ownerEntityId - // is a plain constructor field) — the guards under test operate on whatever - // record exists at id "_config", so tests seed one directly. - async function seedConfig(ownerEntityId = 'owner-123', timezone = 'UTC') { - return adapter.createRecord({ - id: CONFIG_ID, - typeId: CONFIG_TYPE, - createdAt: new Date(), - updatedAt: new Date(), - content: { ownerEntityId, timezone }, - version: 1, - }); - } + // The adapter is created holding _config — owner-123, UTC, at version 1 — + // as every store is, so the guards under test operate on that record. test('patchContent() rejects a change to ownerEntityId', async () => { - await seedConfig(); await expect(stack.patchContent(CONFIG_ID, { ownerEntityId: 'someone-else' })).rejects.toThrow( StackConflictError, ); @@ -3619,32 +3607,27 @@ describe('_config protections', () => { }); test('patchContent() allows changing timezone', async () => { - await seedConfig(); const updated = await stack.patchContent(CONFIG_ID, { timezone: 'America/New_York' }); expect((updated.content as Record).timezone).toBe('America/New_York'); }); test('setting ownerEntityId to its current value is a no-op, not an error', async () => { - await seedConfig('owner-123'); await expect( stack.patchContent(CONFIG_ID, { ownerEntityId: 'owner-123' }), ).resolves.toBeDefined(); }); test('soft delete is rejected', async () => { - await seedConfig(); await expect(stack.delete(CONFIG_ID)).rejects.toThrow(StackConflictError); expect(await adapter.getRecord(CONFIG_ID)).not.toBeNull(); }); test('purge is rejected', async () => { - await seedConfig(); await expect(stack.delete(CONFIG_ID, { purge: true })).rejects.toThrow(StackConflictError); expect(await adapter.getRecord(CONFIG_ID)).not.toBeNull(); }); test('restoreVersion() rejects a snapshot with a different ownerEntityId', async () => { - await seedConfig('owner-123'); // Simulates a snapshot that predates this guard, or a bypassed // direct-adapter write — either way, a stored version whose ownerEntityId // disagrees with the live record's must not be restorable. @@ -3658,37 +3641,31 @@ describe('_config protections', () => { }); test('restoreVersion() allows a snapshot with the same ownerEntityId', async () => { - await seedConfig('owner-123', 'UTC'); await stack.patchContent(CONFIG_ID, { timezone: 'America/New_York' }); const restored = await stack.restoreVersion(CONFIG_ID, 1); expect((restored.content as Record).timezone).toBe('UTC'); }); test('generic query excludes _config', async () => { - await seedConfig(); const result = await stack.query({ filter: { typeId: CONFIG_TYPE } }); expect(result.records).toHaveLength(0); }); test('_config is still addressable directly by ID', async () => { - await seedConfig(); expect(await stack.get(CONFIG_ID)).not.toBeNull(); }); test('ScopedStack delegation: the owner cannot change ownerEntityId via scoped update either', async () => { - await seedConfig('owner-123'); await expect( stack.asEntity('owner-123').patchContent(CONFIG_ID, { ownerEntityId: 'someone-else' }), ).rejects.toThrow(StackConflictError); }); test('ScopedStack delegation: the owner cannot delete _config via scoped delete either', async () => { - await seedConfig('owner-123'); await expect(stack.asEntity('owner-123').delete(CONFIG_ID)).rejects.toThrow(StackConflictError); }); test('commitMigration() rejects a change to ownerEntityId', async () => { - await seedConfig('owner-123'); await stack.defineType({ id: '_config@2', name: 'Config', @@ -3705,7 +3682,6 @@ describe('_config protections', () => { }); test('commitMigration() allows the same ownerEntityId', async () => { - await seedConfig('owner-123'); await stack.defineType({ id: '_config@2', name: 'Config', diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index b13d94d4..d37968dd 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -22,30 +22,35 @@ * SharedSqlRecordAdapter, exactly as it does for record-adapter-sqlite. */ -import { - applyConnectionPragmas, - writeNewStore, - readStoredIdentity, - SharedSqlRecordAdapter, -} from '@haverstack/sqlite-shared/record'; -import { OwnerMismatchError } from '@haverstack/core/adapter'; -import type { StackIdentity } from '@haverstack/core/adapter'; +import { openStore, SharedSqlRecordAdapter } from '@haverstack/sqlite-shared/record'; +import type { StackIdentity, StoreSeed } from '@haverstack/core/adapter'; import { DurableObjectSqliteExecutor } from './executor.js'; // ------------------------------------------------------- // Types // ------------------------------------------------------- -export type DoSQLiteRecordAdapterOpenOptions = { - /** - * Entity ID of the stack owner. A plain string is checked against an - * existing config record (mismatch throws OwnerMismatchError); a lazy - * provider is called only when the DO's storage has none yet. - */ - ownerEntityId: string | (() => string | Promise); - /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */ - timezone?: string; -}; +export type DoSQLiteRecordAdapterOpenOptions = + | { + /** + * Entity ID of the stack owner. A plain string is checked against an + * existing config record (mismatch throws OwnerMismatchError); a lazy + * provider is called only when the DO's storage has none yet. + */ + ownerEntityId: string | (() => string | Promise); + /** IANA timezone string e.g. "America/New_York". Optional passthrough app metadata — no default. */ + timezone?: string; + seed?: never; + } + | { + /** + * The `_config` record and history new storage is created holding: a + * copy's source's. Its owner is checked against storage that has one. + */ + seed: StoreSeed; + ownerEntityId?: never; + timezone?: never; + }; // ------------------------------------------------------- // DoSQLiteRecordAdapter @@ -72,18 +77,12 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: DoSQLiteRecordAdapterOpenOptions, ): Promise { const exec = new DurableObjectSqliteExecutor(storage); - const where = 'the Durable Object storage'; - let identity = readStoredIdentity(exec, where); - applyConnectionPragmas(exec, { wal: false }); - if (identity) { - if (typeof opts.ownerEntityId === 'string' && opts.ownerEntityId !== identity.ownerEntityId) { - throw new OwnerMismatchError(opts.ownerEntityId, identity.ownerEntityId, where); - } - } else { - const ownerEntityId = - typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - identity = writeNewStore(exec, { ownerEntityId, timezone: opts.timezone }); - } + const identity = await openStore(exec, { + ...opts, + mode: 'ifMissing', + where: 'the Durable Object storage', + wal: false, + }); return new DoSQLiteRecordAdapter(exec, identity); } diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index a94a2283..01b982c1 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -28,32 +28,26 @@ import { DatabaseSync } from './node-sqlite.js'; import { existsSync, rmSync } from 'fs'; import { - applyConnectionPragmas, acquireLock, releaseLock, - writeNewStore, - readStoredIdentity, + missingStoreError, + openStore, SharedSqlRecordAdapter, + type OwnerEntityIdInput, + type StoreCreateMode, } from '@haverstack/sqlite-shared'; -import { OwnerMismatchError } from '@haverstack/core/adapter'; -import type { StackIdentity } from '@haverstack/core/adapter'; +import type { StackIdentity, StoreSeed } from '@haverstack/core/adapter'; import { NativeSqliteExecutor } from './executor.js'; // ------------------------------------------------------- // Types // ------------------------------------------------------- -/** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */ -export type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive'; - -/** A plain DID, or a lazy provider called only when a store is actually created. */ -export type OwnerEntityIdInput = string | (() => string | Promise); +export type { OwnerEntityIdInput, StoreCreateMode }; export type NativeSQLiteRecordAdapterOpenOptions = { /** Absolute path to the .db file. */ path: string; - /** IANA timezone string e.g. "America/New_York". Consulted only when a store is created. No default. */ - timezone?: string; /** * Open even if a lock file from another live process is present. * Only needed if that process is gone but its PID was reused by @@ -67,6 +61,8 @@ export type NativeSQLiteRecordAdapterOpenOptions = { create?: 'never'; /** Checked against the existing store's owner; a mismatch throws OwnerMismatchError. */ ownerEntityId?: string; + timezone?: never; + seed?: never; } | { /** `'ifMissing'` opens the store if present and creates it if not; `'exclusive'` creates it and fails if present. */ @@ -76,6 +72,17 @@ export type NativeSQLiteRecordAdapterOpenOptions = { * existing store's owner; a lazy provider is never called just to compare. */ ownerEntityId: OwnerEntityIdInput; + /** IANA timezone string e.g. "America/New_York". Consulted only when a store is created. No default. */ + timezone?: string; + seed?: never; + } + | { + /** A store created from a seed is a new one, so only `'exclusive'` creates from one. */ + create: 'exclusive'; + /** The `_config` record and history the store is created holding: a copy's source's. */ + seed: StoreSeed; + ownerEntityId?: never; + timezone?: never; } ); @@ -103,25 +110,9 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: NativeSQLiteRecordAdapterOpenOptions, ): Promise { const mode: StoreCreateMode = opts.create ?? 'never'; - // `exists` is whether the file holds a stack. See docs/spec/adapters.md - // § Construction. - const checkMode = (exists: boolean): void => { - if (!exists && mode === 'never') { - throw new Error( - `Cannot open: no database found at "${opts.path}". ` + - `Pass create: 'ifMissing' or 'exclusive' to create one.`, - ); - } - if (exists && mode === 'exclusive') { - throw new Error( - `Cannot create: database already exists at "${opts.path}". ` + - `Pass create: 'never' or 'ifMissing' to open it.`, - ); - } - }; - + const where = `"${opts.path}"`; // Judged before opening, which would create the file to say it is missing. - if (mode === 'never' && !existsSync(opts.path)) checkMode(false); + if (mode === 'never' && !existsSync(opts.path)) throw missingStoreError(where); acquireLock(opts.path, opts.force); // Under the lock, so no other process can have made the file meanwhile. @@ -130,36 +121,7 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { try { db = new DatabaseSync(opts.path); const exec = new NativeSqliteExecutor(db); - const stored = readStoredIdentity(exec, `"${opts.path}"`); - checkMode(stored !== null); - applyConnectionPragmas(exec, { wal: true }); - let identity: StackIdentity; - if (stored) { - identity = stored; - if ( - typeof opts.ownerEntityId === 'string' && - opts.ownerEntityId !== identity.ownerEntityId - ) { - throw new OwnerMismatchError( - opts.ownerEntityId, - identity.ownerEntityId, - `"${opts.path}"`, - ); - } - } else { - // Resolved only now a store is being created, under the lock the catch - // below releases, along with any file this open made. - const owner = - typeof opts.ownerEntityId === 'function' - ? await opts.ownerEntityId() - : opts.ownerEntityId; - if (!owner) { - throw new Error( - `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`, - ); - } - identity = writeNewStore(exec, { ownerEntityId: owner, timezone: opts.timezone }); - } + const identity = await openStore(exec, { ...opts, mode, where, wal: true }); return new NativeSQLiteRecordAdapter(opts.path, db, exec, identity); } catch (err) { db?.close(); diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index c781d251..1e89da57 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -247,7 +247,6 @@ describe('open create modes', () => { test.each([ ['another version', 'UPDATE format SET version = 2', 2], ['no version at all', 'DELETE FROM format', undefined], - ['no format table', 'DROP TABLE format', undefined], ] as const)( 'a store naming %s throws FormatMismatchError and releases the lock', async (_name, sql, actual) => { @@ -305,17 +304,6 @@ describe('open create modes', () => { }, ); - test('a store holding its tables but no _config record is refused, not created over', async () => { - await (await initAdapter()).close(); - const db = new DatabaseSync(dbPath); - db.exec(`DELETE FROM records WHERE id = '_config'`); - db.close(); - await expect( - NativeSQLiteRecordAdapter.open({ path: dbPath, create: 'ifMissing', ownerEntityId: 'x' }), - ).rejects.toThrow(/no _config record/); - expect(existsSync(`${dbPath}.lock`)).toBe(false); - }); - test('a matching plain-string owner opens an existing store', async () => { await (await initAdapter({ ownerEntityId: 'owner-abc' })).close(); const adapter = await NativeSQLiteRecordAdapter.open({ diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index 12430775..2816bf6f 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,53 +1,120 @@ -import { FormatMismatchError } from '@haverstack/core/adapter'; -import type { StackIdentity } from '@haverstack/core/adapter'; +import { + FormatMismatchError, + OwnerMismatchError, + identityOf, + newStoreSeed, +} from '@haverstack/core/adapter'; +import type { StackIdentity, StoreSeed } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; -import { FORMAT_VERSION, FTS5_SCHEMA_SQL, RECORD_SCHEMA_SQL } from './schema.js'; +import { SharedSqlRecordLogic } from './record-logic.js'; +import { + applyConnectionPragmas, + FORMAT_VERSION, + FTS5_SCHEMA_SQL, + RECORD_SCHEMA_SQL, +} from './schema.js'; + +/** What open() does when the store is missing or present, like O_CREAT / O_EXCL. */ +export type StoreCreateMode = 'never' | 'ifMissing' | 'exclusive'; + +/** A plain DID, or a lazy provider called only when a store is actually created. */ +export type OwnerEntityIdInput = string | (() => string | Promise); /** The one place the singleton record's id is spelled. */ const CONFIG_ROW_SQL = `SELECT content FROM records WHERE id = '_config'`; -const hasTable = (exec: SqlExecutor, name: string): boolean => - exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, [name]) !== undefined; +/** The refusal of a store that is not there, for an engine to throw before it opens one. */ +export const missingStoreError = (where: string): Error => + new Error( + `Cannot open: no database found at ${where}. ` + + `Pass create: 'ifMissing' or 'exclusive' to create one.`, + ); /** * Writes everything a new store is created with — its tables, its `format` - * row and the singleton _config@1 record — in one transaction, so a store is - * either whole or not there. Returns the identity so a caller that has just - * created a database reaches what an existing one yields without a - * read-back. Only valid on a database readStoredIdentity() found missing. + * row and the `_config` record its seed carries, with that record's history — + * in one transaction, so a store is either whole or not there. `_config` is + * written as importRecord() writes any Record. Only valid on a database + * readStoredIdentity() found missing. */ -export const writeNewStore = (exec: SqlExecutor, identity: StackIdentity): StackIdentity => { - const now = Date.now(); +export const writeNewStore = (exec: SqlExecutor, seed: StoreSeed): StackIdentity => { exec.transaction(() => { exec.exec(RECORD_SCHEMA_SQL); exec.exec(FTS5_SCHEMA_SQL); exec.run('INSERT INTO format (id, version) VALUES (1, ?)', [FORMAT_VERSION]); - exec.run( - `INSERT INTO records (id, type_id, created_at, updated_at, content, version) - VALUES ('_config', '_config@1', ?, ?, ?, 1)`, - [now, now, JSON.stringify(identity)], - ); + new SharedSqlRecordLogic({ exec }).writeImported(seed.config, seed.history); }); - return identity; + return identityOf(seed); }; /** * The identity a store holds, or null when the database holds no store. - * Stores are written whole, so a database with neither a `records` nor a - * `format` table holds none, and one with either is a store: it must name - * FORMAT_VERSION, or it is refused — a store moves to another layout only by - * being copied into one. Reads only, so a refused store is left untouched. - * See docs/spec/adapters.md § Construction. + * Stores are written whole, so the `format` table alone says whether one is + * there; a store must name FORMAT_VERSION, or it is refused — a store moves + * to another layout only by being copied into one. Reads only, so a refused + * store is left untouched. See docs/spec/adapters.md § Construction. */ export const readStoredIdentity = (exec: SqlExecutor, where: string): StackIdentity | null => { - const hasFormat = hasTable(exec, 'format'); - if (!hasFormat && !hasTable(exec, 'records')) return null; - const version = hasFormat - ? exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version - : undefined; + const hasFormat = + exec.get(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'format'`) !== undefined; + if (!hasFormat) return null; + const version = exec.get<{ version: number }>('SELECT version FROM format WHERE id = 1')?.version; if (version !== FORMAT_VERSION) throw new FormatMismatchError(FORMAT_VERSION, version, where); - const row = exec.get<{ content: string }>(CONFIG_ROW_SQL); - if (!row) throw new Error(`Cannot open ${where}: it holds no _config record.`); - const content = JSON.parse(row.content) as StackIdentity; - return { ownerEntityId: content.ownerEntityId, timezone: content.timezone }; + const { content } = exec.get<{ content: string }>(CONFIG_ROW_SQL)!; + const config = JSON.parse(content) as StackIdentity; + return { ownerEntityId: config.ownerEntityId, timezone: config.timezone }; +}; + +export type OpenStoreOptions = { + /** Names the store in a refusal, as OwnerMismatchError's `where` does. */ + where: string; + mode: StoreCreateMode; + wal: boolean; +} & ( + | { ownerEntityId?: OwnerEntityIdInput; timezone?: string; seed?: never } + | { seed: StoreSeed; ownerEntityId?: never; timezone?: never } +); + +/** + * Every SQLite engine's open, after it holds a connection: read what the + * store holds, judge `mode` by it, check the owner a caller expects, and + * create a missing store from `seed` or a fresh one. The one read answers + * whether a store is present, its layout and its owner, so those checks + * cannot disagree. See docs/spec/adapters.md § Construction. + */ +export const openStore = async ( + exec: SqlExecutor, + opts: OpenStoreOptions, +): Promise => { + const stored = readStoredIdentity(exec, opts.where); + if (!stored && opts.mode === 'never') throw missingStoreError(opts.where); + if (stored && opts.mode === 'exclusive') { + throw new Error( + `Cannot create: database already exists at ${opts.where}. ` + + `Pass create: 'never' or 'ifMissing' to open it.`, + ); + } + applyConnectionPragmas(exec, { wal: opts.wal }); + + if (stored) { + const expected = opts.seed + ? identityOf(opts.seed).ownerEntityId + : typeof opts.ownerEntityId === 'string' + ? opts.ownerEntityId + : undefined; + if (expected !== undefined && expected !== stored.ownerEntityId) { + throw new OwnerMismatchError(expected, stored.ownerEntityId, opts.where); + } + return stored; + } + + if (opts.seed) return writeNewStore(exec, opts.seed); + // Resolved only now a store is being created, and refused empty on every + // engine. See docs/spec/adapters.md § Construction. + const owner = + typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; + if (!owner) { + throw new Error(`Cannot create: no ownerEntityId given for the new database at ${opts.where}.`); + } + return writeNewStore(exec, newStoreSeed({ ownerEntityId: owner, timezone: opts.timezone })); }; diff --git a/packages/sqlite-shared/src/record-logic.ts b/packages/sqlite-shared/src/record-logic.ts index 2bff0c77..e10b17c3 100644 --- a/packages/sqlite-shared/src/record-logic.ts +++ b/packages/sqlite-shared/src/record-logic.ts @@ -306,15 +306,21 @@ export class SharedSqlRecordLogic { * docs/spec/portability.md § Writing history. */ async importRecord(record: StackRecord, history: RecordHistory): Promise { - this.exec.transaction(() => { - this.insertRecord(record); - for (const version of history.versions) this.insertVersionRow(record.id, version); - for (const entry of history.journal) this.insertJournalRow(record.id, entry); - }); - + this.exec.transaction(() => this.writeImported(record, history)); return record; } + /** + * importRecord()'s write, for a caller already inside a transaction: + * writeNewStore() writes a store's `_config` seed through it, so the one + * Record a store starts with lands as every other Record does. + */ + writeImported(record: StackRecord, history: RecordHistory): void { + this.insertRecord(record); + for (const version of history.versions) this.insertVersionRow(record.id, version); + for (const entry of history.journal) this.insertJournalRow(record.id, entry); + } + /** * The record row and every table describing it, for a caller already * inside a transaction. A duplicate id maps to StackConflictError. diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index 7e7f9bc7..06b0d523 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -16,8 +16,16 @@ * for lock.ts's plain functions — so avoiding the import (not just the * unused export) has to happen at this file's level. */ -export { applyConnectionPragmas, FORMAT_VERSION } from './schema.js'; +export { FORMAT_VERSION } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; -export { writeNewStore, readStoredIdentity } from './config.js'; +export { + missingStoreError, + openStore, + readStoredIdentity, + writeNewStore, + type OpenStoreOptions, + type OwnerEntityIdInput, + type StoreCreateMode, +} from './config.js'; export { SharedSqlRecordAdapter, SQLITE_RECORD_CAPABILITIES } from './record-adapter.js'; diff --git a/packages/sqlite-shared/tests/config.test.ts b/packages/sqlite-shared/tests/config.test.ts index f265eecc..6f25f126 100644 --- a/packages/sqlite-shared/tests/config.test.ts +++ b/packages/sqlite-shared/tests/config.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; -import { FormatMismatchError } from '@haverstack/core/adapter'; +import { FormatMismatchError, newStoreSeed } from '@haverstack/core/adapter'; import { writeNewStore, readStoredIdentity } from '../src/config.js'; import type { SqlExecutor } from '../src/executor.js'; import { FORMAT_VERSION } from '../src/schema.js'; @@ -30,7 +30,7 @@ const empty = () => executor(new DatabaseSync(':memory:')); /** A store as a create leaves it. */ const created = (timezone: string | undefined) => { const exec = empty(); - writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone }); + writeNewStore(exec, newStoreSeed({ ownerEntityId: 'did:key:owner', timezone })); return exec; }; @@ -61,7 +61,6 @@ describe('readStoredIdentity', () => { test.each([ ['another version', 'UPDATE format SET version = 2', 2], ['no version', 'DELETE FROM format', undefined], - ['no format table', 'DROP TABLE format', undefined], ] as const)('refuses a store naming %s, writing nothing', (_name, sql, actual) => { const exec = created('UTC'); exec.exec(sql); @@ -76,7 +75,10 @@ describe('readStoredIdentity', () => { describe('writeNewStore', () => { test('returns the identity it wrote, matching what a later read yields', () => { const exec = empty(); - const written = writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + const written = writeNewStore( + exec, + newStoreSeed({ ownerEntityId: 'did:key:owner', timezone: 'UTC' }), + ); expect(written).toEqual(readStoredIdentity(exec, 'here')); }); @@ -87,7 +89,7 @@ describe('writeNewStore', () => { exec.exec(`CREATE TABLE format (id INTEGER PRIMARY KEY, version INTEGER NOT NULL)`); exec.run('INSERT INTO format (id, version) VALUES (1, 1)'); expect(() => - writeNewStore(exec, { ownerEntityId: 'did:key:owner', timezone: 'UTC' }), + writeNewStore(exec, newStoreSeed({ ownerEntityId: 'did:key:owner', timezone: 'UTC' })), ).toThrow(); expect(tables(exec)).toBe(1); }); From 02cdbb34cad9ccdfdc8e835acd17574b97528d4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 22:15:46 +0000 Subject: [PATCH 16/16] fix: a seed only creates a store, and store DDL runs once A seed is a new store's whole first state, so openStore() now refuses one for storage that already holds a store, on every engine; the DO adapter no longer drops it silently after an owner check. NativeSQLiteRecordAdapter and LocalAdapter take no `create` beside a seed, which already implies 'exclusive'. Store DDL runs once, inside writeNewStore() on a database with no format table, so it drops IF NOT EXISTS: a same-named foreign table fails the create and rolls it back instead of being adopted. Token-store DDL stays idempotent, since that file is opened every time. Also reflows copy.ts comments past 100 columns. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01N8msm9Vp1KhFthR6Pyf2Jz --- .changeset/portable-copy.md | 4 +- docs/spec/adapters.md | 2 +- docs/spec/portability.md | 3 +- packages/adapter-local/tests/copy.test.ts | 6 +-- packages/core/src/adapter/copy.ts | 18 ++++--- .../record-adapter-do-sqlite/src/index.ts | 2 +- packages/record-adapter-sqlite/src/index.ts | 6 +-- packages/sqlite-shared/src/config.ts | 13 ++--- packages/sqlite-shared/src/schema.ts | 52 ++++++++++--------- packages/sqlite-shared/tests/config.test.ts | 26 +++++++--- 10 files changed, 72 insertions(+), 60 deletions(-) diff --git a/.changeset/portable-copy.md b/.changeset/portable-copy.md index 8a7b4463..1915dfd9 100644 --- a/.changeset/portable-copy.md +++ b/.changeset/portable-copy.md @@ -11,8 +11,8 @@ A stack can be copied whole between adapters, authorship and history included. - `copyStack(from, create)` in `@haverstack/core/adapter` copies every Record, soft-deleted and unlisted ones too, with its versions and journal, plus every Type and every blob, into a new store `create` makes from the source's `_config` record and its history, and returns it open. `_config` travels as every Record does. Both ends must list their blobs, and the target must hold nothing yet, so `APIAdapter` cannot yet be copied from. Copying into a `LocalAdapter` store is an export; copying out of one is an import. - It writes through the new optional `StackRecordAdapter.importRecord(record, { versions, journal })`, which every local adapter implements and `APIAdapter` does not. -- `StackIdentity`, `CreateStore`, `StoreSeed`, `newStoreSeed()` and `identityOf()` are exported beside it. Every adapter that creates stores takes a `seed` in place of `ownerEntityId` and `timezone`: the `_config` record, with its history, a new store is created holding. `MemoryAdapter` now holds a real `_config` record. -- Every SQLite store names its layout in a one-row `format` table, and opening one in another layout throws the new `FormatMismatchError` (from `@haverstack/core/adapter`). A new store is written whole, tables included, in one transaction, and a failed `NativeSQLiteRecordAdapter.open()` removes a file it created. +- `StackIdentity`, `CreateStore`, `StoreSeed`, `newStoreSeed()` and `identityOf()` are exported beside it. Every adapter that creates stores takes a `seed` in place of `ownerEntityId` and `timezone`: the `_config` record, with its history, a new store is created holding. A seed only creates: storage already holding a store refuses it, and `NativeSQLiteRecordAdapter`/`LocalAdapter` take no `create` beside one. `MemoryAdapter` now holds a real `_config` record. +- Every SQLite store names its layout in a one-row `format` table, and opening one in another layout throws the new `FormatMismatchError` (from `@haverstack/core/adapter`). A new store is written whole, tables included, in one transaction — a table of the same name already in the database fails the create — and a failed `NativeSQLiteRecordAdapter.open()` removes a file it created. - `OwnerMismatchError` reads ` is owned by "X", not "Y"`, and `copyStack()` throws it too. - `saveVersion()` is removed from the adapter contract: `importRecord()` is the one standalone history write. - `@haverstack/adapter-conformance` checks `importRecord()` in the record suite and adds `runCopyConformance()`, which holds a full adapter to both halves of a copy. diff --git a/docs/spec/adapters.md b/docs/spec/adapters.md index fa1eb3c2..9e565972 100644 --- a/docs/spec/adapters.md +++ b/docs/spec/adapters.md @@ -126,7 +126,7 @@ An adapter that holds a stack's identity has exactly one entry point, an async s | `APIAdapter` | `open({ url, ownerEntityId?, … })` | none: a client never creates | | `MemoryAdapter`, `IncapableMemoryAdapter` (testing) | `open({ ownerEntityId, timezone? } or { seed })` | none: always new | -**Every store is created holding one Record, `_config`, which says whose it is.** `open()` takes it in one of two forms: `ownerEntityId` and `timezone`, from which `newStoreSeed()` in `@haverstack/core/adapter` makes a `_config` at version 1 with no past; or a `seed` — a `StoreSeed`, a `_config` record with its history, as [a copy](./portability.md#copying-a-stack) hands over its source's — written as `importRecord()` writes any Record. A seed creates a new store, so `NativeSQLiteRecordAdapter` takes one only with `'exclusive'`; given storage that already holds a store, as the DO adapter can be, its owner is checked against the seed's. `MemoryAdapter` holds its `_config` the same way, so every adapter that stores records holds one. +**Every store is created holding one Record, `_config`, which says whose it is.** `open()` takes it in one of two forms: `ownerEntityId` and `timezone`, from which `newStoreSeed()` in `@haverstack/core/adapter` makes a `_config` at version 1 with no past; or a `seed` — a `StoreSeed`, a `_config` record with its history, as [a copy](./portability.md#copying-a-stack) hands over its source's — written as `importRecord()` writes any Record. A seed is a new store's whole first state, so it only creates, as `'exclusive'` does: storage that already holds a store refuses it, on every engine. No engine takes `create` beside a seed. `MemoryAdapter` holds its `_config` the same way, so every adapter that stores records holds one. **A store is written whole, so it is either present or missing.** A new store's tables, its `format` row and its `_config` record are written in one transaction, and opening an existing one writes none of them. So the `format` table alone says whether a database holds a store: one without it is missing, and one with it holds a whole store. An `open()` that fails leaves things as it found them: `NativeSQLiteRecordAdapter` removes a file it created rather than leaving it for the next open to trip on. diff --git a/docs/spec/portability.md b/docs/spec/portability.md index d9e319aa..58e5c23d 100644 --- a/docs/spec/portability.md +++ b/docs/spec/portability.md @@ -61,8 +61,7 @@ type RecordHistory = { **An export is a `LocalAdapter` store: a SQLite database, with an `attachments/` directory beside it.** It is a working stack, not an archive to unpack — `Stack.open()` reads it as it reads any local stack. ```ts -const portable = (path: string) => (seed: StoreSeed) => - LocalAdapter.open({ path, create: 'exclusive', seed }); +const portable = (path: string) => (seed: StoreSeed) => LocalAdapter.open({ path, seed }); // Export: the host's stack, into a new portable store. const exported = await copyStack(host, portable('./my-stack.db')); diff --git a/packages/adapter-local/tests/copy.test.ts b/packages/adapter-local/tests/copy.test.ts index 676b3967..23fe9384 100644 --- a/packages/adapter-local/tests/copy.test.ts +++ b/packages/adapter-local/tests/copy.test.ts @@ -17,11 +17,7 @@ const dirs = new Map(); const local: CreateStore = async (seed) => { const dir = join(tmpdir(), `copy-local-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); - const adapter = await LocalAdapter.open({ - create: 'exclusive', - path: join(dir, 'stack.db'), - seed, - }); + const adapter = await LocalAdapter.open({ path: join(dir, 'stack.db'), seed }); dirs.set(adapter, dir); return adapter; }; diff --git a/packages/core/src/adapter/copy.ts b/packages/core/src/adapter/copy.ts index 2b3b0f5e..89d8cb14 100644 --- a/packages/core/src/adapter/copy.ts +++ b/packages/core/src/adapter/copy.ts @@ -4,8 +4,9 @@ * One stack, read in full from one adapter and written in full to a new * store: every Record (soft-deleted and unlisted included) with its version * snapshots and journal, `_config` among them as the seed the store is - * created from, every Type, and every blob. Each is read as the store enumerates it, never by what a Record - * says, so nothing is filtered on the way. Export, import and moving + * created from, every Type, and every blob. Each is read as the store + * enumerates it, 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. Full trust, like any direct * adapter access: authorship and history land as recorded, which no write @@ -23,13 +24,13 @@ import type { } from '../types/index.js'; import { SYSTEM_TYPES } from '../types/index.js'; -/** Creates a new store from `seed` — `LocalAdapter.open({ path, create: 'exclusive', seed })`, say. */ +/** Creates a new store from `seed`: `LocalAdapter.open({ path, seed })`, say. */ export type CreateStore = (seed: StoreSeed) => Promise | StackAdapter; -/** Records read per page; any size works, since every page is written before the next is read. */ +/** Records read per page; any size works, as each page is written before the next is read. */ const PAGE_SIZE = 100; -/** Every Record a store holds but `_config`, which no query reaches and every store is created from. */ +/** Every Record a store holds but `_config`, which no query reaches: a store is created from it. */ const EVERYTHING: PlainQuery = { filter: { includeDeleted: true, includeUnlisted: true }, sort: { field: 'createdAt', direction: 'asc' }, @@ -38,9 +39,10 @@ const EVERYTHING: PlainQuery = { /** * Copy everything `from` holds into the store `create` makes from its - * `_config` record and that record's history, and return that store, open. Not atomic: a failure closes the - * store part-written, and the caller, whose store it is, removes it. The - * source must not be written meanwhile; nothing here can check that. + * `_config` record and that record's history, and return that store, open. + * Not atomic: a failure closes the store part-written, and the caller, whose + * store it is, removes it. The source must not be written meanwhile; nothing + * here can check that. * See docs/spec/portability.md § Copying a stack. */ export async function copyStack(from: StackAdapter, create: CreateStore): Promise { diff --git a/packages/record-adapter-do-sqlite/src/index.ts b/packages/record-adapter-do-sqlite/src/index.ts index d37968dd..b451ed21 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -45,7 +45,7 @@ export type DoSQLiteRecordAdapterOpenOptions = | { /** * The `_config` record and history new storage is created holding: a - * copy's source's. Its owner is checked against storage that has one. + * copy's source's. Storage that already holds a store refuses it. */ seed: StoreSeed; ownerEntityId?: never; diff --git a/packages/record-adapter-sqlite/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index 01b982c1..afb8ad2c 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -77,8 +77,8 @@ export type NativeSQLiteRecordAdapterOpenOptions = { seed?: never; } | { - /** A store created from a seed is a new one, so only `'exclusive'` creates from one. */ - create: 'exclusive'; + /** A seed only creates, as `'exclusive'` does: an existing store refuses it. */ + create?: never; /** The `_config` record and history the store is created holding: a copy's source's. */ seed: StoreSeed; ownerEntityId?: never; @@ -109,7 +109,7 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { static async open( opts: NativeSQLiteRecordAdapterOpenOptions, ): Promise { - const mode: StoreCreateMode = opts.create ?? 'never'; + const mode: StoreCreateMode = opts.seed ? 'exclusive' : (opts.create ?? 'never'); const where = `"${opts.path}"`; // Judged before opening, which would create the file to say it is missing. if (mode === 'never' && !existsSync(opts.path)) throw missingStoreError(where); diff --git a/packages/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index 2816bf6f..ce1772f8 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -78,7 +78,7 @@ export type OpenStoreOptions = { /** * Every SQLite engine's open, after it holds a connection: read what the * store holds, judge `mode` by it, check the owner a caller expects, and - * create a missing store from `seed` or a fresh one. The one read answers + * create a missing store from `seed` or a fresh one; a seed never opens one. The one read answers * whether a store is present, its layout and its owner, so those checks * cannot disagree. See docs/spec/adapters.md § Construction. */ @@ -88,20 +88,17 @@ export const openStore = async ( ): Promise => { const stored = readStoredIdentity(exec, opts.where); if (!stored && opts.mode === 'never') throw missingStoreError(opts.where); - if (stored && opts.mode === 'exclusive') { + // A seed is a new store's whole first state, so it creates or fails. + if (stored && (opts.mode === 'exclusive' || opts.seed)) { throw new Error( `Cannot create: database already exists at ${opts.where}. ` + - `Pass create: 'never' or 'ifMissing' to open it.`, + `Pass create: 'never' or 'ifMissing' without a seed to open it.`, ); } applyConnectionPragmas(exec, { wal: opts.wal }); if (stored) { - const expected = opts.seed - ? identityOf(opts.seed).ownerEntityId - : typeof opts.ownerEntityId === 'string' - ? opts.ownerEntityId - : undefined; + const expected = typeof opts.ownerEntityId === 'string' ? opts.ownerEntityId : undefined; if (expected !== undefined && expected !== stored.ownerEntityId) { throw new OwnerMismatchError(expected, stored.ownerEntityId, opts.where); } diff --git a/packages/sqlite-shared/src/schema.ts b/packages/sqlite-shared/src/schema.ts index c9a56d5a..f69eddde 100644 --- a/packages/sqlite-shared/src/schema.ts +++ b/packages/sqlite-shared/src/schema.ts @@ -11,15 +11,19 @@ import type { SqlExecutor } from './executor.js'; /** The layout this code reads and writes: what a new store's `format` row names. */ export const FORMAT_VERSION = 1; +/** + * Run once, by writeNewStore() on a database holding no store, so a table + * of the same name already there fails the create instead of being adopted. + */ export const RECORD_SCHEMA_SQL = ` -- Which layout this store is, in its one row, so an export names the one -- a reader holds. See docs/spec/portability.md § The portable format. - CREATE TABLE IF NOT EXISTS format ( + CREATE TABLE format ( id INTEGER PRIMARY KEY CHECK (id = 1), version INTEGER NOT NULL ) STRICT; - CREATE TABLE IF NOT EXISTS records ( + CREATE TABLE records ( id TEXT PRIMARY KEY, type_id TEXT NOT NULL, created_at INTEGER NOT NULL, @@ -39,7 +43,7 @@ export const RECORD_SCHEMA_SQL = ` -- A record's data associations: tags, attachments and relationships. -- Its permissions are in their own table below, so no predicate over this -- one can match a grantee as a relationship target. - CREATE TABLE IF NOT EXISTS associations ( + CREATE TABLE associations ( record_id TEXT NOT NULL REFERENCES records(id), kind TEXT NOT NULL CHECK (kind IN ('tag', 'attachment', 'relationship')), label TEXT NOT NULL, @@ -65,7 +69,7 @@ export const RECORD_SCHEMA_SQL = ` -- grantee; a group grantee's role is in the key, because member and -- admin name two different sets of people. -- See docs/spec/access-control.md § Record-level permissions. - CREATE TABLE IF NOT EXISTS permissions ( + CREATE TABLE permissions ( record_id TEXT NOT NULL REFERENCES records(id), kind TEXT NOT NULL CHECK (kind IN ('permission', 'anyone')), label TEXT NOT NULL CHECK (label IN ('read', 'write')), @@ -79,7 +83,7 @@ export const RECORD_SCHEMA_SQL = ` -- associations column: none of those aspects bumps version, so no -- version ever snapshots one. See -- docs/spec/versioning.md § Version history. - CREATE TABLE IF NOT EXISTS versions ( + CREATE TABLE versions ( record_id TEXT NOT NULL REFERENCES records(id), version INTEGER NOT NULL, type_id TEXT NOT NULL, @@ -97,7 +101,7 @@ export const RECORD_SCHEMA_SQL = ` -- by design — content lives on a versions row, and copying it here -- would make the journal the larger of the two stores. -- See docs/spec/journal.md. - CREATE TABLE IF NOT EXISTS journal ( + CREATE TABLE journal ( record_id TEXT NOT NULL REFERENCES records(id), seq INTEGER NOT NULL, at INTEGER NOT NULL, @@ -121,7 +125,7 @@ export const RECORD_SCHEMA_SQL = ` PRIMARY KEY (record_id, seq) ) STRICT; - CREATE TABLE IF NOT EXISTS types ( + CREATE TABLE types ( id TEXT PRIMARY KEY, base_id TEXT NOT NULL, version INTEGER NOT NULL, @@ -139,7 +143,7 @@ export const RECORD_SCHEMA_SQL = ` -- value_rank is 0 for a numeric value and 1 for a text one — the same -- split as "num_value IS NULL", but non-null, so one index can carry -- the whole ordering. file_id is set only for a 'file-ref' field. - CREATE TABLE IF NOT EXISTS content_index ( + CREATE TABLE content_index ( record_id TEXT NOT NULL REFERENCES records(id), field TEXT NOT NULL, value_rank INTEGER NOT NULL CHECK (value_rank IN (0, 1)), @@ -155,35 +159,35 @@ export const RECORD_SCHEMA_SQL = ` -- leaves the planner sorting every match before it can honor LIMIT. -- deleted_at and unlisted_at get none: an index on a column every query -- tests IS NULL only lures the planner off the one answering ORDER BY. - CREATE INDEX IF NOT EXISTS idx_records_type_id ON records(type_id, created_at, id); - CREATE INDEX IF NOT EXISTS idx_records_parent_id ON records(parent_id, created_at, id); - CREATE INDEX IF NOT EXISTS idx_records_entity_id ON records(entity_id, created_at, id); - CREATE INDEX IF NOT EXISTS idx_records_app_id ON records(app_id, created_at, id); - CREATE INDEX IF NOT EXISTS idx_records_principal_id ON records(principal_id, created_at, id); - CREATE INDEX IF NOT EXISTS idx_records_created_at ON records(created_at, id); - CREATE INDEX IF NOT EXISTS idx_records_updated_at ON records(updated_at, id); - CREATE INDEX IF NOT EXISTS idx_records_version ON records(version, id); + CREATE INDEX idx_records_type_id ON records(type_id, created_at, id); + CREATE INDEX idx_records_parent_id ON records(parent_id, created_at, id); + CREATE INDEX idx_records_entity_id ON records(entity_id, created_at, id); + CREATE INDEX idx_records_app_id ON records(app_id, created_at, id); + CREATE INDEX idx_records_principal_id ON records(principal_id, created_at, id); + CREATE INDEX idx_records_created_at ON records(created_at, id); + CREATE INDEX idx_records_updated_at ON records(updated_at, id); + CREATE INDEX idx_records_version ON records(version, id); -- Each association index ends with record_id so the semi-joins in -- query.ts read the matching record ids out of the index alone. -- idx_assoc_related carries related_ns ahead of related_id because a -- record- or entity-scoped target always stores '' there, which -- buildQueryPlan states explicitly to keep the prefix contiguous. - CREATE INDEX IF NOT EXISTS idx_assoc_kind_label ON associations(kind, label, record_id); - CREATE INDEX IF NOT EXISTS idx_assoc_kind_file_id ON associations(kind, file_id, record_id); - CREATE INDEX IF NOT EXISTS idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id); - CREATE INDEX IF NOT EXISTS idx_types_base_id ON types(base_id, version); + CREATE INDEX idx_assoc_kind_label ON associations(kind, label, record_id); + CREATE INDEX idx_assoc_kind_file_id ON associations(kind, file_id, record_id); + CREATE INDEX idx_assoc_related ON associations(kind, related_scope, related_ns, related_id, record_id); + CREATE INDEX idx_types_base_id ON types(base_id, version); -- The permissions key with record_id moved last, so a scoped query reads -- the records carrying an element straight out of the index. - CREATE INDEX IF NOT EXISTS idx_permissions_element + CREATE INDEX idx_permissions_element ON permissions(kind, label, grantee_kind, grantee_id, role, record_id); -- The whole content ordering, in index order, for one field at a time: -- a content-sorted page is an ordered walk of a slice of this index. - CREATE INDEX IF NOT EXISTS idx_content_index_sort + CREATE INDEX idx_content_index_sort ON content_index(field, value_rank, num_value, text_key, text_value, record_id); - CREATE INDEX IF NOT EXISTS idx_content_index_file + CREATE INDEX idx_content_index_file ON content_index(file_id, record_id) WHERE file_id IS NOT NULL; `; @@ -212,7 +216,7 @@ export const TOKENS_SCHEMA_SQL = ` * only that setting records. */ export const FTS5_SCHEMA_SQL = ` - CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5( + CREATE VIRTUAL TABLE records_fts USING fts5( content, content='records', content_rowid='rowid', diff --git a/packages/sqlite-shared/tests/config.test.ts b/packages/sqlite-shared/tests/config.test.ts index 6f25f126..533cf690 100644 --- a/packages/sqlite-shared/tests/config.test.ts +++ b/packages/sqlite-shared/tests/config.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; import { FormatMismatchError, newStoreSeed } from '@haverstack/core/adapter'; -import { writeNewStore, readStoredIdentity } from '../src/config.js'; +import { openStore, writeNewStore, readStoredIdentity } from '../src/config.js'; import type { SqlExecutor } from '../src/executor.js'; import { FORMAT_VERSION } from '../src/schema.js'; @@ -82,15 +82,29 @@ describe('writeNewStore', () => { expect(written).toEqual(readStoredIdentity(exec, 'here')); }); - // The tables, the format row and _config are one fact: a create failing on - // its format row leaves none of the tables it had already made. + // The tables, the format row and _config are one fact: a create failing + // partway leaves none of the tables it had already made, and a table of + // the same name already there fails it rather than being adopted. test('writes the whole store or none of it', () => { const exec = empty(); - exec.exec(`CREATE TABLE format (id INTEGER PRIMARY KEY, version INTEGER NOT NULL)`); - exec.run('INSERT INTO format (id, version) VALUES (1, 1)'); + exec.exec(`CREATE TABLE journal (note TEXT)`); expect(() => writeNewStore(exec, newStoreSeed({ ownerEntityId: 'did:key:owner', timezone: 'UTC' })), - ).toThrow(); + ).toThrow(/journal already exists/); expect(tables(exec)).toBe(1); }); }); + +describe('openStore', () => { + const seed = newStoreSeed({ ownerEntityId: 'did:key:owner', timezone: 'UTC' }); + + test.each(['ifMissing', 'exclusive'] as const)( + 'refuses a seed for a database already holding a store, under %s', + async (mode) => { + const exec = created('UTC'); + await expect(openStore(exec, { where: 'here', mode, wal: false, seed })).rejects.toThrow( + /already exists/, + ); + }, + ); +});