@nxgt/janus 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +37 -11
  2. package/dist/auth/port/assert-stores.d.ts.map +1 -1
  3. package/dist/auth/port/memory/users.d.ts.map +1 -1
  4. package/dist/auth/port/types/stores.d.ts +5 -0
  5. package/dist/auth/port/types/stores.d.ts.map +1 -1
  6. package/dist/auth/port/types/users.d.ts +17 -0
  7. package/dist/auth/port/types/users.d.ts.map +1 -1
  8. package/dist/auth/types/user-type.d.ts +15 -0
  9. package/dist/auth/types/user-type.d.ts.map +1 -1
  10. package/dist/auth/users/find-many.d.ts +29 -0
  11. package/dist/auth/users/find-many.d.ts.map +1 -0
  12. package/dist/auth/users/record-flows.d.ts.map +1 -1
  13. package/dist/chunks/{index-kvjj5xkp.js → index-6bjfv4xv.js} +8 -2
  14. package/dist/chunks/{index-kvjj5xkp.js.map → index-6bjfv4xv.js.map} +3 -3
  15. package/dist/conformance/cases/outage.d.ts.map +1 -1
  16. package/dist/conformance/cases/users/batch.d.ts +8 -0
  17. package/dist/conformance/cases/users/batch.d.ts.map +1 -0
  18. package/dist/conformance/cases/users/index.d.ts.map +1 -1
  19. package/dist/conformance/describe.d.ts +2 -1
  20. package/dist/conformance/describe.d.ts.map +1 -1
  21. package/dist/conformance/index.d.ts +1 -1
  22. package/dist/conformance/index.d.ts.map +1 -1
  23. package/dist/conformance/index.js +104 -44
  24. package/dist/conformance/index.js.map +7 -6
  25. package/dist/conformance/types.d.ts +10 -2
  26. package/dist/conformance/types.d.ts.map +1 -1
  27. package/dist/index.js +59 -7
  28. package/dist/index.js.map +6 -5
  29. package/docs/guide/adapters.md +44 -4
  30. package/docs/guide/users.md +48 -0
  31. package/docs/roadmap.md +12 -14
  32. package/docs/troubleshooting.md +39 -2
  33. package/package.json +1 -1
@@ -180,6 +180,7 @@ In the one-type form these are on `auth` itself; with several types, on
180
180
  | --- | --- | --- |
181
181
  | `create(fields & { password?, active? })` | the user; no session | `USER_INVALID`, `PASSWORD_TOO_SHORT`, `LOGIN_TAKEN` |
182
182
  | `find(id)` | the user, or `null` — for a malformed id, an unknown one, or one of another type | |
183
+ | `findMany(ids)` | the users, in the order of `ids`, each once; an id `find` answers `null` for is left out — see [below](#several-users-at-once--findmany) | |
183
184
  | `get(id)` | the user | `NOT_FOUND` |
184
185
  | `list({ after?, limit? })` | `CursorPage<User>`, in creation order | `INVALID_CURSOR` |
185
186
  | `update(user, patch, { ifVersion? })` | the user as written; sends `user.emailChanged` when the e-mail changed | `USER_INVALID`, `LOGIN_TAKEN`, `VERSION_CONFLICT`, `NOT_FOUND` |
@@ -249,6 +250,53 @@ idempotent: calling it again finishes the job. When it deleted the user, it
249
250
  sends a [`user.deleted` event](events.md) — once, after the sessions, tokens and relation tuples are gone, and even when an outage interrupts removing them;
250
251
  `create` and `signUp` send `user.created`.
251
252
 
253
+ ### Several users at once — `findMany`
254
+
255
+ A list of rows that each name a user — a staff list showing every member's
256
+ account state — reads them in one call, not one `find` per row:
257
+
258
+ ```ts
259
+ const rows = await db.staffRows(); // your own table, 100 rows
260
+ const members = await clinic.staff.findMany(rows.map((row) => row.staffId));
261
+ const byId = new Map(members.map((member) => [member.id, member]));
262
+
263
+ const shown = rows.map((row) => {
264
+ const member = byId.get(row.staffId); // undefined: nobody holds this id
265
+ return { ...row, active: member?.active ?? false, verified: member?.emailVerified ?? false };
266
+ });
267
+ ```
268
+
269
+ - **Order**: the order of `ids`. A repeated id is answered once, at its first
270
+ place.
271
+ - **Absence**: an id `find` would answer `null` for — malformed, unknown, or
272
+ held by a user of another type — is **left out**, never an error. The answer
273
+ may be shorter than `ids`, so match by `user.id`, never by position.
274
+ - **Empty**: `[]`, or a list with no well-formed id, answers `[]` without
275
+ reaching the store.
276
+ - **Size**: any length. The store is asked 100 ids at a time, one batch after
277
+ another, so no query grows with your list.
278
+ - **Failure**: `STORE_FAILED`, never a shorter list. Something other than an
279
+ array is a bare `TypeError`: only your code passes one.
280
+ - **Cost**: one query per 100 ids when the users store implements the
281
+ optional `findUsers` — the reference store, `@nxgt/janus-drizzle` and
282
+ `@nxgt/janus-mongo` do. A store that does not is read with `findUser`,
283
+ **ten at a time**, so a third-party adapter keeps working and never opens
284
+ more than ten reads at once for one call; see
285
+ [writing an adapter](adapters.md#userstorefindusers-optional).
286
+
287
+ **A GraphQL resolver per row** can batch through a DataLoader of your own —
288
+ `@nxgt/janus-graphql` loads no users itself:
289
+
290
+ ```ts
291
+ import DataLoader from 'dataloader';
292
+
293
+ // One per request, so nothing is cached across users' requests.
294
+ const staffById = new DataLoader(async (ids: readonly string[]) => {
295
+ const found = new Map((await clinic.staff.findMany(ids)).map((u) => [u.id, u]));
296
+ return ids.map((id) => found.get(id) ?? null);
297
+ });
298
+ ```
299
+
252
300
  ### Paging every user
253
301
 
254
302
  ```ts
package/docs/roadmap.md CHANGED
@@ -20,6 +20,11 @@ Nothing yet.
20
20
  window. A port method to collect them, as sessions are, would let the
21
21
  core do it.
22
22
 
23
+ - **A batch read across types** — `findMany` reads one user type, as `find`
24
+ does. The shared `findUser(id)` has no batch counterpart yet; one on the
25
+ instance, answering a union narrowed by `user.type`, would serve a list that
26
+ mixes types.
27
+
23
28
  - **More official adapters** — the ports are cut where atomicity is not
24
29
  required, so users, sessions and permission tuples can each live in the
25
30
  database that suits them. MongoDB is the first adapter
@@ -71,6 +76,13 @@ Nothing yet.
71
76
  The last ten, newest first, each with the version it came in. Everything
72
77
  before is in the [CHANGELOG](../CHANGELOG.md).
73
78
 
79
+ - **Several users by id in one query, v0.18.0** — `auth.<type>.findMany(ids)`
80
+ answers the type's users in the order of `ids`, each once, and leaves out an
81
+ id nobody of that type holds — for a list of rows that would otherwise make
82
+ one `find` per row and fill the connection pool. The users store gains an
83
+ optional `findUsers`, implemented by the reference store,
84
+ `@nxgt/janus-drizzle` 0.7.0 and `@nxgt/janus-mongo` 0.8.0; a store without it
85
+ is read with `findUser`, ten at a time, so third-party adapters keep working.
74
86
  - **Requests that send e-mail are throttled, v0.18.0** —
75
87
  `janus({ mail: { throttle: { attempts, window } } })`, five per ten
76
88
  minutes by default and `false` to turn it off. `magicLink.request`,
@@ -153,20 +165,6 @@ before is in the [CHANGELOG](../CHANGELOG.md).
153
165
  user only — or a security settings page; `null` for a user with no active
154
166
  factor. The listener can now tell the user how many codes remain, as
155
167
  `@nxgt/janus-mail`'s `recoveryCodeUsed` e-mail does.
156
- - **Recovery codes, v0.10.0** — a user who loses their authenticator app
157
- still signs in, with no operator resetting the account.
158
- `secondFactor.activate` answers `{ user, recoveryCodes }`: ten single-use
159
- codes, shown once and stored only as keyed hashes (breaking: read `.user`).
160
- `secondFactor.recover(challenge, code)` redeems a sign-in's challenge with
161
- one and answers the session with `recoveryCodesLeft`;
162
- `secondFactor.regenerateRecoveryCodes(user, code)` replaces them all on a
163
- fresh code from the app, and counts its attempts itself — five per user
164
- per 15-minute window, in the store — so a stolen session cannot guess the
165
- code, and the route needs no limiter of its own. Two user events,
166
- `user.recoveryCodesRegenerated` and `user.recoveryCodeUsed`. For adapters:
167
- `SecondFactorRecord.recoveryCodes`,
168
- stored by the users stores of `@nxgt/janus-drizzle` and
169
- `@nxgt/janus-mongo`, with conformance cases.
170
168
  - **Sending the e-mails, `@nxgt/janus-mail` v0.1.0** — a package of its
171
169
  own, built on the `@nxgt/mail` toolkit: `janusMail({ mailer, from, brand,
172
170
  links })` takes what each flow answers —
@@ -49,6 +49,9 @@ How the messages are shaped:
49
49
  **Users, sessions and tokens**
50
50
  - [`STORE_FAILED` — `<slot>.<method>: the store could not answer`](#store_failed--slotmethod-the-store-could-not-answer)
51
51
  - [`STORE_FAILED` — `<slot>.<method> answered undefined …`](#store_failed--slotmethod-answered-undefined-an-absence-is-null-so-this-store-forgot-to-answer)
52
+ - [`STORE_FAILED` — `users.findUsers answered no list …`](#store_failed--usersfindusers-answered-no-list-an-absence-is-a-shorter-list-so-this-store-forgot-to-answer)
53
+ - [`<call>: ids must be an array of user ids`](#call-ids-must-be-an-array-of-user-ids)
54
+ - [`findMany` answers fewer users than ids](#findmany-answers-fewer-users-than-ids)
52
55
  - [`NOT_FOUND` — `<call>: no <type> has this id`](#not_found--call-no-type-has-this-id)
53
56
  - [`LOGIN_TAKEN` — `<call>: the login is taken by another <type>`](#login_taken--call-the-login-is-taken-by-another-type)
54
57
  - [`VERSION_CONFLICT` — `<call>: expected version <n>, found <m>`](#version_conflict--call-expected-version-n-found-m)
@@ -283,10 +286,10 @@ janus({ ..., hasher: scryptHasher(), verifiers: [legacyBcrypt] }); // not [scryp
283
286
 
284
287
  ### `janus: store.<slot> has no method <method>, which the port requires`
285
288
 
286
- Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`.
289
+ Also: `janus: store.<slot> is missing`, `janus: store must be an object with users, sessions and tokens`, `janus: store.sessions.deleteExpiredSessions must be a function or absent`, `janus: store.users.findUsers must be a function or absent`.
287
290
 
288
291
  **When:** `janus({...})`, from JavaScript or with a store typed loosely. TypeScript refuses a partial store at compile time and names the method.
289
- **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. `deleteExpiredSessions` is the one optional method: absent, or a function.
292
+ **Why:** `store` is `{ users, sessions, tokens }`, and each slot must answer every method of the port. Two methods are optional — `sessions.deleteExpiredSessions` and `users.findUsers` — and each is absent, or a function: a value in its place (`findUsers: true`, a property copied from a config) is a wiring mistake, not an absent capability.
290
293
  An adapter written for an earlier `@nxgt/janus` reports the method a later
291
294
  release added to the port: `store.tokens has no method countAttempt` for one
292
295
  written against 0.3 (the method came in 0.4), and
@@ -543,6 +546,40 @@ try {
543
546
  **Why:** a method that can find nothing must answer `null`. `undefined` is also what a missing `return` produces, so it is treated as a bug in the store, not as "not found".
544
547
  **Fix:** `return doc ?? null;` — and run `@nxgt/janus/conformance` against the store.
545
548
 
549
+ ### `STORE_FAILED` — `users.findUsers answered no list: an absence is a shorter list, so this store forgot to answer`
550
+
551
+ **When:** `findMany`, over a users store you wrote that implements the optional `findUsers`.
552
+ **Why:** `findUsers` answered `null`, `undefined` or something else than an array. An id nobody holds is left out of the list, and nobody at all is `[]`; anything else is a store that forgot to answer, so it fails rather than read as "nobody".
553
+ **Fix:** answer the rows you found, mapped to records — or leave `findUsers` out, and `findMany` reads with `findUser`:
554
+
555
+ ```ts
556
+ findUsers: async (ids) => (await db.users.where('id', 'in', ids)).map(toUser), // [] when none
557
+ ```
558
+
559
+ ### `<call>: ids must be an array of user ids`
560
+
561
+ A bare `TypeError` — `findMany: …`, or `staff.findMany: …` with several user types.
562
+
563
+ **When:** `findMany` was given something other than an array: one id, a `Set`, a comma-separated string.
564
+ **Why:** `findMany` takes a list; one id is `find`. Only your code passes the wrong shape, and TypeScript refuses it.
565
+ **Fix:** pass an array, or use `find` for one id:
566
+
567
+ ```ts
568
+ await auth.staff.findMany([...selectedIds]); // from a Set
569
+ await auth.staff.find(id); // one id
570
+ ```
571
+
572
+ ### `findMany` answers fewer users than ids
573
+
574
+ **When:** always, when an id is malformed, unknown, held by a user of another type, or repeated.
575
+ **Why:** `findMany` leaves out what `find` answers `null` for, and answers a repeated id once — it never puts `null` in a place. Reading `users[i]` as the user of `ids[i]` then shows one row's account on another.
576
+ **Fix:** match by id:
577
+
578
+ ```ts
579
+ const byId = new Map((await auth.staff.findMany(ids)).map((user) => [user.id, user]));
580
+ const member = byId.get(row.staffId); // undefined: nobody of this type holds it
581
+ ```
582
+
546
583
  ### `NOT_FOUND` — `<call>: no <type> has this id`
547
584
 
548
585
  `NotFoundError`, from `get`, `getUser`, `update`, `setActive`, `setPassword`, `verifyEmail.send`, `secondFactor.recoveryCodesLeft`. Also `<call>: the user has no e-mail`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/janus",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Embeddable, type-safe identities and permissions: bring your own database",
5
5
  "license": "MIT",
6
6
  "type": "module",