@bakery-framework/core 2.1.2 → 2.2.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/src/session.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { TieredCache } from './cache/tiered'
2
2
  import { Bakery, hostKey } from './core/bakery'
3
+ import { peekConfig, resolveHostname } from './core/config'
4
+ import { hostStore } from './core/context'
5
+ import { errorMsg, serveLog } from './logger/serve-log'
3
6
  import type { MapOf } from './types'
4
- import { hasDeferredValue } from './utils'
7
+ import { deferredValue, hasDeferredValue } from './utils'
5
8
  import { DEFAULT_SESSION_PERSIST, DEFAULT_SESSION_TTL } from './utils/constants'
6
9
 
7
10
  /**
@@ -75,6 +78,141 @@ function inScope(key: string, scope: string): boolean {
75
78
  return key.startsWith(scope) && !key.slice(scope.length).includes(':')
76
79
  }
77
80
 
81
+ /** A session as a {@link SessionStore} keeps it. Times are epoch milliseconds. */
82
+ export interface StoredSession {
83
+ id: string
84
+ /** The configured host it belongs to, `''` for the default namespace. */
85
+ host: string
86
+ /**
87
+ * The account it belongs to, as text: the value under the key that
88
+ * `sessions.account` names, or null when there is none.
89
+ */
90
+ account: string | null
91
+ createdAt: number
92
+ /** When it was last written, which is when its cookie was last issued. */
93
+ accessedAt: number
94
+ /** When a store may forget it: `accessedAt` plus its idle timeout. */
95
+ expiresAt: number
96
+ persistKeys: string[]
97
+ data: Record<string, unknown>
98
+ }
99
+
100
+ export interface SessionListOptions {
101
+ search?: string
102
+ page: number
103
+ pageSize: number
104
+ sortBy: string
105
+ sortOrder: 'ASC' | 'DESC'
106
+ }
107
+
108
+ export interface SessionPage {
109
+ rows: Session<any>[]
110
+ totalRows: number
111
+ page: number
112
+ pageSize: number
113
+ totalPages: number
114
+ }
115
+
116
+ /**
117
+ * Where sessions live when `sessions.store` replaces the built-in store,
118
+ * which is memory with `bakery/sessions.db` behind it.
119
+ *
120
+ * Every method is a round trip, and nothing here keeps a session between
121
+ * requests. That is the point: with the built-in store each `--threads`
122
+ * worker serves sessions from its own memory, so a session ended in one
123
+ * worker lived on in another. A store every worker asks, and nobody caches,
124
+ * ends it for the next request in all of them.
125
+ * `@bakery-framework/orm/sessions` implements it over the app's database.
126
+ */
127
+ export interface SessionStore {
128
+ /** The session under this id and host, if it is still live at `now`. */
129
+ load(
130
+ host: string,
131
+ id: string,
132
+ now: number,
133
+ ): Promise<StoredSession | undefined>
134
+ /** Store a session that has never been stored. */
135
+ insert(session: StoredSession): Promise<void>
136
+ /**
137
+ * Rewrite the session stored as `storedId`, which is its own id or the one
138
+ * it had before `regenerate()`. False when nothing is stored under that id
139
+ * any more, because something ended it after this request read it; then
140
+ * nothing is written, since an update must never bring a session back.
141
+ */
142
+ update(storedId: string, session: StoredSession): Promise<boolean>
143
+ /** Renew a stored session's times without rewriting it. False as for `update`. */
144
+ touch(
145
+ host: string,
146
+ id: string,
147
+ accessedAt: number,
148
+ expiresAt: number,
149
+ ): Promise<boolean>
150
+ /** True when a session was removed. */
151
+ remove(host: string, id: string): Promise<boolean>
152
+ /** Remove every session of one account under one host: how many went. */
153
+ removeAccount(host: string, account: string): Promise<number>
154
+ /** Live sessions across every host. */
155
+ count(now: number): Promise<number>
156
+ /**
157
+ * One page of the host's live sessions, and how many it has in all. A page
158
+ * past the end is the last page.
159
+ */
160
+ list(
161
+ host: string,
162
+ options: SessionListOptions,
163
+ now: number,
164
+ ): Promise<{ rows: StoredSession[]; totalRows: number }>
165
+ /** Forget what has expired by `now`: how many went. */
166
+ prune(now: number): Promise<number>
167
+ }
168
+
169
+ /** `sessions` in `server.config.ts`. */
170
+ export interface SessionOptions {
171
+ /** Where sessions live. Omitted, the built-in store. */
172
+ store?: SessionStore
173
+ /**
174
+ * The session key that holds the account id. A store indexes it, so
175
+ * `Session.endForAccount` can end every session of one account.
176
+ */
177
+ account?: string
178
+ }
179
+
180
+ /**
181
+ * The configured `sessions`, or undefined before `initConfig()`. Sessions are
182
+ * built outside a server too (a unit test, a script), and there the defaults
183
+ * are the answer rather than an error.
184
+ */
185
+ function sessionOptions(): SessionOptions | undefined {
186
+ return (hostStore.getStore()?.config ?? peekConfig())?.sessions
187
+ }
188
+
189
+ /** The configured store, or undefined for the built-in one. */
190
+ function sessionStore(): SessionStore | undefined {
191
+ return sessionOptions()?.store
192
+ }
193
+
194
+ /** The host `hostKey()` would prefix, without the separator: a store's scope. */
195
+ function sessionHost(): string {
196
+ return resolveHostname(hostStore.getStore()?.hostname || '')
197
+ }
198
+
199
+ /**
200
+ * Ids worth a lookup: `newSessionId` mints 43 of these characters. A cookie
201
+ * holding anything else names no session a store could have, and costs no
202
+ * round trip.
203
+ */
204
+ const SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/
205
+
206
+ /** The session `attach` read for a request, where `from` finds it. */
207
+ const PRELOADED = Symbol('bakery.session.preloaded')
208
+
209
+ /** A synchronous static that reads the built-in store, called with another configured. */
210
+ function builtInOnly(what: string, instead: string): never {
211
+ throw new Error(
212
+ `${what} reads the built-in session store, and sessions.store replaces it: use ${instead}.`,
213
+ )
214
+ }
215
+
78
216
  export class Session<
79
217
  T extends MapOf<any> = MapOf<any>,
80
218
  TK extends keyof T | (string & {}) = keyof T | (string & {}),
@@ -99,6 +237,7 @@ export class Session<
99
237
  * would mean walking every key on every sample.
100
238
  */
101
239
  public static get count() {
240
+ if (sessionStore()) builtInOnly('Session.count', 'await Session.total()')
102
241
  return Session.cache.count
103
242
  }
104
243
 
@@ -116,6 +255,12 @@ export class Session<
116
255
  * request it belongs to issued a cookie this turn.
117
256
  */
118
257
  public static bind(req: Request, response?: Response) {
258
+ if (sessionStore()) {
259
+ builtInOnly(
260
+ 'Session.bind',
261
+ 'await Session.commit(req) and append the cookie it returns',
262
+ )
263
+ }
119
264
  if (!response) return response
120
265
 
121
266
  const cookieValue = Session.getCookie(req)
@@ -125,9 +270,16 @@ export class Session<
125
270
  }
126
271
 
127
272
  public static getCookie(req: Request): string {
273
+ if (sessionStore()) {
274
+ builtInOnly('Session.getCookie', 'await Session.commit(req)')
275
+ }
128
276
  if (!hasDeferredValue(req, 'session')) return ''
129
277
  const session = req.session
130
278
 
279
+ // Ended from outside this request (`Session.delete`, `endForAccount`):
280
+ // storing it again would undo that.
281
+ if (session.ended) return ''
282
+
131
283
  // Issue only when the session actually changed, or when the read path
132
284
  // flagged a half-life refresh (see `markAccessed`), not on every read.
133
285
  // A per-request Set-Cookie made every session-carrying response unique,
@@ -144,12 +296,58 @@ export class Session<
144
296
  session.cookieRefreshDue = false
145
297
  session.cookieIssuedAt = clock()
146
298
 
147
- // `persistedKeys` allocates an Array off the Set purely to read `.length`;
148
- // `hasPersistedKeys()` reads `Set.size` and answers the same question.
149
- const hasPersistedKeys = session.hasPersistedKeys()
150
- const maxAgeSeconds = hasPersistedKeys
151
- ? Math.floor(DEFAULT_SESSION_PERSIST / 1000)
152
- : Math.floor(DEFAULT_SESSION_TTL / 1000)
299
+ return Session.cookieHeader(session, req)
300
+ }
301
+
302
+ /**
303
+ * Write what this request did to its session, and return the `Set-Cookie`
304
+ * value for the response, `''` for none. `processResponse` calls it for
305
+ * every response but a WebSocket upgrade, whose headers are not its to set.
306
+ *
307
+ * With the built-in store this is `getCookie`. With `sessions.store` the
308
+ * writes happen here, awaited, so the response leaves after the store has
309
+ * them: a login answered with a redirect finds its session on the next
310
+ * request, whichever worker takes it.
311
+ */
312
+ public static async commit(req: Request): Promise<string> {
313
+ const store = sessionStore()
314
+ if (!store) return Session.getCookie(req)
315
+ if (!hasDeferredValue(req, 'session')) return ''
316
+ return await req.session.commitTo(store, req)
317
+ }
318
+
319
+ /**
320
+ * Give the request its `req.session`. `worker.ts` calls it first, so the
321
+ * rate limiter's `keyBy` and every handler after it read the session
322
+ * synchronously.
323
+ *
324
+ * With the built-in store nothing is read until something asks: the
325
+ * property is deferred, and a request that never touches it costs nothing.
326
+ * A configured store cannot be asked synchronously, so a request carrying a
327
+ * session cookie waits here for one lookup by primary key, and one without
328
+ * a cookie does not wait at all.
329
+ */
330
+ public static attach(req: Request): Promise<void> | undefined {
331
+ deferredValue(req, 'session', Session.from)
332
+ const store = sessionStore()
333
+ if (!store) return undefined
334
+
335
+ const id = Session.getSessionId(req)
336
+ if (!SESSION_ID.test(id)) return undefined
337
+
338
+ return store.load(sessionHost(), id, clock()).then(stored => {
339
+ // `load` asks for live sessions only. Checked again so a store that
340
+ // forgets to cannot hand back an expired one.
341
+ if (!stored || stored.expiresAt <= clock()) return
342
+ const session = Session.revive(stored)
343
+ session.markAccessed()
344
+ ;(req as any)[PRELOADED] = session
345
+ })
346
+ }
347
+
348
+ /** The `Set-Cookie` value naming `session`, for the response to `req`. */
349
+ private static cookieHeader(session: Session<any>, req: Request): string {
350
+ const maxAgeSeconds = Math.floor(session.idleTimeout() / 1000)
153
351
 
154
352
  // Only believe x-forwarded-proto behind a trusted proxy: the same rule
155
353
  // getHostname and getClientIp already apply. In production default to
@@ -165,8 +363,16 @@ export class Session<
165
363
  }
166
364
 
167
365
  public static delete(reqOrId: string | Request): boolean {
366
+ if (sessionStore()) builtInOnly('Session.delete', 'await Session.end(id)')
168
367
  const id = this.getSessionId(reqOrId)
169
- return id ? Session.cache.delete(sessionKey(id)) : false
368
+ if (!id) return false
369
+ const key = sessionKey(id)
370
+ // A request holding this session right now must not store it again on
371
+ // its way out. The memory tier hands every request the same object, so
372
+ // marking it reaches them all.
373
+ const live = Session.cache.peek(key)
374
+ if (live) live.ended = true
375
+ return Session.cache.delete(key)
170
376
  }
171
377
 
172
378
  public static create<T extends MapOf<any>>(metadata: {
@@ -174,6 +380,9 @@ export class Session<
174
380
  persistKeys: string[] | Set<string>
175
381
  data: Partial<T>
176
382
  }): Session<T> {
383
+ if (sessionStore()) {
384
+ builtInOnly('Session.create', 'req.session, which the response stores')
385
+ }
177
386
  const session = Session.reconstruct<T>(metadata)
178
387
  Session.cache.set(sessionKey(session.id), session)
179
388
  return session
@@ -212,6 +421,8 @@ export class Session<
212
421
  ): Session<T> {
213
422
  const rawReq = request as any
214
423
  if (rawReq._session) return rawReq._session as any
424
+ // A configured store was read by `attach`, before anything could ask.
425
+ if (sessionStore()) return rawReq[PRELOADED] ?? new Session()
215
426
  const sessionId = Session.getSessionId(request)
216
427
 
217
428
  if (sessionId) {
@@ -260,6 +471,27 @@ export class Session<
260
471
  protected rawData: Partial<T> = {}
261
472
  public data!: Partial<T>
262
473
 
474
+ /**
475
+ * Ended from outside the request holding it, by `Session.delete`,
476
+ * `Session.end` or `Session.endForAccount`: nothing it writes is stored
477
+ * again. `destroy()` is different: the request ending its own session may
478
+ * go on to start a new one.
479
+ */
480
+ private ended = false
481
+
482
+ /**
483
+ * With `sessions.store`: the id the store holds this session under, or null
484
+ * while it holds nothing. It differs from `id` after `regenerate()`, until
485
+ * the commit moves the row.
486
+ */
487
+ private storedId: string | null = null
488
+
489
+ /** With `sessions.store`: stored ids to remove at commit, in order. */
490
+ private removals: string[] = []
491
+
492
+ /** With `sessions.store`: when the store last wrote it. */
493
+ private storedAccessedAt: number | undefined
494
+
263
495
  constructor(sessid?: string, createdAt?: number) {
264
496
  sessid = sessid || newSessionId()
265
497
 
@@ -313,19 +545,33 @@ export class Session<
313
545
  * of maxAge, so active sessions never age out server-side either.
314
546
  */
315
547
  private markAccessed(): void {
316
- const maxAgeMs = this.hasPersistedKeys()
317
- ? DEFAULT_SESSION_PERSIST
318
- : DEFAULT_SESSION_TTL
319
- if (clock() - this.cookieIssuedAt > maxAgeMs / 2) {
548
+ if (clock() - this.cookieIssuedAt > this.idleTimeout() / 2) {
320
549
  this.cookieRefreshDue = true
321
550
  }
322
551
  }
323
552
 
553
+ /**
554
+ * How long the session lives unused, which is also its cookie's Max-Age:
555
+ * 30 days once a key is persisted, an hour otherwise.
556
+ *
557
+ * `hasPersistedKeys()` rather than `persistedKeys.length`: the getter
558
+ * allocates an Array off the Set purely to read its length.
559
+ */
560
+ private idleTimeout(): number {
561
+ return this.hasPersistedKeys()
562
+ ? DEFAULT_SESSION_PERSIST
563
+ : DEFAULT_SESSION_TTL
564
+ }
565
+
324
566
  public get isModified() {
325
567
  return this.modified
326
568
  }
327
569
  public get accessedAt() {
328
- return Session.cache.getAccessedAt(sessionKey(this.id)) ?? Date.now()
570
+ return (
571
+ this.storedAccessedAt ??
572
+ Session.cache.getAccessedAt(sessionKey(this.id)) ??
573
+ Date.now()
574
+ )
329
575
  }
330
576
  public get persistedKeys() {
331
577
  return Array.from(this.persistKeys)
@@ -340,17 +586,15 @@ export class Session<
340
586
  }
341
587
 
342
588
  public isExpired(): boolean {
343
- const accessed = this.accessedAt
344
- return this.hasPersistedKeys()
345
- ? Date.now() - accessed > DEFAULT_SESSION_PERSIST
346
- : Date.now() - accessed > DEFAULT_SESSION_TTL
589
+ return Date.now() - this.accessedAt > this.idleTimeout()
347
590
  }
348
591
 
349
592
  public persist(key: keyof T | (string & {}), state: boolean = true): this {
350
593
  this.persistKeys[state ? 'add' : 'delete'](key as string)
351
594
  this.modified = true
352
595
 
353
- if (this.persistKeys.size > 0) {
596
+ // A configured store writes at commit, like every other change.
597
+ if (this.persistKeys.size > 0 && !sessionStore()) {
354
598
  Session.cache.set(sessionKey(this.id), this)
355
599
  }
356
600
 
@@ -383,8 +627,13 @@ export class Session<
383
627
  // `id` is readonly to callers; rotating it is the one legitimate write.
384
628
  ;(this as { id: string }).id = newSessionId()
385
629
 
386
- Session.cache.delete(sessionKey(previous))
387
- Session.cache.set(sessionKey(this.id), this)
630
+ // A configured store moves the row at commit, from `storedId` to the new
631
+ // id in one statement, so a session ended meanwhile is not recreated
632
+ // under its new name.
633
+ if (!sessionStore()) {
634
+ Session.cache.delete(sessionKey(previous))
635
+ Session.cache.set(sessionKey(this.id), this)
636
+ }
388
637
  this.modified = true
389
638
 
390
639
  return this
@@ -398,17 +647,30 @@ export class Session<
398
647
  delete this.rawData[key as keyof T]
399
648
  }
400
649
 
650
+ const store = sessionStore()
401
651
  if (!this.hasPersistedKeys()) {
402
- Session.cache.delete(sessionKey(this.id))
652
+ if (store) this.forgetStored()
653
+ else Session.cache.delete(sessionKey(this.id))
403
654
  this.modified = false
404
655
  // A pending half-life refresh would make `getCookie` re-store the
405
656
  // session this branch just deleted.
406
657
  this.cookieRefreshDue = false
658
+ } else if (store) {
659
+ this.modified = true
407
660
  } else {
408
661
  Session.cache.set(sessionKey(this.id), this)
409
662
  }
410
663
  }
411
664
 
665
+ /**
666
+ * With `sessions.store`: remove the stored row at commit, and store
667
+ * anything written after this as a session of its own.
668
+ */
669
+ private forgetStored(): void {
670
+ if (this.storedId !== null) this.removals.push(this.storedId)
671
+ this.storedId = null
672
+ }
673
+
412
674
  get<K extends keyof T>(key: K): T[K] | undefined
413
675
  get<K extends keyof T>(key: K, defaultValue: T[K]): T[K]
414
676
  get<R = string>(key: string & {}): R | undefined
@@ -436,8 +698,110 @@ export class Session<
436
698
  return this
437
699
  }
438
700
 
701
+ /**
702
+ * End this session, and leave in its place a new one that nothing has
703
+ * written to.
704
+ *
705
+ * Dropping the stored entry was never enough on its own, because the object
706
+ * outlives the call: the same `req.session` reaches `getCookie` on the way
707
+ * out, and `getCookie` stores whatever is modified or due a cookie refresh.
708
+ * A login past half its cookie's Max-Age is due one, and a flash message
709
+ * written after the logout marks it modified, so either brought the whole
710
+ * session back, account id and all, under the id the visitor still held.
711
+ *
712
+ * So the object becomes a new session: a fresh id, no data, nothing
713
+ * persisted, nothing due. A request that stops here stores nothing and
714
+ * issues no cookie; one that goes on writing starts an anonymous session of
715
+ * its own, which the response issues as usual.
716
+ */
439
717
  public destroy(): void {
440
- Session.delete(this.id)
718
+ if (sessionStore()) this.forgetStored()
719
+ else Session.cache.delete(sessionKey(this.id))
720
+ ;(this as { id: string }).id = newSessionId()
721
+ ;(this as { createdAt: number }).createdAt = Date.now()
722
+ this.persistKeys.clear()
723
+ for (const key of Object.keys(this.rawData)) {
724
+ delete this.rawData[key as keyof T]
725
+ }
726
+ this.modified = false
727
+ this.cookieRefreshDue = false
728
+ this.cookieIssuedAt = 0
729
+ this.ended = false
730
+ }
731
+
732
+ /**
733
+ * With `sessions.store`, the writes `commit` makes: the removals first and
734
+ * in order, then the session itself.
735
+ *
736
+ * One statement at a time, never in parallel. A pool can run two
737
+ * statements on two connections, and a removal overtaken by the insert
738
+ * queued after it (`reset()` then a write keeps the id) would remove the
739
+ * session just written.
740
+ */
741
+ private async commitTo(store: SessionStore, req: Request): Promise<string> {
742
+ const host = sessionHost()
743
+ for (const id of this.removals.splice(0)) await store.remove(host, id)
744
+ if (this.ended || (!this.modified && !this.cookieRefreshDue)) return ''
745
+
746
+ const now = clock()
747
+ const stored = this.toStored(host, now)
748
+ let kept = true
749
+ if (this.storedId === null) await store.insert(stored)
750
+ else if (this.modified) kept = await store.update(this.storedId, stored)
751
+ else kept = await store.touch(host, this.storedId, now, stored.expiresAt)
752
+
753
+ if (!kept) {
754
+ // Ended after this request read it: by `endForAccount`, by a logout in
755
+ // another tab, in this worker or another. Writing it back would undo
756
+ // that, and a new cookie would name a session that is not there.
757
+ this.ended = true
758
+ return ''
759
+ }
760
+
761
+ this.storedId = this.id
762
+ this.storedAccessedAt = now
763
+ this.cookieIssuedAt = now
764
+ this.modified = false
765
+ this.cookieRefreshDue = false
766
+ return Session.cookieHeader(this, req)
767
+ }
768
+
769
+ /**
770
+ * As a store keeps it, last written at `accessedAt`. The account is read
771
+ * off the key `sessions.account` names, as text, so `7` and `'7'` are one
772
+ * account.
773
+ */
774
+ private toStored(host: string, accessedAt: number): StoredSession {
775
+ const key = sessionOptions()?.account
776
+ const held = key ? (this.rawData as MapOf<unknown>)[key] : undefined
777
+ return {
778
+ id: this.id,
779
+ host,
780
+ account:
781
+ held === undefined || held === null || held === ''
782
+ ? null
783
+ : String(held),
784
+ createdAt: this.createdAt,
785
+ accessedAt,
786
+ expiresAt: accessedAt + this.idleTimeout(),
787
+ persistKeys: Array.from(this.persistKeys),
788
+ data: { ...this.rawData },
789
+ }
790
+ }
791
+
792
+ /** A session read from a store, remembering where it is stored. */
793
+ private static revive(stored: StoredSession): Session<any> {
794
+ const session = Session.reconstruct({
795
+ id: stored.id,
796
+ createdAt: stored.createdAt,
797
+ persistKeys: stored.persistKeys,
798
+ data: stored.data,
799
+ // A store writes a session exactly when its cookie is issued.
800
+ cookieIssuedAt: stored.accessedAt,
801
+ })
802
+ session.storedId = stored.id
803
+ session.storedAccessedAt = stored.accessedAt
804
+ return session
441
805
  }
442
806
 
443
807
  public toJSON() {
@@ -457,16 +821,134 @@ export class Session<
457
821
  }
458
822
  }
459
823
 
824
+ /**
825
+ * The live session under this id, on the current host. With a configured
826
+ * store this is a copy read for the caller: change it, then
827
+ * `await Session.save(session)`.
828
+ */
460
829
  static async get(sessid: string): Promise<Session<any> | undefined> {
461
- return await Session.cache.get(sessionKey(sessid))
830
+ const store = sessionStore()
831
+ if (!store) return Session.cache.get(sessionKey(sessid))
832
+ const stored = await store.load(sessionHost(), sessid, clock())
833
+ return stored ? Session.revive(stored) : undefined
834
+ }
835
+
836
+ /**
837
+ * End the session under this id, on the current host: true when there was
838
+ * one. Unlike `Session.delete`, works with any store.
839
+ *
840
+ * A request holding that session right now finishes with what it read,
841
+ * and writes nothing back on its way out.
842
+ */
843
+ static async end(sessid: string): Promise<boolean> {
844
+ const store = sessionStore()
845
+ if (!store) return Session.delete(sessid)
846
+ return await store.remove(sessionHost(), sessid)
847
+ }
848
+
849
+ /**
850
+ * End every session of one account, on the current host: how many there
851
+ * were. The account is matched as text against the session key that
852
+ * `sessions.account` names, so `7` and `'7'` are one account.
853
+ *
854
+ * With a configured store this is one indexed `DELETE`, and since nothing
855
+ * caches a session between requests, the next request in every worker
856
+ * finds none. Inside `DB.transaction()` the database store takes part in
857
+ * the transaction, so removing an account and its sessions can be one
858
+ * commit. With the built-in store it ends what this process holds, which
859
+ * under `--threads` is not what another worker holds in its memory: that is
860
+ * the case the database store exists for.
861
+ */
862
+ static async endForAccount(account: string | number): Promise<number> {
863
+ const key = sessionOptions()?.account
864
+ if (!key) {
865
+ throw new Error(
866
+ 'Session.endForAccount needs sessions.account in server.config.ts: the session key that holds the account id.',
867
+ )
868
+ }
869
+ const wanted = String(account)
870
+ const store = sessionStore()
871
+ if (store) return await store.removeAccount(sessionHost(), wanted)
872
+
873
+ // Collected first and deleted after: deleting rows from the table a
874
+ // statement is still walking is not something SQLite promises to handle.
875
+ const ended: string[] = []
876
+ for (const [id, session] of Session.entries()) {
877
+ const held = (session.rawData as MapOf<unknown>)[key]
878
+ if (held !== undefined && held !== null && String(held) === wanted) {
879
+ ended.push(id)
880
+ }
881
+ }
882
+ for (const id of ended) Session.delete(id)
883
+ return ended.length
884
+ }
885
+
886
+ /** How many sessions are live, across every host. Works with any store. */
887
+ static async total(): Promise<number> {
888
+ const store = sessionStore()
889
+ return store ? await store.count(clock()) : Session.cache.count
890
+ }
891
+
892
+ /**
893
+ * One page of the current host's sessions, as `Session.list` gives it, from
894
+ * any store. Sorted by `'id'`, `'keys'` (how many are persisted) or last
895
+ * access, the default.
896
+ */
897
+ static async page(options: SessionListOptions): Promise<SessionPage> {
898
+ const store = sessionStore()
899
+ if (!store) return Session.list(options)
900
+ const { rows, totalRows } = await store.list(
901
+ sessionHost(),
902
+ options,
903
+ clock(),
904
+ )
905
+ const totalPages = Math.max(1, Math.ceil(totalRows / options.pageSize))
906
+ return {
907
+ rows: rows.map(Session.revive),
908
+ totalRows,
909
+ page: Math.min(Math.max(1, options.page), totalPages),
910
+ pageSize: options.pageSize,
911
+ totalPages,
912
+ }
913
+ }
914
+
915
+ /**
916
+ * Store a session changed outside its own requests, such as one an admin
917
+ * edits from `Session.get`. False when it has been ended meanwhile, and
918
+ * then it stays ended.
919
+ *
920
+ * Its last access is left alone. With a configured store that time is
921
+ * when the session's cookie was issued, and moving it on an edit would
922
+ * delay the owner's cookie refresh past the cookie's own expiry.
923
+ */
924
+ static async save(session: Session<any>): Promise<boolean> {
925
+ const store = sessionStore()
926
+ if (!store) {
927
+ Session.cache.set(sessionKey(session.id), session)
928
+ return true
929
+ }
930
+ if (session.ended) return false
931
+ const stored = session.toStored(
932
+ sessionHost(),
933
+ session.storedAccessedAt ?? clock(),
934
+ )
935
+ if (session.storedId === null) await store.insert(stored)
936
+ else if (!(await store.update(session.storedId, stored))) return false
937
+ session.storedId = session.id
938
+ session.storedAccessedAt = stored.accessedAt
939
+ return true
462
940
  }
463
941
 
464
942
  /**
465
943
  * Every enumeration below filters to the current host and yields the bare
466
944
  * session id, not the cache key, so `Session.keys()` still returns ids the
467
945
  * dashboard can hand back to `Session.get` / `Session.delete`.
946
+ *
947
+ * They read the built-in store, synchronously, and refuse when a configured
948
+ * store replaces it: `Session.page()` and `Session.total()` ask any store.
468
949
  */
469
950
  static *entries(): IterableIterator<[string, Session<any>]> {
951
+ if (sessionStore()) builtInOnly('Session.entries', 'await Session.page()')
470
952
  const scope = sessionScope()
471
953
  for (const [key, sess] of Session.cache.entries()) {
472
954
  if (!inScope(key, scope)) continue
@@ -482,13 +964,8 @@ export class Session<
482
964
  for (const [id] of Session.entries()) yield id
483
965
  }
484
966
 
485
- static list(options: {
486
- search?: string
487
- page: number
488
- pageSize: number
489
- sortBy: string
490
- sortOrder: 'ASC' | 'DESC'
491
- }) {
967
+ static list(options: SessionListOptions): SessionPage {
968
+ if (sessionStore()) builtInOnly('Session.list', 'await Session.page()')
492
969
  // Fast path: with no `hosts` configured every key is already in the default
493
970
  // bucket, so the cache's own SQL paging is correctly scoped and there is no
494
971
  // reason to give it up.
@@ -551,6 +1028,15 @@ export class Session<
551
1028
 
552
1029
  const sessionPruneTimer = setInterval(
553
1030
  function cleanUpSessions() {
1031
+ // Every worker runs this timer. Against a shared store that is one
1032
+ // indexed DELETE each per quarter hour, and a second finds nothing.
1033
+ const store = sessionStore()
1034
+ if (store) {
1035
+ store
1036
+ .prune(clock())
1037
+ .catch(error => serveLog.SESSION_PRUNE_ERR({ error: errorMsg(error) }))
1038
+ return
1039
+ }
554
1040
  Session.cache.prune(DEFAULT_SESSION_TTL, '$.persistKeys')
555
1041
  Session.cache.prune(DEFAULT_SESSION_PERSIST)
556
1042
  },