@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.
@@ -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.
@@ -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.