Skip to content
Merged
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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ const notes = await stack.query({
});

// Tear down when done (flushes pending writes and releases resources)
await stack.flush();
await stack.close();
```

Expand Down
14 changes: 14 additions & 0 deletions docs/spec/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,3 +105,17 @@ How each adapter honors the single-writer rule differs by what it actually is:
- **`record-adapter-sqlite`** (Node, real files) writes through `node:sqlite` under WAL journaling — page-level writes and crash safety are properties of the storage engine itself. It still acquires a PID-stamped lock file beside the database on `open()`/`initialize()`, released on `close()`, so a second opener gets a clear, immediate error rather than discovering the trust-boundary problem the hard way. A stale lock (owning process no longer alive) is reclaimed automatically, and an explicit override is available for the rare case of PID reuse.
- **`record-adapter-sqljs`** (browser, no filesystem of its own) is a purely in-memory engine — no file, no PID, no lock to speak of. Durability and multi-tab/multi-process coordination are the embedding host's concern entirely: the adapter calls an optional `persist(bytes) => Promise<void>` callback after every write, and the host wires that to OPFS, IndexedDB, or a download, with whatever locking that storage layer provides.
- **The planned whole-file `adapter-json`** reads its entire store into memory on open and rewrites it whole on every persist, so it must supply both guarantees itself: a PID lock file (to fail loudly on double-open) and an atomic temp-file-and-`rename()` persist (so a crash mid-write can't leave a torn, unreadable file). `record-adapter-sqlite` gets both from WAL and real file locking instead.

## Lifecycle

**`Stack.close()` flushes, then releases.** Teardown is one call: `close()` invokes `flush()` before `adapter.close?.()`, so no app has to know whether its adapter buffers writes. An adapter's own `close()` is therefore not required to be flush-inclusive — the ordering is an invariant of the `Stack` layer, guaranteed once for every backend rather than reimplemented per adapter.

A failed flush still releases resources before the error propagates. The alternative — abandoning `close()` on a flush error — leaves a lock file or a connection behind precisely when the stack is in trouble, turning one failure into two.

**`close()` is idempotent; calling it twice is a no-op and never reaches the adapter twice.** Adapters are not independently required to tolerate a double close (`node:sqlite` throws on an already-closed handle, and lock release is not re-entrant), so `Stack` absorbs it.

**`flush()` alone is for a stack that stays open** — checkpointing before a backup, or forcing a buffered adapter to persist at a known point. It is not part of teardown.

**Every other method throws `StackClosedError` once closed**, on both `Stack` and `ScopedStack`. Without the guard the failure surfaces as whatever the underlying engine says about a dangling handle — `node:sqlite`'s `ERR_INVALID_STATE`, or nothing at all on an adapter that silently accepts writes it will never persist. The asymmetry with `close()` is deliberate: teardown is idempotent because a caller cannot always know whether it already ran, while doing _work_ through a closed client is unambiguously a bug, and `flush()` is work.

`StackClosedError` sits outside the `StackError` taxonomy, alongside `IdGenerationError` and `InvalidDidError` (see [Wire format § The taxonomy root](./wire-format.md#the-taxonomy-root)). Every `StackError` maps to a wire status, and no server ever answers "your client is closed" — it is a local programming error, not a transportable failure. The stack-identity getters (`ownerEntityId`, `timezone`, `features`) keep working after close: they read values cached at open and touch no storage.
12 changes: 8 additions & 4 deletions docs/spec/attachments.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
Binary files are stored and retrieved through the library using **content-addressed storage**. A file's ID is the SHA-256 hash of its bytes, so uploading identical bytes twice returns the same `fileId` without writing a second binary copy. Each upload creates a new `_attachment@1` metadata record regardless of deduplication, so metadata (mimeType, size, filename) is tracked per upload.

```ts
// Upload a file — returns a stable SHA-256 hex ID and creates an _attachment@1 record
const fileId = await stack.putAttachment(data: Uint8Array, mimeType: string, filename?: string): Promise<string>
// Upload a file — stores the bytes and creates the _attachment@1 record,
// returning that record. content.fileId is a stable SHA-256 hex ID.
const record = await stack.putAttachment(data: Uint8Array, mimeType: string, filename?: string)
: Promise<StackRecord & { content: AttachmentContent }>

// Fetch the binary
const data: Uint8Array = await stack.getAttachment(fileId)
const data: Uint8Array = await stack.getAttachment(record.content.fileId)

// Delete the binary and its _attachment@1 metadata record(s)
// Throws StackConflictError if any record still references the file
Expand All @@ -29,7 +31,9 @@ type AttachmentContent = {

An `_attachment@1` record is created on every `putAttachment()` call — even if the same bytes were previously uploaded. Multiple `_attachment@1` records may therefore exist for the same `fileId`, each with its own `filename`; the binary is stored only once.

To read metadata for a given `fileId`, query `_attachment@1` records:
**`putAttachment()` returns the record it created**, matching what `POST /attachments` returns on the wire — the uploader's own metadata record is never something they have to go looking for. The `id` is the part that matters: `filename` is the only mutable field on an `_attachment@1` record, and setting it later needs an id. Without this, every caller wanting one would have to query by `fileId` and disambiguate among the several records a shared `fileId` can have.

To read metadata for a `fileId` uploaded by _someone else_, query `_attachment@1` records — note that a `fileId` may have several, one per upload:

```ts
const results = await stack.query({
Expand Down
10 changes: 8 additions & 2 deletions docs/spec/identity.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,16 @@ Crucially, an `_entity` record is a **stack-local profile card about a DID** —
type EntityContent = {
did: string; // The identity this profile is about, e.g. "did:key:z6Mk..."
name: string; // Display name — human-friendly, not necessarily unique. May contain spaces and punctuation. e.g. "Jane Smith"
handle?: string; // Short unique identifier — URL-safe, no spaces. e.g. "janesmith". Like a username. Optional for private entities.
handle?: string; // Short, conventionally URL-safe label. e.g. "janesmith". Optional.
};
```

`name`/`handle` are _this stack owner's_ labels for that DID — the petname pattern (Zooko's triangle: global, human-readable, decentralized — pick two; the escape is names local to the observer). Two stacks holding `_entity` cards with different display names for the same `did:key:...` is correct behavior: each is its owner's own contact card for that identity. Cross-stack ID collisions are a non-issue mechanically — DIDs are globally unique by construction, unlike a `RecordId` (unique within a stack only; see [Record IDs](./data-model.md#record-ids)).

**`handle` is a label, not a key, and nothing enforces its uniqueness — deliberately.** The petname model makes global uniqueness incoherent: a handle is one observer's name for a DID, so two stacks are free to use the same handle for different people, or different handles for the same person. Uniqueness _within_ a stack is coherent but pointless, since `did` already identifies the profile and nothing in the library ever resolves an entity by handle. Two cards labeled `janesmith` are a display problem for the app that allowed it, not a data-integrity violation — and forbidding them would reject ordinary states like a half-finished rename or a contact import. The URL-safe, no-spaces shape is likewise conventional: `_entity@1` declares `handle` as a plain string and validates nothing beyond that.

An app that wants handle lookup anyway builds it on `query({ filter: { content: { handle } } })` and must handle duplicates itself. Note that filtering on `content` requires the `contentFieldQuery` capability, which a server behind `adapter-api` may decline (see [Adapters](./adapters.md#adapter-capabilities)) — another reason not to design a lookup around it.

The Stack has a designated owner, identified by `_config.entityId` (a DID) — not by pointing at any particular `_entity` record's `RecordId`. The owner's own `_entity` record (`content.did === ownerEntityId`) is created automatically by `Stack.create(adapter, { ownerProfile })` if one doesn't exist yet — idempotent, safe to pass on every open. An Entity record's `entityId` (author) may point to itself but doesn't have to; `Stack.create()`'s bootstrap leaves it unset, matching the owner-attributed, no-`entityId` convention used elsewhere.

## App
Expand Down Expand Up @@ -59,11 +63,13 @@ A permission group can be promoted to a collaborative group at any time by addin
```ts
type GroupContent = {
name: string; // Display name — human-friendly, not necessarily unique. e.g. "Jane's Book Club"
handle?: string; // Short unique identifier — URL-safe, no spaces. e.g. "janes-book-club". Optional for private groups.
handle?: string; // Short, conventionally URL-safe label. e.g. "janes-book-club". Optional.
stackUrl?: string; // If present, this group owns a shared collaborative stack at this URL. Absent = permission-only group.
};
```

A group's `handle` is a label on the same terms as an entity's — unenforced, not a key, and not what addresses the group. A collaborative group is reached at its `stackUrl`; a permission group is referenced by its record id.

**Group identity.** "A group with cohesive identity" is anything that controls a key: a group can be given its own keypair (held by its admins), so it can be granted access, own a collaborative stack (`stackUrl`), and sign as itself. Membership associations list member DIDs, same as any other entity reference — no new machinery. Group key generation/custody is deferred; nothing here blocks it.

**Membership** is expressed via associations on the `_group` Record, using the existing Association model:
Expand Down
14 changes: 14 additions & 0 deletions docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ GET /.well-known/stack
}
```

### Version negotiation

`version` is the wire protocol's own version, `MAJOR.MINOR`, and it is required — not the server's software version, which is the server's business and appears nowhere in this spec.

**A client refuses a server whose major differs from its own**, at `open()`, before any other request. A major bump is defined as a change that would make a client of an earlier major read a response wrongly — a field whose meaning changed, a shape that no longer parses the same way. There is no way to use such a server safely, and discovering it mid-session is worse than refusing: the caller is left unsure which writes landed.

**A minor difference is never a refusal, in either direction.** A higher server minor is additive fields an older client ignores; a higher client minor is optional fields the server may omit. Neither can make a response read wrongly — that is what makes them minor.

A response with no `version`, or one that isn't `MAJOR.MINOR`, is refused the same way a major mismatch is. The field is mandatory here, so its absence is a server that isn't implementing this spec, and guessing on its behalf would defeat the check.

`@haverstack/wire-types` exports the current `WIRE_PROTOCOL_VERSION` along with `parseProtocolVersion()` and `isProtocolCompatible()`, so a server implementation applies the same rule as `adapter-api` rather than reimplementing it.

## Authentication

Bearer token in the `Authorization` header. Token issuance itself is out of scope for this spec — that is the server's concern — but _how a token is earned_ has a shape worth stating: see [Authentication: challenge–response](./identity.md#authentication-challengeresponse) for the nonce/signature handshake a server implements before calling `createToken()`. The adapter sends the token if configured; the server returns `401` if missing or invalid, `403` if the requester verified but lacks a grant (see [Error responses](#error-responses)).
Expand Down Expand Up @@ -247,6 +259,8 @@ Returns `413 Request Entity Too Large` (code `payload_too_large`, reconstructed

**SDK usage.** `Stack.putAttachment()`, when backed by `APIAdapter`, calls this endpoint directly — one request, carrying the real `mimeType`/`filename` — via the optional [`putAttachmentWithMetadata()`](./adapters.md#interface-split) capability. Local storage adapters don't implement that capability — bytes and records are different backends there, with no shared transaction — so `Stack.putAttachment()` falls back to its own `create()` call.

Either way the caller gets the created `_attachment@1` record back, the same thing this endpoint returns — the atomic path passes the server's response through, and the fallback path returns what its own `create()` produced. See [Attachments](./attachments.md#the-_attachment-record-type).

One consequence: there is no bytes-only upload anywhere on the wire — this endpoint always creates a record. Accordingly, **bytes-only upload has no public SDK surface either**: `putAttachment(data, mimeType, filename?)` is the upload operation, everywhere, for everyone. `StackBlobAdapter.putAttachment()` remains the required adapter-level primitive local storage needs (it's what `Stack.putAttachment()`'s fallback writes bytes through), but on `APIAdapter` it is **unsupported and throws** rather than mapping to this endpoint — implementing it anyway would silently create a record with a default `mimeType`, a bytes-only upload that isn't. `Stack.putAttachment()` never reaches it there (the atomic capability takes precedence), so the throw guards direct adapter-level callers only.

### Download
Expand Down
47 changes: 35 additions & 12 deletions packages/adapter-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ import type {
RecordId,
FileId,
} from '@haverstack/core';
import type { WireRecord, WireType, WireVersion } from '@haverstack/wire-types';
import { isWireError, deserializeError, errorForStatus } from '@haverstack/wire-types';
import type { WireRecord, WireType, WireVersion, DiscoveryResponse } from '@haverstack/wire-types';
import {
isWireError,
deserializeError,
errorForStatus,
isProtocolCompatible,
WIRE_PROTOCOL_VERSION,
} from '@haverstack/wire-types';

// -------------------------------------------------------
// Public option types
Expand Down Expand Up @@ -89,16 +95,22 @@ export class APIAdapterCapabilityError extends APIAdapterError {
}
}

// -------------------------------------------------------
// Discovery response shape
// -------------------------------------------------------

type DiscoveryResponse = {
version: string;
entityId: string;
timezone?: string;
capabilities: AdapterCapabilities;
};
/**
* Thrown by open() when the server's protocol major differs from this
* client's, or when discovery reports no parseable version at all. Refusing
* at the door beats the alternative: a major difference means some response
* reads wrongly, and finding out mid-session leaves the caller unsure which
* writes landed. See docs/spec/wire-format.md § Version negotiation.
*/
export class APIAdapterVersionError extends APIAdapterError {
constructor(
public readonly serverVersion: string | undefined,
message: string,
) {
super(message);
this.name = 'APIAdapterVersionError';
}
}

// -------------------------------------------------------
// Domain object parsers (wire JSON → typed domain objects)
Expand Down Expand Up @@ -239,6 +251,17 @@ export class APIAdapter implements StackAdapter {

const discovery = (await res.json()) as DiscoveryResponse;

if (!isProtocolCompatible(discovery.version ?? '')) {
throw new APIAdapterVersionError(
discovery.version,
discovery.version
? `Server at "${baseUrl}" speaks wire protocol "${discovery.version}"; this client ` +
`speaks "${WIRE_PROTOCOL_VERSION}".`
: `Server at "${baseUrl}" reported no wire protocol version in discovery; ` +
`"${WIRE_PROTOCOL_VERSION}" is required.`,
);
}

return new APIAdapter(
baseUrl,
opts.token,
Expand Down
59 changes: 59 additions & 0 deletions packages/adapter-api/tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
APIAdapterConnectionError,
APIAdapterError,
APIAdapterCapabilityError,
APIAdapterVersionError,
} from '../src/index.js';
import { WIRE_PROTOCOL_VERSION } from '@haverstack/wire-types';
import type { StackRecord, StackType, RecordVersion, Association } from '@haverstack/core';
import {
StackPermissionError,
Expand Down Expand Up @@ -167,6 +169,63 @@ describe('open', () => {
});
});

// -------------------------------------------------------
// open() — wire protocol version negotiation
// -------------------------------------------------------

describe('open — version negotiation', () => {
test('opens against a server declaring this client’s protocol version', async () => {
const adapter = await openAdapter({ ...DISCOVERY, version: WIRE_PROTOCOL_VERSION });
expect(adapter.ownerEntityId).toBe('entity-owner-123');
});

test('refuses a server whose protocol major differs', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, version: '2.0' }));
await expect(APIAdapter.open({ url: BASE_URL, token: TOKEN })).rejects.toThrow(
APIAdapterVersionError,
);
});

test('a higher server minor opens — added fields an older client ignores', async () => {
const adapter = await openAdapter({ ...DISCOVERY, version: '1.7' });
expect(adapter.ownerEntityId).toBe('entity-owner-123');
});

test('a lower server minor opens — omitted fields an older server never had', async () => {
const adapter = await openAdapter({ ...DISCOVERY, version: '1.0' });
expect(adapter.ownerEntityId).toBe('entity-owner-123');
});

test('refuses discovery with no version at all', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, version: undefined }));
await expect(APIAdapter.open({ url: BASE_URL, token: TOKEN })).rejects.toThrow(
APIAdapterVersionError,
);
});

test('refuses a version that is not MAJOR.MINOR', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, version: 'v1' }));
await expect(APIAdapter.open({ url: BASE_URL, token: TOKEN })).rejects.toThrow(
APIAdapterVersionError,
);
});

test('carries the offending version for a caller that wants to report it', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, version: '2.0' }));
const err = await APIAdapter.open({ url: BASE_URL, token: TOKEN }).catch((e: unknown) => e);
expect(err).toBeInstanceOf(APIAdapterVersionError);
expect((err as APIAdapterVersionError).serverVersion).toBe('2.0');
});

test('refuses before sending any other request', async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ ...DISCOVERY, version: '2.0' }));
await expect(APIAdapter.open({ url: BASE_URL, token: TOKEN })).rejects.toThrow(
APIAdapterVersionError,
);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});

// -------------------------------------------------------
// createRecord
// -------------------------------------------------------
Expand Down
Loading
Loading