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
8 changes: 8 additions & 0 deletions .changeset/typed-collections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@haverstack/core': minor
'@haverstack/commons': patch
---

Typed access goes through collections. `client.collection(handle)` returns a `Collection` typed by the handle's content, with one signature per verb — `get`, `query`, `subscribe`, `create`, `mutate`, `patchContent`, `associate`, `dissociate`, `amendAssociations`, `grantAccess`, `revokeAccess`, `amendAccess`, `delete`, `undelete` and `restoreVersion` — every one returning a typed record without a re-read. A collection spans the handle's whole family and migrates each record in memory to the handle's version. A record stored at a newer version is a misfit: `query()` reports it in `misfits` and keeps the page and cursor, `get()` throws `MisfitError`, and `subscribe()` carries it as `misfit`. A content write to a record at an older version commits its migration as a write of its own first. `collection()` refuses a handle whose `migratesFrom` has no migration passed to `Stack.open()`.

The handle-first overloads of `get`, `query`, `create`, `mutate`, `patchContent` and `subscribe` are removed, with `TypedRecord`, `TypedQuery`, `TypedChange`, `TypedChangeSet` and `TypedSubscribeOptions`. `PatchOf` is removed: a collection's patch type is `ContentPatch<C>`, derived from its content type, and `StoredContent<C>` is what its reads return.
25 changes: 16 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,17 +148,20 @@ const Note = typeHandle({
});
await stack.defineType(Note);

// Typed access to the Note family
const notes = stack.collection(Note);

// Create a record
const note = await stack.create(Note, {
const note = await notes.create({
text: 'Hello, Haverstack!',
title: 'My first note',
});

// Update its content (partial merge — only changed fields needed)
await stack.patchContent(Note, note.id, { title: 'Updated title' });
await notes.patchContent(note.id, { title: 'Updated title' });

// Read it back, typed: `content.text` is a string
const same = await stack.get(Note, note.id);
const same = await notes.get(note.id);

// Tag it
await stack.associate(note.id, [{ kind: 'tag', label: 'favourite' }]);
Expand All @@ -172,7 +175,7 @@ await stack.mutate(note.id, {

// Query. With no filter, query() returns every record you can read, from every
// app and system types included, so filter by typeId, baseId or appId to get your own.
const notes = await stack.query({
const favourites = await stack.query({
filter: { typeId: 'com.example.myapp/note@1', tags: ['favourite'] },
sort: { field: 'createdAt', direction: 'desc' },
});
Expand All @@ -191,7 +194,8 @@ An app has two layers, because the stack draws a line between them:
Migrations belong to neither. They are passed to `Stack.open()` on **every startup**, so the chain is complete before the first read.

```ts
import { Stack, migration, typeHandle, type StackClient } from '@haverstack/core';
import { Stack, migration, typeHandle } from '@haverstack/core';
import type { Collection, ContentOf, StackClient } from '@haverstack/core';

const NoteV1 = typeHandle({
id: 'com.example.myapp/note@1',
Expand All @@ -216,15 +220,18 @@ export const noteMigrations = [

// Data layer: whoever the stack lets in.
export class Notes {
constructor(private readonly client: StackClient) {}
private readonly notes: Collection<ContentOf<typeof Note.schema>>;
constructor(client: StackClient) {
this.notes = client.collection(Note);
}
add(text: string) {
return this.client.create(Note, { text, pinned: false });
return this.notes.create({ text, pinned: false });
}
pin(id: string) {
return this.client.patchContent(Note, id, { pinned: true });
return this.notes.patchContent(id, { pinned: true });
}
list() {
return this.client.query(Note);
return this.notes.query();
}
}

Expand Down
33 changes: 23 additions & 10 deletions docs/spec/data-model.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/spec/wire-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ Empty is not an encoding of anything — nor is a literal `null`. Absence is `40
| **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) |
| **503** | Service unavailable | `StackTimeoutError` (code `timeout`) — the server abandoned an operation for taking too long, in practice a full-text search (see [Bounding query cost](#bounding-query-cost)). Nothing was applied, and the request is worth retrying — which is why this is not `bad_request`: that code tells a client its request was malformed and retrying won't help. Status-only reconstruction deliberately does **not** map 503 to this class: a bodyless 503 is usually a load balancer or a restarting process, and reporting that as a timeout would tell an app its query was too expensive when the server never ran it |
| **500** | Internal server error | Reserved for `StackMigrationError` (code `migration`). Core throws it client-side — registering migrations, reading with `presentAt: 'latest'`, `migrateAll()` — and never from a server, so no response carries this code; it has a status because every code does. **Not** used as a generic catch-all: an unrelated server crash is a plain 500 with no wire error body, and clients must not infer `StackMigrationError` from status 500 alone (see below) |
| **500** | Internal server error | Reserved for `StackMigrationError` (code `migration`). Core throws it client-side — opening a stack with migrations it cannot walk, reading with `presentAt: 'latest'`, `migrateAll()`, `collection()` for a handle whose migration is missing and a collection's read of a record no migration reaches — and never from a server, so no response carries this code; it has a status because every code does. **Not** used as a generic catch-all: an unrelated server crash is a plain 500 with no wire error body, and clients must not infer `StackMigrationError` from status 500 alone (see below) |

The distinction between **400** and **422** matters for write endpoints (`POST /records`, `PATCH /records/:id`, `POST /records/:id/migrate`, `POST /types`): a 400 covers a request that couldn't be parsed at all, or that is well-formed but addresses something that doesn't exist (an undefined `typeId`, a malformed TypeId, search text the engine cannot parse); a 422 means the server understood the request and what it addressed, but the content didn't satisfy the type schema. The distinguishing question is whether there was a schema to fail against: `StackValidationError.details` names the fields that broke, and an unknown type has no such detail to report — `Stack.create()` resolves the type and throws before the validation block runs for exactly this reason.

Expand Down Expand Up @@ -221,7 +221,7 @@ The body parsers split errors the way [Records](#records) does for a create: a b

### The taxonomy root

Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`, `InvalidAuthChallengeError`, `UseAfterCloseError`, `InvalidAdapterError`, `OwnerMismatchError`, `RelayScopeError`) stay outside the hierarchy for that reason.
Every class in the table above extends the abstract `StackError`, so `err instanceof StackError` answers the one question a server's error middleware asks first: is this a Stack-domain failure with a wire representation, or an ordinary bug that should surface as a bare 500? Membership is exactly that guarantee — a `StackError` always has a `code`, and every code has a status. Errors with no wire mapping (`IdGenerationError`, `InvalidDidError`, `InvalidAuthChallengeError`, `UseAfterCloseError`, `InvalidAdapterError`, `OwnerMismatchError`, `RelayScopeError`, `MisfitError`) stay outside the hierarchy for that reason.

**The `Stack` prefix is reserved for members.** A class named `Stack…Error` extends `StackError`, and no error outside the hierarchy carries the prefix, so the name alone says whether a server can serialize it.

Expand Down
8 changes: 4 additions & 4 deletions packages/commons/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,15 @@ import { defineCommonsTypes, NOTE, TASK } from '@haverstack/commons';
await defineCommonsTypes(stack, [NOTE, TASK]);

// Each constant is a type handle, so content is typed from the schema.
const note = await stack.create(NOTE, { text: 'Hello, Haverstack!' });
const tasks = await stack.query(TASK); // records: { content: { title: string; done: boolean; ... } }[]
const note = await stack.collection(NOTE).create({ text: 'Hello, Haverstack!' });
const tasks = await stack.collection(TASK).query(); // records: { content: { title: string; done: boolean; ... } }[]
```

Each export (`NOTE`, `BOOKMARK`, `TASK`, `CONTACT`, `ARTICLE`, `PLACE`, `PAGE`, `IMAGE`,
`POST`, `SITE`) is a [type handle](https://github.com/haverstack/core/blob/main/docs/spec/data-model.md#type-handles)
— `{ id, baseId, name, schema }` — mirroring its type's `stack.defineType(...)` block in
[`docs/commons/`](https://github.com/haverstack/core/tree/main/docs/commons) exactly. Pass it to the typed overloads of `get`, `query`, `create`, `mutate`,
`patchContent` and `subscribe`; use `NOTE.baseId` for calls about the whole family.
[`docs/commons/`](https://github.com/haverstack/core/tree/main/docs/commons) exactly. Pass it to `stack.collection()` for typed access to its family; use `NOTE.baseId` for
untyped calls about the whole family.
`defineCommonsTypes()` is a thin loop over `stack.defineType()` — calling it again with
types already registered is the ordinary idempotent no-op path (see `defineType`'s own
docs), so it's safe to call on every app startup.
Expand Down
6 changes: 3 additions & 3 deletions packages/commons/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
* markdown, which is the drift the Schema Commons' governance process exists
* to prevent.
*
* Each constant is also a type handle, so `stack.create(NOTE, …)` and the
* other typed overloads derive content types from the same literal. See
* docs/spec/data-model.md § Type handles.
* Each constant is also a type handle, so `stack.collection(NOTE)` derives
* its content type from the same literal. See docs/spec/data-model.md
* § Type handles.
*
* Only Draft-status types are exported here. Proposed types (not yet backed
* by a concrete intended writer) stay docs-only until they graduate.
Expand Down
7 changes: 4 additions & 3 deletions packages/commons/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,13 +118,14 @@ describe('typed handles', () => {

test('typed create and get round-trip through the constant', async () => {
await defineCommonsTypes(stack, [TASK]);
const created = await stack.create(TASK, { title: 'Write tests', done: false });
const tasks = stack.collection(TASK);
const created = await tasks.create({ title: 'Write tests', done: false });
expectTypeOf(created.content.done).toEqualTypeOf<boolean>();

const read = await stack.get(TASK, created.id);
const read = await tasks.get(created.id);
expect(read?.content).toEqual({ title: 'Write tests', done: false });

// @ts-expect-error a required field is missing
await expect(stack.create(TASK, { title: 'No done flag' })).rejects.toThrow();
await expect(tasks.create({ title: 'No done flag' })).rejects.toThrow();
});
});
25 changes: 16 additions & 9 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,20 @@ const Note = typeHandle({
});
await stack.defineType(Note);

// Typed access to the Note family
const notes = stack.collection(Note);

// Create a record
const note = await stack.create(Note, {
const note = await notes.create({
text: 'Hello, Haverstack!',
title: 'My first note',
});

// Update its content (partial merge — only changed fields needed)
await stack.patchContent(Note, note.id, { title: 'Updated title' });
await notes.patchContent(note.id, { title: 'Updated title' });

// Read it back, typed: `content.text` is a string
const same = await stack.get(Note, note.id);
const same = await notes.get(note.id);

// Tag it
await stack.associate(note.id, [{ kind: 'tag', label: 'favourite' }]);
Expand All @@ -82,7 +85,7 @@ await stack.mutate(note.id, {

// Query. With no filter, query() returns every record you can read, from every
// app and system types included, so filter by typeId, baseId or appId to get your own.
const notes = await stack.query({
const favourites = await stack.query({
filter: { typeId: 'com.example.myapp/note@1', tags: ['favourite'] },
sort: { field: 'createdAt', direction: 'desc' },
});
Expand All @@ -101,7 +104,8 @@ An app has two layers, because the stack draws a line between them:
Migrations belong to neither. They are passed to `Stack.open()` on **every startup**, so the chain is complete before the first read.

```ts
import { Stack, migration, typeHandle, type StackClient } from '@haverstack/core';
import { Stack, migration, typeHandle } from '@haverstack/core';
import type { Collection, ContentOf, StackClient } from '@haverstack/core';

const NoteV1 = typeHandle({
id: 'com.example.myapp/note@1',
Expand All @@ -126,15 +130,18 @@ export const noteMigrations = [

// Data layer: whoever the stack lets in.
export class Notes {
constructor(private readonly client: StackClient) {}
private readonly notes: Collection<ContentOf<typeof Note.schema>>;
constructor(client: StackClient) {
this.notes = client.collection(Note);
}
add(text: string) {
return this.client.create(Note, { text, pinned: false });
return this.notes.create({ text, pinned: false });
}
pin(id: string) {
return this.client.patchContent(Note, id, { pinned: true });
return this.notes.patchContent(id, { pinned: true });
}
list() {
return this.client.query(Note);
return this.notes.query();
}
}

Expand Down
Loading
Loading