typion 0.1.0 → 0.1.1
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/dist/auth/better-auth.d.ts +2 -4
- package/dist/auth/better-auth.js +1 -1
- package/dist/auth/better-auth.js.map +1 -1
- package/dist/cli/index.js +63 -7
- package/dist/cli/index.js.map +1 -1
- package/dist/{context-CpLQy7OI.d.ts → context-B4e2eREP.d.ts} +6 -4
- package/dist/contract/index.d.ts +1 -1
- package/dist/db/pglite.d.ts +1 -1
- package/dist/{handler-D03-rJ-t.d.ts → handler-BWVU8TCR.d.ts} +2 -2
- package/dist/hono/index.d.ts +1 -1
- package/dist/hono/index.js +1 -1
- package/dist/http/index.d.ts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +2 -2
- package/dist/next/index.js +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/seed/index.d.ts +1 -1
- package/dist/testing/index.d.ts +2 -4
- package/dist/testing/index.js +1 -1
- package/dist/testing/index.js.map +1 -1
- package/dist/{typion-TJWDkHGh.js → typion-Dh2BCSRz.js} +3 -2
- package/dist/{typion-TJWDkHGh.js.map → typion-Dh2BCSRz.js.map} +1 -1
- package/dist/validation/zod.d.ts +1 -1
- package/docs/README.md +4 -4
- package/docs/access.md +2 -0
- package/docs/auth.md +73 -18
- package/docs/collections.md +4 -0
- package/docs/database.md +3 -1
- package/docs/features/multi-tenant.md +5 -4
- package/docs/http.md +4 -0
- package/docs/queries.md +3 -0
- package/docs/testing.md +1 -1
- package/package.json +1 -1
package/docs/auth.md
CHANGED
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Typion does not sign people in. The config's context factory reads who is calling from the request, through an auth
|
|
4
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
|
|
5
|
+
email and password, on the database Typion uses: on the connection of each Typion instance, so one recipe serves
|
|
6
|
+
production and tests.
|
|
6
7
|
|
|
7
8
|
```ts
|
|
8
9
|
// src/db.ts
|
|
9
10
|
import { Pool } from 'pg'
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
-
// tests do — opens nothing. Whatever the config imports must not connect when it is imported.
|
|
12
|
+
// Typion's pool, which Better Auth shares through the instance. pg connects on the first query, so importing the
|
|
13
|
+
// config — the CLI does, the tests do — opens nothing. Whatever the config imports must not connect when it is imported.
|
|
13
14
|
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
|
14
15
|
```
|
|
15
16
|
|
|
@@ -73,23 +74,43 @@ export const verification = pgTable('verification', {
|
|
|
73
74
|
export const authTables = { user, session, account, verification }
|
|
74
75
|
```
|
|
75
76
|
|
|
77
|
+
A property is named after Better Auth's field, and the column is named as the application likes
|
|
78
|
+
(`emailVerified: boolean('email_verified')`). A plugin's tables follow the same rule, from the fields its
|
|
79
|
+
documentation lists: the passkey plugin's table needs `credentialID`, whatever its column is called. Add them to
|
|
80
|
+
`authTables`.
|
|
81
|
+
|
|
82
|
+
Better Auth is built on the Drizzle connection of the Typion instance it serves, once per connection: the
|
|
83
|
+
application's in production, a test's own database in a test.
|
|
84
|
+
|
|
76
85
|
```ts
|
|
77
86
|
// src/auth.ts
|
|
78
87
|
import { betterAuth } from 'better-auth'
|
|
79
88
|
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
|
80
|
-
import { drizzle } from 'drizzle-orm/node-postgres'
|
|
81
89
|
import { authTables } from './auth-schema.ts'
|
|
82
|
-
import { pool } from './db.ts'
|
|
83
90
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
type AuthDb = Parameters<typeof drizzleAdapter>[0]
|
|
92
|
+
|
|
93
|
+
function createAuth(db: AuthDb) {
|
|
94
|
+
return betterAuth({
|
|
95
|
+
database: drizzleAdapter(db, { provider: 'pg', schema: authTables }),
|
|
96
|
+
emailAndPassword: { enabled: true },
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const auths = new WeakMap<AuthDb, ReturnType<typeof createAuth>>()
|
|
101
|
+
|
|
102
|
+
export function authFor(db: AuthDb) {
|
|
103
|
+
let auth = auths.get(db)
|
|
104
|
+
if (auth === undefined) auths.set(db, (auth = createAuth(db)))
|
|
105
|
+
return auth
|
|
106
|
+
}
|
|
88
107
|
```
|
|
89
108
|
|
|
90
|
-
The config gives Typion the
|
|
91
|
-
`betterAuthContext(
|
|
92
|
-
|
|
109
|
+
The config gives Typion the pool, lists the tables, and turns a session into the context with
|
|
110
|
+
`betterAuthContext(authFor, map)` from `typion/auth/better-auth`, as [the guide's config](README.md#the-model) does.
|
|
111
|
+
The context factory gets the instance's connection as `db`, and `betterAuthContext` hands it to `authFor`, so the
|
|
112
|
+
config never reaches for the instance itself. `map` gets the session (null for an anonymous caller) and the factory's
|
|
113
|
+
input, with `system` to look the caller up.
|
|
93
114
|
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
115
|
multi-tenant application the same lookup finds the caller's membership, and with it their tenant.
|
|
95
116
|
|
|
@@ -98,7 +119,8 @@ import { drizzle } from 'drizzle-orm/node-postgres'
|
|
|
98
119
|
import { dbAdapter } from 'typion'
|
|
99
120
|
import { pool } from '@/db.ts'
|
|
100
121
|
|
|
101
|
-
//
|
|
122
|
+
// The config's `db`: Typion's connection on the pool, which Better Auth then uses too; `close` ends the pool when the
|
|
123
|
+
// CLI is done.
|
|
102
124
|
export const db = dbAdapter((relations) => drizzle({ client: pool, relations }), {
|
|
103
125
|
url: process.env.DATABASE_URL,
|
|
104
126
|
close: () => pool.end(),
|
|
@@ -107,14 +129,20 @@ export const db = dbAdapter((relations) => drizzle({ client: pool, relations }),
|
|
|
107
129
|
|
|
108
130
|
## Its routes
|
|
109
131
|
|
|
110
|
-
Better Auth answers under `/api/auth/*`, next to Typion's transports under `/api/*`.
|
|
132
|
+
Better Auth answers under `/api/auth/*`, next to Typion's transports under `/api/*`. A route reaches it through the
|
|
133
|
+
instance, `authFor((await getTypion(config)).$db)`, which in a test is the test's instance ([testing.md](testing.md));
|
|
134
|
+
so does server code that calls Better Auth's `api` itself.
|
|
111
135
|
|
|
112
136
|
```ts
|
|
113
137
|
// src/app/api/auth/[...all]/route.ts
|
|
114
138
|
import { toNextJsHandler } from 'better-auth/next-js'
|
|
115
|
-
import {
|
|
139
|
+
import { getTypion } from 'typion'
|
|
140
|
+
import { authFor } from '@/auth.ts'
|
|
141
|
+
import { config } from '@/typion.config.ts'
|
|
116
142
|
|
|
117
|
-
export const { GET, POST } = toNextJsHandler(
|
|
143
|
+
export const { GET, POST } = toNextJsHandler(async (request: Request) =>
|
|
144
|
+
authFor((await getTypion(config)).$db).handler(request),
|
|
145
|
+
)
|
|
118
146
|
```
|
|
119
147
|
|
|
120
148
|
A server action that signs in sets cookies: add Better Auth's `nextCookies()` to its `plugins`. On Hono, the route goes
|
|
@@ -124,12 +152,15 @@ before the transports' catch-all, which would otherwise answer it:
|
|
|
124
152
|
import { rest } from '@typion/rest'
|
|
125
153
|
import { rpc } from '@typion/rpc'
|
|
126
154
|
import { Hono } from 'hono'
|
|
155
|
+
import { getTypion } from 'typion'
|
|
127
156
|
import { typionRoutes } from 'typion/hono'
|
|
128
|
-
import {
|
|
157
|
+
import { authFor } from '@/auth.ts'
|
|
129
158
|
import { config } from '@/typion.config.ts'
|
|
130
159
|
|
|
131
160
|
export const server = new Hono()
|
|
132
|
-
.on(['GET', 'POST'], '/api/auth/*', (c) =>
|
|
161
|
+
.on(['GET', 'POST'], '/api/auth/*', async (c) =>
|
|
162
|
+
authFor((await getTypion(config)).$db).handler(c.req.raw),
|
|
163
|
+
)
|
|
133
164
|
.all('/api/*', typionRoutes(config, { transports: [rest(), rpc()] }))
|
|
134
165
|
```
|
|
135
166
|
|
|
@@ -138,3 +169,27 @@ export const server = new Hono()
|
|
|
138
169
|
A test binds its callers itself (`t.asUser(ctx)`), or replaces the context factory with
|
|
139
170
|
`createTestTypion(config, { context })` ([testing.md](testing.md)), so it needs no session. Better Auth's tables are
|
|
140
171
|
created with the collections either way.
|
|
172
|
+
|
|
173
|
+
A test of signing in goes through Better Auth on the test's database: the instance's connection is `t.db`, so
|
|
174
|
+
`authFor(t.db)` is the Better Auth that the context factory and the routes use while the test is open.
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { expect, it } from 'vitest'
|
|
178
|
+
import { createTestTypion } from 'typion/testing'
|
|
179
|
+
import { authFor } from '@/auth.ts'
|
|
180
|
+
import { config } from '@/typion.config.ts'
|
|
181
|
+
|
|
182
|
+
const t = await createTestTypion(config)
|
|
183
|
+
|
|
184
|
+
it('knows who signed in', async () => {
|
|
185
|
+
await t.asSystem('seed').users.create({ name: 'Ada', email: 'ada@example.test', role: 'admin' })
|
|
186
|
+
const signedUp = await authFor(t.db).api.signUpEmail({
|
|
187
|
+
body: { name: 'Ada', email: 'ada@example.test', password: 'correct horse battery' },
|
|
188
|
+
asResponse: true,
|
|
189
|
+
})
|
|
190
|
+
const cookie = signedUp.headers.get('set-cookie')!.split(';')[0]!
|
|
191
|
+
const ada = await t.typion.$fromRequest(new Request('http://localhost/', { headers: { cookie } }))
|
|
192
|
+
expect(ada.$ctx.role).toBe('admin')
|
|
193
|
+
await t.close()
|
|
194
|
+
})
|
|
195
|
+
```
|
package/docs/collections.md
CHANGED
|
@@ -15,6 +15,10 @@ them, so it cannot be part of what they read.
|
|
|
15
15
|
Every collection gets `id` (a uuid primary key) and `createdAt`/`updatedAt` unless it declares its own (`idColumn`,
|
|
16
16
|
`timestamps: false`). Unknown keys are compile errors, so a misspelt option never passes in silence.
|
|
17
17
|
|
|
18
|
+
`public: true` is the collection's `read` rule, `true`. It sits in the structure because the other collections read
|
|
19
|
+
it there: a relation to a public collection is typed non-null. A collection that has it takes no `read` in
|
|
20
|
+
`define()`, and one there is a compile error (`'read' does not exist in type 'Omit<AccessRules<…>, "read">'`).
|
|
21
|
+
|
|
18
22
|
`fields` is part of the structure, which is typed before the config exists: field rules see the context type
|
|
19
23
|
registered in `interface Register { context }` ([README.md](README.md)), and without it only the base context
|
|
20
24
|
(`actorId`, `mode`, `now`) and what the features add (`locale`, `tenantId`).
|
package/docs/database.md
CHANGED
|
@@ -45,7 +45,9 @@ the shell set; an adapter given `url: process.env.DATABASE_URL` with the variabl
|
|
|
45
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
46
|
it: data is lost otherwise.
|
|
47
47
|
3. Commit the SQL and the snapshot with the change.
|
|
48
|
-
4. Deploys run `typion db migrate` before the new code serves requests.
|
|
48
|
+
4. Deploys run `typion db migrate` before the new code serves requests. A deploy has no `.env.local` or `.env`:
|
|
49
|
+
`DATABASE_URL= pnpm build` tries the build without the URL even where they exist, since the CLI never writes
|
|
50
|
+
over what the shell set, an empty value included.
|
|
49
51
|
|
|
50
52
|
`typion db push` is for a development database you can throw away; it never touches a shared one.
|
|
51
53
|
|
|
@@ -9,7 +9,7 @@ turned on per collection.
|
|
|
9
9
|
import { type AnyPgColumn, uuid } from 'drizzle-orm/pg-core'
|
|
10
10
|
import { defineConfig, defineSetup } from 'typion'
|
|
11
11
|
import { pglite } from 'typion/db/pglite'
|
|
12
|
-
import {
|
|
12
|
+
import { authFor } from './auth.ts'
|
|
13
13
|
import { members } from './typion/collections/members.ts'
|
|
14
14
|
import { projects } from './typion/collections/projects.ts'
|
|
15
15
|
import { tenants } from './typion/collections/tenants.ts'
|
|
@@ -32,9 +32,10 @@ export const config = defineConfig({
|
|
|
32
32
|
setup,
|
|
33
33
|
collections: [tenants, members, projects],
|
|
34
34
|
db: pglite({ dataDir: '.pglite' }),
|
|
35
|
-
// `system` here crosses tenants: it looks the caller up before their tenant is known.
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
// `system` here crosses tenants: it looks the caller up before their tenant is known. `db` is the instance's
|
|
36
|
+
// connection, which Better Auth is built on (auth.md).
|
|
37
|
+
context: async ({ headers, system, db }): Promise<AppContext> => {
|
|
38
|
+
const session = headers === undefined ? null : await authFor(db).api.getSession({ headers })
|
|
38
39
|
const member =
|
|
39
40
|
session === null
|
|
40
41
|
? null
|
package/docs/http.md
CHANGED
|
@@ -102,6 +102,10 @@ export default app
|
|
|
102
102
|
has not signed in with `401`, as the transports do.
|
|
103
103
|
- Node, Bun and Vercel run it as written. Cloudflare Workers do not yet: the instance keeps its database connection
|
|
104
104
|
across requests, which Workers forbid.
|
|
105
|
+
- On Vercel with TypeScript 7, whose `types` default to `[]`, put `/// <reference types="node" />` in a `.d.ts` under
|
|
106
|
+
`src/` rather than `"types": ["node"]` in `tsconfig.json`. Vercel's builder compiles through a tsconfig of its own,
|
|
107
|
+
in a temporary directory, that extends the project's and finds no `node_modules` from there (`TS2688`). A build
|
|
108
|
+
script of `typion db migrate` migrates before the function is deployed ([database.md](database.md#migrations)).
|
|
105
109
|
|
|
106
110
|
## Other frameworks
|
|
107
111
|
|
package/docs/queries.md
CHANGED
|
@@ -103,6 +103,9 @@ if (actorId !== null) {
|
|
|
103
103
|
- `create(data, options)`, `update(id, data, options)`, `delete(id, options)`; `updateMany(where, data, { max })` and
|
|
104
104
|
`deleteMany(where, { max })` lock every match first and run the whole pipeline per row, all or nothing, refusing
|
|
105
105
|
more than `max` rows (1000 by default).
|
|
106
|
+
- There is no `createMany`: many new rows are `create` in a loop inside `$transaction`, all or nothing, each through
|
|
107
|
+
its rules and hooks, a few queries a row. A list that is always read and written whole, such as a snapshot, is one
|
|
108
|
+
validated `jsonb` column rather than a row per item.
|
|
106
109
|
- A write returns the row as the caller would read it straight after.
|
|
107
110
|
- The columns the features manage are not in the input types; their operations write them (`delete`, `restore`,
|
|
108
111
|
`publish`, `unpublish`, `discardDraft`).
|
package/docs/testing.md
CHANGED
|
@@ -49,7 +49,7 @@ describe('posts', () => {
|
|
|
49
49
|
routes — a Hono app, a route handler — serve the test's database, and are tested with requests (below).
|
|
50
50
|
- `t.reset()` empties every table, faster than a database per test. One instance per test file.
|
|
51
51
|
- `createTestTypion(config, { now: () => date })` fixes the clock the engine writes timestamps with;
|
|
52
|
-
`{ context }` replaces the context factory, and gets what the config's gets, `system` included.
|
|
52
|
+
`{ context }` replaces the context factory, and gets what the config's gets, `system` and `db` included.
|
|
53
53
|
- Every rule gets two tests: a caller it allows and one it refuses. A refused read is an empty result or
|
|
54
54
|
`NOT_FOUND`; a refused write is `FORBIDDEN` or `NOT_FOUND`, with nothing written.
|
|
55
55
|
- With `TYPION_TEST_DATABASE_URL=postgres://…` the same tests run on that Postgres server, each instance in a
|