Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/owner-entity-id.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 18 additions & 0 deletions .changeset/portable-copy.md
Original file line number Diff line number Diff line change
@@ -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 `<where> 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.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand All @@ -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();
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/spec/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading