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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"@astrojs/check": "^0.9.8",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"astro": "^6.1.8",
"astro": "^6.1.9",
"date-fns": "^4.1.0",
"remark-smartypants": "^3.0.2",
"sharp": "^0.34.5"
Expand All @@ -44,6 +44,6 @@
"prettier": "^3.8.3",
"prettier-plugin-astro": "^0.14.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.58.2"
"typescript-eslint": "^8.59.0"
}
}
15 changes: 14 additions & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,22 @@ const posts = defineCollection({
date: z.coerce.date(),
draft: z.boolean().default(false),
excerpt: z.string().nullable().optional(),
deprecated: z.boolean().default(false),
supersededBy: z.string().nullable().optional(),
}),
});

const docs = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/docs' }),
schema: z.object({
title: z.string(),
description: z.string(),
package: z.string().optional(),
lastVerified: z.coerce.date(),
order: z.number().default(0),
}),
});

const authors = defineCollection({
loader: file('src/data/authors.yaml'),
schema: z.object({
Expand All @@ -39,4 +52,4 @@ const tags = defineCollection({
}),
});

export const collections = { posts, authors, tags };
export const collections = { posts, docs, authors, tags };
168 changes: 168 additions & 0 deletions src/content/docs/data-stores.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
---
title: Data Stores
description: Declare physical stores as DI tokens that carry model and primary-key metadata, with a dedicated helper per backend.
package: '@furystack/core'
lastVerified: '2026-04-25T00:00:00.000Z'
order: 2
---

A **physical store** is the minimal interface for persisting a collection of entities — create, read, filter, update, delete, count. Stores don't know about authorization, relations, or business logic. That lives one layer up, in [DataSets](/getting-started/repository/).

A **`StoreToken`** is a DI token that resolves to a physical store and carries the store's `model` and `primaryKey` as metadata. Backend adapter packages ship dedicated helpers that mint the right token for their flavour.

## Declaring a store

```ts
import { defineStore, InMemoryStore } from '@furystack/core';

class TodoItem {
declare id: string;
declare title: string;
declare completed: boolean;
}

export const TodoStore = defineStore<TodoItem, 'id'>({
name: 'my-app/TodoStore',
model: TodoItem,
primaryKey: 'id',
factory: () => new InMemoryStore({ model: TodoItem, primaryKey: 'id' }),
});
```

`defineStore` wraps `defineService({ lifetime: 'singleton' })` and registers an `onDispose` so the store cleans itself up when the injector is disposed.

> **Tip:** Pass the generics explicitly (`<TodoItem, 'id'>`). Inferred generics inside helper wrappers tend to widen `'id'` back to `keyof T`, which loses the literal primary-key type.

## Resolving the store

```ts
import { createInjector } from '@furystack/inject';

const injector = createInjector();
const store = injector.get(TodoStore);
await store.add({ id: '1', title: 'first', completed: false });
```

> **Heads up:** Resolving a `StoreToken` directly in application code is a smell — data should flow through DataSets, not raw stores. The `furystack/no-direct-store-token` lint rule flags this. See [Repository](/getting-started/repository/).

## Backend adapters

Each adapter exports a `defineXxxStore<T, PK>(opts)` helper:

| Package | Helper | Use when… |
| ----------------------------- | ----------------------- | --------------------------------------------------- |
| `@furystack/core` | `InMemoryStore` factory | Tests, demos, ephemeral state. |
| `@furystack/filesystem-store` | `defineFileSystemStore` | Development, prototypes, low-volume persistence. |
| `@furystack/sequelize-store` | `defineSequelizeStore` | Any SQL DB via Sequelize (Postgres, MySQL, SQLite). |
| `@furystack/mongodb-store` | `defineMongoDbStore` | MongoDB document storage. |
| `@furystack/redis-store` | `defineRedisStore` | Redis key-value storage (e.g. shared sessions). |

### `defineFileSystemStore`

```ts
import { defineFileSystemStore } from '@furystack/filesystem-store';

export const TodoStore = defineFileSystemStore<TodoItem, 'id'>({
name: 'my-app/TodoStore',
model: TodoItem,
primaryKey: 'id',
fileName: './data/todos.json',
tickMs: 5000, // Optional: throttle disk writes to once per N ms
});
```

### `defineMongoDbStore`

```ts
import { defineMongoDbStore } from '@furystack/mongodb-store';

export const TodoStore = defineMongoDbStore<TodoItem, 'id'>({
name: 'my-app/TodoStore',
model: TodoItem,
primaryKey: 'id',
url: process.env.MONGODB_URL!,
db: 'my-app',
collection: 'todos',
});
```

The shared `MongoClientFactory` token pools clients per URL and closes them all on injector teardown — no connection leaks across tests.

### `defineRedisStore`

```ts
import { createClient } from 'redis';
import { defineRedisStore } from '@furystack/redis-store';

const redisClient = await createClient({ url: process.env.REDIS_URL }).connect();

export const SessionStore = defineRedisStore<Session, 'sessionId'>({
name: 'my-app/SessionStore',
model: Session,
primaryKey: 'sessionId',
client: redisClient,
});
```

The caller owns the `redis` client lifecycle — connect at startup, `quit()` on shutdown.

### `defineSequelizeStore`

```ts
import { DataTypes, Model } from 'sequelize';
import { defineSequelizeStore } from '@furystack/sequelize-store';

class TodoModel extends Model {}

export const TodoStore = defineSequelizeStore<TodoItem, typeof TodoModel, 'id'>({
name: 'my-app/TodoStore',
model: TodoItem,
sequelizeModel: TodoModel,
primaryKey: 'id',
options: { dialect: 'postgres' /* ... */ },
initModel: ({ model }) => {
model.init(
{
id: { type: DataTypes.STRING, primaryKey: true },
title: DataTypes.STRING,
completed: DataTypes.BOOLEAN,
},
{ sequelize: model.sequelize!, tableName: 'todos' },
);
},
});
```

The shared `SequelizeClientFactory` token pools clients per `JSON.stringify(options)` key and disposes them on teardown.

## Throw-by-default stores

Some packages ship `StoreToken`s that **throw** when resolved without a binding. This is intentional — they represent stores the framework needs but cannot pick on the application's behalf. Examples: `UserStore` and `SessionStore` from `@furystack/rest-service`; `RefreshTokenStore` from `@furystack/auth-jwt`; `PasswordCredentialStore` and `PasswordResetTokenStore` from `@furystack/security`.

Bind a concrete implementation at app bootstrap:

```ts
import { UserStore } from '@furystack/rest-service';
import { defineSequelizeStore } from '@furystack/sequelize-store';

const AppUserStore = defineSequelizeStore<User, typeof UserModel, 'username'>({
name: 'my-app/AppUserStore',
model: User,
sequelizeModel: UserModel,
primaryKey: 'username',
options: { dialect: 'postgres' /* ... */ },
});

injector.bind(UserStore, ctx => ctx.inject(AppUserStore));
```

In tests, bind an `InMemoryStore` per scope:

```ts
injector.bind(UserStore, () => new InMemoryStore({ model: User, primaryKey: 'username' }));
```

## Where to look next

- [Repository](/getting-started/repository/) — wrap a `StoreToken` in a `DataSet` with authorization and events.
- [Dependency Injection](/getting-started/inject/) — how `defineService` and tokens work in general.
117 changes: 117 additions & 0 deletions src/content/docs/data-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
---
title: Data Validation
description: Generate JSON Schemas from your TypeScript REST API and enforce them at runtime with the Validate middleware.
package: '@furystack/rest-service'
lastVerified: '2026-04-25T00:00:00.000Z'
order: 5
---

A type-safe REST API gives you compile-time confidence that the server and client agree on shapes. It does **not** stop a malformed request from reaching your handler at runtime — TypeScript types are erased before the bytes hit the wire.

The fix is to project the same TypeScript interfaces into runtime-checkable JSON Schemas, and run incoming payloads through them with the `Validate` middleware from `@furystack/rest-service`.

## The plan

1. Define the API interface (see [REST](/getting-started/rest/)).
2. Generate a JSON Schema from those interfaces with `ts-json-schema-generator`.
3. Wrap each endpoint handler with `Validate({ schema, schemaName })`.

## 1. Generate the schema

Add `ts-json-schema-generator` as a dev dependency in your `common` workspace:

```sh
yarn add -D ts-json-schema-generator
```

Add an npm script that emits a schema for the file containing your endpoint types:

```json
{
"scripts": {
"build:schema": "ts-json-schema-generator -f tsconfig.json --no-type-check -p src/api.ts -o src/api.schema.json"
}
}
```

Then re-export it so both server and client can import it as JSON:

```ts
// common/src/index.ts
import schema from './api.schema.json' with { type: 'json' };
export { schema };
```

Make sure `resolveJsonModule: true` is in your `tsconfig.json`.

## 2. Validate on the server

Wrap the endpoint handler with `Validate({ schema, schemaName })`. The `schemaName` should match the type the request payload conforms to.

```ts
import type { RequestAction } from '@furystack/rest-service';
import { JsonResult, Validate } from '@furystack/rest-service';
import { schema } from 'common';
import type { CreateTodo } from 'common';

const createTodoEndpoint: RequestAction<CreateTodo> = Validate({
schema,
schemaName: 'CreateTodo',
})(async ({ getBody, injector }) => {
const body = await getBody();
const dataSet = injector.get(TodoDataSet);
const todo = await dataSet.add(injector, body);
return JsonResult(todo);
});
```

`Validate` checks **every** field of the request — `body`, `query`, `url`, `headers`. If something doesn't match the schema, the middleware short-circuits with a `400 Bad Request` and a verbose `ajv` error message describing exactly what failed. Your handler never runs with bad data.

## 3. Wire it into the API

Drop the validated handler into your `useRestService` call like any other endpoint:

```ts
import { useRestService } from '@furystack/rest-service';

await useRestService<TodoApi>({
injector,
port: 3000,
root: '/api',
api: {
POST: {
'/todos': createTodoEndpoint,
},
},
});
```

## What gets validated

Everything declared on the endpoint type:

```ts
export interface CreateTodo {
body: { title: string; description?: string };
query: { dryRun?: boolean };
result: { id: string };
}
```

The `Validate` middleware enforces:

- Required fields (`title` must be present)
- Type correctness (`title` must be a string, `dryRun` must be a boolean)
- Optionality (missing `description` is fine; missing `title` is a `400`)
- Nested objects, unions, intersections, string literals, regex constraints — anything `ts-json-schema-generator` can express.

## Tips

- **Add `additionalProperties: false`** to your schema if you want unknown fields to fail validation. By default they pass through silently.
- **Regenerate the schema in CI** alongside your other build steps — it's a sync between two source-of-truth descriptions of your API, and a stale schema can hide drift.
- **Reuse the schema on the client** for early form validation if you want — but the server is still the trust boundary.

## Where to look next

- [REST](/getting-started/rest/) — designing the API interface that the schema is generated from.
- [Repository](/getting-started/repository/) — DataSet authorization callbacks pair naturally with `Validate` for "is this payload well-formed _and_ are you allowed to do this?"
Loading
Loading