typion 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +4 -2
- package/bin/typion.js +7 -0
- package/dist/auth/better-auth.d.ts +15 -0
- package/dist/auth/better-auth.js +11 -0
- package/dist/auth/better-auth.js.map +1 -0
- package/dist/cli/index.d.ts +14 -0
- package/dist/cli/index.js +827 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/column-kinds-Bkm4NJI2.js +47 -0
- package/dist/column-kinds-Bkm4NJI2.js.map +1 -0
- package/dist/context-CpLQy7OI.d.ts +2098 -0
- package/dist/contract/index.d.ts +137 -0
- package/dist/contract/index.js +42 -0
- package/dist/contract/index.js.map +1 -0
- package/dist/db/pglite.d.ts +12 -0
- package/dist/db/pglite.js +27 -0
- package/dist/db/pglite.js.map +1 -0
- package/dist/descriptor-Cc3mGp73.js +12 -0
- package/dist/descriptor-Cc3mGp73.js.map +1 -0
- package/dist/errors-ChDBxeS-.js +17 -0
- package/dist/errors-ChDBxeS-.js.map +1 -0
- package/dist/errors-D-CKO-px.js +114 -0
- package/dist/errors-D-CKO-px.js.map +1 -0
- package/dist/handler-D03-rJ-t.d.ts +23 -0
- package/dist/handler-_87bFkbP.js +565 -0
- package/dist/handler-_87bFkbP.js.map +1 -0
- package/dist/hono/index.d.ts +17 -0
- package/dist/hono/index.js +26 -0
- package/dist/hono/index.js.map +1 -0
- package/dist/http/index.d.ts +13 -0
- package/dist/http/index.js +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1324 -0
- package/dist/index.js.map +1 -0
- package/dist/issues-DLqCBQT1.js +42 -0
- package/dist/issues-DLqCBQT1.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.js +18 -0
- package/dist/next/index.js.map +1 -0
- package/dist/operations-D3rOacLX.js +1811 -0
- package/dist/operations-D3rOacLX.js.map +1 -0
- package/dist/schema/index.d.ts +18 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema-Rzzq97cK.js +68 -0
- package/dist/schema-Rzzq97cK.js.map +1 -0
- package/dist/seed/index.d.ts +19 -0
- package/dist/seed/index.js +85 -0
- package/dist/seed/index.js.map +1 -0
- package/dist/structural-BZnjFmCs.js +247 -0
- package/dist/structural-BZnjFmCs.js.map +1 -0
- package/dist/testing/index.d.ts +28 -0
- package/dist/testing/index.js +92 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/typion-TJWDkHGh.js +1186 -0
- package/dist/typion-TJWDkHGh.js.map +1 -0
- package/dist/validation/zod.d.ts +21 -0
- package/dist/validation/zod.js +84 -0
- package/dist/validation/zod.js.map +1 -0
- package/docs/README.md +145 -0
- package/docs/access.md +112 -0
- package/docs/auth.md +140 -0
- package/docs/collections.md +168 -0
- package/docs/database.md +74 -0
- package/docs/features/localization.md +112 -0
- package/docs/features/multi-tenant.md +165 -0
- package/docs/features/soft-delete.md +85 -0
- package/docs/features/versions.md +106 -0
- package/docs/hooks.md +106 -0
- package/docs/http.md +184 -0
- package/docs/queries.md +197 -0
- package/docs/testing.md +85 -0
- package/package.json +125 -3
package/docs/access.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Access
|
|
2
|
+
|
|
3
|
+
Who may read and write what is decided by each collection's `access` rules, and nowhere else. A page, a server action
|
|
4
|
+
or a route that checks permissions on top is a convenience for the interface; the rule is what holds for every caller,
|
|
5
|
+
over HTTP included.
|
|
6
|
+
|
|
7
|
+
## The rules
|
|
8
|
+
|
|
9
|
+
| Rule | Decides | Gets | Returns |
|
|
10
|
+
| -------------- | --------------------------------------------------------------------- | ----------------------- | ----------------- |
|
|
11
|
+
| `read` | which rows a read sees, at every level | `ctx`, `meta`, `typion` | boolean or filter |
|
|
12
|
+
| `create` | whether this input may be created | the same, and `data` | boolean |
|
|
13
|
+
| `update` | which rows may be updated, and with what | the same, and `data` | boolean or filter |
|
|
14
|
+
| `delete` | which rows may be deleted | the same, and `options` | boolean or filter |
|
|
15
|
+
| `readDrafts` | which drafts a `draft: true` read sees (collections with drafts) | `ctx`, `meta`, `typion` | boolean or filter |
|
|
16
|
+
| `readVersions` | whether the history may be read (collections with versions) | `ctx`, `meta`, `typion` | boolean |
|
|
17
|
+
| `publish` | which rows may be published and unpublished (collections with drafts) | `ctx`, `meta`, `typion` | boolean or filter |
|
|
18
|
+
|
|
19
|
+
A rule is `true`, a filter object, or a function, possibly async, returning either. A rule a collection cannot have
|
|
20
|
+
(`readDrafts` without drafts) is a compile error. `update` also governs the features' operations that write a row:
|
|
21
|
+
`restore`, `discardDraft`, `versions.restore`.
|
|
22
|
+
|
|
23
|
+
- **Deny by default.** A missing rule is `FORBIDDEN`, naming the collection, the operation and the rule.
|
|
24
|
+
- **`false` is `FORBIDDEN`** for the operation itself, decided before any SQL, so it says nothing about any row. A
|
|
25
|
+
collection reached through `with` reads as `null` or `[]` instead.
|
|
26
|
+
- **A filter limits the rows.** It is applied at the root, inside every `with`, inside every relation filter of a
|
|
27
|
+
`where`, to `count` and `page.total`, and to the row a write locks. A row outside it does not exist for the caller:
|
|
28
|
+
`findById`, `update` and `delete` on it are `NOT_FOUND`, never `FORBIDDEN`, so a caller cannot learn that it exists.
|
|
29
|
+
- **Rules never receive the row.** Row-dependent logic is a filter, which the database applies. `create` has no row
|
|
30
|
+
yet: it checks the input (`data.authorId === ctx.actorId`).
|
|
31
|
+
- **A filter may follow relations** (`{ project: { members: { userId: ctx.actorId } } }`). There a related row counts
|
|
32
|
+
only if the features let the call see it: a membership removed with soft delete, a draft or another tenant's row
|
|
33
|
+
grants nothing, whatever options the call gave. The related collection's own rules do not apply inside a rule,
|
|
34
|
+
which may look at rows the caller cannot read (an author whose profile is private).
|
|
35
|
+
- **`typion` in a rule is a system instance**, for a lookup the rule needs (`typion.users.findById(…)`). It skips the
|
|
36
|
+
rules, so what it reads must not leak into anything but the rule's answer.
|
|
37
|
+
|
|
38
|
+
## Patterns
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { collection } from 'typion'
|
|
42
|
+
import { boolean, text, uuid } from 'drizzle-orm/pg-core'
|
|
43
|
+
|
|
44
|
+
// Owners only: every operation is a filter on the owner, and creating checks the claimed owner.
|
|
45
|
+
export const notes = collection('notes', {
|
|
46
|
+
columns: {
|
|
47
|
+
body: text().notNull(),
|
|
48
|
+
ownerId: uuid('owner_id').notNull(),
|
|
49
|
+
pinned: boolean().notNull(),
|
|
50
|
+
},
|
|
51
|
+
}).define({
|
|
52
|
+
access: {
|
|
53
|
+
read: ({ ctx }) => (ctx.actorId === null ? false : { ownerId: ctx.actorId }),
|
|
54
|
+
create: ({ ctx, data }) => ctx.actorId !== null && data.ownerId === ctx.actorId,
|
|
55
|
+
update: ({ ctx, data }) =>
|
|
56
|
+
// The owner column is what makes the row theirs: moving it is refused, the rest is a filter.
|
|
57
|
+
ctx.actorId === null || data.ownerId !== undefined ? false : { ownerId: ctx.actorId },
|
|
58
|
+
delete: ({ ctx }) => (ctx.actorId === null ? false : { ownerId: ctx.actorId }),
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That collection is not in the config: a snippet like this one shows a shape, and a collection exists only once the
|
|
64
|
+
config's `collections` lists it.
|
|
65
|
+
|
|
66
|
+
- **Published to everyone, all of it to the author**: `read: true` on a collection with drafts already hides drafts
|
|
67
|
+
from plain reads; `readDrafts: ({ ctx }) => ({ authorId: ctx.actorId })` opens the author's own
|
|
68
|
+
([collections.md](collections.md), `posts`).
|
|
69
|
+
- **Approved to everyone, own to the author, all to admins**: an `OR` filter (`comments` in [hooks.md](hooks.md)).
|
|
70
|
+
- **Roles**: compare `ctx.role` in the function; return `true` for the roles that see everything and a filter for the
|
|
71
|
+
others. The role comes from the context factory, which reads it from the database or the session, never from the
|
|
72
|
+
request's own claims.
|
|
73
|
+
- **Tenants** are not a rule: `tenant: true` with multi-tenancy keeps every call in its tenant, and no rule can lift it
|
|
74
|
+
([features/multi-tenant.md](features/multi-tenant.md)).
|
|
75
|
+
|
|
76
|
+
## Columns
|
|
77
|
+
|
|
78
|
+
`fields` in the structure decides, per column, beyond the row:
|
|
79
|
+
|
|
80
|
+
- `read: false` removes the column from every type, read and filter: it cannot even be selected.
|
|
81
|
+
- `read: ({ ctx }) => boolean` keeps it, typed `T | null`, and returns `null` to the callers it refuses.
|
|
82
|
+
- `write: false | 'system' | ({ ctx, meta }) => boolean`: a write that names the column without the right is
|
|
83
|
+
`FORBIDDEN`, never silently dropped. It covers creates as well as updates: a column only admins may change but
|
|
84
|
+
anyone may set when creating a row says `({ ctx, meta }) => meta.operation === 'create' || ctx.role === 'admin'`.
|
|
85
|
+
Columns the features manage (`tenantId`, `deletedAt`, `status`, `publishedAt`) are `'system'` already.
|
|
86
|
+
|
|
87
|
+
Field rules see the context registered in `interface Register { context }` ([collections.md](collections.md)).
|
|
88
|
+
|
|
89
|
+
## What a caller sees
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const anonymous = typion.$as({ actorId: null, role: null })
|
|
93
|
+
|
|
94
|
+
// Published posts only; the drafts and the deleted ones do not exist here.
|
|
95
|
+
const feed = await anonymous.posts.find({ columns: { id: true, title: true }, limit: 10 })
|
|
96
|
+
|
|
97
|
+
// The author is never null (users are public); the address is, for anyone but an admin.
|
|
98
|
+
const [first] = await anonymous.posts.find({
|
|
99
|
+
columns: { title: true },
|
|
100
|
+
with: { author: { columns: { name: true, email: true } } },
|
|
101
|
+
limit: 1,
|
|
102
|
+
})
|
|
103
|
+
first?.author satisfies { name: string; email: string | null } | undefined
|
|
104
|
+
void feed
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## System mode
|
|
108
|
+
|
|
109
|
+
`typion.$system({ reason })` skips every rule, and only the rules: tenants, soft delete and drafts still apply, and so
|
|
110
|
+
do validation and hooks. The reason is stored in the history and the audit trail. Use it for seeds, jobs, webhooks
|
|
111
|
+
and the context factory's lookups — code no user drives — and never to answer a request because a rule refuses it:
|
|
112
|
+
that is the rule to change.
|
package/docs/auth.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Signing in
|
|
2
|
+
|
|
3
|
+
Typion does not sign people in. The config's context factory reads who is calling from the request, through an auth
|
|
4
|
+
library, and looks up what the application knows of them: their role, their tenant. This page sets up Better Auth,
|
|
5
|
+
email and password, on the database Typion uses.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// src/db.ts
|
|
9
|
+
import { Pool } from 'pg'
|
|
10
|
+
|
|
11
|
+
// One pool for Typion and Better Auth. pg connects on the first query, so importing the config — the CLI does, the
|
|
12
|
+
// tests do — opens nothing. Whatever the config imports must not connect when it is imported.
|
|
13
|
+
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Better Auth's tables are plain Drizzle tables in the config's `tables`, so `typion db generate` migrates them with the
|
|
17
|
+
collections. They are not collections: no rule governs them, and nothing of the application's data goes through them.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// src/auth-schema.ts
|
|
21
|
+
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
|
|
22
|
+
|
|
23
|
+
export const user = pgTable('user', {
|
|
24
|
+
id: text().primaryKey(),
|
|
25
|
+
name: text().notNull(),
|
|
26
|
+
email: text().notNull().unique(),
|
|
27
|
+
emailVerified: boolean('email_verified').notNull().default(false),
|
|
28
|
+
image: text(),
|
|
29
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
30
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const session = pgTable('session', {
|
|
34
|
+
id: text().primaryKey(),
|
|
35
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
36
|
+
token: text().notNull().unique(),
|
|
37
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
38
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
39
|
+
ipAddress: text('ip_address'),
|
|
40
|
+
userAgent: text('user_agent'),
|
|
41
|
+
userId: text('user_id')
|
|
42
|
+
.notNull()
|
|
43
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
export const account = pgTable('account', {
|
|
47
|
+
id: text().primaryKey(),
|
|
48
|
+
accountId: text('account_id').notNull(),
|
|
49
|
+
providerId: text('provider_id').notNull(),
|
|
50
|
+
userId: text('user_id')
|
|
51
|
+
.notNull()
|
|
52
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
53
|
+
accessToken: text('access_token'),
|
|
54
|
+
refreshToken: text('refresh_token'),
|
|
55
|
+
idToken: text('id_token'),
|
|
56
|
+
accessTokenExpiresAt: timestamp('access_token_expires_at'),
|
|
57
|
+
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
|
|
58
|
+
scope: text(),
|
|
59
|
+
password: text(),
|
|
60
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
61
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export const verification = pgTable('verification', {
|
|
65
|
+
id: text().primaryKey(),
|
|
66
|
+
identifier: text().notNull(),
|
|
67
|
+
value: text().notNull(),
|
|
68
|
+
expiresAt: timestamp('expires_at').notNull(),
|
|
69
|
+
createdAt: timestamp('created_at').notNull().defaultNow(),
|
|
70
|
+
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
export const authTables = { user, session, account, verification }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// src/auth.ts
|
|
78
|
+
import { betterAuth } from 'better-auth'
|
|
79
|
+
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
80
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
81
|
+
import { authTables } from './auth-schema.ts'
|
|
82
|
+
import { pool } from './db.ts'
|
|
83
|
+
|
|
84
|
+
export const auth = betterAuth({
|
|
85
|
+
database: drizzleAdapter(drizzle({ client: pool }), { provider: 'pg', schema: authTables }),
|
|
86
|
+
emailAndPassword: { enabled: true },
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The config gives Typion the same pool, lists the tables, and turns a session into the context with
|
|
91
|
+
`betterAuthContext(auth, map)` from `typion/auth/better-auth`, as [the guide's config](README.md#the-model) does:
|
|
92
|
+
`map` gets the session (null for an anonymous caller) and the factory's input, with `system` to look the caller up.
|
|
93
|
+
Better Auth's user has no role: the application keeps it on a row of its own, here `users`, found by e-mail. In a
|
|
94
|
+
multi-tenant application the same lookup finds the caller's membership, and with it their tenant.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
98
|
+
import { dbAdapter } from 'typion'
|
|
99
|
+
import { pool } from '@/db.ts'
|
|
100
|
+
|
|
101
|
+
// What the config's `db` is when Better Auth shares the pool; `close` ends it when the CLI is done.
|
|
102
|
+
export const db = dbAdapter((relations) => drizzle({ client: pool, relations }), {
|
|
103
|
+
url: process.env.DATABASE_URL,
|
|
104
|
+
close: () => pool.end(),
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Its routes
|
|
109
|
+
|
|
110
|
+
Better Auth answers under `/api/auth/*`, next to Typion's transports under `/api/*`.
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
// src/app/api/auth/[...all]/route.ts
|
|
114
|
+
import { toNextJsHandler } from 'better-auth/next-js'
|
|
115
|
+
import { auth } from '@/auth.ts'
|
|
116
|
+
|
|
117
|
+
export const { GET, POST } = toNextJsHandler(auth)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A server action that signs in sets cookies: add Better Auth's `nextCookies()` to its `plugins`. On Hono, the route goes
|
|
121
|
+
before the transports' catch-all, which would otherwise answer it:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { rest } from '@typion/rest'
|
|
125
|
+
import { rpc } from '@typion/rpc'
|
|
126
|
+
import { Hono } from 'hono'
|
|
127
|
+
import { typionRoutes } from 'typion/hono'
|
|
128
|
+
import { auth } from '@/auth.ts'
|
|
129
|
+
import { config } from '@/typion.config.ts'
|
|
130
|
+
|
|
131
|
+
export const server = new Hono()
|
|
132
|
+
.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw))
|
|
133
|
+
.all('/api/*', typionRoutes(config, { transports: [rest(), rpc()] }))
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## In tests
|
|
137
|
+
|
|
138
|
+
A test binds its callers itself (`t.asUser(ctx)`), or replaces the context factory with
|
|
139
|
+
`createTestTypion(config, { context })` ([testing.md](testing.md)), so it needs no session. Better Auth's tables are
|
|
140
|
+
created with the collections either way.
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# Collections
|
|
2
|
+
|
|
3
|
+
A collection is one file: its **structure** in the first object, its **behaviour** in `.define()`. The split is forced
|
|
4
|
+
by the types: the structure is what every other collection is typed from, and the behaviour is typed against all of
|
|
5
|
+
them, so it cannot be part of what they read.
|
|
6
|
+
|
|
7
|
+
| In the structure | In `define()` |
|
|
8
|
+
| -------------------------------------------------- | ---------------------------------------------- |
|
|
9
|
+
| `columns` (Drizzle builders) | `access` ([access.md](access.md)) |
|
|
10
|
+
| `relations` | `hooks` ([hooks.md](hooks.md)) |
|
|
11
|
+
| `public`, `fields` (who reads and writes a column) | `validate` (Standard Schema refinements) |
|
|
12
|
+
| `softDelete`, `versions`, `tenant`, plugin options | `indexes` (Drizzle's third `pgTable` argument) |
|
|
13
|
+
| `idColumn`, `timestamps`, `dbName` | `seed`, `defaultOrder`, `maxLimit` |
|
|
14
|
+
|
|
15
|
+
Every collection gets `id` (a uuid primary key) and `createdAt`/`updatedAt` unless it declares its own (`idColumn`,
|
|
16
|
+
`timestamps: false`). Unknown keys are compile errors, so a misspelt option never passes in silence.
|
|
17
|
+
|
|
18
|
+
`fields` is part of the structure, which is typed before the config exists: field rules see the context type
|
|
19
|
+
registered in `interface Register { context }` ([README.md](README.md)), and without it only the base context
|
|
20
|
+
(`actorId`, `mode`, `now`) and what the features add (`locale`, `tenantId`).
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
// src/typion/collections/users.ts
|
|
24
|
+
import { text } from 'drizzle-orm/pg-core'
|
|
25
|
+
import { collection } from 'typion'
|
|
26
|
+
|
|
27
|
+
export const users = collection('users', {
|
|
28
|
+
columns: {
|
|
29
|
+
name: text().notNull(),
|
|
30
|
+
email: text().notNull().unique(),
|
|
31
|
+
role: text({ enum: ['admin', 'editor'] })
|
|
32
|
+
.notNull()
|
|
33
|
+
.default('editor'),
|
|
34
|
+
},
|
|
35
|
+
relations: (r) => ({ posts: r.many('posts', { from: 'id', to: 'authorId' }) }),
|
|
36
|
+
// Anyone may read a user's row, so `post.author` is typed non-null.
|
|
37
|
+
public: true,
|
|
38
|
+
fields: {
|
|
39
|
+
// The row is public, the address is not: typed `string | null`, `null` for everyone but admins.
|
|
40
|
+
email: { read: ({ ctx }) => ctx.role === 'admin' },
|
|
41
|
+
// Users edit their own row (below), but never their role.
|
|
42
|
+
role: { write: ({ ctx }) => ctx.role === 'admin' },
|
|
43
|
+
},
|
|
44
|
+
}).define({
|
|
45
|
+
access: {
|
|
46
|
+
create: ({ ctx }) => ctx.role === 'admin',
|
|
47
|
+
update: ({ ctx }) =>
|
|
48
|
+
ctx.role === 'admin' ? true : ctx.actorId === null ? false : { id: ctx.actorId },
|
|
49
|
+
delete: false,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// src/typion/collections/posts.ts
|
|
56
|
+
import { integer, text, uuid } from 'drizzle-orm/pg-core'
|
|
57
|
+
import { collection, softUnique } from 'typion'
|
|
58
|
+
import { users } from './users.ts'
|
|
59
|
+
|
|
60
|
+
export const posts = collection('posts', {
|
|
61
|
+
columns: {
|
|
62
|
+
title: text().notNull(),
|
|
63
|
+
slug: text().notNull(),
|
|
64
|
+
body: text(),
|
|
65
|
+
views: integer().notNull().default(0),
|
|
66
|
+
authorId: uuid('author_id')
|
|
67
|
+
.notNull()
|
|
68
|
+
.references(() => users.table.id),
|
|
69
|
+
},
|
|
70
|
+
relations: (r) => ({
|
|
71
|
+
author: r.one('users', { from: 'authorId', to: 'id', optional: false }),
|
|
72
|
+
comments: r.many('comments', { from: 'id', to: 'postId' }),
|
|
73
|
+
tags: r.many('tags', {
|
|
74
|
+
from: 'id',
|
|
75
|
+
to: 'id',
|
|
76
|
+
through: { table: 'postsTags', from: 'postId', to: 'tagId' },
|
|
77
|
+
// Posts write their tags: `app.posts.link(id, 'tags', ids)`, under the post's update rule.
|
|
78
|
+
writable: true,
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
// Drafts and a history: `status`, `publishedAt`, `publish()`, `versions.list()` (features/versions.md).
|
|
82
|
+
versions: { drafts: true, max: 20 },
|
|
83
|
+
// A delete keeps the row: `deletedAt`, `restore()`, `withDeleted` (features/soft-delete.md).
|
|
84
|
+
softDelete: true,
|
|
85
|
+
}).define({
|
|
86
|
+
access: {
|
|
87
|
+
// Plain reads see published posts only; `draft: true` reads need `readDrafts`.
|
|
88
|
+
read: true,
|
|
89
|
+
readDrafts: ({ ctx }) =>
|
|
90
|
+
ctx.role === 'admin' ? true : ctx.actorId === null ? false : { authorId: ctx.actorId },
|
|
91
|
+
readVersions: ({ ctx }) => ctx.actorId !== null,
|
|
92
|
+
// Nobody writes a post in someone else's name.
|
|
93
|
+
create: ({ ctx, data }) => ctx.actorId !== null && data.authorId === ctx.actorId,
|
|
94
|
+
update: ({ ctx }) =>
|
|
95
|
+
ctx.role === 'admin' ? true : ctx.actorId === null ? false : { authorId: ctx.actorId },
|
|
96
|
+
// A delete keeps the row; removing it for good is for code that runs in system mode.
|
|
97
|
+
delete: ({ ctx, options }) => ctx.role === 'admin' && options.hard !== true,
|
|
98
|
+
publish: ({ ctx }) => ctx.role === 'admin',
|
|
99
|
+
},
|
|
100
|
+
// Unique among the posts that are not deleted.
|
|
101
|
+
indexes: (t) => [softUnique('posts_slug_live', t.deletedAt).on(t.slug)],
|
|
102
|
+
defaultOrder: { createdAt: 'desc' },
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Relations
|
|
107
|
+
|
|
108
|
+
- Targets are named with a string (`r.one('users', …)`), so two collections that point at each other do not import
|
|
109
|
+
each other's types; the config checks that the target and the columns exist.
|
|
110
|
+
- `r.one(target, { from, to, optional })`: `from` is the column here, `to` the one there. `optional: false` says the
|
|
111
|
+
row is always there (a `notNull` foreign key). The result is still `| null` when the target is **governed**: it has
|
|
112
|
+
a read rule other than `true` (or no `public: true`), or soft delete, drafts or a tenant, since the caller may not
|
|
113
|
+
see it. That `null` is a fact about access, not a bug to cast away.
|
|
114
|
+
- `r.many(target, { from, to })` is always an array, filtered by the target's rules.
|
|
115
|
+
- A **many-to-many** goes `through` a plain Drizzle table listed in the config's `tables`. Never make the join a
|
|
116
|
+
collection: its rules would be a way around the two sides'.
|
|
117
|
+
- The side that owns a many-to-many says so with `writable: true`, and gets `link(id, relation, ids)` and
|
|
118
|
+
`unlink(id, relation, ids)`: a link is an edit of that row, under its `update` rule, to rows of the other side the
|
|
119
|
+
caller may read ([queries.md](queries.md#links)). The other side reads the relation and cannot write it, so a
|
|
120
|
+
permissive rule there (renaming tags) never lets anyone attach tags to posts that are not theirs.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// src/typion/collections/tags.ts
|
|
124
|
+
import { pgTable, primaryKey, text, uuid } from 'drizzle-orm/pg-core'
|
|
125
|
+
import { collection } from 'typion'
|
|
126
|
+
import { posts } from './posts.ts'
|
|
127
|
+
|
|
128
|
+
export const tags = collection('tags', {
|
|
129
|
+
columns: { label: text().notNull().unique() },
|
|
130
|
+
relations: (r) => ({
|
|
131
|
+
posts: r.many('posts', {
|
|
132
|
+
from: 'id',
|
|
133
|
+
to: 'id',
|
|
134
|
+
through: { table: 'postsTags', from: 'tagId', to: 'postId' },
|
|
135
|
+
}),
|
|
136
|
+
}),
|
|
137
|
+
public: true,
|
|
138
|
+
}).define({ access: { create: ({ ctx }) => ctx.role === 'admin' } })
|
|
139
|
+
|
|
140
|
+
// Not a collection: it has no API, and reads cross it under the rules of posts and tags.
|
|
141
|
+
export const postsTags = pgTable(
|
|
142
|
+
'posts_tags',
|
|
143
|
+
{
|
|
144
|
+
postId: uuid('post_id')
|
|
145
|
+
.notNull()
|
|
146
|
+
.references(() => posts.table.id, { onDelete: 'cascade' }),
|
|
147
|
+
tagId: uuid('tag_id')
|
|
148
|
+
.notNull()
|
|
149
|
+
.references(() => tags.table.id, { onDelete: 'cascade' }),
|
|
150
|
+
},
|
|
151
|
+
(t) => [primaryKey({ columns: [t.postId, t.tagId] })],
|
|
152
|
+
)
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Adding a collection
|
|
156
|
+
|
|
157
|
+
1. `typion make collection <name>` writes `src/typion/collections/<name>.ts` and registers it in the config's
|
|
158
|
+
`collections` (or write the file and add it there yourself).
|
|
159
|
+
2. Columns and relations in the structure. A column another collection references is `.references(() =>
|
|
160
|
+
other.table.id)`; `other.table` exists once the config includes `other`.
|
|
161
|
+
3. `access` for every operation callers use ([access.md](access.md)). With none, everything is `FORBIDDEN`.
|
|
162
|
+
`define({ access: allowAll() })` allows everything while prototyping, and must not reach production.
|
|
163
|
+
4. `typion db generate --name add-<name>`, read the SQL, commit it ([database.md](database.md)).
|
|
164
|
+
5. A test with a caller the rules allow and one they deny ([testing.md](testing.md)).
|
|
165
|
+
|
|
166
|
+
A collection whose structure fails to type-check can make the whole registered config circular (TS2456 on
|
|
167
|
+
`RegisteredPlugins`, "circularly references itself"). Fix the first error in the collection's own file; the rest
|
|
168
|
+
usually follows from it.
|
package/docs/database.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# The database
|
|
2
|
+
|
|
3
|
+
The schema lives in the config: the CLI reads it, so there is no `drizzle.config.ts` and no schema file to keep in
|
|
4
|
+
step. Every table — the collections, the history tables the features derive (`typion.posts_versions`), a plugin's
|
|
5
|
+
tables, the config's `tables` — is in `config.schema`.
|
|
6
|
+
|
|
7
|
+
## Drivers
|
|
8
|
+
|
|
9
|
+
The config's `db` opens the database on first use, not when the config is imported, so the CLI and drizzle-kit can
|
|
10
|
+
read the config without one. The same must hold for whatever the config imports: an auth library built on a Drizzle
|
|
11
|
+
instance connects when it is built, so it shares a `pg` pool, which connects on the first query ([auth.md](auth.md)).
|
|
12
|
+
|
|
13
|
+
- **PGlite** in development and in tests: `db: pglite({ dataDir: '.pglite' })` from `typion/db/pglite`, Postgres in
|
|
14
|
+
the process, no server.
|
|
15
|
+
- **Postgres** anywhere else: `dbAdapter((relations) => drizzle(url, { relations }), { url })` around any Drizzle
|
|
16
|
+
driver ([README.md](README.md) has it with node-postgres). Give `url` so `typion db studio` can open it.
|
|
17
|
+
- **Neon**: node-postgres on the connection string from a Node runtime, or the `Pool` of `drizzle-orm/neon-serverless`
|
|
18
|
+
(WebSockets). Not Neon's HTTP driver: it has no interactive transactions, and every Typion write runs in one (the row
|
|
19
|
+
is locked, hooks run, the version is recorded, then it commits).
|
|
20
|
+
|
|
21
|
+
At boot the instance compares its tables with the database and names what is missing (`checkSchema`, on by default):
|
|
22
|
+
a database never pushed or migrated fails at once, not at the first query.
|
|
23
|
+
|
|
24
|
+
## Commands
|
|
25
|
+
|
|
26
|
+
| Command | Does |
|
|
27
|
+
| ------------------------------------ | -------------------------------------------------------------------------- |
|
|
28
|
+
| `typion db push [--force]` | makes a development database match the config; `--force` when data is lost |
|
|
29
|
+
| `typion db generate --name <change>` | writes the SQL of what changed to `typion/migrations/`, with its snapshot |
|
|
30
|
+
| `typion db migrate` | applies the migrations the database has not applied |
|
|
31
|
+
| `typion db status` | what is applied and what is pending |
|
|
32
|
+
| `typion db seed` | runs `src/typion/seed.ts` through the engine |
|
|
33
|
+
| `typion db seed --random` | rows from drizzle-seed instead; each feature fills its own columns |
|
|
34
|
+
| `typion db studio` | Drizzle Studio on the config's database |
|
|
35
|
+
| `typion make collection <name>` | a collection file, registered in the config |
|
|
36
|
+
| `typion init` | sets Typion up in an existing project, and refreshes the agent notes |
|
|
37
|
+
|
|
38
|
+
The config is found by walking up from the working directory (`typion.config.ts`, also under `src/`), or named with
|
|
39
|
+
`--config`. The CLI reads `.env.local` and `.env` from the working directory, as Next.js does, never over a variable
|
|
40
|
+
the shell set; an adapter given `url: process.env.DATABASE_URL` with the variable unset says so.
|
|
41
|
+
|
|
42
|
+
## Migrations
|
|
43
|
+
|
|
44
|
+
1. Change the collections.
|
|
45
|
+
2. `typion db generate --name <what-changed>`; read the SQL it wrote. A rename is a drop and an add unless you edit
|
|
46
|
+
it: data is lost otherwise.
|
|
47
|
+
3. Commit the SQL and the snapshot with the change.
|
|
48
|
+
4. Deploys run `typion db migrate` before the new code serves requests.
|
|
49
|
+
|
|
50
|
+
`typion db push` is for a development database you can throw away; it never touches a shared one.
|
|
51
|
+
|
|
52
|
+
## Seed
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// src/typion/seed.ts
|
|
56
|
+
import type { Typion } from 'typion'
|
|
57
|
+
import type { config } from '../typion.config.ts'
|
|
58
|
+
|
|
59
|
+
// `typion db seed` calls it with the config's instance; a test can call it with its own.
|
|
60
|
+
export async function seed({ typion }: { typion: Typion<typeof config> }) {
|
|
61
|
+
const system = typion.$system({ reason: 'seed' })
|
|
62
|
+
const ada = await system.users.create({ name: 'Ada', email: 'ada@example.test', role: 'admin' })
|
|
63
|
+
|
|
64
|
+
// Content written as its author, so rules, hooks and the history see what a real caller does.
|
|
65
|
+
const asAda = typion.$as({ actorId: ada.id, role: 'admin' })
|
|
66
|
+
const post = await asAda.posts.create({ title: 'Welcome', slug: 'welcome', authorId: ada.id })
|
|
67
|
+
await asAda.posts.publish(post.id)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`typion db seed --if-empty` does nothing when the collections already hold rows, which is what a setup script wants;
|
|
72
|
+
`--reset` empties every table of the config first — the collections, their history, the plugins' tables (the audit
|
|
73
|
+
trail) and the config's `tables` (an auth library's users) — so the seed starts from nothing. `--random` needs
|
|
74
|
+
`drizzle-seed@1.0.0-rc.5-ab785fc`, the build that runs on this Drizzle.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Localization
|
|
2
|
+
|
|
3
|
+
Localized columns: one jsonb per field, resolved per locale. A feature of Typion's core ([the guide](../README.md)),
|
|
4
|
+
configured once for the application in `defineSetup`.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// src/typion.config.ts
|
|
8
|
+
import { defineConfig, defineSetup } from 'typion'
|
|
9
|
+
import { pglite } from 'typion/db/pglite'
|
|
10
|
+
import { pages } from './typion/collections/pages.ts'
|
|
11
|
+
|
|
12
|
+
export const setup = defineSetup({
|
|
13
|
+
localization: {
|
|
14
|
+
locales: ['it', 'en'],
|
|
15
|
+
defaultLocale: 'it',
|
|
16
|
+
// Where the locale of a call comes from; without it the `x-locale` header, then the default locale.
|
|
17
|
+
locale: ({ headers }) => (headers?.get('cookie')?.includes('locale=en') ? 'en' : undefined),
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const config = defineConfig({
|
|
22
|
+
setup,
|
|
23
|
+
collections: [pages],
|
|
24
|
+
db: pglite({ dataDir: '.pglite' }),
|
|
25
|
+
context: (): { actorId: string | null } => ({ actorId: null }),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
declare module 'typion' {
|
|
29
|
+
interface Register {
|
|
30
|
+
setup: typeof setup
|
|
31
|
+
config: typeof config
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// src/typion/collections/pages.ts
|
|
38
|
+
import { text } from 'drizzle-orm/pg-core'
|
|
39
|
+
import { collection, localized, localizedIndex, localizedUnique } from 'typion'
|
|
40
|
+
|
|
41
|
+
export const pages = collection('pages', {
|
|
42
|
+
columns: {
|
|
43
|
+
title: localized(text()).notNull(), // jsonb: { it: string; en?: string }
|
|
44
|
+
slug: localized(text()).notNull(),
|
|
45
|
+
body: localized(text()),
|
|
46
|
+
},
|
|
47
|
+
public: true,
|
|
48
|
+
}).define({
|
|
49
|
+
access: { create: ({ ctx }) => ctx.actorId !== null, update: ({ ctx }) => ctx.actorId !== null },
|
|
50
|
+
indexes: (t) => [
|
|
51
|
+
localizedUnique('pages_slug_it', t.slug, 'it'),
|
|
52
|
+
localizedUnique('pages_slug_en', t.slug, 'en'),
|
|
53
|
+
localizedIndex('pages_title_it', t.title, 'it'),
|
|
54
|
+
],
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
// In the locale of the call, falling back to the default one.
|
|
60
|
+
const [page] = await app.pages.find({ columns: { title: true }, limit: 1 })
|
|
61
|
+
page satisfies { title: string } | undefined
|
|
62
|
+
|
|
63
|
+
// One locale, without the fallback, or the stored object.
|
|
64
|
+
const english = await app.pages.find({ columns: { title: true }, locale: 'en', fallback: false })
|
|
65
|
+
english satisfies { title: string | null }[]
|
|
66
|
+
const stored = await app.pages.find({ columns: { title: true }, locale: 'all' })
|
|
67
|
+
stored satisfies { title: { it: string; en?: string } }[]
|
|
68
|
+
|
|
69
|
+
// A create gives the default locale of a NOT NULL column; `locale: 'all'` writes several at once.
|
|
70
|
+
const about = await app.pages.create(
|
|
71
|
+
{ title: { it: 'Chi siamo', en: 'About us' }, slug: { it: 'chi-siamo', en: 'about-us' } },
|
|
72
|
+
{ locale: 'all' },
|
|
73
|
+
)
|
|
74
|
+
await app.pages.update(about.id, { title: 'About' }, { locale: 'en' })
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **Reads** resolve a localized column in the locale of the call and fall back to the default locale:
|
|
78
|
+
`title` is a `string`. `locale: 'en'` forces a locale, `fallback: false` gives `string | null`, `locale: 'all'`
|
|
79
|
+
returns the stored object (`{ it: string; en?: string }`). The types follow the option you pass.
|
|
80
|
+
- **Filters and ordering** compare in the locale being read: `where: { title: { ilike: '%ciao%' } }` becomes
|
|
81
|
+
`coalesce(title->>'it', title->>'en') ilike …`. A filter an access rule returns compares the same way, in the
|
|
82
|
+
locale of the context, whatever locale the query asks for.
|
|
83
|
+
- **Writes** take a plain value in the locale of the call, merged with the stored value under the row lock:
|
|
84
|
+
`update(id, { title: 'Hello' })` writes `ctx.locale`, `update(id, { title: 'Hello' }, { locale: 'en' })` writes
|
|
85
|
+
`en`, and `{ locale: 'all' }` takes the whole object (`{ it: 'Ciao', en: 'Hello' }`). A NOT NULL column always
|
|
86
|
+
holds the default locale: create it in that locale, or with `locale: 'all'`. `locale` is an option of the
|
|
87
|
+
collections with localized columns only.
|
|
88
|
+
- **What a write returns** is what a read returns: `create(…)` gives `title` as a `string` in the locale written,
|
|
89
|
+
`create(…, { locale: 'all' })` the object.
|
|
90
|
+
- **The context** gets `ctx.locale`, typed with your locales; `$as(ctx, { locale })`, `$system({ locale })` and
|
|
91
|
+
`getApp(config, { locale })` choose it explicitly.
|
|
92
|
+
- **Validation** checks each locale's value as the column you wrapped (`localized(text())` takes strings,
|
|
93
|
+
`localized(integer())` numbers), with per-locale refinements (`validate.fields.title.it`). `localized()` refuses a
|
|
94
|
+
column JSON would not give back as it was (a date, a bigint). The audit trail records only the locales that changed.
|
|
95
|
+
- **Indexes**, in the collection's `define({ indexes })`: `localizedIndex(name, column, locale)` for filters and
|
|
96
|
+
ordering, `localizedUnique(name, column, locale)` for a value unique within one locale (a slug per language), and
|
|
97
|
+
`localizedValue(column, locale)` for an index of your own:
|
|
98
|
+
`uniqueIndex('pages_path').on(t.tenantId, localizedValue(t.path, 'it'))`.
|
|
99
|
+
- **The application reads its locales** from `setup.localization` (`locales`, `defaultLocale`), read-only: a
|
|
100
|
+
language switcher, the locale a form writes.
|
|
101
|
+
- `typion db seed --random` writes a distinct object keyed by locale into every row.
|
|
102
|
+
|
|
103
|
+
Without `localization` in the setup none of this exists: `locale` and `fallback` are unknown options,
|
|
104
|
+
`ctx.locale` is not there, `localized()` does not compile, and `defineConfig` refuses a localized column.
|
|
105
|
+
|
|
106
|
+
## For plugin authors
|
|
107
|
+
|
|
108
|
+
Localization uses nothing a plugin of your own cannot: it is built on the column-kind API of `typion`
|
|
109
|
+
(`defineColumnKind`, `Kinded`, `columnKinds` in `definePlugin`), the way a plugin adds a field type of its own,
|
|
110
|
+
described with its rules in §2.13.1 of [Typion's ARCHITECTURE](https://github.com/the-escape-studio/typion/blob/main/docs/ARCHITECTURE.md).
|
|
111
|
+
Its source, [`src/features/localization.ts`](https://github.com/the-escape-studio/typion/blob/main/packages/typion/src/features/localization.ts),
|
|
112
|
+
is the worked example: neither is in the package.
|