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 new file mode 100644 index 00000000..8a7b4463 --- /dev/null +++ b/.changeset/portable-copy.md @@ -0,0 +1,18 @@ +--- +'@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, 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. +- `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/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/README.md b/README.md index 5ec6146f..8c84d5ba 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, 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). + --- ## Development diff --git a/docs/spec.md b/docs/spec.md index c9eda826..45b2d228 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 | @@ -30,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(); @@ -94,12 +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. +**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/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..fa1eb3c2 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, 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. @@ -119,12 +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 | + +**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`: @@ -134,15 +138,17 @@ 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. -`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.** 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. ## 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 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/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/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/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..d9e319aa --- /dev/null +++ b/docs/spec/portability.md @@ -0,0 +1,126 @@ +# 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. 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'; + +const target = await copyStack(from, (seed) => createStore(seed)); + +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 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**, 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 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. + +**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. + +**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 + +```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. +- **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 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. + +**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) => (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')); +await exported.close(); + +// 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; 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. + +**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 | +| --------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| `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` | +| `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**, 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. + +### 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 | +| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `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` | +| `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 `{ 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. + +## 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..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) | @@ -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..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, @@ -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..e815b874 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, @@ -301,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)); @@ -314,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, @@ -333,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, @@ -351,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); @@ -365,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 }), @@ -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-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/adapter-conformance/src/copy.ts b/packages/adapter-conformance/src/copy.ts new file mode 100644 index 00000000..50fbf26c --- /dev/null +++ b/packages/adapter-conformance/src/copy.ts @@ -0,0 +1,150 @@ +/** + * Stack copy conformance suite + * ------------------------------------------------------- + * The copy a move between hosts makes: a stack with a past is written + * 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. + */ + +import { describe, test, expect } from 'vitest'; +import { Stack, SYSTEM_TYPES } from '@haverstack/core'; +import type { FileId, StackRecord } from '@haverstack/core'; +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 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; + /** Release what either factory acquired. Called on every store they made. */ + 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'; +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 => + 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, fileIds: FileId[]) { + 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); + // `_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 = {}; + for (const fileId of fileIds) 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, bytes a content field names with no metadata record, a soft + * 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' } }); + await stack.defineType({ + id: NOTE, + name: 'Note', + 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 }); + 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); + + 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 [attached.content.fileId, bare]; +} + +export function runCopyConformance(options: CopyConformanceOptions): void { + const { name, create, source: createSource = create, close } = options; + + describe(`stack copy conformance: ${name}`, () => { + test('implements importRecord()', async () => { + const store = await create(SEED); + try { + expect(store.importRecord).toBeTypeOf('function'); + } finally { + await close?.(store); + } + }); + + test('a copy carries every Record, version, journal entry, Type and blob unchanged', async () => { + const source = await createSource(SEED); + let target: StackAdapter | undefined; + try { + const fileIds = await writePast(source); + const expected = await contentsOf(source, fileIds); + expect(expected.records.length).toBeGreaterThan(3); + + target = await copyStack(source, create); + expect(await contentsOf(target, fileIds)).toEqual(expected); + } finally { + await close?.(source); + if (target) 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..a34678fc 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 } from './copy.js'; diff --git a/packages/adapter-conformance/src/record.ts b/packages/adapter-conformance/src/record.ts index 63cd4ec1..0240982d 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,146 @@ 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); + 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); + }); + + 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..1bc33ac9 --- /dev/null +++ b/packages/adapter-conformance/tests/copy-memory.test.ts @@ -0,0 +1,11 @@ +/** + * 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', + create: (seed) => MemoryAdapter.open({ seed }), +}); 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..0a6a458d 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..676b3967 --- /dev/null +++ b/packages/adapter-local/tests/copy.test.ts @@ -0,0 +1,50 @@ +/** + * 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 { 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 (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, + }); + dirs.set(adapter, dir); + return adapter; +}; + +const memory: CreateStore = (seed) => MemoryAdapter.open({ seed }); + +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 into MemoryAdapter', + create: memory, + source: local, + close, +}); 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/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/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..2b3b0f5e --- /dev/null +++ b/packages/core/src/adapter/copy.ts @@ -0,0 +1,121 @@ +/** + * Copying a stack + * ------------------------------------------------------- + * 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 + * 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, OwnerMismatchError } from '../errors.js'; +import { resolveQuery } from '../query-validation.js'; +import type { + ConfigContent, + PlainQuery, + StackAdapter, + StackRecordAdapter, + StoreSeed, +} from '../types/index.js'; +import { SYSTEM_TYPES } from '../types/index.js'; + +/** 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 every store is created from. */ +const EVERYTHING: PlainQuery = { + filter: { includeDeleted: true, includeUnlisted: true }, + sort: { field: 'createdAt', direction: 'asc' }, + limit: PAGE_SIZE, +}; + +/** + * 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. + */ +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 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, (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)); + await copyRecords(from, importRecord); + return to; + } catch (err) { + // The failure that stopped the copy is the one to report. + await to.close?.().catch(() => {}); + throw err; + } +} + +function refuseTarget(why: string): never { + throw new InvalidAdapterError(`Cannot copy into the store create() returned: ${why}.`); +} + +/** + * 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, + listHeld: NonNullable, +): Promise { + if (to.ownerEntityId !== 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( + from: StackAdapter, + importRecord: NonNullable, +): Promise { + 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 importRecord(record, { versions, journal }); + } + cursor = page.cursor ?? undefined; + } while (cursor); +} diff --git a/packages/core/src/adapter/index.ts b/packages/core/src/adapter/index.ts index eccc0c4e..00755af8 100644 --- a/packages/core/src/adapter/index.ts +++ b/packages/core/src/adapter/index.ts @@ -23,10 +23,21 @@ export type { AspectWriteOptions, ElementWriteOptions, Reach, + RecordHistory, + StackIdentity, + StoreSeed, } 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 } 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 aa1bc4ed..4ff4560e 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,13 +258,35 @@ 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'; } } +/** + * 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/adapters.md § Construction. + */ +export class FormatMismatchError extends Error { + constructor( + public readonly expected: number, + public readonly actual: number | undefined, + public readonly where: string, + ) { + super( + actual === undefined + ? `${where} names no format version, not ${expected}.` + : `${where} is format version ${actual}, not ${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/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 ebf7c8ed..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, ); } @@ -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/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/testing.ts b/packages/core/src/testing.ts index 395cc115..d6a92105 100644 --- a/packages/core/src/testing.ts +++ b/packages/core/src/testing.ts @@ -35,6 +35,8 @@ import type { StackCapabilities, BlobInfo, Reach, + RecordHistory, + StoreSeed, } from './types/index.js'; import { SYSTEM_TYPES } from './types/index.js'; import { applyMergePatch } from './merge.js'; @@ -42,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, @@ -62,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 @@ -102,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); } @@ -122,6 +133,30 @@ export class MemoryAdapter implements StackAdapter { return stored; } + async importRecord(record: StackRecord, history: RecordHistory) { + 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, + [...history.versions].sort((a, b) => a.version - b.version), + ); + this.journals.set( + record.id, + [...history.journal].sort((a, b) => a.seq - b.seq), + ); + return stored; + } + async getRecord(id: string) { return this.records.get(id) ?? null; } @@ -536,31 +571,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 @@ -640,7 +650,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/src/types/adapter.ts b/packages/core/src/types/adapter.ts index 8f3af875..de22250c 100644 --- a/packages/core/src/types/adapter.ts +++ b/packages/core/src/types/adapter.ts @@ -119,6 +119,29 @@ 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[]; +}; + +/** 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; @@ -152,6 +175,14 @@ export interface StackRecordAdapter { */ createRecord(record: StackRecord, opts?: JournalOptions): Promise; getRecord(id: RecordId): Promise; + /** + * 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; + /** * 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 +264,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/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..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: { entityId: OWNER, timezone: 'UTC' }, - version: 1, - }); const { seen, handler } = collector(); await stack.subscribe(handler); diff --git a/packages/core/tests/combine.test.ts b/packages/core/tests/combine.test.ts index f1cb67c5..a33d156e 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,30 @@ 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]; + return record; + }, + }), + blob: makeBlobAdapter(), + }); + const history: RecordHistory = { versions: [], journal: [] }; + expect(await adapter.importRecord!(purgedRecord, history)).toBe(purgedRecord); + 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..44a5e882 --- /dev/null +++ b/packages/core/tests/copy.test.ts @@ -0,0 +1,127 @@ +import { describe, test, expect } from 'vitest'; +import { copyStack } 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, StoreSeed } from '../src/types/index.js'; + +const OWNER = 'did:key:owner'; +const NOTE = 'com.example.test/note@1'; + +const create = (seed: StoreSeed) => MemoryAdapter.open({ seed }); + +async function sourceWith(notes: number): Promise { + 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' }, 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: (seed: StoreSeed) => Promise) { + const made: { closed: boolean } = { closed: false }; + return { + made, + create: async (seed: StoreSeed) => { + const store = await make(seed); + store.close = async () => void (made.closed = true); + return store; + }, + }; +} + +describe('copyStack', () => { + // _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 () => { + const source = await sourceWith(250); + const target = (await copyStack(source, create)) as MemoryAdapter; + expect([...target.records.keys()].sort()).toEqual([...source.records.keys()].sort()); + }); + + 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' }); + 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 source with no listBlobs(), before creating anything', async () => { + const source = await sourceWith(1); + source.listBlobs = undefined; + let created = false; + await expect( + copyStack(source, (seed) => { + created = true; + return create(seed); + }), + ).rejects.toThrow(InvalidAdapterError); + expect(created).toBe(false); + }); + + // 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', () => { + type Make = (seed: StoreSeed) => Promise; + const without = + (method: 'importRecord' | 'listBlobs'): Make => + async (seed) => { + const store = await create(seed); + 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 () => MemoryAdapter.open({ ownerEntityId: 'did:key:other' }), + OwnerMismatchError, + ], + ['a Record already in it', async () => sourceWith(1), InvalidAdapterError], + [ + 'the system Types Stack.open() seeds', + async (seed) => { + const store = await create(seed); + await Stack.open(store); + return store; + }, + InvalidAdapterError, + ], + [ + 'a blob already in it', + async (seed) => { + const store = await create(seed); + await store.putBlob(new Uint8Array([5])); + return store; + }, + InvalidAdapterError, + ], + ]; + + test.each(cases)('%s', async (_name, make, error) => { + const source = await sourceWith(1); + 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/scoped-stack.test.ts b/packages/core/tests/scoped-stack.test.ts index 0e92ddc7..139cdd2f 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,22 @@ 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({ + version: 2, + 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 +935,47 @@ 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({ + 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: 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({ + 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: NOTE, + content: { title: 'snapshotted' }, + updatedAt: new Date(0), + createdBy: { subjectId: 'someone-else' }, + }, + ], + journal: [], }); const [memberList, memberSingle] = await Promise.all([ @@ -977,20 +994,24 @@ 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({ + version: 2, 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 +1037,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 +1077,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 +1120,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 +1148,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 +2325,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..2b77ff53 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`. */ @@ -139,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 () => { @@ -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' }, @@ -3589,125 +3596,103 @@ 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(entityId = 'owner-123', timezone = 'UTC') { - return adapter.createRecord({ - id: CONFIG_ID, - typeId: CONFIG_TYPE, - createdAt: new Date(), - updatedAt: new Date(), - content: { entityId, 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 entityId', async () => { - await seedConfig(); - await expect(stack.patchContent(CONFIG_ID, { entityId: 'someone-else' })).rejects.toThrow( + test('patchContent() rejects a change to ownerEntityId', async () => { + 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 () => { - 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 entityId 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(); + test('setting ownerEntityId to its current value is a no-op, not an error', async () => { + 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 entityId', async () => { - await seedConfig('owner-123'); + test('restoreVersion() rejects a snapshot with a different ownerEntityId', async () => { // 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. - await adapter.saveVersion(CONFIG_ID, { + 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 () => { - await seedConfig('owner-123', 'UTC'); + test('restoreVersion() allows a snapshot with the same ownerEntityId', async () => { 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 entityId via scoped update either', async () => { - await seedConfig('owner-123'); + test('ScopedStack delegation: the owner cannot change ownerEntityId via scoped update either', async () => { 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); }); 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 entityId', async () => { - await seedConfig('owner-123'); + test('commitMigration() rejects a change to ownerEntityId', async () => { 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 () => { - await seedConfig('owner-123'); + test('commitMigration() allows the same ownerEntityId', async () => { 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 5f23c7e0..d37968dd 100644 --- a/packages/record-adapter-do-sqlite/src/index.ts +++ b/packages/record-adapter-do-sqlite/src/index.ts @@ -22,46 +22,51 @@ * SharedSqlRecordAdapter, exactly as it does for record-adapter-sqlite. */ -import { - applyRecordSchema, - insertConfigRecord, - tryReadStackConfig, - SharedSqlRecordAdapter, - type StackConfig, -} from '@haverstack/sqlite-shared/record'; -import { OwnerMismatchError } 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 // ------------------------------------------------------- export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { - private constructor(exec: DurableObjectSqliteExecutor, config: StackConfig) { - super(exec, config); + private constructor(exec: DurableObjectSqliteExecutor, identity: StackIdentity) { + super(exec, identity); } /** * 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 @@ -72,22 +77,13 @@ export class DoSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: DoSQLiteRecordAdapterOpenOptions, ): Promise { const exec = new DurableObjectSqliteExecutor(storage); - applyRecordSchema(exec, { wal: false }); - 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', - ); - } - } else { - const ownerEntityId = - typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - config = insertConfigRecord(exec, ownerEntityId, opts.timezone); - } - return new DoSQLiteRecordAdapter(exec, config); + 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-do-sqlite/tests/record.test.ts b/packages/record-adapter-do-sqlite/tests/record.test.ts index e1a75c02..46a8b62a 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,51 @@ 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); + }); + + // 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({ version: 2 }); + 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/src/index.ts b/packages/record-adapter-sqlite/src/index.ts index adfb6a61..01b982c1 100644 --- a/packages/record-adapter-sqlite/src/index.ts +++ b/packages/record-adapter-sqlite/src/index.ts @@ -26,34 +26,28 @@ */ import { DatabaseSync } from './node-sqlite.js'; -import { existsSync } from 'fs'; +import { existsSync, rmSync } from 'fs'; import { - applyRecordSchema, acquireLock, releaseLock, - insertConfigRecord, - readStackConfig, + missingStoreError, + openStore, SharedSqlRecordAdapter, - type StackConfig, + type OwnerEntityIdInput, + type StoreCreateMode, } from '@haverstack/sqlite-shared'; -import { OwnerMismatchError } 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; } ); @@ -88,9 +95,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); } /** @@ -103,57 +110,25 @@ export class NativeSQLiteRecordAdapter extends SharedSqlRecordAdapter { opts: NativeSQLiteRecordAdapterOpenOptions, ): Promise { const mode: StoreCreateMode = opts.create ?? 'never'; - 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 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 = - typeof opts.ownerEntityId === 'function' ? await opts.ownerEntityId() : opts.ownerEntityId; - if (!newOwner) { - throw new Error( - `Cannot create: no ownerEntityId given for the new database at "${opts.path}".`, - ); - } - } + 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); 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-checked under the lock: another process may have created the file meanwhile. - const exists = existsSync(opts.path); - checkMode(exists); db = new DatabaseSync(opts.path); const exec = new NativeSqliteExecutor(db); - applyRecordSchema(exec, { wal: true }); - 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}"`); - } - } else { - if (!newOwner) throw new Error(`Cannot create: "${opts.path}" vanished while opening.`); - config = insertConfigRecord(exec, newOwner, opts.timezone); - } - return new NativeSQLiteRecordAdapter(opts.path, db, exec, config); + const identity = await openStore(exec, { ...opts, mode, where, wal: true }); + 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/portable-format.test.ts b/packages/record-adapter-sqlite/tests/portable-format.test.ts new file mode 100644 index 00000000..67feddbc --- /dev/null +++ b/packages/record-adapter-sqlite/tests/portable-format.test.ts @@ -0,0 +1,63 @@ +/** + * 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, 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'); + 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), + ]), + ); + const format = db.prepare('SELECT version FROM format').all(); + db.close(); + + expect(actual).toEqual(documentedColumns()); + expect(format).toEqual([{ version: 1 }]); +}); diff --git a/packages/record-adapter-sqlite/tests/record.test.ts b/packages/record-adapter-sqlite/tests/record.test.ts index 2e7db4cc..1e89da57 100644 --- a/packages/record-adapter-sqlite/tests/record.test.ts +++ b/packages/record-adapter-sqlite/tests/record.test.ts @@ -10,8 +10,8 @@ import { StackNotFoundError, StackBadRequestError, } from '@haverstack/core'; -import { OwnerMismatchError, resolveQuery } from '@haverstack/core/adapter'; -import type { AuthorityAssociation, StackRecord } from '@haverstack/core'; +import { FormatMismatchError, OwnerMismatchError, resolveQuery } from '@haverstack/core/adapter'; +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', @@ -221,6 +242,68 @@ describe('open create modes', () => { }, ); + // A store moves to a new layout only by being copied into one. + // See docs/spec/adapters.md § Construction. + 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 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(); + }); + + // A failed create leaves nothing behind, so a later open finds the store + // missing and creates it. + // See docs/spec/adapters.md § Construction. + 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: 'owner-new', + }); + expect(adapter.ownerEntityId).toBe('owner-new'); + }, + ); + test('a matching plain-string owner opens an existing store', async () => { await (await initAdapter({ ownerEntityId: 'owner-abc' })).close(); const adapter = await NativeSQLiteRecordAdapter.open({ @@ -436,13 +519,17 @@ 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, + const record = makeRecord({ version: 2 }); + 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,14 +724,18 @@ 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({ version: 2, content: { text: 'v2' } }); + 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 await expect(adapter.restoreVersion(record.id, 1, { ifVersion: 1 })).rejects.toBeInstanceOf( StackVersionConflictError, @@ -676,14 +767,18 @@ 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({ version: 2, content: { text: 'v2' } }); + 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 const results = await Promise.allSettled([ adapter.restoreVersion(record.id, 1, { ifVersion: 2 }), @@ -2161,10 +2256,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 record = makeRecord({ version: 2 }); const version = { version: 1, typeId: record.typeId, @@ -2172,7 +2266,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'); @@ -2183,14 +2277,14 @@ 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(); - await adapter.createRecord(record); + const record = makeRecord({ version: 3 }); 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 +2296,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 +2320,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 +2338,16 @@ describe('versions', () => { ).toHaveLength(2); }); - test('saveVersion throws on a (record, version) collision instead of silently dropping the snapshot', 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(); - 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); + 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, + ); + expect(await adapter.getRecord(record.id)).toBeNull(); }); // A versions row at the record's own current version is an orphan: no @@ -2264,7 +2362,7 @@ describe('versions', () => { 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, { + plantVersionRow(record.id, { version: 1, typeId: record.typeId, content: { text: 'original' }, @@ -2299,7 +2397,7 @@ describe('versions', () => { 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, { + plantVersionRow(record.id, { version: 1, typeId: record.typeId, content: { text: 'stale' }, @@ -2380,15 +2478,18 @@ describe('versions', () => { 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(), + const record = makeRecord({ version: 2, content: { text: 'changed' } }); + await adapter.importRecord(record, { + versions: [ + { + version: 1, + typeId: record.typeId, + content: { text: 'original' }, + updatedAt: new Date(), + }, + ], + journal: [], }); - await adapter.patchContent(record.id, { text: 'changed' }); const restored = await adapter.restoreVersion(record.id, 1); expect(restored.content).toEqual({ text: 'original' }); @@ -2397,15 +2498,18 @@ 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(), + const record = makeRecord({ version: 2, content: { text: 'changed unrelated text' } }); + 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' }); await adapter.restoreVersion(record.id, 1); @@ -2433,13 +2537,17 @@ describe('restoreVersion', () => { // § Version history. 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(), + const record = makeRecord({ version: 2 }); + 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' }]); @@ -2452,15 +2560,18 @@ 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' } }); - await adapter.createRecord(record); - await adapter.saveVersion(record.id, { - version: 1, - typeId: record.typeId, - content: { text: 'original' }, - updatedAt: new Date(), + const record = makeRecord({ version: 2, content: { text: 'changed' } }); + 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' }]); const restored = await adapter.restoreVersion(record.id, 1); @@ -2470,13 +2581,17 @@ 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(), + const record = makeRecord({ typeId: 'com.example.test/note@1', version: 2 }); + 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/sqlite-shared/src/config.ts b/packages/sqlite-shared/src/config.ts index b544633e..2816bf6f 100644 --- a/packages/sqlite-shared/src/config.ts +++ b/packages/sqlite-shared/src/config.ts @@ -1,46 +1,120 @@ +import { + FormatMismatchError, + OwnerMismatchError, + identityOf, + newStoreSeed, +} from '@haverstack/core/adapter'; +import type { StackIdentity, StoreSeed } from '@haverstack/core/adapter'; import type { SqlExecutor } from './executor.js'; +import { SharedSqlRecordLogic } from './record-logic.js'; +import { + applyConnectionPragmas, + FORMAT_VERSION, + FTS5_SCHEMA_SQL, + RECORD_SCHEMA_SQL, +} from './schema.js'; -/** timezone is optional passthrough app metadata — no default. */ -export type StackConfig = { entityId: string; timezone: string | undefined }; +/** 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'`; +/** 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.`, + ); + /** - * 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 everything a new store is created with — its tables, its `format` + * 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 insertConfigRecord = ( - exec: SqlExecutor, - entityId: string, - timezone: string | undefined, -): StackConfig => { - const now = Date.now(); - 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 }; +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]); + new SharedSqlRecordLogic({ exec }).writeImported(seed.config, seed.history); + }); + return identityOf(seed); }; /** - * 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 the database holds no store. + * 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 tryReadStackConfig = (exec: SqlExecutor): StackConfig | null => { - const row = exec.get<{ content: string }>(CONFIG_ROW_SQL); - if (!row) return null; - const content = JSON.parse(row.content) as { entityId: string; timezone?: string }; - return { entityId: content.entityId, timezone: content.timezone }; +export const readStoredIdentity = (exec: SqlExecutor, where: string): StackIdentity | null => { + 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 { content } = exec.get<{ content: string }>(CONFIG_ROW_SQL)!; + const config = JSON.parse(content) as StackIdentity; + return { ownerEntityId: config.ownerEntityId, timezone: config.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; +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-adapter.ts b/packages/sqlite-shared/src/record-adapter.ts index cab1efcc..7d65e602 100644 --- a/packages/sqlite-shared/src/record-adapter.ts +++ b/packages/sqlite-shared/src/record-adapter.ts @@ -39,9 +39,10 @@ import type { JournalOptions, 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'; /** @@ -76,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; } // ------------------------------------------------------- @@ -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..e10b17c3 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,53 @@ const versionRowValues = (version: RecordVersion): unknown[] => [ version.updatedBy?.principalId ?? null, ]; +/** + * 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', + 'seq', + 'at', + 'kind', + 'ops', + 'previous_parent_id', + 'actor', + 'associations', + 'version', + 'type_id', + 'parent_id', +] as const; + +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 }; + +/** 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 = { exec: SqlExecutor; }; @@ -244,46 +292,76 @@ 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.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. + */ + 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 { @@ -625,8 +703,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; @@ -648,15 +725,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 @@ -670,7 +738,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); } @@ -682,9 +756,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; @@ -698,28 +772,29 @@ export class SharedSqlRecordLogic { }>('SELECT version, type_id, parent_id FROM records WHERE id = ?', [recordId]); if (!row) throw recordNotFound(recordId); + const { seq } = this.exec.get<{ seq: number }>( + 'SELECT COALESCE(MAX(seq), 0) + 1 AS seq FROM journal WHERE record_id = ?', + [recordId], + )!; this.exec.run( - `INSERT INTO journal - (record_id, seq, at, kind, ops, version, type_id, parent_id, - previous_parent_id, actor, associations) - VALUES ( - ?, - (SELECT COALESCE(MAX(seq), 0) + 1 FROM journal WHERE record_id = ?), - ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [ - recordId, - recordId, - toMs(new Date()), - entry.kind, - JSON.stringify(entry.ops), - 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, - ], + INSERT_JOURNAL_SQL, + journalValues(recordId, seq, 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_JOURNAL_SQL, + journalValues(recordId, entry.seq, entry.at, entry, { + version: entry.version, + typeId: entry.typeId, + parentId: entry.parentId ?? null, + }), ); } diff --git a/packages/sqlite-shared/src/record.ts b/packages/sqlite-shared/src/record.ts index d813b4c4..06b0d523 100644 --- a/packages/sqlite-shared/src/record.ts +++ b/packages/sqlite-shared/src/record.ts @@ -16,13 +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 { applyRecordSchema } from './schema.js'; +export { FORMAT_VERSION } from './schema.js'; export { sanitizeFts5Query } from './fts5.js'; export { type SqlExecutor } from './executor.js'; export { - insertConfigRecord, - readStackConfig, - tryReadStackConfig, - type StackConfig, + 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/src/schema.ts b/packages/sqlite-shared/src/schema.ts index 1b05f1a2..c9a56d5a 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 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. + CREATE TABLE IF NOT EXISTS format ( + id INTEGER PRIMARY KEY CHECK (id = 1), + version INTEGER NOT NULL + ) STRICT; + CREATE TABLE IF NOT EXISTS records ( id TEXT PRIMARY KEY, type_id TEXT NOT NULL, @@ -225,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 @@ -235,14 +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. + * 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 88d49948..6f25f126 100644 --- a/packages/sqlite-shared/tests/config.test.ts +++ b/packages/sqlite-shared/tests/config.test.ts @@ -1,70 +1,96 @@ import { describe, test, expect } from 'vitest'; -import { - insertConfigRecord, - readStackConfig, - tryReadStackConfig, - type StackConfig, -} from '../src/config.js'; +import { DatabaseSync } from 'node:sqlite'; +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'; -/** - * 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(); +const empty = () => executor(new DatabaseSync(':memory:')); + +/** A store as a create leaves it. */ +const created = (timezone: string | undefined) => { + const exec = empty(); + writeNewStore(exec, newStoreSeed({ ownerEntityId: 'did:key:owner', timezone })); + return exec; +}; + +const tables = (exec: SqlExecutor) => + exec.all<{ name: string }>(`SELECT name FROM sqlite_master WHERE type = 'table'`).length; + +/** The error a call throws, or undefined when it returns. */ +const thrown = (fn: () => unknown): unknown => { + try { + fn(); + return undefined; + } catch (e) { + return e; + } +}; + +describe('readStoredIdentity', () => { + test('is null for a database holding no store', () => { + expect(readStoredIdentity(empty(), '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 identity = readStoredIdentity(created(undefined), '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], + ] as const)('refuses a store naming %s, writing nothing', (_name, sql, actual) => { + const exec = created('UTC'); + exec.exec(sql); + const before = tables(exec); + const err = thrown(() => readStoredIdentity(exec, 'here')); + 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)); +describe('writeNewStore', () => { + test('returns the identity it wrote, matching what a later read yields', () => { + const exec = empty(); + const written = writeNewStore( + exec, + newStoreSeed({ 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/); + // 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, newStoreSeed({ ownerEntityId: 'did:key:owner', timezone: 'UTC' })), + ).toThrow(); + 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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8ebd291..572a401e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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