@nxgt/janus-drizzle 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 +171 -0
- package/dist/adapter.d.ts +29 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +448 -0
- package/dist/index.js.map +14 -0
- package/dist/relations.d.ts +18 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/stores.d.ts +31 -0
- package/dist/stores.d.ts.map +1 -0
- package/dist/tables.d.ts +694 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/translate.d.ts +31 -0
- package/dist/translate.d.ts.map +1 -0
- package/docs/README.md +12 -0
- package/docs/guide/database.md +172 -0
- package/docs/guide/migrations.md +153 -0
- package/docs/guide/wiring.md +170 -0
- package/docs/roadmap.md +44 -0
- package/docs/troubleshooting.md +324 -0
- package/package.json +69 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tables.d.ts","sourceRoot":"","sources":["../src/tables.ts"],"names":[],"mappings":"AACA,OAAO,EAQN,KAAK,QAAQ,EAMb,MAAM,qBAAqB,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,6BAA6B;AAC7B,MAAM,WAAW,kBAAkB;IAClC;;;;;;OAMG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAiBD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAkJjE;AAED,mDAAmD;AACnD,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAE/D;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,qBAAqB,CACrC,CAAC,SAAS,MAAM,WAAW,GAAG,MAAM,WAAW;IAE/C,QAAQ,CAAC,MAAM,CAAC,EAAE,IAAI,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC;CACvC"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { StoreConflict } from '@nxgt/janus';
|
|
2
|
+
/**
|
|
3
|
+
* **The database's errors, as the port's.** One file, and one default.
|
|
4
|
+
*
|
|
5
|
+
* | What happens | Becomes |
|
|
6
|
+
* | --- | --- |
|
|
7
|
+
* | A login another user of the type holds | `StoreConflict('login')`, carrying `login` and `userType` |
|
|
8
|
+
* | A row with this id already there | nothing: the insert is a retry, and answers what is stored |
|
|
9
|
+
* | A unique violation on **any other constraint** | **`StoreFailure`** |
|
|
10
|
+
* | A check or foreign key refusing a row | **`StoreFailure`** |
|
|
11
|
+
* | Anything else, absolutely | `StoreFailure`, with `@nxgt/drizzle`'s `DataError` as `cause` |
|
|
12
|
+
*
|
|
13
|
+
* The first two are never errors here: a login is written with `on conflict
|
|
14
|
+
* do nothing`, and a login the statement did not write is one somebody else
|
|
15
|
+
* holds — so no conflict is ever told apart by parsing a driver's message.
|
|
16
|
+
*
|
|
17
|
+
* The bold rows go against the obvious reading. A violation the adapter did
|
|
18
|
+
* not mean to cause is an adapter bug, and reporting it as `LOGIN_TAKEN`
|
|
19
|
+
* would tell somebody their login is taken when it is not. The core
|
|
20
|
+
* validated every record before it reached a store, so a constraint refusing
|
|
21
|
+
* one means the adapter and the port disagree — never the caller's fault.
|
|
22
|
+
*
|
|
23
|
+
* **The default is a failure, not an absence.** That branch is what keeps the
|
|
24
|
+
* port's first rule: nothing here answers `null` for an error.
|
|
25
|
+
*/
|
|
26
|
+
export type Slot = 'users' | 'sessions' | 'tokens' | 'relations';
|
|
27
|
+
/** Runs one port method, and makes every rejection one the port allows. */
|
|
28
|
+
export declare function run<T>(slot: Slot, operation: string, body: () => Promise<T>): Promise<T>;
|
|
29
|
+
/** The refusal the port promises for a taken login. */
|
|
30
|
+
export declare function loginTaken(operation: string, type: string, login: string): StoreConflict;
|
|
31
|
+
//# sourceMappingURL=translate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"translate.d.ts","sourceRoot":"","sources":["../src/translate.ts"],"names":[],"mappings":"AACA,OAAO,EAAc,aAAa,EAAgB,MAAM,aAAa,CAAC;AAEtE;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,WAAW,CAAC;AAEjE,2EAA2E;AAC3E,wBAAsB,GAAG,CAAC,CAAC,EAC1B,IAAI,EAAE,IAAI,EACV,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACpB,OAAO,CAAC,CAAC,CAAC,CAYZ;AAED,uDAAuD;AACvD,wBAAgB,UAAU,CACzB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,GACX,aAAa,CAMf"}
|
package/docs/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# @nxgt/janus-drizzle — documentation
|
|
2
|
+
|
|
3
|
+
The [README](../README.md) shows the whole wiring in one example; these pages
|
|
4
|
+
give the detail.
|
|
5
|
+
|
|
6
|
+
| Page | Read it when |
|
|
7
|
+
| --- | --- |
|
|
8
|
+
| [Wiring the stores and the relations](guide/wiring.md) | You are passing your Drizzle instance to `janus()` and `permissions()`, choosing a driver, or handling what a failure throws |
|
|
9
|
+
| [Where the tables live](guide/database.md) | You are choosing between a database of Janus's own and a PostgreSQL schema in yours, or backing up and restoring Janus's tables |
|
|
10
|
+
| [The tables in your migrations](guide/migrations.md) | You are adding the tables to your schema, generating the migration, collecting lapsed sessions, or want to know what the database holds |
|
|
11
|
+
| [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
|
|
12
|
+
| [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Where the tables live
|
|
2
|
+
|
|
3
|
+
This page covers:
|
|
4
|
+
- giving Janus **a database of its own**, the recommended setup;
|
|
5
|
+
- or **a PostgreSQL schema of its own**, beside your tables in one database;
|
|
6
|
+
- backing up and restoring Janus's tables, in either setup.
|
|
7
|
+
|
|
8
|
+
The five tables are `users`, `logins`, `sessions`, `tokens` and `relations`,
|
|
9
|
+
with no prefix. Where they live is the one choice you make:
|
|
10
|
+
|
|
11
|
+
| | A database of its own | A schema of its own |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| The tables | `users`, … in `public` | `janus.users`, … |
|
|
14
|
+
| `defineJanusTables(…)` | `defineJanusTables()` | `defineJanusTables({ schema: janus })` |
|
|
15
|
+
| The adapter | `createDrizzleAdapter(janusDb)` | `createDrizzleAdapter(db, { tables: janusTables })` |
|
|
16
|
+
| Migrations | a drizzle-kit config of its own | your application's drizzle-kit config |
|
|
17
|
+
| Back up and restore Janus alone | `pg_dump` / `pg_restore` of the database | `pg_dump -n janus` / `pg_restore -n janus` |
|
|
18
|
+
| A foreign key from your tables to `users`, one transaction over both | no | yes |
|
|
19
|
+
|
|
20
|
+
The core never needs a transaction over your tables and Janus's, and never
|
|
21
|
+
reads your tables. Choose a schema of its own only if your own tables must
|
|
22
|
+
reference Janus's users, or if you cannot run a second database.
|
|
23
|
+
|
|
24
|
+
## A database of its own
|
|
25
|
+
|
|
26
|
+
Janus's tables in `janus`, your application's in `app`: restoring one never
|
|
27
|
+
rolls the other back, and each is dumped, restored, moved or sized alone.
|
|
28
|
+
|
|
29
|
+
### The schema file and its drizzle-kit config
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
// src/janus/schema.ts
|
|
33
|
+
import { defineJanusTables } from '@nxgt/janus-drizzle';
|
|
34
|
+
|
|
35
|
+
export const { users, logins, sessions, tokens, relations } = defineJanusTables();
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// drizzle.janus.config.ts — beside your application's drizzle.config.ts
|
|
40
|
+
import { defineConfig } from 'drizzle-kit';
|
|
41
|
+
|
|
42
|
+
export default defineConfig({
|
|
43
|
+
dialect: 'postgresql',
|
|
44
|
+
schema: './src/janus/schema.ts',
|
|
45
|
+
out: './drizzle/janus',
|
|
46
|
+
dbCredentials: { url: process.env.JANUS_DATABASE_URL ?? 'postgres://localhost:5432/janus' },
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Create the database once, then generate and apply Janus's migrations with this
|
|
51
|
+
config:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
createdb janus
|
|
55
|
+
bunx drizzle-kit generate --config drizzle.janus.config.ts
|
|
56
|
+
bunx drizzle-kit migrate --config drizzle.janus.config.ts
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`drizzle-kit generate` writes `CREATE TABLE "users"`, `"logins"`,
|
|
60
|
+
`"sessions"`, `"tokens"` and `"relations"`, with their indexes and the
|
|
61
|
+
foreign key from `logins` to `users`.
|
|
62
|
+
|
|
63
|
+
### A Drizzle instance of its own
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createDrizzleAdapter } from '@nxgt/janus-drizzle';
|
|
67
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
68
|
+
|
|
69
|
+
// Your application's own tables stay on its own instance, over DATABASE_URL.
|
|
70
|
+
const janusDb = drizzle(process.env.JANUS_DATABASE_URL ?? 'postgres://localhost:5432/janus');
|
|
71
|
+
const postgres = createDrizzleAdapter(janusDb); // { store, relations }
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Everything else is wired as in [Wiring the stores and the relations](wiring.md).
|
|
75
|
+
|
|
76
|
+
## A schema of its own
|
|
77
|
+
|
|
78
|
+
The tables in a PostgreSQL schema named `janus`, in your application's
|
|
79
|
+
database, created by your application's migrations.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
// src/db/schema.ts — the file your drizzle.config.ts names as `schema`
|
|
83
|
+
import { defineJanusTables } from '@nxgt/janus-drizzle';
|
|
84
|
+
import { pgSchema } from 'drizzle-orm/pg-core';
|
|
85
|
+
|
|
86
|
+
export const janus = pgSchema('janus');
|
|
87
|
+
export const { users, logins, sessions, tokens, relations } = defineJanusTables({ schema: janus });
|
|
88
|
+
|
|
89
|
+
// your own tables below
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Export the schema, `janus`, too.** drizzle-kit writes `CREATE SCHEMA
|
|
93
|
+
"janus"` only for a schema it finds exported. Without it, the migration
|
|
94
|
+
creates `"janus"."users"` in a schema that does not exist, and fails with
|
|
95
|
+
`schema "janus" does not exist`.
|
|
96
|
+
|
|
97
|
+
**Pass the adapter the tables you exported**, so the stores query exactly
|
|
98
|
+
what your migration created:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// src/db/schema.ts
|
|
102
|
+
export const janus = pgSchema('janus');
|
|
103
|
+
export const janusTables = defineJanusTables({ schema: janus });
|
|
104
|
+
export const { users, logins, sessions, tokens, relations } = janusTables;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { janusTables } from './db/schema';
|
|
109
|
+
|
|
110
|
+
const postgres = createDrizzleAdapter(db, { tables: janusTables });
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
drizzle-kit ignores `janusTables`, an object and not a table, and creates the
|
|
114
|
+
five from the exports below it. `createDrizzleStores(db, { tables })` and
|
|
115
|
+
`createDrizzleRelations(db, { tables })` take them the same way.
|
|
116
|
+
|
|
117
|
+
**Without `{ tables }`, the stores query `users`, `sessions`, … in the
|
|
118
|
+
connection's `search_path`: `public`.** If your application has a `users`
|
|
119
|
+
table of its own there, deleting a user through the store would delete from
|
|
120
|
+
it. Always pass the tables with a schema of its own.
|
|
121
|
+
|
|
122
|
+
The names in your schema file are yours to choose. If `users` is taken by a
|
|
123
|
+
table of your own, rename on export:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
export const janusTables = defineJanusTables({ schema: janus });
|
|
127
|
+
export const janusUsers = janusTables.users;
|
|
128
|
+
// …and the four others
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Backing up and restoring
|
|
132
|
+
|
|
133
|
+
`pg_dump`'s custom format, `-Fc`, is what `pg_restore` reads. Both setups were
|
|
134
|
+
measured on PostgreSQL 17.
|
|
135
|
+
|
|
136
|
+
### A database of its own
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
pg_dump -Fc -f janus.dump janus
|
|
140
|
+
|
|
141
|
+
# Into the same database, replacing what is there:
|
|
142
|
+
pg_restore --clean --if-exists -d janus janus.dump
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The dump holds drizzle-kit's migration journal too, the `drizzle` schema, so a
|
|
146
|
+
restored database knows which migrations it has.
|
|
147
|
+
|
|
148
|
+
### A schema of its own
|
|
149
|
+
|
|
150
|
+
```sh
|
|
151
|
+
pg_dump -Fc -n janus -f janus.dump app
|
|
152
|
+
|
|
153
|
+
# Into the same database, replacing Janus's tables and nothing else:
|
|
154
|
+
pg_restore --clean -n janus -d app janus.dump
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`-n janus` dumps the schema and its five tables only: your tables are neither
|
|
158
|
+
dumped nor touched by the restore. The migration journal is not in the dump,
|
|
159
|
+
since it is your application's.
|
|
160
|
+
|
|
161
|
+
### What a restore means for your users
|
|
162
|
+
|
|
163
|
+
A restore brings back the users, logins and relations as they were, and the
|
|
164
|
+
sessions and one-time tokens too:
|
|
165
|
+
- a session revoked since the dump stands again until it lapses. If that
|
|
166
|
+
matters, `delete from sessions` (or `delete from janus.sessions`) after the
|
|
167
|
+
restore signs everybody out;
|
|
168
|
+
- a user created since the dump is gone, with their sign-in;
|
|
169
|
+
- a reset link sent since the dump answers `TOKEN_UNKNOWN`.
|
|
170
|
+
|
|
171
|
+
With sessions in Redis, through `@nxgt/janus-redis`, sessions and tokens are
|
|
172
|
+
not in the dump at all, and a restore leaves them as they are.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# The tables in your migrations
|
|
2
|
+
|
|
3
|
+
This page covers:
|
|
4
|
+
- adding the five tables to the schema drizzle-kit reads;
|
|
5
|
+
- what the migration creates;
|
|
6
|
+
- collecting lapsed sessions;
|
|
7
|
+
- what the database holds.
|
|
8
|
+
|
|
9
|
+
The core never manages a schema, and the stores create nothing per request.
|
|
10
|
+
**Your migrations create the tables**, as they create your own.
|
|
11
|
+
|
|
12
|
+
## In your schema
|
|
13
|
+
|
|
14
|
+
`defineJanusTables()` returns the five tables. Export them from the file, or
|
|
15
|
+
one of the files, that a drizzle-kit config names as `schema`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/janus/schema.ts
|
|
19
|
+
import { defineJanusTables } from '@nxgt/janus-drizzle';
|
|
20
|
+
|
|
21
|
+
export const { users, logins, sessions, tokens, relations } = defineJanusTables();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Which config, and which database, is the choice
|
|
25
|
+
[Where the tables live](database.md) walks through: a database of its own,
|
|
26
|
+
with a drizzle-kit config of its own (recommended), or a PostgreSQL schema of
|
|
27
|
+
its own, `defineJanusTables({ schema: janus })`, in your application's
|
|
28
|
+
schema file.
|
|
29
|
+
|
|
30
|
+
Then generate and apply the migration with that config:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
bunx drizzle-kit generate --config drizzle.janus.config.ts
|
|
34
|
+
bunx drizzle-kit migrate --config drizzle.janus.config.ts
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
With a schema of its own in your application's database, it is your
|
|
38
|
+
application's config, so the plain `bunx drizzle-kit generate` and `migrate`.
|
|
39
|
+
|
|
40
|
+
drizzle-kit only picks up top-level table exports, so export each table: the
|
|
41
|
+
object `defineJanusTables()` returns is not itself a table, and drizzle-kit
|
|
42
|
+
finds nothing in it. Measured on drizzle-kit 1.0.0-rc.4.
|
|
43
|
+
|
|
44
|
+
**Keep all five.** drizzle-kit writes a `DROP TABLE` for a table the schema no
|
|
45
|
+
longer names, and that drops every user. Read the migration before you apply
|
|
46
|
+
it, as with any other.
|
|
47
|
+
|
|
48
|
+
An application that only authenticates can leave out `relations`, as long as
|
|
49
|
+
it never passes `createDrizzleRelations`. `createDrizzleAdapter` needs all
|
|
50
|
+
five.
|
|
51
|
+
|
|
52
|
+
## What the migration creates
|
|
53
|
+
|
|
54
|
+
It needs **PostgreSQL 15 or later**: the tuple constraint is `unique nulls not
|
|
55
|
+
distinct`.
|
|
56
|
+
|
|
57
|
+
```sql
|
|
58
|
+
CREATE TABLE "users" (
|
|
59
|
+
"id" text collate "C" PRIMARY KEY,
|
|
60
|
+
"type" text collate "C" NOT NULL,
|
|
61
|
+
"schema_version" text NOT NULL,
|
|
62
|
+
"active" boolean NOT NULL,
|
|
63
|
+
"fields" jsonb NOT NULL,
|
|
64
|
+
"logins" text[] NOT NULL,
|
|
65
|
+
"password_hash" text,
|
|
66
|
+
"password_updated_at" timestamp(3) with time zone,
|
|
67
|
+
"email_verified_at" timestamp(3) with time zone,
|
|
68
|
+
"version" integer NOT NULL,
|
|
69
|
+
"created_at" timestamp(3) with time zone NOT NULL,
|
|
70
|
+
"updated_at" timestamp(3) with time zone NOT NULL,
|
|
71
|
+
CONSTRAINT "users_id_type_unique" UNIQUE("id","type"),
|
|
72
|
+
CONSTRAINT "users_password_whole" CHECK (("password_hash" is null) = ("password_updated_at" is null))
|
|
73
|
+
);
|
|
74
|
+
CREATE TABLE "logins" (
|
|
75
|
+
"type" text collate "C",
|
|
76
|
+
"login" text collate "C",
|
|
77
|
+
"user_id" text collate "C" NOT NULL,
|
|
78
|
+
CONSTRAINT "logins_pkey" PRIMARY KEY("type","login")
|
|
79
|
+
);
|
|
80
|
+
-- sessions, tokens, relations, the indexes, and the foreign key from
|
|
81
|
+
-- logins to users, on delete cascade.
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
With `defineJanusTables({ schema: janus })`, it starts with `CREATE SCHEMA "janus"`, and every
|
|
85
|
+
name is qualified: `CREATE TABLE "janus"."users"`.
|
|
86
|
+
|
|
87
|
+
The specs build their database from exactly this: drizzle-kit's own
|
|
88
|
+
`generateMigration` over the exported tables, in both layouts. So what your
|
|
89
|
+
migration creates is what the conformance suites ran on.
|
|
90
|
+
|
|
91
|
+
### The choices in it
|
|
92
|
+
|
|
93
|
+
- **No prefix.** In a database of its own, nothing can collide; beside your
|
|
94
|
+
tables, a PostgreSQL schema keeps them apart, and `pg_dump -n janus` backs
|
|
95
|
+
them up alone. A prefix would do neither.
|
|
96
|
+
- **`snake_case` columns.** This follows PostgreSQL's catalog and
|
|
97
|
+
`@nxgt/drizzle`'s `timestamps()`, so a query written by hand needs no quotes.
|
|
98
|
+
The records the stores return are camelCase.
|
|
99
|
+
- **`text collate "C"` for every key.** The port compares ids, logins and
|
|
100
|
+
types byte for byte. Under a database collation such as `en-US`, `B` sorts
|
|
101
|
+
after `a`, and a page in ascending id order repeats or skips rows.
|
|
102
|
+
- **`text`, not `uuid`, for ids.** The core mints UUIDv7s, but a `uuid` column
|
|
103
|
+
would refuse any other id with an error rather than find nothing, and would
|
|
104
|
+
return a different spelling than the one written.
|
|
105
|
+
- **`timestamp(3)`.** A JavaScript `Date` has milliseconds. At PostgreSQL's
|
|
106
|
+
default of microseconds, a date written and read back is not the one the
|
|
107
|
+
core wrote.
|
|
108
|
+
- **`jsonb` for `fields`.** The core validated them against your schema. Keys
|
|
109
|
+
come back in `jsonb`'s own order; values are unchanged.
|
|
110
|
+
- **Logins twice.** They are stored as the `logins` array on the user,
|
|
111
|
+
returned verbatim and in order, and as rows in `logins`, whose primary
|
|
112
|
+
key is the per-type uniqueness. PostgreSQL has no unique index over the
|
|
113
|
+
elements of an array.
|
|
114
|
+
- **No foreign key from sessions and tokens to users.** Deleting a user
|
|
115
|
+
deletes the user; the core deletes the sessions and tokens next, as it does
|
|
116
|
+
when they live in another store.
|
|
117
|
+
|
|
118
|
+
## Collecting lapsed sessions
|
|
119
|
+
|
|
120
|
+
PostgreSQL has no TTL. A lapsed session is refused on every read, but it stays
|
|
121
|
+
in `sessions` until something deletes it. `auth.collectExpired()`
|
|
122
|
+
deletes every session whose `expiresAt` has passed, and returns how many:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// Hourly, from whatever runs your scheduled jobs.
|
|
126
|
+
const collected = await auth.collectExpired();
|
|
127
|
+
console.info(`janus: ${collected} lapsed sessions deleted`);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`expires_at` is indexed, so this is one indexed `delete`. Spent and lapsed
|
|
131
|
+
one-time tokens stay until their user is deleted; there are few of them per
|
|
132
|
+
user.
|
|
133
|
+
|
|
134
|
+
## Reading the tables yourself
|
|
135
|
+
|
|
136
|
+
The tables your schema file exports are ordinary Drizzle tables, for a query
|
|
137
|
+
of your own: an admin listing, a report, a data export.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
import { count, eq } from 'drizzle-orm';
|
|
141
|
+
import { users } from './janus/schema';
|
|
142
|
+
|
|
143
|
+
// janusDb: the Drizzle instance over Janus's tables
|
|
144
|
+
const [row] = await janusDb
|
|
145
|
+
.select({ patients: count() })
|
|
146
|
+
.from(users)
|
|
147
|
+
.where(eq(users.type, 'patient'));
|
|
148
|
+
const patients = row?.patients ?? 0;
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**Read, never write.** A row written behind the store's back skips its
|
|
152
|
+
invariants: the logins' uniqueness, `version`, the password check. Write
|
|
153
|
+
through `auth` and `access`.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Wiring the stores and the relations
|
|
2
|
+
|
|
3
|
+
This page covers connecting `@nxgt/janus` to PostgreSQL through Drizzle:
|
|
4
|
+
- the three user stores for `janus()`;
|
|
5
|
+
- the relation store for `permissions()`;
|
|
6
|
+
- what each one throws when the database cannot answer.
|
|
7
|
+
|
|
8
|
+
Creating the tables is covered in [the migrations page](migrations.md), and
|
|
9
|
+
choosing their database in [Where the tables live](database.md).
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { janus, scryptHasher } from '@nxgt/janus';
|
|
13
|
+
import { defineModel, permissions } from '@nxgt/janus/permissions';
|
|
14
|
+
import { createDrizzleAdapter } from '@nxgt/janus-drizzle';
|
|
15
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
// Janus's own database; your application's tables stay on their own instance.
|
|
19
|
+
const db = drizzle(process.env.JANUS_DATABASE_URL ?? 'postgres://localhost:5432/janus');
|
|
20
|
+
|
|
21
|
+
const postgres = createDrizzleAdapter(db); // { store, relations }
|
|
22
|
+
|
|
23
|
+
export const auth = janus({
|
|
24
|
+
user: z.object({ email: z.email(), name: z.string() }),
|
|
25
|
+
password: { login: 'email' },
|
|
26
|
+
hasher: scryptHasher(),
|
|
27
|
+
...postgres, // deleting a user deletes every tuple naming them
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const model = defineModel({
|
|
31
|
+
subjects: auth.types,
|
|
32
|
+
types: {
|
|
33
|
+
document: {
|
|
34
|
+
related: { owners: ['user'], viewers: ['user'] },
|
|
35
|
+
permits: { view: ['owners', 'viewers'] },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
export const access = permissions({ model, store: postgres.relations });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## `createDrizzleAdapter(db, options?)`
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import type { PgDatabase } from '@nxgt/drizzle/pg'; // any Drizzle PostgreSQL instance
|
|
47
|
+
|
|
48
|
+
function createDrizzleAdapter(
|
|
49
|
+
db: PgDatabase,
|
|
50
|
+
options?: { tables?: JanusTables }, // DrizzleAdapterOptions
|
|
51
|
+
): DrizzleAdapter; // { store, relations }
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
It returns `createDrizzleStores(db, options)` as `store` and
|
|
55
|
+
`createDrizzleRelations(db, options)` as `relations`: the two keys `janus()` takes them under, so a spread wires
|
|
56
|
+
both. `permissions()` takes the same relation store as `postgres.relations`.
|
|
57
|
+
It connects to nothing and creates nothing.
|
|
58
|
+
|
|
59
|
+
`options.tables` is what your schema file's `defineJanusTables(…)` returned,
|
|
60
|
+
so the stores query the tables your migration created. Leave it out for
|
|
61
|
+
tables in the connection's `search_path`, `public` by default. **Pass it
|
|
62
|
+
whenever the tables are in a PostgreSQL schema of their own**: without it, the
|
|
63
|
+
stores query `public`, where your application may have a `users` of its own
|
|
64
|
+
([Where the tables live](database.md#a-schema-of-its-own)).
|
|
65
|
+
|
|
66
|
+
**Pass the relation store to both.** If only `permissions()` gets it, tuples
|
|
67
|
+
are still written, but deleting a user leaves behind every tuple naming them.
|
|
68
|
+
The spread makes that impossible to forget. An application that only
|
|
69
|
+
authenticates takes `createDrizzleStores(db)` alone, below.
|
|
70
|
+
|
|
71
|
+
`db` is any Drizzle PostgreSQL instance, whatever its driver:
|
|
72
|
+
|
|
73
|
+
| Driver | Import |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `pg` (node-postgres) | `drizzle-orm/node-postgres` |
|
|
76
|
+
| `postgres` (postgres.js) | `drizzle-orm/postgres-js` |
|
|
77
|
+
| Bun's own `SQL` | `drizzle-orm/bun-sql` |
|
|
78
|
+
| PGlite, for tests | `drizzle-orm/pglite` |
|
|
79
|
+
|
|
80
|
+
The adapter's specs run on all four: on PGlite, and on PostgreSQL 17 over each
|
|
81
|
+
of the other three, on every CI run.
|
|
82
|
+
|
|
83
|
+
## `createDrizzleStores(db, options?)`
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
function createDrizzleStores(db: PgDatabase, options?: { tables?: Pick<JanusTables, 'users' | 'logins' | 'sessions' | 'tokens'> }): JanusStores; // { users, sessions, tokens }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This is what `janus({ store })` takes. It uses `users`, `logins`, `sessions`
|
|
90
|
+
and `tokens`.
|
|
91
|
+
|
|
92
|
+
**Every read is one statement.** An absence comes back as an empty result,
|
|
93
|
+
which becomes `null`. Anything that rejects is a failure, so an outage can
|
|
94
|
+
never be confused with "no such user".
|
|
95
|
+
|
|
96
|
+
**Writing a user is one transaction** that covers the user's row and its rows
|
|
97
|
+
in `logins`. It runs through `@nxgt/drizzle`'s `withTransaction`, inside
|
|
98
|
+
the method; the core never opens one. Every other write is a single
|
|
99
|
+
statement. `consumeToken` is one statement, `with before as (select … for update) update … from before`: of
|
|
100
|
+
twenty concurrent redemptions of one token, exactly one sees `spentAt: null`.
|
|
101
|
+
This is measured on PostgreSQL 17 on every CI run.
|
|
102
|
+
|
|
103
|
+
Every method of the port is implemented, **including the optional
|
|
104
|
+
`deleteExpiredSessions`**. PostgreSQL has no TTL, so
|
|
105
|
+
`auth.collectExpired()` is how lapsed sessions leave the table; schedule it.
|
|
106
|
+
The [migrations page](migrations.md#collecting-lapsed-sessions) shows how.
|
|
107
|
+
|
|
108
|
+
## `createDrizzleRelations(db, options?)`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
function createDrizzleRelations(db: PgDatabase, options?: { tables?: Pick<JanusTables, 'relations'> }): RelationStore;
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This is what `permissions({ store })` takes, and `janus({ relations })`. It
|
|
115
|
+
uses `relations`.
|
|
116
|
+
|
|
117
|
+
**A write of more than one tuple is a transaction**: removals first, then
|
|
118
|
+
additions, all or nothing. `grant` and `revoke` write one tuple, in one
|
|
119
|
+
statement. A transaction is not retried; a write is idempotent, so retry it
|
|
120
|
+
yourself if you want to.
|
|
121
|
+
|
|
122
|
+
`findObjects` orders ids byte by byte: the columns are `collate "C"`, so the
|
|
123
|
+
order does not depend on your database's collation. Under `en-US`, `a` sorts
|
|
124
|
+
before `B`. Here `B` comes first, as JavaScript's `<` puts it.
|
|
125
|
+
|
|
126
|
+
## What a failure looks like
|
|
127
|
+
|
|
128
|
+
The adapter defines **no error class**. Every rejection is `@nxgt/janus`'s own,
|
|
129
|
+
so `instanceof` holds in your code: `@nxgt/janus` is a peer, and there is one
|
|
130
|
+
copy of it.
|
|
131
|
+
|
|
132
|
+
| What PostgreSQL reports | What you get |
|
|
133
|
+
| --- | --- |
|
|
134
|
+
| A login another user of the type holds | `StoreConflict`, code `LOGIN_TAKEN`, carrying `login` and `userType` |
|
|
135
|
+
| A row with this id already there | Nothing: the insert was a retry, and returns what is stored — or `NOT_FOUND` if the user was deleted between the two |
|
|
136
|
+
| A unique violation on **any other constraint** | `StoreFailure`: an adapter bug, never reported as a taken login |
|
|
137
|
+
| A check or foreign key refusing a row | `StoreFailure`: the core validated the record already, so it is never the caller's fault |
|
|
138
|
+
| Anything else: a refused connection, a timeout, a missing table | `StoreFailure`, with the driver's error as `cause` |
|
|
139
|
+
|
|
140
|
+
A login is claimed with `on conflict do nothing`, so no conflict is ever told
|
|
141
|
+
apart by parsing a driver message. The logins are claimed in sorted order, so
|
|
142
|
+
two sign-ups racing for the same ones never deadlock: the second waits, then
|
|
143
|
+
gets `LOGIN_TAKEN`.
|
|
144
|
+
|
|
145
|
+
The `cause` is `@nxgt/drizzle`'s `toDataError(error)`. Over `node-postgres`,
|
|
146
|
+
postgres.js and PGlite that is a `DataError`, whose `sqlState`, `table` and
|
|
147
|
+
`constraint` say what PostgreSQL refused. Over Bun's `SQL` it is Drizzle's
|
|
148
|
+
`DrizzleQueryError`, with PostgreSQL's code as `errno` on its own `cause`.
|
|
149
|
+
|
|
150
|
+
Nothing returns `null` for an error, so a route answers 503, not 401 or 404:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
import { StoreFailure } from '@nxgt/janus';
|
|
154
|
+
|
|
155
|
+
export async function me(request: Request): Promise<Response> {
|
|
156
|
+
try {
|
|
157
|
+
const current = await auth.authenticate(request);
|
|
158
|
+
return current === null ? new Response(null, { status: 401 }) : Response.json({ id: current.user.id });
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof StoreFailure) {
|
|
161
|
+
console.error('janus: the database could not answer', error.cause);
|
|
162
|
+
return new Response(null, { status: 503 });
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
With `@nxgt/janus-hono`, `app.onError(janusErrors())` does the same for every
|
|
170
|
+
route.
|
package/docs/roadmap.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Where `@nxgt/janus-drizzle` is heading. It shows a direction, not a
|
|
4
|
+
commitment: there are no dates here, and the only number is the version
|
|
5
|
+
something shipped in.
|
|
6
|
+
|
|
7
|
+
## Now
|
|
8
|
+
|
|
9
|
+
Nothing yet.
|
|
10
|
+
|
|
11
|
+
## Next
|
|
12
|
+
|
|
13
|
+
Nothing yet.
|
|
14
|
+
|
|
15
|
+
## Later
|
|
16
|
+
|
|
17
|
+
Nothing yet.
|
|
18
|
+
|
|
19
|
+
## Not planned
|
|
20
|
+
|
|
21
|
+
- **MySQL and SQLite.** `@nxgt/drizzle` is PostgreSQL only, and so is this
|
|
22
|
+
adapter: the tuple constraint, `for update` and `collate "C"` are
|
|
23
|
+
PostgreSQL's.
|
|
24
|
+
- **PostgreSQL before 15.** The tuple constraint needs `unique nulls not
|
|
25
|
+
distinct`.
|
|
26
|
+
- **Creating the tables from the store.** Your migrations create them, from
|
|
27
|
+
the exported tables. The stores never create anything per request.
|
|
28
|
+
- **Retrying a transaction for you.** A write is idempotent, so retry it
|
|
29
|
+
yourself, on your own terms.
|
|
30
|
+
- **Error classes of its own.** The adapter throws `@nxgt/janus`'s classes,
|
|
31
|
+
which is why `@nxgt/janus` is a required peer and never a dependency:
|
|
32
|
+
`instanceof StoreFailure` has to hold in your code.
|
|
33
|
+
- **`moduleResolution: "nodenext"`.** Like `@nxgt/janus`, the package imports
|
|
34
|
+
without extensions and resolves as Bun and every bundler do. Use
|
|
35
|
+
`"moduleResolution": "bundler"`.
|
|
36
|
+
|
|
37
|
+
## Shipped
|
|
38
|
+
|
|
39
|
+
- **The first release, v0.1.0.** Both sides of `@nxgt/janus` over one
|
|
40
|
+
PostgreSQL database, on Drizzle; the adapter passes both conformance suites
|
|
41
|
+
on PostgreSQL 17 and PGlite. `defineJanusTables({ schema? })` puts the tables
|
|
42
|
+
in a database or a schema of their own, with no prefix, and the factories
|
|
43
|
+
take the tables your schema file exports, so the stores query exactly what
|
|
44
|
+
your drizzle-kit migration created.
|