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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# Multi-tenancy
|
|
2
|
+
|
|
3
|
+
Every row of a collection that asks for it belongs to a tenant, and every read and write stays in the tenant of the
|
|
4
|
+
call. A feature of Typion's core ([the guide](../README.md)), configured once for the application in `defineSetup`,
|
|
5
|
+
turned on per collection.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// src/typion.config.ts
|
|
9
|
+
import { type AnyPgColumn, uuid } from 'drizzle-orm/pg-core'
|
|
10
|
+
import { defineConfig, defineSetup } from 'typion'
|
|
11
|
+
import { pglite } from 'typion/db/pglite'
|
|
12
|
+
import { auth } from './auth.ts'
|
|
13
|
+
import { members } from './typion/collections/members.ts'
|
|
14
|
+
import { projects } from './typion/collections/projects.ts'
|
|
15
|
+
import { tenants } from './typion/collections/tenants.ts'
|
|
16
|
+
|
|
17
|
+
export const setup = defineSetup({
|
|
18
|
+
// Every tenant column references the tenants. The column's type is written, since the tenants' table is typed
|
|
19
|
+
// from the setup itself: without it, `setup` refers to itself (TS7022).
|
|
20
|
+
multiTenant: {
|
|
21
|
+
column: () =>
|
|
22
|
+
uuid('tenant_id')
|
|
23
|
+
.notNull()
|
|
24
|
+
.references((): AnyPgColumn => tenants.table.id),
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// The context carries the caller's tenant, typed as the column is.
|
|
29
|
+
export type AppContext = { actorId: string | null; tenantId: string | null }
|
|
30
|
+
|
|
31
|
+
export const config = defineConfig({
|
|
32
|
+
setup,
|
|
33
|
+
collections: [tenants, members, projects],
|
|
34
|
+
db: pglite({ dataDir: '.pglite' }),
|
|
35
|
+
// `system` here crosses tenants: it looks the caller up before their tenant is known.
|
|
36
|
+
context: async ({ headers, system }): Promise<AppContext> => {
|
|
37
|
+
const session = headers === undefined ? null : await auth.api.getSession({ headers })
|
|
38
|
+
const member =
|
|
39
|
+
session === null
|
|
40
|
+
? null
|
|
41
|
+
: await system.members.findOne({
|
|
42
|
+
where: { userId: session.user.id },
|
|
43
|
+
columns: { tenantId: true },
|
|
44
|
+
})
|
|
45
|
+
return { actorId: session?.user.id ?? null, tenantId: member?.tenantId ?? null }
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
declare module 'typion' {
|
|
49
|
+
interface Register {
|
|
50
|
+
setup: typeof setup
|
|
51
|
+
config: typeof config
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// src/typion/collections/tenants.ts
|
|
58
|
+
import { text } from 'drizzle-orm/pg-core'
|
|
59
|
+
import { collection } from 'typion'
|
|
60
|
+
|
|
61
|
+
// The tenants themselves belong to no tenant. Anyone reads an agency's name and slug, visitors of its pages included.
|
|
62
|
+
export const tenants = collection('tenants', {
|
|
63
|
+
columns: { name: text().notNull(), slug: text().notNull().unique() },
|
|
64
|
+
public: true,
|
|
65
|
+
})
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// src/typion/collections/members.ts
|
|
70
|
+
import { text, uuid } from 'drizzle-orm/pg-core'
|
|
71
|
+
import { collection } from 'typion'
|
|
72
|
+
|
|
73
|
+
export const members = collection('members', {
|
|
74
|
+
columns: { userId: uuid('user_id').notNull(), role: text().notNull() },
|
|
75
|
+
tenant: true, // adds `tenantId`, built by `column`
|
|
76
|
+
public: true,
|
|
77
|
+
})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// src/typion/collections/projects.ts
|
|
82
|
+
import { text, uniqueIndex } from 'drizzle-orm/pg-core'
|
|
83
|
+
import { collection } from 'typion'
|
|
84
|
+
|
|
85
|
+
export const projects = collection('projects', {
|
|
86
|
+
columns: { name: text().notNull() },
|
|
87
|
+
tenant: true,
|
|
88
|
+
}).define({
|
|
89
|
+
access: {
|
|
90
|
+
read: true,
|
|
91
|
+
create: ({ ctx }) => ctx.actorId !== null,
|
|
92
|
+
update: ({ ctx }) => ctx.actorId !== null,
|
|
93
|
+
},
|
|
94
|
+
// Unique within a tenant.
|
|
95
|
+
indexes: (t) => [uniqueIndex('projects_name').on(t.tenantId, t.name)],
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// A caller of one tenant reads and writes there only; `create` fills the tenant.
|
|
101
|
+
const inAcme = typion.$as({ actorId: null, tenantId: '0b5c…' })
|
|
102
|
+
const project = await inAcme.projects.create({ name: 'Roadmap' })
|
|
103
|
+
project.tenantId satisfies string
|
|
104
|
+
|
|
105
|
+
// Jobs: one tenant, or every tenant with `unscoped`, where nothing fills the column and the job names it.
|
|
106
|
+
const importer = typion.$system({ reason: 'import', ctx: { tenantId: '0b5c…' } })
|
|
107
|
+
await importer.projects.create({ name: 'Imported' })
|
|
108
|
+
const report = typion.$system({ reason: 'billing report', unscoped: true })
|
|
109
|
+
const perTenant = await report.projects.count()
|
|
110
|
+
void perTenant
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- **Reads** stay in the context's tenant at the root, in every `with` and in every relation filter, counts included:
|
|
114
|
+
another tenant's row is `NOT_FOUND` and cannot be probed. Relations to a tenant collection are typed `| null`, in
|
|
115
|
+
system mode too.
|
|
116
|
+
- **Writes** lock inside the tenant, so another tenant's row is `NOT_FOUND`; `create` fills `tenantId`, and a write
|
|
117
|
+
that names another tenant is `FORBIDDEN` (repeating the call's own is harmless). `tenantId` is managed: callers do
|
|
118
|
+
not write it, system mode can.
|
|
119
|
+
- **References** stay in the tenant: a write that points a tenant collection's row at another tenant's (a project's
|
|
120
|
+
`clientId` naming another agency's client) is `VALIDATION`, with an issue on the column, as a reference to a row
|
|
121
|
+
that does not exist is, so it tells the caller nothing. Only `unscoped` calls are not checked.
|
|
122
|
+
- **History and drafts** stay in the tenant too: the versions table clones the column, and `@typion/audit` records the
|
|
123
|
+
tenant of each entry and keeps its log in the tenant of the call, whoever reads it.
|
|
124
|
+
- **It fails closed**: a call without a tenant is `FORBIDDEN`, system mode included. A job works in one tenant with
|
|
125
|
+
`$system({ reason, ctx: { tenantId } })` and across all of them with `$system({ reason, unscoped: true })`, where
|
|
126
|
+
nothing fills the column and the job names it. `unscoped` is not an option of `$as()`, in the types or at runtime.
|
|
127
|
+
- **The context factory** gets a system instance that crosses tenants: it looks the caller up before their tenant is
|
|
128
|
+
known.
|
|
129
|
+
- **Rules and hooks** see `ctx.unscoped`, true when a system call crosses tenants.
|
|
130
|
+
- **Uniqueness** within a tenant is an index in the collection's `define()`:
|
|
131
|
+
`indexes: (t) => [uniqueIndex('projects_name').on(t.tenantId, t.name)]`.
|
|
132
|
+
- **Any column** can hold the tenant. A not-null `uuid`, `text` or `integer` costs next to nothing to type-check; the
|
|
133
|
+
builder of any other is carried in the setup's type, which costs about 13,500 instantiations more and 33 per
|
|
134
|
+
read on the engine's stress fixture.
|
|
135
|
+
- `typion db seed --random` puts generated rows in `multiTenant: { seedTenant }` or the config's
|
|
136
|
+
`seed: { values: { tenant } }`.
|
|
137
|
+
|
|
138
|
+
Without `multiTenant` in the setup none of this exists: no `tenant` option or column, no `unscoped`, no
|
|
139
|
+
`ctx.unscoped`. On a collection that does not ask for it the same holds, and a system call needs no tenant there.
|
|
140
|
+
|
|
141
|
+
`tenantColumn(entity)` gives the tenant column of an entity's metadata, or `null` where multi-tenancy does not act:
|
|
142
|
+
for a plugin that needs to know which rows belong to a tenant.
|
|
143
|
+
|
|
144
|
+
## Visitors of a tenant's pages
|
|
145
|
+
|
|
146
|
+
A visitor has no membership, so the context factory finds no tenant for them, and a read of a tenant collection fails
|
|
147
|
+
closed. The tenant of a public page comes from where the page is: its address. A page that has it in its path binds
|
|
148
|
+
the visitor itself:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// The public page /acme/projects: nobody signed in, the tenant from the address. `tenants` is public, so the slug
|
|
152
|
+
// resolves without one.
|
|
153
|
+
const visitor = typion.$as({ actorId: null, tenantId: null })
|
|
154
|
+
const acme = await visitor.tenants.findOne({ where: { slug: 'acme' }, columns: { id: true } })
|
|
155
|
+
if (acme !== null) {
|
|
156
|
+
const projects = await typion
|
|
157
|
+
.$as({ actorId: null, tenantId: acme.id })
|
|
158
|
+
.projects.find({ columns: { name: true } })
|
|
159
|
+
void projects
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The rules decide the rest as for anyone: a visitor reads what `read` lets an anonymous caller read, in that tenant.
|
|
164
|
+
When the tenant is the host (`acme.example.com`), the context factory can read it from `headers` instead, with the
|
|
165
|
+
same lookup, for every route of the application at once.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Soft delete
|
|
2
|
+
|
|
3
|
+
A deleted row keeps its row and its id, and every read hides it. A feature of Typion's core
|
|
4
|
+
([the guide](../README.md)), in every config: a collection turns it on.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// src/typion.config.ts
|
|
8
|
+
import { defineConfig, defineSetup } from 'typion'
|
|
9
|
+
import { pglite } from 'typion/db/pglite'
|
|
10
|
+
import { notes } from './typion/collections/notes.ts'
|
|
11
|
+
|
|
12
|
+
// Soft delete and versions need no line here: a collection turns them on.
|
|
13
|
+
export const setup = defineSetup({})
|
|
14
|
+
|
|
15
|
+
export const config = defineConfig({
|
|
16
|
+
setup,
|
|
17
|
+
collections: [notes],
|
|
18
|
+
db: pglite({ dataDir: '.pglite' }),
|
|
19
|
+
context: (): { actorId: string | null; role: 'admin' | 'editor' | null } => ({
|
|
20
|
+
actorId: null,
|
|
21
|
+
role: null,
|
|
22
|
+
}),
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
declare module 'typion' {
|
|
26
|
+
interface Register {
|
|
27
|
+
setup: typeof setup
|
|
28
|
+
config: typeof config
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// src/typion/collections/notes.ts
|
|
35
|
+
import { text } from 'drizzle-orm/pg-core'
|
|
36
|
+
import { collection, softUnique } from 'typion'
|
|
37
|
+
|
|
38
|
+
export const notes = collection('notes', {
|
|
39
|
+
columns: { title: text().notNull(), slug: text().notNull() },
|
|
40
|
+
softDelete: true, // adds a nullable `deletedAt` (`deleted_at timestamptz`)
|
|
41
|
+
public: true,
|
|
42
|
+
}).define({
|
|
43
|
+
access: {
|
|
44
|
+
create: ({ ctx }) => ctx.actorId !== null,
|
|
45
|
+
update: ({ ctx }) => ctx.actorId !== null, // also `restore`
|
|
46
|
+
// The rule sees the options of the call: removing a row for good is for admins.
|
|
47
|
+
delete: ({ ctx, options }) => options.hard !== true || ctx.role === 'admin',
|
|
48
|
+
},
|
|
49
|
+
// Unique among the rows that are not deleted.
|
|
50
|
+
indexes: (t) => [softUnique('notes_slug_live', t.deletedAt).on(t.slug)],
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
const note = await app.notes.create({ title: 'Draft plan', slug: 'plan' })
|
|
56
|
+
await app.notes.delete(note.id)
|
|
57
|
+
|
|
58
|
+
await app.notes.count() // the deleted note is not counted
|
|
59
|
+
await app.notes.find({ columns: { title: true }, withDeleted: true }) // it is here
|
|
60
|
+
await app.notes.restore(note.id)
|
|
61
|
+
await app.notes.delete(note.id, { hard: true }) // gone for good
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Deletes** set `deletedAt` on the row; `delete(id, { hard: true })` removes it for good, deleted or not. The
|
|
65
|
+
`delete` rule sees the options of the call: `delete: ({ options, ctx }) => !options.hard || ctx.role === 'admin'`.
|
|
66
|
+
- **Reads** hide deleted rows at the root, in every `with` and in every relation filter, counts included;
|
|
67
|
+
`withDeleted: true` shows them for the whole read. Relations to a soft-deleted collection are typed `| null`.
|
|
68
|
+
- **`restore(id)`** clears `deletedAt` through the update pipeline (the `update` rule, hooks with `op: 'restore'`, the
|
|
69
|
+
version, the audit trail), on deleted rows only. It is also a descriptor (`q.posts.restore({ id })`), a route, and
|
|
70
|
+
a method of `typion` in hooks and rules, which get the application's own instance.
|
|
71
|
+
- **References** to a deleted row are refused: a create or an update that points at one (a task on an archived
|
|
72
|
+
project) is `VALIDATION`, with an issue on the column, as a reference to a row that does not exist is. A write that
|
|
73
|
+
means it says so, `create(data, { withDeleted: true })`: the option exists on the collections with a relation to a
|
|
74
|
+
soft-deleted one. Drafts are not refused; another tenant's row is, whatever the option (multi-tenancy).
|
|
75
|
+
- **`deletedAt` is managed**: callers cannot write it, system mode can.
|
|
76
|
+
- **Over HTTP**: `DELETE /api/posts/:id?hard=true`, `GET /api/posts?withDeleted=true`, `POST /api/posts/:id/restore`.
|
|
77
|
+
- **Uniqueness** among the rows that are not deleted is a partial index, in the collection's `define()`:
|
|
78
|
+
`indexes: (t) => [softUnique('posts_slug_live', t.deletedAt).on(t.slug)]`.
|
|
79
|
+
- `typion db seed --random` generates rows that are not deleted.
|
|
80
|
+
|
|
81
|
+
On a collection that does not ask for it none of this exists: no column, no `withDeleted`, `hard` or `restore`, no
|
|
82
|
+
route; the types refuse them, and the runtime has no `restore` there and answers `UNSUPPORTED` to its options.
|
|
83
|
+
|
|
84
|
+
`softDeleteColumn(entity)` gives the column of an entity's metadata, or `null` where soft delete does not act: for a
|
|
85
|
+
plugin that needs to know which rows are deleted.
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Versions and drafts
|
|
2
|
+
|
|
3
|
+
Every change of a collection that asks for it appends a version, and with drafts a published document keeps its live
|
|
4
|
+
row while edits wait for `publish`. A feature of Typion's core ([the guide](../README.md)), in every config: a
|
|
5
|
+
collection turns it on.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// src/typion.config.ts
|
|
9
|
+
import { defineConfig, defineSetup } from 'typion'
|
|
10
|
+
import { pglite } from 'typion/db/pglite'
|
|
11
|
+
import { posts } from './typion/collections/posts.ts'
|
|
12
|
+
|
|
13
|
+
// Soft delete and versions need no line here: a collection turns them on.
|
|
14
|
+
export const setup = defineSetup({})
|
|
15
|
+
|
|
16
|
+
export const config = defineConfig({
|
|
17
|
+
setup,
|
|
18
|
+
collections: [posts],
|
|
19
|
+
db: pglite({ dataDir: '.pglite' }),
|
|
20
|
+
context: (): { actorId: string | null; role: 'admin' | 'editor' | null } => ({
|
|
21
|
+
actorId: null,
|
|
22
|
+
role: null,
|
|
23
|
+
}),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
declare module 'typion' {
|
|
27
|
+
interface Register {
|
|
28
|
+
setup: typeof setup
|
|
29
|
+
config: typeof config
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// src/typion/collections/posts.ts
|
|
36
|
+
import { text, uuid } from 'drizzle-orm/pg-core'
|
|
37
|
+
import { collection } from 'typion'
|
|
38
|
+
|
|
39
|
+
export const posts = collection('posts', {
|
|
40
|
+
columns: {
|
|
41
|
+
title: text().notNull(),
|
|
42
|
+
slug: text().notNull(),
|
|
43
|
+
authorId: uuid('author_id').notNull(),
|
|
44
|
+
},
|
|
45
|
+
versions: { drafts: true, max: 20 }, // adds `status` and `publishedAt`; `versions: true` for a history alone
|
|
46
|
+
}).define({
|
|
47
|
+
access: {
|
|
48
|
+
read: true, // plain reads see published rows only
|
|
49
|
+
readDrafts: ({ ctx }) => (ctx.actorId === null ? false : { authorId: ctx.actorId }), // `draft: true` reads, instead of `read`
|
|
50
|
+
readVersions: ({ ctx }) => ctx.actorId !== null, // the history
|
|
51
|
+
create: ({ ctx, data }) => ctx.actorId !== null && data.authorId === ctx.actorId,
|
|
52
|
+
update: ({ ctx }) => (ctx.actorId === null ? false : { authorId: ctx.actorId }),
|
|
53
|
+
publish: ({ ctx }) => ctx.role === 'admin', // publish, unpublish, create({ publish: true })
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const actorId = app.$ctx.actorId
|
|
60
|
+
if (actorId !== null) {
|
|
61
|
+
const post = await app.posts.create({ title: 'Hello', slug: 'hello', authorId: actorId }) // a draft
|
|
62
|
+
await app.posts.publish(post.id)
|
|
63
|
+
await app.posts.update(post.id, { title: 'Hello, world' }, { draft: true }) // the live row stays as it is
|
|
64
|
+
|
|
65
|
+
const drafts = await app.posts.find({ draft: true, columns: { title: true } }) // the latest drafts
|
|
66
|
+
drafts satisfies { title: string }[]
|
|
67
|
+
const [latest] = await app.posts.versions.list(post.id, { limit: 1 })
|
|
68
|
+
if (latest !== undefined) {
|
|
69
|
+
latest.data.title satisfies string
|
|
70
|
+
await app.posts.versions.restore(post.id, latest.versionId, { draft: true })
|
|
71
|
+
}
|
|
72
|
+
await app.posts.discardDraft(post.id)
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
- **History**: every create, update and kept delete records a version in `typion.posts_versions`, a copy of the
|
|
77
|
+
collection's columns with the actor, the mode, the system reason and the request. `versions.list(id, { limit,
|
|
78
|
+
offset, state })`, `versions.get(id, versionId)` and `versions.diff(id, from, to)` read it under `readVersions`;
|
|
79
|
+
`versions.restore(id, versionId, { draft })` writes a version back through the update pipeline. `max` keeps the
|
|
80
|
+
newest versions, the latest and the last published one.
|
|
81
|
+
- **Drafts**: `create` makes a draft unless `{ publish: true }`; `update(id, data, { draft: true })` on a published
|
|
82
|
+
document writes a draft version and leaves the live row as it is, `publish(id)` makes the latest draft live,
|
|
83
|
+
`unpublish(id)` turns the live row back into a draft, and `discardDraft(id)` records the live row again. A live
|
|
84
|
+
update over a pending draft supersedes it, or is refused with `onPendingDraft: 'reject'`.
|
|
85
|
+
- **Reads** see published rows only, at the root, in every `with` and in every relation filter, so relations to a
|
|
86
|
+
collection with drafts are typed `| null`. `find({ draft: true })` reads the latest drafts at the root, under
|
|
87
|
+
`readDrafts`; nested levels stay live.
|
|
88
|
+
- **Hooks** get `draft` (the write left the live row as it is), `previousDraft` (the pending draft it built on,
|
|
89
|
+
published, superseded or discarded) and `version` (the version it recorded) — on every collection, `false` and
|
|
90
|
+
`null` where it does not act; a plugin's hooks get them too, typed. The
|
|
91
|
+
workflow's `op` is `publish`, `unpublish`, `discardDraft` or `versions.restore`; soft delete's `restore` is
|
|
92
|
+
recorded under its own name.
|
|
93
|
+
- **`status` and `publishedAt` are managed**: callers cannot write them, system mode can.
|
|
94
|
+
- **Over HTTP**: `POST /api/posts/:id/publish` (also `/unpublish`, `/discard-draft`), `GET /api/posts?draft=true`,
|
|
95
|
+
`GET /api/posts/:id/versions?limit=10`, `GET /api/posts/:id/versions/:versionId`,
|
|
96
|
+
`GET /api/posts/:id/diff?from=&to=`, `POST /api/posts/:id/versions/:versionId/restore`; each is also a descriptor
|
|
97
|
+
(`q.posts.publish({ id })`, `q.posts.versions.list({ id, limit })`).
|
|
98
|
+
- **Partitions** hold for the history and the drafts: with multi-tenancy a tenant never reads another's versions,
|
|
99
|
+
since the versions table copies its column.
|
|
100
|
+
- `typion db seed --random` generates published rows: a draft would be invisible to every plain read.
|
|
101
|
+
|
|
102
|
+
On a collection that does not ask for drafts none of the workflow exists, and without `versions` none of the history:
|
|
103
|
+
no columns or table, no `draft` or `publish` option, no method, descriptor or route, no `readDrafts`, `readVersions`
|
|
104
|
+
or `publish` rule; the types refuse them, and the runtime answers `UNSUPPORTED`.
|
|
105
|
+
|
|
106
|
+
`versionsTable(entity)` gives the versions table of an entity's metadata, or `null` where versions does not act.
|
package/docs/hooks.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Hooks
|
|
2
|
+
|
|
3
|
+
Hooks run when a row is written. They sit in a collection's `define({ hooks })` and are typed against the registered
|
|
4
|
+
config, so `typion` inside them is the application's own instance.
|
|
5
|
+
|
|
6
|
+
```ts
|
|
7
|
+
// src/typion/collections/comments.ts
|
|
8
|
+
import { boolean, text, uuid } from 'drizzle-orm/pg-core'
|
|
9
|
+
import { collection } from 'typion'
|
|
10
|
+
import { sendMail } from '../../lib/mailer.ts'
|
|
11
|
+
import { posts } from './posts.ts'
|
|
12
|
+
import { users } from './users.ts'
|
|
13
|
+
|
|
14
|
+
export const comments = collection('comments', {
|
|
15
|
+
columns: {
|
|
16
|
+
body: text().notNull(),
|
|
17
|
+
approved: boolean().notNull().default(false),
|
|
18
|
+
postId: uuid('post_id')
|
|
19
|
+
.notNull()
|
|
20
|
+
.references(() => posts.table.id),
|
|
21
|
+
authorId: uuid('author_id')
|
|
22
|
+
.notNull()
|
|
23
|
+
.references(() => users.table.id),
|
|
24
|
+
},
|
|
25
|
+
relations: (r) => ({
|
|
26
|
+
post: r.one('posts', { from: 'postId', to: 'id', optional: false }),
|
|
27
|
+
author: r.one('users', { from: 'authorId', to: 'id', optional: false }),
|
|
28
|
+
}),
|
|
29
|
+
// Only admins approve; a caller who sets `approved` gets FORBIDDEN, never a silent drop.
|
|
30
|
+
fields: { approved: { write: ({ ctx }) => ctx.role === 'admin' } },
|
|
31
|
+
}).define({
|
|
32
|
+
access: {
|
|
33
|
+
read: ({ ctx }) =>
|
|
34
|
+
ctx.role === 'admin'
|
|
35
|
+
? true
|
|
36
|
+
: ctx.actorId === null
|
|
37
|
+
? { approved: true }
|
|
38
|
+
: { OR: [{ approved: true }, { authorId: ctx.actorId }] },
|
|
39
|
+
create: ({ ctx, data }) => ctx.actorId !== null && data.authorId === ctx.actorId,
|
|
40
|
+
update: ({ ctx }) => ctx.role === 'admin',
|
|
41
|
+
delete: ({ ctx }) => ctx.role === 'admin',
|
|
42
|
+
},
|
|
43
|
+
hooks: {
|
|
44
|
+
beforeChange: async ({ op, data, typion }) => {
|
|
45
|
+
if (op !== 'create') return
|
|
46
|
+
// The caller's own instance, in the same transaction: a post they may not read is NOT_FOUND here too,
|
|
47
|
+
// so nobody comments on a draft they cannot see.
|
|
48
|
+
await typion.posts.findById(data.postId, { columns: { id: true } })
|
|
49
|
+
return { ...data, body: data.body.trim() }
|
|
50
|
+
},
|
|
51
|
+
// After the commit, outside the transaction: e-mail, webhooks and caches go here, never in afterChange, which
|
|
52
|
+
// would still have run if the write rolled back.
|
|
53
|
+
afterCommit: async ({ op, doc }) => {
|
|
54
|
+
if (op === 'create' && doc !== null) {
|
|
55
|
+
await sendMail({
|
|
56
|
+
to: 'moderation@example.test',
|
|
57
|
+
subject: 'A comment to review',
|
|
58
|
+
text: doc.body,
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Order of a write
|
|
67
|
+
|
|
68
|
+
Access → the features' values (the tenant set or checked, the draft plan) → `beforeValidate` → defaults → validation →
|
|
69
|
+
`beforeChange` → the references checked (the tenant, deleted rows) → the write → the version recorded → `afterChange` → `COMMIT` →
|
|
70
|
+
`afterCommit`. A throw anywhere before
|
|
71
|
+
the commit rolls everything back, the history and the audit trail included.
|
|
72
|
+
|
|
73
|
+
| Hook | Gets | May return |
|
|
74
|
+
| ---------------- | ------------------------------------------------------------------- | -------------------- |
|
|
75
|
+
| `beforeValidate` | `op`, `data` (as the caller gave it), `original` (`null` on create) | a replacement `data` |
|
|
76
|
+
| `beforeChange` | the same, after defaults and validation | a replacement `data` |
|
|
77
|
+
| `afterChange` | `op`, `doc`, `previous`, `changes` (the keys that changed) | nothing |
|
|
78
|
+
| `beforeDelete` | `doc`, `options` (`{ hard }` with soft delete) | nothing |
|
|
79
|
+
| `afterDelete` | `doc`, `options` | nothing |
|
|
80
|
+
| `afterCommit` | `op`, `doc`, `previous`, `ctx`, `meta` | nothing |
|
|
81
|
+
|
|
82
|
+
- `op` is `'create'` or `'update'`, or the operation of a feature that writes the row: `restore`, `publish`,
|
|
83
|
+
`unpublish`, `discardDraft`, `versions.restore`. Narrow on it before reading `data`: on create every required
|
|
84
|
+
column is there, on update `data` is partial.
|
|
85
|
+
- Every hook but `afterCommit` also gets `ctx`, `meta` (entity, operation, mode, request id, time), `typion`, `tx` and
|
|
86
|
+
`bag`, and with versions `draft`, `previousDraft` and `version` ([features/versions.md](features/versions.md)).
|
|
87
|
+
- `doc`, `previous` and `original` are whole stored rows: every column, a localized one as its object of locales.
|
|
88
|
+
|
|
89
|
+
## What hooks may do
|
|
90
|
+
|
|
91
|
+
- **Write through `typion`.** It is the caller's own instance, in the caller's mode, joined to the same transaction:
|
|
92
|
+
rules, tenant and features apply, and each nested call runs in a savepoint, so a hook can catch a failure without
|
|
93
|
+
aborting the write. This is how a hook keeps another collection in step.
|
|
94
|
+
- **Not leave the transaction for side effects.** E-mail, HTTP calls and cache revalidation go in `afterCommit`; its
|
|
95
|
+
errors go to the config's `onAfterCommitError` and cannot fail the write, which has already committed.
|
|
96
|
+
- **`tx` is the raw Drizzle transaction**, for a table that is not a collection (an outbox, a plugin's table). Rule 1
|
|
97
|
+
of [README.md](README.md) holds: the application's collections are written through `typion`.
|
|
98
|
+
- **`bag`** is a `Map` shared by every hook and plugin of one operation, for what one hook computes and another reads.
|
|
99
|
+
- A hook does not authorize. A check that decides who may write belongs in `access`, which runs first and which every
|
|
100
|
+
caller hits, the HTTP routes included.
|
|
101
|
+
|
|
102
|
+
## An audit trail
|
|
103
|
+
|
|
104
|
+
`@typion/audit` records every write in a table of its own, inside the write's transaction, with who made it and what
|
|
105
|
+
changed; installed in `defineSetup({ plugins: [audit()] })`. Its README, `node_modules/@typion/audit/README.md`, says
|
|
106
|
+
how to read the trail and who may: in a multi-tenant application each tenant reads its own.
|
package/docs/http.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Pages, routes and the browser
|
|
2
|
+
|
|
3
|
+
Typion reaches HTTP through **transports**, each a package of its own: `@typion/rest` gives every collection a
|
|
4
|
+
resource, `@typion/rpc` an endpoint for descriptors, which the browser client speaks. The handler mounts the ones the
|
|
5
|
+
application lists and nothing else; every transport turns its requests into descriptors that the core checks before
|
|
6
|
+
they run, so they all answer to the same rules.
|
|
7
|
+
|
|
8
|
+
## Next.js
|
|
9
|
+
|
|
10
|
+
The routes of every collection, with the rules of each, in one file:
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// src/app/api/[...path]/route.ts
|
|
14
|
+
import { rest } from '@typion/rest'
|
|
15
|
+
import { rpc } from '@typion/rpc'
|
|
16
|
+
import { routeHandlers } from 'typion/next'
|
|
17
|
+
import { config } from '@/typion.config.ts'
|
|
18
|
+
|
|
19
|
+
export const { GET, POST, PATCH, PUT, DELETE, OPTIONS } = await routeHandlers(config, {
|
|
20
|
+
transports: [rest(), rpc()],
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Server components and server actions have no `Request`: `getApp(config)` binds the caller from the headers Next
|
|
25
|
+
resolves for them, through the config's context factory. A page that calls it is rendered per request: with Cache
|
|
26
|
+
Components on, the prerender stops there (`getApp` calls `connection()`), so put what may be static above it or in a
|
|
27
|
+
`<Suspense>` boundary of its own.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { getApp } from 'typion/next'
|
|
31
|
+
import { config } from '@/typion.config.ts'
|
|
32
|
+
import { postCards } from '@/typion/queries.ts'
|
|
33
|
+
|
|
34
|
+
// What a server component calls; the reader sees what the rules let them see.
|
|
35
|
+
export async function loadFeed() {
|
|
36
|
+
const app = await getApp(config)
|
|
37
|
+
return app.$run(postCards)
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
'use server'
|
|
43
|
+
|
|
44
|
+
import { isTypionError } from 'typion'
|
|
45
|
+
import { getApp } from 'typion/next'
|
|
46
|
+
import { config } from '@/typion.config.ts'
|
|
47
|
+
|
|
48
|
+
export async function approveComment(id: string): Promise<{ error: string } | null> {
|
|
49
|
+
const app = await getApp(config)
|
|
50
|
+
try {
|
|
51
|
+
await app.comments.update(id, { approved: true })
|
|
52
|
+
return null
|
|
53
|
+
} catch (error) {
|
|
54
|
+
// Refusals and invalid input are outcomes to show; anything else is a bug.
|
|
55
|
+
if (isTypionError(error)) return { error: error.message }
|
|
56
|
+
throw error
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A server action needs no permission check of its own: `comments.update` is `FORBIDDEN` for anyone but an admin,
|
|
62
|
+
because the collection says so.
|
|
63
|
+
|
|
64
|
+
## Hono
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
// src/server.ts
|
|
68
|
+
import { rest } from '@typion/rest'
|
|
69
|
+
import { rpc } from '@typion/rpc'
|
|
70
|
+
import { Hono } from 'hono'
|
|
71
|
+
import { type TypionEnv, typionMiddleware, typionRoutes } from 'typion/hono'
|
|
72
|
+
import { typionErrorResponse } from 'typion/http'
|
|
73
|
+
import { config } from './typion.config.ts'
|
|
74
|
+
|
|
75
|
+
const app = new Hono<TypionEnv<typeof config>>()
|
|
76
|
+
// The transports, under the prefix the handler expects (`/api`, its `basePath`).
|
|
77
|
+
.all('/api/*', typionRoutes(config, { transports: [rest(), rpc()] }))
|
|
78
|
+
// A route of the application's own, with the caller's instance as `c.var.typion`.
|
|
79
|
+
.post('/app/posts/:id/archive', typionMiddleware(config), async (c) => {
|
|
80
|
+
const id = c.req.param('id')
|
|
81
|
+
const post = await c.var.typion.$transaction(async (tx) => {
|
|
82
|
+
await tx.comments.deleteMany({ postId: id }, { max: 500 })
|
|
83
|
+
return tx.posts.delete(id)
|
|
84
|
+
})
|
|
85
|
+
return c.json({ data: post })
|
|
86
|
+
})
|
|
87
|
+
// A refusal in the application's own routes answers as the transports do: `{ error: { code, status, … } }`, and
|
|
88
|
+
// `401` to a caller who has not signed in.
|
|
89
|
+
.onError((error, c) => typionErrorResponse(error, { caller: c.var.typion }))
|
|
90
|
+
|
|
91
|
+
export default app
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `typionRoutes` passes the request on as it came; mount it with `app.all` at the handler's base path. Hono's `mount()`
|
|
95
|
+
strips the prefix first and would leave the handler nothing to match.
|
|
96
|
+
- `typionMiddleware` runs the config's context factory for every request it covers: put it on the routes that use it,
|
|
97
|
+
not on `*`. Routes written this way are typed end to end by Hono's client (`hc`).
|
|
98
|
+
- Nothing before Typion may read the request's body. An auth route (Better Auth's `/api/auth/*`) goes before the
|
|
99
|
+
`/api/*` catch-all ([auth.md](auth.md)).
|
|
100
|
+
- `typionErrorResponse(error)` answers a `TypionError` with its status and the wire envelope, and anything else with
|
|
101
|
+
a bare `500` that does not leak its message. Given the caller (`{ caller: app }`), it answers a refusal of one who
|
|
102
|
+
has not signed in with `401`, as the transports do.
|
|
103
|
+
- Node, Bun and Vercel run it as written. Cloudflare Workers do not yet: the instance keeps its database connection
|
|
104
|
+
across requests, which Workers forbid.
|
|
105
|
+
|
|
106
|
+
## Other frameworks
|
|
107
|
+
|
|
108
|
+
`createHandler(typion, { transports })` from `typion/http` is a `(request: Request) => Promise<Response>`, mounted
|
|
109
|
+
under `/api` by default; `toRouteHandlers(handler)` turns it into one function per method. On TanStack Start, a server
|
|
110
|
+
route gives each method `({ request }) => handler(request)`.
|
|
111
|
+
|
|
112
|
+
## The routes
|
|
113
|
+
|
|
114
|
+
`@typion/rest`:
|
|
115
|
+
|
|
116
|
+
| Route | Does |
|
|
117
|
+
| ------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
118
|
+
| `GET /api/posts?columns=slug,title&with=author&limit=10` | `find`, configured by the query string |
|
|
119
|
+
| `GET /api/posts?where[title][ilike]=%25x%25&sort=-createdAt` | filters in brackets, sorting with `sort` |
|
|
120
|
+
| `POST /api/posts/find`, `/find-one`, `/page`, `/count` | the read with its whole config as JSON |
|
|
121
|
+
| `GET /api/posts/:id` | `findById` |
|
|
122
|
+
| `POST /api/posts` · `PATCH /api/posts/:id` · `DELETE /api/posts/:id` | `create` (`{ data, options }`), `update`, `delete` |
|
|
123
|
+
| `POST /api/posts/:id/publish`, `/unpublish`, `/discard-draft`, `/restore` | the features' operations |
|
|
124
|
+
| `GET /api/posts/:id/versions`, `/versions/:versionId`, `/diff?from=&to=` | the history |
|
|
125
|
+
| `POST /api/posts/:id/link/tags`, `/unlink/tags` | the links of a many-to-many (`{ ids }`) |
|
|
126
|
+
|
|
127
|
+
`@typion/rpc`: `POST /api/_rpc` takes one descriptor, or an array of them run one after the other.
|
|
128
|
+
|
|
129
|
+
What a request carries is checked before anything reaches SQL: an unknown key, column, relation or operator is
|
|
130
|
+
`400`, as are `RAW`, `extras`, callbacks, a `limit` over `maxLimit` (100) and a `with` deeper than `maxDepth` (4).
|
|
131
|
+
Bulk writes (`update-many`, `delete-many`, over either transport) run only with `exposeBulk: true`. A refusal is
|
|
132
|
+
`403 FORBIDDEN`, or `401 UNAUTHENTICATED` when the caller has not signed in (the context's `actorId` is null), so a
|
|
133
|
+
page can tell "sign in" from "not allowed"; in process both are `FORBIDDEN`. The handler's
|
|
134
|
+
options: `transports`, `basePath`, `expose` (the collections to serve; all by default, each behind its rules, a
|
|
135
|
+
plugin's entity such as audit's `auditLog` included), `exclude` (the ones to leave out),
|
|
136
|
+
`exposeBulk`, `maxLimit`, `maxDepth`, `codecs` (`['json', 'superjson']` to keep dates exact), `cors`, `debug`,
|
|
137
|
+
`onError` (where a `500` is reported: the server's log by default, an error tracker's `captureException`). The
|
|
138
|
+
plugins' own routes (`/api/_plugins/<id>/…`) are served whatever transports are mounted.
|
|
139
|
+
|
|
140
|
+
## The browser client
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { createClient } from '@typion/rpc/client'
|
|
144
|
+
import { postCards } from '@/typion/queries.ts'
|
|
145
|
+
|
|
146
|
+
const client = createClient({ url: '/api' })
|
|
147
|
+
// Plain JSON: dates arrive as ISO strings, which the result type says.
|
|
148
|
+
const cards = await client.run(postCards)
|
|
149
|
+
cards satisfies { id: string; slug: string; title: string; author: { name: string } }[]
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The client posts descriptors to `/api/_rpc`, so it needs `rpc()` mounted; it imports no server code and no Drizzle.
|
|
153
|
+
A mobile app uses the same client with `headers: () => ({ authorization: 'Bearer ' + token })`, and the config's
|
|
154
|
+
context factory reads the token. A failed call throws `TypionRequestError` with the wire error's `code`, `status` and
|
|
155
|
+
`issues`: `UNAUTHENTICATED` is the cue to send the user to sign in.
|
|
156
|
+
|
|
157
|
+
## Business operations
|
|
158
|
+
|
|
159
|
+
Typion has no procedures of its own. An operation that is more than one write — archiving a post with its comments,
|
|
160
|
+
an order with its lines — is a server action, a route handler or a Hono route (above) that binds the caller and runs
|
|
161
|
+
the writes in one transaction. The rules still decide each write; the operation only composes them.
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// src/app/api/posts/[id]/archive/route.ts
|
|
165
|
+
import { getTypion } from 'typion'
|
|
166
|
+
import { typionErrorResponse } from 'typion/http'
|
|
167
|
+
import { config } from '@/typion.config.ts'
|
|
168
|
+
|
|
169
|
+
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
170
|
+
const typion = await getTypion(config)
|
|
171
|
+
const app = await typion.$fromRequest(request)
|
|
172
|
+
const { id } = await params
|
|
173
|
+
try {
|
|
174
|
+
const post = await app.$transaction(async (tx) => {
|
|
175
|
+
await tx.comments.deleteMany({ postId: id }, { max: 500 })
|
|
176
|
+
return tx.posts.delete(id)
|
|
177
|
+
})
|
|
178
|
+
return Response.json({ data: post })
|
|
179
|
+
} catch (error) {
|
|
180
|
+
// NOT_FOUND for a post the caller may not delete, VALIDATION, CONFLICT: as the transports answer them.
|
|
181
|
+
return typionErrorResponse(error)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
```
|