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/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
- // 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.
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
- export const auth = betterAuth({
85
- database: drizzleAdapter(drizzle({ client: pool }), { provider: 'pg', schema: authTables }),
86
- emailAndPassword: { enabled: true },
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 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.
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
- // What the config's `db` is when Better Auth shares the pool; `close` ends it when the CLI is done.
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 { auth } from '@/auth.ts'
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(auth)
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 { auth } from '@/auth.ts'
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) => auth.handler(c.req.raw))
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
+ ```
@@ -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 { auth } from './auth.ts'
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
- context: async ({ headers, system }): Promise<AppContext> => {
37
- const session = headers === undefined ? null : await auth.api.getSession({ headers })
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typion",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Headless, type-safe application engine on top of Drizzle ORM",
5
5
  "type": "module",
6
6
  "license": "MIT",