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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bin/typion.js +7 -0
  4. package/dist/auth/better-auth.d.ts +15 -0
  5. package/dist/auth/better-auth.js +11 -0
  6. package/dist/auth/better-auth.js.map +1 -0
  7. package/dist/cli/index.d.ts +14 -0
  8. package/dist/cli/index.js +827 -0
  9. package/dist/cli/index.js.map +1 -0
  10. package/dist/column-kinds-Bkm4NJI2.js +47 -0
  11. package/dist/column-kinds-Bkm4NJI2.js.map +1 -0
  12. package/dist/context-CpLQy7OI.d.ts +2098 -0
  13. package/dist/contract/index.d.ts +137 -0
  14. package/dist/contract/index.js +42 -0
  15. package/dist/contract/index.js.map +1 -0
  16. package/dist/db/pglite.d.ts +12 -0
  17. package/dist/db/pglite.js +27 -0
  18. package/dist/db/pglite.js.map +1 -0
  19. package/dist/descriptor-Cc3mGp73.js +12 -0
  20. package/dist/descriptor-Cc3mGp73.js.map +1 -0
  21. package/dist/errors-ChDBxeS-.js +17 -0
  22. package/dist/errors-ChDBxeS-.js.map +1 -0
  23. package/dist/errors-D-CKO-px.js +114 -0
  24. package/dist/errors-D-CKO-px.js.map +1 -0
  25. package/dist/handler-D03-rJ-t.d.ts +23 -0
  26. package/dist/handler-_87bFkbP.js +565 -0
  27. package/dist/handler-_87bFkbP.js.map +1 -0
  28. package/dist/hono/index.d.ts +17 -0
  29. package/dist/hono/index.js +26 -0
  30. package/dist/hono/index.js.map +1 -0
  31. package/dist/http/index.d.ts +13 -0
  32. package/dist/http/index.js +2 -0
  33. package/dist/index.d.ts +2 -0
  34. package/dist/index.js +1324 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/issues-DLqCBQT1.js +42 -0
  37. package/dist/issues-DLqCBQT1.js.map +1 -0
  38. package/dist/next/index.d.ts +8 -0
  39. package/dist/next/index.js +18 -0
  40. package/dist/next/index.js.map +1 -0
  41. package/dist/operations-D3rOacLX.js +1811 -0
  42. package/dist/operations-D3rOacLX.js.map +1 -0
  43. package/dist/schema/index.d.ts +18 -0
  44. package/dist/schema/index.js +3 -0
  45. package/dist/schema-Rzzq97cK.js +68 -0
  46. package/dist/schema-Rzzq97cK.js.map +1 -0
  47. package/dist/seed/index.d.ts +19 -0
  48. package/dist/seed/index.js +85 -0
  49. package/dist/seed/index.js.map +1 -0
  50. package/dist/structural-BZnjFmCs.js +247 -0
  51. package/dist/structural-BZnjFmCs.js.map +1 -0
  52. package/dist/testing/index.d.ts +28 -0
  53. package/dist/testing/index.js +92 -0
  54. package/dist/testing/index.js.map +1 -0
  55. package/dist/typion-TJWDkHGh.js +1186 -0
  56. package/dist/typion-TJWDkHGh.js.map +1 -0
  57. package/dist/validation/zod.d.ts +21 -0
  58. package/dist/validation/zod.js +84 -0
  59. package/dist/validation/zod.js.map +1 -0
  60. package/docs/README.md +145 -0
  61. package/docs/access.md +112 -0
  62. package/docs/auth.md +140 -0
  63. package/docs/collections.md +168 -0
  64. package/docs/database.md +74 -0
  65. package/docs/features/localization.md +112 -0
  66. package/docs/features/multi-tenant.md +165 -0
  67. package/docs/features/soft-delete.md +85 -0
  68. package/docs/features/versions.md +106 -0
  69. package/docs/hooks.md +106 -0
  70. package/docs/http.md +184 -0
  71. package/docs/queries.md +197 -0
  72. package/docs/testing.md +85 -0
  73. package/package.json +125 -3
@@ -0,0 +1,197 @@
1
+ # Reading and writing
2
+
3
+ Every call goes through an app bound to a caller:
4
+
5
+ | Where | The app |
6
+ | ----------------------------------------- | ----------------------------------------------- |
7
+ | a Next.js server component or action | `await getApp(config)` from `typion/next` |
8
+ | a route handler with a `Request` | `await typion.$fromRequest(request)` |
9
+ | a known caller (a test, a job for a user) | `typion.$as({ actorId, role })` |
10
+ | a seed, a job, a webhook | `typion.$system({ reason: 'nightly cleanup' })` |
11
+
12
+ `typion` is `await getTypion(config)`, one instance per config and process. The bound app has one property per
13
+ collection (`app.posts`), and `$ctx`, `$transaction`, `$run`.
14
+
15
+ ## Reads
16
+
17
+ ```ts
18
+ const rows = await app.posts.find({
19
+ columns: { id: true, title: true, slug: true },
20
+ with: {
21
+ author: { columns: { name: true } },
22
+ tags: { columns: { label: true } },
23
+ comments: { columns: { body: true }, orderBy: { createdAt: 'asc' }, limit: 3 },
24
+ },
25
+ where: { title: { ilike: '%typion%' }, tags: { label: 'release' } },
26
+ orderBy: { createdAt: 'desc' },
27
+ limit: 20,
28
+ })
29
+ rows satisfies {
30
+ id: string
31
+ title: string
32
+ slug: string
33
+ author: { name: string }
34
+ tags: { label: string }[]
35
+ comments: { body: string }[]
36
+ }[]
37
+ ```
38
+
39
+ - `find`, `findOne` (no `limit`), `findById(id, config)` (`NOT_FOUND` when missing or hidden), `count({ where })`,
40
+ `page({ limit, offset, … })` → `{ items, total, limit, offset, hasMore }`.
41
+ - The config is Drizzle's relational query config: `columns` (`{ x: true }` to pick, `{ x: false }` to leave out),
42
+ nested `with` with its own `columns`, `where`, `orderBy`, `limit` and `offset`, and `where` with `eq`, `ne`, `gt`,
43
+ `gte`, `lt`, `lte`, `in`, `notIn`, `like`, `ilike`, `isNull`, `isNotNull`, `AND`, `OR`, `NOT`, and relation filters
44
+ (`author: { role: 'admin' }`, `comments: true` for "has at least one").
45
+ - The features add options: `withDeleted` (soft delete), `draft` (drafts), `locale` and `fallback` (localization).
46
+ A collection without the feature has no such option: the types refuse it.
47
+ - **The result type is the query's.** A relation is `| null` when the caller may not see its target, `T[]` for
48
+ `many`; a column a field rule may hide is `T | null`. Do not cast these away: they are what the rules allow.
49
+ - A list read without `limit` returns every row the caller may see. Lists are `page` or `find` with a `limit`; over
50
+ HTTP a missing limit is capped (`maxLimit`, 100 by default). A `limit` above `maxLimit` is `BAD_REQUEST` in
51
+ process too.
52
+
53
+ ### Descriptors
54
+
55
+ A descriptor is a query as a value, carrying its result type: pages, components and the browser share it, and nobody
56
+ writes the row type by hand.
57
+
58
+ ```ts
59
+ // src/typion/queries.ts
60
+ import type { InferItem, Typion } from 'typion'
61
+ import { defineQueries } from 'typion/contract'
62
+ import type { config } from '../typion.config.ts'
63
+
64
+ // `import type`: the browser bundle gets the types, not the server.
65
+ const q = defineQueries<Typion<typeof config>>()
66
+
67
+ export const postCards = q.posts.find({
68
+ columns: { id: true, slug: true, title: true },
69
+ with: { author: { columns: { name: true } } },
70
+ orderBy: { createdAt: 'desc' },
71
+ limit: 20,
72
+ })
73
+
74
+ export type PostCard = InferItem<typeof postCards>
75
+ ```
76
+
77
+ ```ts
78
+ import { type PostCard, postCards } from '@/typion/queries.ts'
79
+
80
+ const cards = await app.$run(postCards)
81
+ cards satisfies PostCard[]
82
+ ```
83
+
84
+ `defineQueries` accepts what can travel as JSON (no `RAW`, `extras` or callbacks), so the same descriptor runs in
85
+ process with `app.$run` and from the browser through the client ([http.md](http.md)). On the server,
86
+ `typion.posts.query({…})` gives the same value without that restriction.
87
+
88
+ ## Writes
89
+
90
+ ```ts
91
+ const actorId = app.$ctx.actorId
92
+ if (actorId !== null) {
93
+ const post = await app.posts.create({ title: 'Hello', slug: 'hello', authorId: actorId })
94
+ await app.posts.update(post.id, { body: 'First draft' })
95
+ // Published posts are edited as drafts: the live row stays as it is until `publish`.
96
+ await app.posts.update(post.id, { title: 'Hello, world' }, { draft: true })
97
+ await app.posts.publish(post.id)
98
+ // Soft delete: the row stays, every read hides it, `restore` brings it back.
99
+ await app.posts.delete(post.id)
100
+ }
101
+ ```
102
+
103
+ - `create(data, options)`, `update(id, data, options)`, `delete(id, options)`; `updateMany(where, data, { max })` and
104
+ `deleteMany(where, { max })` lock every match first and run the whole pipeline per row, all or nothing, refusing
105
+ more than `max` rows (1000 by default).
106
+ - A write returns the row as the caller would read it straight after.
107
+ - The columns the features manage are not in the input types; their operations write them (`delete`, `restore`,
108
+ `publish`, `unpublish`, `discardDraft`).
109
+
110
+ ## Transactions
111
+
112
+ `app.$transaction(fn)` runs `fn` with an app bound to the same caller and to one transaction: every call in it commits
113
+ or rolls back together, and `afterCommit` hooks wait for the outermost commit. A business operation of several writes
114
+ is one transaction.
115
+
116
+ ```ts
117
+ await app.$transaction(async (tx) => {
118
+ const post = await tx.posts.findById('7f8e…', { columns: { id: true } })
119
+ await tx.comments.updateMany(
120
+ { postId: post.id, approved: false },
121
+ { approved: true },
122
+ { max: 100 },
123
+ )
124
+ await tx.posts.update(post.id, { body: 'Comments reviewed.' })
125
+ })
126
+ ```
127
+
128
+ A function that takes the caller's app — a business operation that a server action, a route and a test all call —
129
+ types it as `TypionAppOf<typeof config>`:
130
+
131
+ ```ts
132
+ import type { TypionAppOf } from 'typion'
133
+ import type { config } from '@/typion.config.ts'
134
+
135
+ export async function approveAll(app: TypionAppOf<typeof config>, postId: string) {
136
+ return app.$transaction((tx) =>
137
+ tx.comments.updateMany({ postId, approved: false }, { approved: true }, { max: 100 }),
138
+ )
139
+ }
140
+ ```
141
+
142
+ ## Links
143
+
144
+ The side that owns a many-to-many (`writable: true`, [collections.md](collections.md)) writes its links:
145
+
146
+ ```ts
147
+ const post = await app.posts.findById('7f8e…', { columns: { id: true } })
148
+ const tag = await app.tags.findOne({ where: { label: 'release' }, columns: { id: true } })
149
+ if (tag !== null) {
150
+ await app.posts.link(post.id, 'tags', [tag.id]) // { count: 1 }; a link already there is kept
151
+ await app.posts.unlink(post.id, 'tags', [tag.id])
152
+ }
153
+ ```
154
+
155
+ - A link is an edit of the owning row: it locks the post under its `update` rule, tenant and features, so a post the
156
+ caller may not update is `NOT_FOUND`. Every tag must be one the caller may read, or nothing is written and the call
157
+ is `NOT_FOUND`.
158
+ - `count` is how many links were added or removed. Links run no collection hooks and are not in the history;
159
+ `@typion/audit` records them on the owning row.
160
+ - Over HTTP: `POST /api/posts/:id/link/tags` and `/unlink/tags` with `{ ids }`, or the descriptors
161
+ `q.posts.link({ id, relation: 'tags', ids })` and `q.posts.unlink(…)`.
162
+
163
+ ## Errors
164
+
165
+ Operations throw a `TypionError` with a `code`; over HTTP the same error is the response's `{ error }` with its status.
166
+
167
+ | `code` | Status | When |
168
+ | --------------- | ------ | ----------------------------------------------------------------------- |
169
+ | `NOT_FOUND` | 404 | the row is missing, or the caller may not see it |
170
+ | `FORBIDDEN` | 403 | a rule refused, a field may not be written, a call has no tenant |
171
+ | `VALIDATION` | 422 | the input does not fit the columns or the schemas (`issues`) |
172
+ | `CONFLICT` | 409 | a unique, foreign key or check constraint; `retryable` on serialization |
173
+ | `INVALID_STATE` | 409 | the workflow refuses it (unpublish a draft, restore a live row) |
174
+ | `BAD_REQUEST` | 400 | an unknown option, column or key; a limit or depth over the caps |
175
+ | `UNSUPPORTED` | 400 | a feature's option or operation on a collection without the feature |
176
+
177
+ ```ts
178
+ import { isTypionError } from 'typion'
179
+
180
+ try {
181
+ await app.posts.update('7f8e…', { title: 'Renamed' })
182
+ } catch (error) {
183
+ if (isTypionError(error, 'NOT_FOUND')) {
184
+ // show "not found": the caller cannot tell a hidden post from a missing one, by design
185
+ } else {
186
+ throw error
187
+ }
188
+ }
189
+ ```
190
+
191
+ `attempt(() => …)` returns `{ ok: true, data }` or `{ ok: false, error }` instead of throwing.
192
+
193
+ ## The escape hatch
194
+
195
+ `typion.$db` is the Drizzle instance underneath. It skips everything Typion does — rules, tenants, soft delete,
196
+ drafts, validation, hooks, the history, the audit trail — so it is only for set-based maintenance in code no user
197
+ drives (a backfill, a cleanup job), and never with input from a request.
@@ -0,0 +1,85 @@
1
+ # Testing
2
+
3
+ `createTestTypion(config)` from `typion/testing` gives the config's instance on an in-memory PGlite, with the schema
4
+ pushed: no server, no network. It keeps the config's rules, hooks and context factory, and replaces the database.
5
+
6
+ ```ts
7
+ import { afterAll, beforeEach, describe, expect, it } from 'vitest'
8
+ import { createTestTypion } from 'typion/testing'
9
+ import { config } from '@/typion.config.ts'
10
+
11
+ const t = await createTestTypion(config)
12
+ afterAll(() => t.close())
13
+ beforeEach(() => t.reset())
14
+
15
+ describe('posts', () => {
16
+ it('lets authors edit their own posts, and nobody write in their name', async () => {
17
+ const system = t.asSystem('seed')
18
+ const ada = await system.users.create({
19
+ name: 'Ada',
20
+ email: 'ada@example.test',
21
+ role: 'editor',
22
+ })
23
+ const bob = await system.users.create({
24
+ name: 'Bob',
25
+ email: 'bob@example.test',
26
+ role: 'editor',
27
+ })
28
+ const asAda = t.asUser({ actorId: ada.id, role: 'editor' })
29
+ const asBob = t.asUser({ actorId: bob.id, role: 'editor' })
30
+
31
+ const post = await asAda.posts.create({ title: 'Mine', slug: 'mine', authorId: ada.id })
32
+ expect((await asAda.posts.update(post.id, { title: 'Still mine' })).title).toBe('Still mine')
33
+
34
+ // Another author's post does not exist for Bob: NOT_FOUND, not FORBIDDEN.
35
+ await expect(asBob.posts.update(post.id, { title: 'Taken' })).rejects.toMatchObject({
36
+ code: 'NOT_FOUND',
37
+ })
38
+ await expect(
39
+ asBob.posts.create({ title: 'Forged', slug: 'forged', authorId: ada.id }),
40
+ ).rejects.toMatchObject({ code: 'FORBIDDEN' })
41
+ })
42
+ })
43
+ ```
44
+
45
+ - `t.asUser(ctx)` and `t.asSystem(reason)` are `typion.$as` and `typion.$system`; `t.typion` is the instance, and
46
+ `createHandler(t.typion, { transports: [rest(), rpc()] })` from `typion/http` its HTTP handler, for a test that
47
+ sends a `Request`.
48
+ - Until `t.close()`, the test's instance is the config's: `getTypion(config)` returns it, so the application's own
49
+ routes — a Hono app, a route handler — serve the test's database, and are tested with requests (below).
50
+ - `t.reset()` empties every table, faster than a database per test. One instance per test file.
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.
53
+ - Every rule gets two tests: a caller it allows and one it refuses. A refused read is an empty result or
54
+ `NOT_FOUND`; a refused write is `FORBIDDEN` or `NOT_FOUND`, with nothing written.
55
+ - With `TYPION_TEST_DATABASE_URL=postgres://…` the same tests run on that Postgres server, each instance in a
56
+ database of its own, created and dropped by the harness: what PGlite cannot show, such as concurrent transactions,
57
+ shows there. The server's role must be allowed to create databases.
58
+
59
+ ## An application's routes
60
+
61
+ A Hono app is tested with `app.request()`, against the test's instance; Hono's client `hc` and the browser client
62
+ take the same in-process `fetch`.
63
+
64
+ ```ts
65
+ import { createClient } from '@typion/rpc/client'
66
+ import { rest } from '@typion/rest'
67
+ import { rpc } from '@typion/rpc'
68
+ import { Hono } from 'hono'
69
+ import { expect, it } from 'vitest'
70
+ import { typionRoutes } from 'typion/hono'
71
+ import { createTestTypion } from 'typion/testing'
72
+ import { config } from '@/typion.config.ts'
73
+ import { postCards } from '@/typion/queries.ts'
74
+
75
+ const t = await createTestTypion(config)
76
+ const server = new Hono().all('/api/*', typionRoutes(config, { transports: [rest(), rpc()] }))
77
+
78
+ it('serves the published posts to anyone', async () => {
79
+ const response = await server.request('/api/posts?columns=slug')
80
+ expect(response.status).toBe(200)
81
+ const client = createClient({ url: '/api', fetch: (input, init) => server.request(input, init) })
82
+ expect(await client.run(postCards)).toEqual([])
83
+ await t.close()
84
+ })
85
+ ```
package/package.json CHANGED
@@ -1,6 +1,128 @@
1
1
  {
2
2
  "name": "typion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Headless, type-safe application engine on top of Drizzle ORM",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/the-escape-studio/typion.git",
10
+ "directory": "packages/typion"
11
+ },
12
+ "homepage": "https://github.com/the-escape-studio/typion/tree/main/packages/typion#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/the-escape-studio/typion/issues"
15
+ },
16
+ "sideEffects": false,
17
+ "files": [
18
+ "dist",
19
+ "bin",
20
+ "docs",
21
+ "README.md"
22
+ ],
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./schema": {
29
+ "types": "./dist/schema/index.d.ts",
30
+ "default": "./dist/schema/index.js"
31
+ },
32
+ "./contract": {
33
+ "types": "./dist/contract/index.d.ts",
34
+ "default": "./dist/contract/index.js"
35
+ },
36
+ "./http": {
37
+ "types": "./dist/http/index.d.ts",
38
+ "default": "./dist/http/index.js"
39
+ },
40
+ "./db/pglite": {
41
+ "types": "./dist/db/pglite.d.ts",
42
+ "default": "./dist/db/pglite.js"
43
+ },
44
+ "./seed": {
45
+ "types": "./dist/seed/index.d.ts",
46
+ "default": "./dist/seed/index.js"
47
+ },
48
+ "./next": {
49
+ "types": "./dist/next/index.d.ts",
50
+ "default": "./dist/next/index.js"
51
+ },
52
+ "./hono": {
53
+ "types": "./dist/hono/index.d.ts",
54
+ "default": "./dist/hono/index.js"
55
+ },
56
+ "./validation/zod": {
57
+ "types": "./dist/validation/zod.d.ts",
58
+ "default": "./dist/validation/zod.js"
59
+ },
60
+ "./auth/better-auth": {
61
+ "types": "./dist/auth/better-auth.d.ts",
62
+ "default": "./dist/auth/better-auth.js"
63
+ },
64
+ "./testing": {
65
+ "types": "./dist/testing/index.d.ts",
66
+ "default": "./dist/testing/index.js"
67
+ },
68
+ "./cli": {
69
+ "types": "./dist/cli/index.d.ts",
70
+ "default": "./dist/cli/index.js"
71
+ },
72
+ "./package.json": "./package.json"
73
+ },
74
+ "dependencies": {
75
+ "@standard-schema/spec": "^1.1.0"
76
+ },
77
+ "peerDependencies": {
78
+ "@electric-sql/pglite": ">=0.5.0",
79
+ "drizzle-kit": "1.0.0-rc.4",
80
+ "drizzle-orm": "1.0.0-rc.4",
81
+ "hono": "^4.13.13 || ^5",
82
+ "next": ">=15",
83
+ "pg": "^8.0.0",
84
+ "superjson": "^2.2.0",
85
+ "zod": "^4.0.0",
86
+ "drizzle-seed": "1.0.0-rc.5-ab785fc"
87
+ },
88
+ "peerDependenciesMeta": {
89
+ "@electric-sql/pglite": {
90
+ "optional": true
91
+ },
92
+ "drizzle-kit": {
93
+ "optional": true
94
+ },
95
+ "superjson": {
96
+ "optional": true
97
+ },
98
+ "zod": {
99
+ "optional": true
100
+ },
101
+ "hono": {
102
+ "optional": true
103
+ },
104
+ "next": {
105
+ "optional": true
106
+ },
107
+ "pg": {
108
+ "optional": true
109
+ },
110
+ "drizzle-seed": {
111
+ "optional": true
112
+ }
113
+ },
114
+ "keywords": [
115
+ "drizzle",
116
+ "orm",
117
+ "headless",
118
+ "cms",
119
+ "access-control",
120
+ "typescript"
121
+ ],
122
+ "bin": {
123
+ "typion": "./bin/typion.js"
124
+ },
125
+ "publishConfig": {
126
+ "access": "public"
127
+ }
6
128
  }