@nxgt/janus-mongo 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,37 @@
1
+ import type { JanusStores } from '@nxgt/janus';
2
+ import { type SyncOptions, type SyncReport } from '@nxgt/mongo';
3
+ import type { Db } from 'mongodb';
4
+ /**
5
+ * The three stores `janus()` takes, over one MongoDB database.
6
+ *
7
+ * ```ts
8
+ * const db = (await connectMongo(process.env.MONGO_URI)).db;
9
+ * await syncMongoStores(db); // a deployment step: creates the indexes
10
+ * const auth = janus({ user, password: { login: 'email' }, store: createMongoStores(db), hasher });
11
+ * ```
12
+ *
13
+ * **Reads go through `@nxgt/mongo`'s `find*`, never its `get*`.** `findById`
14
+ * answers `undefined` for an absence, so an absence arrives as a *value*,
15
+ * turned into `null` here, and everything that arrives as a rejection is a
16
+ * failure. There is no path on which an absence and an outage travel the same
17
+ * channel, so there is none on which a `catch` could confuse them.
18
+ *
19
+ * **Every conditional write is one driver call** on `raw`, the driver's own
20
+ * collection: `@nxgt/mongo` names no update conditioned on anything but its
21
+ * own lock, and the port needs `version`, `revokedAt` and `kind`.
22
+ *
23
+ * `sessions.deleteExpiredSessions` is not implemented: a TTL index drops lapsed
24
+ * sessions, so `sessions.collectExpired()` answers `UNSUPPORTED` — which is
25
+ * what the port asks of a store with its own expiry.
26
+ */
27
+ export declare function createMongoStores(db: Db): JanusStores;
28
+ /**
29
+ * Creates the three collections, their validators and their indexes, and says
30
+ * what it changed. Run it twice and the second run sends nothing.
31
+ *
32
+ * A deployment step, never a request-time one: it needs the `dbAdmin` role,
33
+ * and the core never calls it (rule 6). An application that deploys with
34
+ * `@nxgt/mongo`'s `syncAll(db)` already syncs these three with its own.
35
+ */
36
+ export declare function syncMongoStores(db: Db, options?: SyncOptions): Promise<SyncReport[]>;
37
+ //# sourceMappingURL=stores.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stores.d.ts","sourceRoot":"","sources":["../src/stores.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACX,WAAW,EAQX,MAAM,aAAa,CAAC;AAErB,OAAO,EAGN,KAAK,WAAW,EAChB,KAAK,UAAU,EAEf,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAclC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,EAAE,GAAG,WAAW,CAMrD;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC9B,EAAE,EAAE,EAAE,EACN,OAAO,CAAC,EAAE,WAAW,GACnB,OAAO,CAAC,UAAU,EAAE,CAAC,CAEvB"}
@@ -0,0 +1,58 @@
1
+ import { StoreConflict, StoreFailure } from '@nxgt/janus';
2
+ /**
3
+ * **The driver's errors, as the port's.** One file, and one default.
4
+ *
5
+ * | What happens | Becomes |
6
+ * | --- | --- |
7
+ * | A duplicate key on `(type, logins)` | `StoreConflict('login')`, naming the login and the type |
8
+ * | A duplicate key on `_id` | nothing — the insert is a retry, and answers what is stored |
9
+ * | A duplicate key on **any other index** | **`StoreFailure`** |
10
+ * | A `$jsonSchema` validation failure (121) | **`StoreFailure`** |
11
+ * | Anything else, absolutely | `StoreFailure`, with the driver error as `cause` |
12
+ *
13
+ * The two bold rows go against the obvious reading. A duplicate on an index
14
+ * the adapter did not mean to collide on is an adapter bug: reporting it as
15
+ * `LOGIN_TAKEN` would tell somebody their login is taken when it is
16
+ * not. And the core has validated every record before it reaches a store, so a
17
+ * validator refusing one means the adapter and the port disagree — never the
18
+ * caller's fault, never a 400.
19
+ *
20
+ * **The default is a failure, not an absence.** That branch is what keeps the
21
+ * port's first rule: nothing here answers `null` for an error.
22
+ */
23
+ /** Runs one port method, and makes every rejection one the port allows. */
24
+ export declare function run<T>(slot: 'users' | 'sessions' | 'tokens' | 'relations', operation: string, body: () => Promise<T>): Promise<T>;
25
+ /** The shape of the driver's `E11000`, as far as this file reads it. */
26
+ export interface DuplicateKey {
27
+ readonly code: 11000;
28
+ readonly keyPattern?: Readonly<Record<string, unknown>>;
29
+ readonly keyValue?: Readonly<Record<string, unknown>>;
30
+ }
31
+ export declare function isDuplicateKey(error: unknown): error is DuplicateKey;
32
+ /**
33
+ * Settles a write: what it answered when it landed, or the duplicate-key error
34
+ * when an index refused it. **Any other rejection is rethrown**, so a caller
35
+ * of this is only ever told "written" or "duplicate", and never "nothing".
36
+ */
37
+ export declare function settle<T>(write: Promise<T>): Promise<{
38
+ readonly written: T;
39
+ } | {
40
+ readonly written?: never;
41
+ duplicate: DuplicateKey;
42
+ }>;
43
+ /** A login one type holds. */
44
+ export interface TypedLogin {
45
+ readonly type: string;
46
+ readonly login: string;
47
+ }
48
+ /**
49
+ * The login a duplicate names, when it is the login index that refused the
50
+ * write — and `null` for every other index, which the caller then reports as
51
+ * a failure.
52
+ */
53
+ export declare function takenLogin(duplicate: DuplicateKey): TypedLogin | null;
54
+ /** The refusal the port promises for a taken login. */
55
+ export declare function loginTaken(operation: string, { type, login }: TypedLogin, cause: unknown): StoreConflict;
56
+ /** A duplicate on an index the adapter never meant to collide on: a bug, reported as one. */
57
+ export declare function unexpectedDuplicate(slot: 'users' | 'sessions' | 'tokens', operation: string, duplicate: DuplicateKey): StoreFailure;
58
+ //# sourceMappingURL=translate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"translate.d.ts","sourceRoot":"","sources":["../src/translate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,aAAa,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEtE;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,2EAA2E;AAC3E,wBAAsB,GAAG,CAAC,CAAC,EAC1B,IAAI,EAAE,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,WAAW,EACnD,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACpB,OAAO,CAAC,CAAC,CAAC,CAaZ;AAED,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,IAAI,EAAE,KAAK,CAAC;IACrB,QAAQ,CAAC,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACxD,QAAQ,CAAC,QAAQ,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACtD;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAMpE;AAED;;;;GAIG;AACH,wBAAgB,MAAM,CAAC,CAAC,EACvB,KAAK,EAAE,OAAO,CAAC,CAAC,CAAC,GACf,OAAO,CACP;IAAE,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAA;CAAE,GACvB;IAAE,QAAQ,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC;IAAC,SAAS,EAAE,YAAY,CAAA;CAAE,CACvD,CAQA;AAED,8BAA8B;AAC9B,MAAM,WAAW,UAAU;IAC1B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACvB;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,SAAS,EAAE,YAAY,GAAG,UAAU,GAAG,IAAI,CAKrE;AAED,uDAAuD;AACvD,wBAAgB,UAAU,CACzB,SAAS,EAAE,MAAM,EACjB,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,UAAU,EAC3B,KAAK,EAAE,OAAO,GACZ,aAAa,CAMf;AAED,6FAA6F;AAC7F,wBAAgB,mBAAmB,CAClC,IAAI,EAAE,OAAO,GAAG,UAAU,GAAG,QAAQ,EACrC,SAAS,EAAE,MAAM,EACjB,SAAS,EAAE,YAAY,GACrB,YAAY,CAKd"}
package/docs/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @nxgt/janus-mongo — 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 MongoDB to `janus()` and `permissions()`, mixing adapters, or handling what a failure throws |
9
+ | [Collections and indexes](guide/sync.md) | You are creating the collections and indexes at deploy time, checking them in CI, or want to know what the database holds |
10
+ | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
11
+ | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -0,0 +1,122 @@
1
+ # Collections and indexes
2
+
3
+ This page is for creating what the adapter needs in the database — the
4
+ collections, their validators and their indexes — as a deployment step, and
5
+ for knowing what ends up stored. Wiring the stores into `janus()` and
6
+ `permissions()` is on [the wiring page](wiring.md).
7
+
8
+ ```ts
9
+ import { syncMongoRelations, syncMongoStores } from '@nxgt/janus-mongo';
10
+ import { MongoClient } from 'mongodb';
11
+
12
+ const client = await MongoClient.connect(process.env.MONGO_URL ?? 'mongodb://localhost:27017');
13
+ const db = client.db('janus');
14
+
15
+ await syncMongoStores(db); // users, sessions, tokens
16
+ await syncMongoRelations(db); // relations — only if you use @nxgt/janus/permissions
17
+
18
+ await client.close();
19
+ ```
20
+
21
+ **The core never manages a schema, and neither function is called for you.**
22
+ Without `syncMongoStores` there is no unique index on logins, and nothing stops
23
+ two concurrent sign-ups with one e-mail.
24
+
25
+ ## `syncMongoStores` and `syncMongoRelations`
26
+
27
+ ```ts
28
+ function syncMongoStores(db: Db, options?: SyncOptions): Promise<SyncReport[]>;
29
+ function syncMongoRelations(db: Db, options?: SyncOptions): Promise<SyncReport[]>;
30
+ ```
31
+
32
+ Each creates its collections when they are missing, writes their `$jsonSchema`
33
+ validators, and creates or rebuilds their indexes, then answers one report per
34
+ collection saying what it changed. **Run it twice and the second run sends
35
+ nothing.**
36
+
37
+ - It is a **deployment step**, never a request-time one: `collMod` needs the
38
+ `dbAdmin` role, and neither it nor an index build may run in a transaction.
39
+ - The relation store is a separate step because an application that only
40
+ authenticates keeps no tuples.
41
+
42
+ ```ts
43
+ for (const report of await syncMongoStores(db)) {
44
+ console.log(report.name, report.created, report.indexes);
45
+ }
46
+ ```
47
+
48
+ | Option | Type | Default | Effect |
49
+ | --- | --- | --- | --- |
50
+ | `dryRun` | boolean | `false` | Compare and report, send nothing — a CI check, or a look before a deploy |
51
+ | `dropUnknownIndexes` | boolean | `false` | Drop indexes the server has and no definition names. Off: an index someone added on purpose is not this package's to remove |
52
+ | `session` | `ClientSession` | none | A session for the reads — not one inside a transaction |
53
+
54
+ `SyncOptions` and `SyncReport` are `@nxgt/mongo`'s types, which is where the
55
+ sync comes from.
56
+
57
+ ```ts
58
+ const reports = await syncMongoStores(db, { dryRun: true });
59
+ const pending = reports.filter(
60
+ (report) => report.created || report.indexes.created.length + report.indexes.recreated.length > 0,
61
+ );
62
+ if (pending.length > 0) process.exitCode = 1; // the database is behind the code
63
+ ```
64
+
65
+ ## With `@nxgt/mongo`'s `syncAll`
66
+
67
+ The definitions are exported — `users`, `sessions`, `tokens`, `relations`, and
68
+ `janusCollections` (the first three, in sync order) — and defining them
69
+ registered them with `@nxgt/mongo`. An application that already deploys with
70
+ `syncAll(db)` gets them synced with its own collections:
71
+
72
+ ```ts
73
+ import '@nxgt/janus-mongo'; // importing it registers the four definitions
74
+ import { syncAll, syncCollections } from '@nxgt/mongo';
75
+ import { janusCollections, relations } from '@nxgt/janus-mongo';
76
+
77
+ await syncAll(db); // every registered collection: yours, and the four of this package
78
+
79
+ // or name them yourself
80
+ await syncCollections(db, [...janusCollections, relations]);
81
+ ```
82
+
83
+ Importing the package registers `relations` too, so `syncAll` creates it even
84
+ for an application that does not use permissions. It stays empty.
85
+
86
+ ## What the database holds
87
+
88
+ The port's records, with the id under `_id` and nothing else renamed or
89
+ encoded: a document read in a shell reads like the record in the code.
90
+
91
+ | Collection | Holds | Indexes |
92
+ | --- | --- | --- |
93
+ | `users` | one user: `type`, `fields`, `logins`, `password`, `emailVerifiedAt`, `version`, … | `loginUnique` on `{ type, logins }`, unique — a login is unique **per user type** · `typeId` on `{ type, _id }`, for listing |
94
+ | `sessions` | one session, by the `sha256` of its token | `tokenHashUnique` · `userId` · `expiry`, a TTL index |
95
+ | `tokens` | one one-time token; `_id` **is** its `sha256` | `userId` · `expiry`, a TTL index |
96
+ | `relations` | one tuple; `_id` **is** the tuple, `{ object: { type, id }, relation, subject: { type, id, relation? } }` | `objectRelation`, one hop forwards · `subjectObjects`, the reverse index `list()` walks |
97
+
98
+ - **No secret is stored.** Sessions and tokens hold the `sha256` of the
99
+ secret; passwords a self-describing hash (`$scrypt$…`, `$argon2id$…`).
100
+ - **Ids are strings**, the UUIDv7s the core minted — never an `ObjectId`: the
101
+ store mints nothing.
102
+ - **A tuple is unique by construction**, since it is its own `_id`, so writing
103
+ a stored tuple again is a no-op rather than a duplicate. A subject set and
104
+ its entity (`team:t1#member`, `team:t1`) are two different keys.
105
+ - `findObjects` is served by `subjectObjects` with no in-memory sort —
106
+ measured, 11 keys examined for a page of 10.
107
+ - The collection names are fixed: `users`, `sessions`, `tokens`, `relations`.
108
+ Give the adapter a database of its own — `client.db('janus')` — so they
109
+ never meet a collection of your application's.
110
+
111
+ ## The TTL indexes are not the expiry
112
+
113
+ MongoDB's TTL monitor runs every sixty seconds, so a lapsed session can stay
114
+ readable for up to a minute. The core compares `expiresAt` on every read, and
115
+ that is what expires it; the TTL index only keeps storage tidy. It is also why
116
+ `deleteExpiredSessions` is not implemented, and `auth.collectExpired()` answers
117
+ `UNSUPPORTED` with this adapter.
118
+
119
+ ## See also
120
+
121
+ - [Wiring the stores](wiring.md) — `createMongoStores`, `createMongoRelations`, and what a failure looks like
122
+ - [`@nxgt/mongo`](https://www.npmjs.com/package/@nxgt/mongo) — `syncAll`, `syncCollections`, `SyncReport`
@@ -0,0 +1,159 @@
1
+ # Wiring the stores and the relations
2
+
3
+ This page is for connecting `@nxgt/janus` to MongoDB: the three user stores
4
+ for `janus()`, the relation store for `permissions()`, and what each gives
5
+ back when the database cannot answer. Creating the collections and indexes is
6
+ on [the sync page](sync.md).
7
+
8
+ ```ts
9
+ import { janus, scryptHasher } from '@nxgt/janus';
10
+ import { defineModel, permissions } from '@nxgt/janus/permissions';
11
+ import { createMongoRelations, createMongoStores } from '@nxgt/janus-mongo';
12
+ import { MongoClient } from 'mongodb';
13
+ import { z } from 'zod';
14
+
15
+ const client = new MongoClient(process.env.MONGO_URL ?? 'mongodb://localhost:27017');
16
+ const db = client.db('janus');
17
+
18
+ const relations = createMongoRelations(db);
19
+
20
+ export const auth = janus({
21
+ user: z.object({ email: z.email(), name: z.string() }),
22
+ password: { login: 'email' },
23
+ store: createMongoStores(db),
24
+ relations, // deleting a user deletes every tuple naming them
25
+ hasher: scryptHasher(),
26
+ });
27
+
28
+ export const model = defineModel({
29
+ subjects: auth.types,
30
+ types: {
31
+ document: {
32
+ relations: { owner: ['user'], viewer: ['user'] },
33
+ permissions: { view: ['owner', 'viewer'] },
34
+ },
35
+ },
36
+ });
37
+
38
+ export const access = permissions({ model, store: relations });
39
+ ```
40
+
41
+ ## `createMongoStores(db)`
42
+
43
+ ```ts
44
+ function createMongoStores(db: Db): JanusStores; // { users, sessions, tokens }
45
+ ```
46
+
47
+ What `janus({ store })` takes. **It connects to nothing** and sends nothing:
48
+ the first command goes out on the first call that needs one. `db` is a
49
+ `mongodb` `Db`; the adapter reads the `users`, `sessions` and `tokens`
50
+ collections of it.
51
+
52
+ Every method of the port is implemented except the optional
53
+ `deleteExpiredSessions`: a TTL index drops lapsed sessions, so
54
+ `auth.collectExpired()` answers `UNSUPPORTED` with this adapter. Do not
55
+ schedule it.
56
+
57
+ ## `createMongoRelations(db)`
58
+
59
+ ```ts
60
+ function createMongoRelations(db: Db): RelationStore;
61
+ ```
62
+
63
+ What `permissions({ store })` takes, and `janus({ relations })`. It connects to
64
+ nothing either, and reads the `relations` collection.
65
+
66
+ **A write of more than one tuple is a transaction**, and MongoDB runs
67
+ transactions on a replica set only. `grant` and `revoke` write one tuple and
68
+ run anywhere, including a standalone `mongod`; a multi-tuple `write` there
69
+ fails with `STORE_FAILED`. The transaction is not retried: a write is
70
+ idempotent, so retry it yourself if you want to.
71
+
72
+ `findObjects` orders ids as MongoDB does, by UTF-8 bytes. `list()` sorts what
73
+ it gathers, so its pages do not depend on that; a caller of the store directly
74
+ sees byte order, which differs from JavaScript's `<` only past U+FFFF.
75
+
76
+ ## What a failure looks like
77
+
78
+ The adapter defines **no error class**. Every rejection is `@nxgt/janus`'s
79
+ own, so `instanceof` holds in your code — `@nxgt/janus` is a peer, and there
80
+ is one copy of it:
81
+
82
+ | What the driver reports | What you get |
83
+ | --- | --- |
84
+ | A duplicate key on the login index | `StoreConflict` — `LOGIN_TAKEN`, naming the login and the user type |
85
+ | A duplicate key on `_id` | nothing: the insert was a retry, and answers what is stored |
86
+ | A duplicate key on **any other index** | `StoreFailure` — an adapter bug, never reported as a taken login |
87
+ | A validator refusing a document (121) | `StoreFailure` — the core validated it already, so it is never the caller's fault |
88
+ | Anything else: a timeout, a stepped-down primary, a refused connection | `StoreFailure`, with the driver's error as `cause` |
89
+
90
+ Nothing answers `null` for an error. So a route answers 503, not 401 or 404:
91
+
92
+ ```ts
93
+ import { StoreFailure } from '@nxgt/janus';
94
+
95
+ export async function me(request: Request): Promise<Response> {
96
+ try {
97
+ const current = await auth.authenticate(request);
98
+ return current === null ? new Response(null, { status: 401 }) : Response.json({ id: current.user.id });
99
+ } catch (error) {
100
+ if (error instanceof StoreFailure) return new Response(null, { status: 503 });
101
+ throw error;
102
+ }
103
+ }
104
+ ```
105
+
106
+ The error's message names the slot and the method (`users.findUser: the store
107
+ could not answer`) and never the connection string.
108
+
109
+ ## Mixing adapters
110
+
111
+ Each slot of `JanusStores` may come from a different store. Users in MongoDB,
112
+ sessions and tokens elsewhere:
113
+
114
+ ```ts
115
+ import { createMemoryStores, janus, scryptHasher } from '@nxgt/janus';
116
+ import { createMongoStores } from '@nxgt/janus-mongo';
117
+ import { z } from 'zod';
118
+
119
+ const mongo = createMongoStores(db);
120
+ const other = createMemoryStores(); // or another adapter's stores
121
+
122
+ export const auth = janus({
123
+ user: z.object({ email: z.email() }),
124
+ password: { login: 'email' },
125
+ store: { users: mongo.users, sessions: other.sessions, tokens: other.tokens },
126
+ hasher: scryptHasher(),
127
+ });
128
+ ```
129
+
130
+ ## Testing against a real `mongod`
131
+
132
+ The reference store `createMemoryStores()` is faster, but it is not MongoDB.
133
+ When a test depends on a transaction or on the unique index, run a
134
+ single-node replica set — the adapter's own specs use
135
+ `mongodb-memory-server-core`, one database per case:
136
+
137
+ ```ts
138
+ import { createMongoRelations, createMongoStores, syncMongoRelations, syncMongoStores } from '@nxgt/janus-mongo';
139
+ import { MongoClient } from 'mongodb';
140
+ import { MongoMemoryReplSet } from 'mongodb-memory-server-core';
141
+
142
+ const server = await MongoMemoryReplSet.create({ replSet: { count: 1 } });
143
+ const client = await MongoClient.connect(server.getUri());
144
+
145
+ const db = client.db('janusTest');
146
+ await syncMongoStores(db);
147
+ await syncMongoRelations(db);
148
+ const store = createMongoStores(db);
149
+ const relations = createMongoRelations(db);
150
+
151
+ // after the suite
152
+ await client.close();
153
+ await server.stop();
154
+ ```
155
+
156
+ ## See also
157
+
158
+ - [Collections and indexes](sync.md) — `syncMongoStores`, `syncMongoRelations`, and what the database holds
159
+ - [`@nxgt/janus`](https://www.npmjs.com/package/@nxgt/janus) — its `docs/guide/` covers users, sessions and permissions
@@ -0,0 +1,54 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/janus-mongo` is heading. A direction, not a commitment: there are
4
+ no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing in progress.
9
+
10
+ ## Next
11
+
12
+ Nothing yet.
13
+
14
+ ## Later
15
+
16
+ Nothing yet.
17
+
18
+ ## Not planned
19
+
20
+ - **`collectExpired()` with this adapter** — it answers `UNSUPPORTED` on
21
+ purpose. The TTL indexes on `sessions` and `tokens` already drop lapsed
22
+ records, and the core decides expiry on every read regardless.
23
+ - **Retrying a relation transaction for you** — the driver's
24
+ `withTransaction` would retry an outage for two minutes before answering. A
25
+ relation write is idempotent, so retry it yourself, on your own terms.
26
+ - **Multi-tuple relation writes on a standalone mongod** — a write of more
27
+ than one tuple is a transaction, and MongoDB runs transactions on a replica
28
+ set only. `grant` and `revoke` write one tuple and run anywhere.
29
+ - **Managing the schema from the store** — the collections and indexes are
30
+ created by `syncMongoStores` and `syncMongoRelations`, a deployment step you
31
+ run. The stores never create anything per request.
32
+ - **Error classes of its own** — the adapter throws `@nxgt/janus`'s classes,
33
+ which is why `@nxgt/janus` is a required peer and never a dependency:
34
+ `instanceof StoreFailure` has to hold in your code.
35
+ - **`moduleResolution: "nodenext"`** — like `@nxgt/janus`, the package imports
36
+ without extensions and resolves as Bun and every bundler do. Use
37
+ `"moduleResolution": "bundler"`.
38
+
39
+ ## Shipped
40
+
41
+ The first public release, v0.1.
42
+
43
+ - **Guides and troubleshooting pages** — a `docs/` folder shipped in the
44
+ package, with the errors and traps you can meet running the adapter, each
45
+ with its cause and fix. — v0.1
46
+ - **The relation store** — `createMongoRelations(db)` and
47
+ `syncMongoRelations(db)`: the `RelationStore` of `@nxgt/janus/permissions`,
48
+ one document per tuple, and `findObjects` served by an index with no
49
+ in-memory sort. — v0.1
50
+ - **Deleting a user, and everything of theirs** — the stores delete a user's
51
+ sessions and one-time tokens with them, as `delete(user)` requires. — v0.1
52
+ - **Users, sessions and one-time tokens** — `createMongoStores(db)` and
53
+ `syncMongoStores(db)`, passing the `@nxgt/janus/conformance` suite against a
54
+ real mongod, outages included. — v0.1
@@ -0,0 +1,193 @@
1
+ # Troubleshooting `@nxgt/janus-mongo`
2
+
3
+ Each entry is headed by the text you see: a compiler error, a message, or an
4
+ error `code`. Search this page for the words of your message.
5
+
6
+ This adapter **defines no error class**. Every error it throws is one of
7
+ `@nxgt/janus`'s — `StoreFailure`, `StoreConflict`, `NotFoundError` — so the
8
+ codes, and what each one deserves as an answer, are those of the core. The
9
+ errors the core raises itself (`CREDENTIALS_INVALID`, `TOKEN_EXPIRED`,
10
+ `PERMISSION_DEPTH`, …) are in
11
+ [`@nxgt/janus`'s troubleshooting](https://github.com/softistx/nxgt-janus/blob/develop/packages/janus/docs/troubleshooting.md).
12
+
13
+ How the driver's errors become the port's:
14
+
15
+ | What MongoDB answers | What you get |
16
+ | --- | --- |
17
+ | A duplicate key on the login index | `LOGIN_TAKEN`, naming the login and the user type |
18
+ | A duplicate key on `_id` | Nothing: the insert was a retry, and what is stored is answered |
19
+ | A duplicate key on any other index | `STORE_FAILED` |
20
+ | A `$jsonSchema` validation failure (code 121) | `STORE_FAILED` |
21
+ | Anything else | `STORE_FAILED`, with the driver's error as `cause` |
22
+
23
+ ## Index
24
+
25
+ **Install and types**
26
+ - [`TS2834: Relative import paths need explicit file extensions …`](#ts2834-relative-import-paths-need-explicit-file-extensions-in-ecmascript-imports-when---moduleresolution-is-node16-or-nodenext)
27
+ - [`error instanceof StoreFailure` is `false` for an outage](#error-instanceof-storefailure-is-false-for-an-outage)
28
+
29
+ **Setup**
30
+ - [Two sign-ups with the same login both succeed](#two-sign-ups-with-the-same-login-both-succeed)
31
+ - [`STORE_FAILED` — `relations.write: the store could not answer` on a standalone mongod](#store_failed--relationswrite-the-store-could-not-answer-on-a-standalone-mongod)
32
+ - [`STORE_FAILED` with `Document failed validation` as its cause](#store_failed-with-document-failed-validation-as-its-cause)
33
+
34
+ **Runtime**
35
+ - [`STORE_FAILED` — `<slot>.<operation>: the store could not answer`](#store_failed--slotoperation-the-store-could-not-answer)
36
+ - [`LOGIN_TAKEN` — `<operation>: the login "<login>" is taken by another <type>`](#login_taken--operation-the-login-login-is-taken-by-another-type)
37
+ - [`STORE_FAILED` — `<slot>.<operation>: a duplicate key on <fields>, which this adapter never writes on purpose`](#store_failed--slotoperation-a-duplicate-key-on-fields-which-this-adapter-never-writes-on-purpose)
38
+ - [`NOT_FOUND` / `VERSION_CONFLICT` — `updateUser: …`](#not_found--version_conflict--updateuser-)
39
+ - [`UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions …`](#unsupported--collectexpired-storesessions-does-not-implement-deleteexpiredsessions--)
40
+ - [Lapsed sessions and tokens are still in the collection](#lapsed-sessions-and-tokens-are-still-in-the-collection)
41
+ - [`findObjects` answers ids in a different order than JavaScript sorts them](#findobjects-answers-ids-in-a-different-order-than-javascript-sorts-them)
42
+
43
+ ---
44
+
45
+ ## Install and types
46
+
47
+ ### `TS2834: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'.`
48
+
49
+ With `skipLibCheck: true`, the same cause shows up instead as
50
+ `TS2305: Module '"@nxgt/janus-mongo"' has no exported member '<name>'.`
51
+
52
+ **When:** `tsc` on your project, reported inside the package's `dist/*.d.ts`.
53
+ **Why:** the declarations of this package and of `@nxgt/janus` import their siblings without an extension, the way a bundler resolves them. `moduleResolution: "nodenext"` (or `"node16"`) is **not supported**.
54
+ **Fix:**
55
+
56
+ ```jsonc
57
+ // tsconfig.json
58
+ {
59
+ "compilerOptions": {
60
+ "module": "preserve", // or "esnext"
61
+ "moduleResolution": "bundler"
62
+ }
63
+ }
64
+ ```
65
+
66
+ Do not patch the declarations to add `.js` extensions: that support is out of
67
+ scope, and a patched copy breaks on the next install.
68
+
69
+ ### `error instanceof StoreFailure` is `false` for an outage
70
+
71
+ **When:** at run time, testing an error from this adapter against the class you imported from `@nxgt/janus`.
72
+ **Why:** two copies of `@nxgt/janus` are installed. This adapter throws the `StoreFailure` of the copy *it* resolves; if that is not yours, `instanceof` fails, and an outage — or a taken login — is handled as an unknown error.
73
+ **Fix:** `@nxgt/janus` is a **required peer** of this package: install it once, in your application, at a version the peer range accepts.
74
+
75
+ ```sh
76
+ bun add @nxgt/janus-mongo @nxgt/janus @nxgt/mongo mongodb zod
77
+ bun pm ls --all | grep @nxgt/janus # one @nxgt/janus, not two
78
+ ```
79
+
80
+ ---
81
+
82
+ ## Setup
83
+
84
+ ### Two sign-ups with the same login both succeed
85
+
86
+ **When:** concurrent `signUp` or `create` calls with one login — or any second sign-up — after deploying to a new database.
87
+ **Why:** `syncMongoStores(db)` was never run, so the `loginUnique` index does not exist. The core never reads first to decide uniqueness; only the index refuses a duplicate, and it is what produces `LOGIN_TAKEN`. The core never manages a schema, so nothing creates the index for you.
88
+ **Fix:** run the sync as a deployment step, with a user that has the `dbAdmin` role — never per request:
89
+
90
+ ```ts
91
+ import { syncMongoRelations, syncMongoStores } from '@nxgt/janus-mongo';
92
+
93
+ await syncMongoStores(db); // users, sessions, tokens: validators and indexes
94
+ await syncMongoRelations(db); // only if you use @nxgt/janus/permissions
95
+ ```
96
+
97
+ An application that deploys with `@nxgt/mongo`'s `syncAll(db)` already syncs
98
+ the three stores' collections, since defining them registers them.
99
+
100
+ ### `STORE_FAILED` — `relations.write: the store could not answer` on a standalone mongod
101
+
102
+ `error.cause` is the driver's
103
+ `This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.`
104
+
105
+ **When:** a relation-store `write` of more than one tuple — `store.write({ add: [a, b] })`, or an add and a remove together — against a standalone `mongod`. `grant` and `revoke` write one tuple and are not affected.
106
+ **Why:** a write of several tuples runs in a transaction, and MongoDB runs transactions on a replica set only. Adding `retryWrites=false`, as the driver suggests, does not help: the transaction is still refused.
107
+ **Fix:** run a replica set. For local development and tests, a single-node replica set is enough:
108
+
109
+ ```sh
110
+ mongod --replSet rs0 --dbpath ./data
111
+ mongosh --eval 'rs.initiate()'
112
+ ```
113
+
114
+ ```ts
115
+ // In tests, with mongodb-memory-server
116
+ import { MongoMemoryReplSet } from 'mongodb-memory-server-core';
117
+ const replSet = await MongoMemoryReplSet.create({ replSet: { count: 1 } });
118
+ ```
119
+
120
+ The transaction is not retried by the adapter — the driver's `withTransaction`
121
+ would retry an outage for two minutes before answering. A relation write is
122
+ idempotent, so retry it yourself.
123
+
124
+ ### `STORE_FAILED` with `Document failed validation` as its cause
125
+
126
+ **When:** a write — `insertUser`, `updateUser`, `insertSession`, … — rejected by the collection's `$jsonSchema` validator (MongoDB error code 121).
127
+ **Why:** the core validates every record before it reaches the store, so a validator refusing one means the collection and the adapter disagree: the collection's validator was set by another version of this adapter, or a collection of the same name (`users`, `sessions`, `tokens`, `relations`) already belonged to your application, with its own validator. It is never the caller's fault, and never a 400.
128
+ **Fix:** after upgrading this package, run `syncMongoStores(db)` again. If your application already has a `users` collection, the adapter is sharing its database: give it one of its own, `janus`:
129
+
130
+ ```ts
131
+ const auth = janus({ ..., store: createMongoStores(client.db('janus')) });
132
+ ```
133
+
134
+ ---
135
+
136
+ ## Runtime
137
+
138
+ ### `STORE_FAILED` — `<slot>.<operation>: the store could not answer`
139
+
140
+ `StoreFailure`, for example `users.findUserByLogin: the store could not answer`, with `slot`, `operation` and the driver's error as `cause`.
141
+
142
+ **When:** any call that reaches MongoDB while it cannot answer: the server is unreachable, server selection timed out, the credentials were refused, a primary stepped down.
143
+ **Why:** a store that cannot answer throws — it never answers `null`. The driver's message stays out of the error's message, because it can hold the connection string, and a connection string holds a password.
144
+ **Fix:** answer **503** and log `cause`; never map it to 401, 404, `null` or `false`.
145
+
146
+ ```ts
147
+ import { JanusError } from '@nxgt/janus';
148
+
149
+ if (error instanceof JanusError && error.code === 'STORE_FAILED') {
150
+ logger.error({ slot: error.slot, operation: error.operation, cause: error.cause });
151
+ return new Response('Try again shortly', { status: 503 });
152
+ }
153
+ ```
154
+
155
+ ### `LOGIN_TAKEN` — `<operation>: the login "<login>" is taken by another <type>`
156
+
157
+ `StoreConflict` with `on: 'login'`, for example `insertUser: the login "ada@example.com" is taken by another patient`. It carries `login` and `userType`.
158
+
159
+ **When:** `signUp`, `create`, or an `update` that changes the login, when another user **of the same type** already holds it. The message names the port method (`insertUser`, `updateUser`), not your call.
160
+ **Why:** the `loginUnique` index on `{ type, logins }` refused the write. The same e-mail may hold one account per user type.
161
+ **Fix:** answer 409.
162
+
163
+ ### `STORE_FAILED` — `<slot>.<operation>: a duplicate key on <fields>, which this adapter never writes on purpose`
164
+
165
+ **When:** a write refused by a unique index that is not the login index — typically one you added to `users` or `sessions` yourself.
166
+ **Why:** this adapter only ever collides on the login index and on `_id`. A duplicate anywhere else is reported as a failure, not as `LOGIN_TAKEN`: that would tell somebody their e-mail is in use when it is not.
167
+ **Fix:** drop the extra unique index (`db.users.getIndexes()` lists them), or make it non-unique. Uniqueness of your own fields belongs in your own collection.
168
+
169
+ ### `NOT_FOUND` / `VERSION_CONFLICT` — `updateUser: …`
170
+
171
+ `updateUser: no user has this id`, and `updateUser: expected version <n>, found <m>`.
172
+
173
+ **When:** calling `createMongoStores(db).users.updateUser(...)` directly. Through `janus()`, the same refusals carry the name of your call instead (`update: …`).
174
+ **Why:** the write matched nothing, and the adapter reads again to tell an unknown id from a version that moved. On a conflict nothing was written.
175
+ **Fix:** for a conflict, read the user again and retry; see `VERSION_CONFLICT` in the core's troubleshooting.
176
+
177
+ ### `UNSUPPORTED` — `collectExpired: store.sessions does not implement deleteExpiredSessions — …`
178
+
179
+ **When:** `auth.collectExpired()`.
180
+ **Why:** deliberate. The `expiry` TTL index already deletes lapsed sessions, so this adapter does not implement `deleteExpiredSessions`.
181
+ **Fix:** do not schedule `collectExpired` with this adapter. Nothing is lost: expiry never depended on it.
182
+
183
+ ### Lapsed sessions and tokens are still in the collection
184
+
185
+ **When:** looking at `sessions` or `tokens` in a shell shortly after `expiresAt`.
186
+ **Why:** MongoDB's TTL monitor runs every sixty seconds, so a document can outlive its `expiresAt` by up to a minute, or more on a loaded server. The TTL index is storage hygiene, not the expiry mechanism.
187
+ **Fix:** none needed. The core compares `expiresAt` on every read, so `authenticate` answers a lapsed session as anonymous and refuses a lapsed token (`TOKEN_EXPIRED`, or `TOKEN_UNKNOWN` once the document is gone).
188
+
189
+ ### `findObjects` answers ids in a different order than JavaScript sorts them
190
+
191
+ **When:** calling the relation store's `findObjects` directly, with ids holding characters past U+FFFF (emoji, some CJK).
192
+ **Why:** MongoDB orders strings by UTF-8 bytes; JavaScript's `<` compares UTF-16 code units. The two agree everywhere else.
193
+ **Fix:** none through `access.list()`: it sorts what it gathers itself, so its pages do not depend on the store's order. A direct caller that needs JavaScript's order sorts the page.