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
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ db/users.schema.json
db/users.json
```

If a schema file still contains embedded `seed` while a data fixture exists, jsondb ignores the embedded seed and warns. Use `jsondb schema unbundle users` to move non-empty seed data into a fixture and keep the schema source contract-only. Use `jsondb schema bundle users --out artifacts/users.bundle.schema.json` when you need a portable schema-plus-seed artifact; bundled outputs should stay outside `db/` unless you intentionally pass `--force`.

By default, unknown fields produce warnings for local development. Use [schema strictness](#schema-strictness) when you want drift to fail.

Schema fields can use `nullable: true` when `null` is an intentional value. `datetime` fields validate as strings and generate TypeScript `string` types. Object fields can set `additionalProperties: true` when nested keys are intentionally flexible.
Expand Down Expand Up @@ -940,23 +942,44 @@ app.route('/api', await createJsonDbHonoApp({
}));
```

Apps that already own their Hono instance can register only the REST routes and wrap them with hooks:
Apps that already own their Hono instance can register only the REST routes and wrap them with hooks. Use lifecycle hooks for cross-cutting behavior such as auth/session loading and shared write policy, then keep method or resource hooks for resource-specific rules:

```ts
import { registerRestRoutes } from 'jsondb/hono';

registerRestRoutes(app, db, {
prefix: '/api',
resources: ['pages', 'charts'],
lifecycleHooks: {
beforeRequest({ c }) {
const session = readSession(c.req.header('authorization'));
if (!session) return c.json({ error: 'Unauthorized' }, 401);
c.set('session', session);
},
beforeWrite({ c, body }) {
if (c.get('session')?.role !== 'admin') {
return c.json({ error: 'Forbidden' }, 403);
}
if (body) body.updatedAt = new Date().toISOString();
},
},
hooks: {
beforeCreate({ c }) {
// Return a Hono response to short-circuit auth or permission checks.
if (!c.get('session')) return c.json({ error: 'Unauthorized' }, 401);
beforeCreate({ body }) {
body.createdAt ??= body.updatedAt;
},
},
});
```

Hook order is deterministic: `beforeRequest`, `beforeWrite` for `create`/`patch`/`put`/`delete`, the matching global method hook, the matching resource method hook, then the JSONDB operation. Any hook can return a Hono response to short-circuit the request, and write hooks can mutate `body` before JSONDB validates and writes it.

See `examples/hono-auth` for a runnable Hono app with bearer-token auth:

- `Bearer user-token` can read.
- `Bearer admin-token` can read and write.
- missing or invalid tokens return `401`.
- non-admin writes return `403`.

## Configuration Details

Create `jsondb.config.mjs` only when the defaults stop being enough. Use `defineConfig` so editors can show valid values and comments from the package types.
Expand Down
17 changes: 17 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,21 @@ users.schema.jsonc controls the type/schema
users.json controls the seed records
```

If the schema file also contains `seed`, that embedded seed is ignored in favor of
the data fixture. The CLI should warn and suggest unbundling the seed from the
schema source so mixed mode keeps contracts and seed records in separate files.
`jsondb schema unbundle users` removes embedded seed from the schema source and
writes non-empty seed data to `db/users.json`. Empty schema-only seed is removed
without creating an empty fixture unless `--empty-seed` is passed. In-place JSONC
rewrites may lose comments, so the CLI should warn when it rewrites `.schema.jsonc`
without `--schema-out`.

`jsondb schema bundle users` creates a portable schema-plus-seed artifact. Bundled
outputs should live outside the active fixture directory by default, such as
`artifacts/users.bundle.schema.json`, so they are not rediscovered as live schema
sources. Writing a bundle inside `db/` requires `--force`. Overwriting an existing
different seed or bundle output also requires `--force`.

If the two disagree, the CLI reports the mismatch:

```txt
Expand Down Expand Up @@ -1213,6 +1228,8 @@ jsondb types --watch
jsondb types --out ./src/generated/jsondb.types.ts
jsondb schema
jsondb schema validate
jsondb schema unbundle users
jsondb schema bundle users --out artifacts/users.bundle.schema.json
jsondb generate hono
jsondb generate hono --api rest,graphql --out ./server
jsondb generate hono --api none --app module
Expand Down
61 changes: 61 additions & 0 deletions examples/hono-auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Hono Auth Example

## What This Teaches

Use this when your app already has a Hono server and you want JSONDB to own the CRUD routes while your app owns auth, permissions, and write normalization.

## Files To Inspect

- `src/app.mjs`: registers JSONDB REST routes with `beforeRequest`, `beforeWrite`, and a pages-specific create hook.
- `src/server.mjs`: starts the Hono app locally.
- `db/pages.schema.jsonc`: schema-backed page collection with timestamps set by hooks.
- `db/users.schema.jsonc`: demo users used by the bearer-token sessions.

## Run It

From this example directory:

```bash
npm install
npm run sync
npm run dev
```

## Requests To Try

Missing tokens are rejected:

```bash
curl -i 'http://127.0.0.1:8787/api/pages'
```

Reader tokens can read:

```bash
curl -H 'Authorization: Bearer user-token' 'http://127.0.0.1:8787/api/pages'
```

Reader tokens cannot write:

```bash
curl -i -X PATCH 'http://127.0.0.1:8787/api/pages/home' \
-H 'Authorization: Bearer user-token' \
-H 'content-type: application/json' \
-d '{"title":"Draft"}'
```

Admin tokens can write. The shared write hook trims strings and sets `updatedAt`:

```bash
curl -X PATCH 'http://127.0.0.1:8787/api/pages/home' \
-H 'Authorization: Bearer admin-token' \
-H 'content-type: application/json' \
-d '{"title":" Homepage "}'
```

## Token Map

- `Bearer admin-token`: read and write.
- `Bearer user-token`: read only.

This is intentionally tiny demo auth. In a real app, `beforeRequest` would read your session or token source, and `beforeWrite` would call your permission policy.
44 changes: 44 additions & 0 deletions examples/hono-auth/db/pages.schema.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"kind": "collection",
"idField": "id",
"fields": {
"id": {
"type": "string",
"required": true,
"description": "Stable page id used in routes and links."
},
"title": {
"type": "string",
"required": true,
"description": "Human-readable page title."
},
"visibility": {
"type": "enum",
"values": ["public", "private"],
"default": "private",
"description": "Whether the page can be shown publicly."
},
"body": {
"type": "string",
"description": "Short markdown-like page body for the demo."
},
"createdAt": {
"type": "datetime",
"description": "Timestamp assigned by the pages create hook."
},
"updatedAt": {
"type": "datetime",
"description": "Timestamp assigned by the shared write hook."
}
},
"seed": [
{
"id": "home",
"title": "Home",
"visibility": "public",
"body": "Welcome to the JSONDB Hono auth example.",
"createdAt": "2026-05-14T00:00:00.000Z",
"updatedAt": "2026-05-14T00:00:00.000Z"
}
]
}
42 changes: 42 additions & 0 deletions examples/hono-auth/db/users.schema.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"kind": "collection",
"idField": "id",
"fields": {
"id": {
"type": "string",
"required": true,
"description": "Stable user id."
},
"name": {
"type": "string",
"required": true,
"description": "Display name shown in the app."
},
"email": {
"type": "string",
"required": true,
"unique": true,
"description": "Email address used for local sign-in."
},
"role": {
"type": "enum",
"values": ["admin", "user"],
"default": "user",
"description": "Local authorization role."
}
},
"seed": [
{
"id": "u_admin",
"name": "Admin Example",
"email": "admin@example.com",
"role": "admin"
},
{
"id": "u_user",
"name": "Reader Example",
"email": "reader@example.com",
"role": "user"
}
]
}
5 changes: 5 additions & 0 deletions examples/hono-auth/example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"title": "Hono Auth",
"description": "A small Hono app that uses registerRestRoutes lifecycle hooks for bearer-token auth, write permissions, and payload normalization.",
"tags": ["hono", "auth", "rest"]
}
16 changes: 16 additions & 0 deletions examples/hono-auth/jsondb.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// @ts-check
import { defineConfig } from 'jsondb/config';

export default defineConfig({
dbDir: './db',
stateDir: './.jsondb',
mode: 'mirror',
types: {
enabled: true,
outFile: './.jsondb/types/index.ts',
emitComments: true,
},
schema: {
unknownFields: 'warn',
},
});
14 changes: 14 additions & 0 deletions examples/hono-auth/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "jsondb-hono-auth-example",
"private": true,
"type": "module",
"scripts": {
"dev": "node ./src/server.mjs",
"sync": "jsondb sync --cwd ."
},
"dependencies": {
"@hono/node-server": "^1.13.8",
"hono": "^4.6.0",
"jsondb": "file:../.."
}
}
79 changes: 79 additions & 0 deletions examples/hono-auth/src/app.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { openJsonFixtureDb } from 'jsondb';
import { registerRestRoutes } from 'jsondb/hono';

const exampleRoot = fileURLToPath(new URL('..', import.meta.url));

export const demoAuthorizationHeaders = {
admin: 'Bearer admin-token',
user: 'Bearer user-token',
};

const demoSessions = new Map([
[authorizationToken(demoAuthorizationHeaders.admin), { userId: 'u_admin', role: 'admin' }],
[authorizationToken(demoAuthorizationHeaders.user), { userId: 'u_user', role: 'user' }],
]);

export async function createApp(options = {}) {
const { Hono } = await import('hono');
const app = new Hono();
const db = options.db ?? await openJsonFixtureDb({
cwd: options.cwd ?? path.resolve(exampleRoot),
});

registerRestRoutes(app, db, {
prefix: '/api',
resources: ['pages', 'users'],
lifecycleHooks: {
beforeRequest(ctx) {
const session = sessionFromAuthorizationHeader(ctx.c.req.header('authorization'));
if (!session) {
return ctx.c.json({ error: 'Unauthorized' }, 401);
}
ctx.c.set('session', session);
},
beforeWrite(ctx) {
const session = ctx.c.get('session');
if (session?.role !== 'admin') {
return ctx.c.json({ error: 'Forbidden' }, 403);
}
normalizeWriteBody(ctx.body);
},
},
resourceOptions: {
pages: {
hooks: {
beforeCreate(ctx) {
ctx.body.createdAt ??= ctx.body.updatedAt;
},
},
},
},
});

return app;
}

export function sessionFromAuthorizationHeader(header) {
return demoSessions.get(authorizationToken(header)) ?? null;
}

function authorizationToken(header) {
const match = /^Bearer\s+(.+)$/i.exec(String(header ?? ''));
return match?.[1] ?? null;
}

function normalizeWriteBody(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return;
}

for (const [key, value] of Object.entries(body)) {
if (typeof value === 'string') {
body[key] = value.trim();
}
}

body.updatedAt = new Date().toISOString();
}
13 changes: 13 additions & 0 deletions examples/hono-auth/src/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { serve } from '@hono/node-server';
import { createApp } from './app.mjs';

const port = Number(process.env.PORT ?? 8787);
const app = await createApp();

serve({
fetch: app.fetch,
hostname: '127.0.0.1',
port,
});

console.log(`JSONDB Hono auth example: http://127.0.0.1:${port}`);
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"scripts",
"examples/*/README.md",
"examples/*/example.json",
"examples/*/package.json",
"examples/*/db/**",
"examples/*/jsondb.config.mjs",
"examples/*/src/**",
Expand Down
Loading