@quazardous/qdadm 2.19.0 → 2.21.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 (39) hide show
  1. package/package.json +2 -2
  2. package/src/components/lists/ListPage.vue +7 -0
  3. package/src/composables/index.ts +3 -0
  4. package/src/composables/useListPage.filters.ts +137 -67
  5. package/src/composables/useListPage.ts +106 -18
  6. package/src/composables/useListPage.types.ts +74 -5
  7. package/src/composables/useListPage.utils.ts +58 -0
  8. package/src/entity/EntityManager.interface.ts +11 -0
  9. package/src/entity/EntityManager.ts +5 -0
  10. package/src/entity/EntityManager.types.ts +11 -0
  11. package/src/entity/auth/SecurityChecker.ts +88 -2
  12. package/src/entity/auth/index.ts +2 -0
  13. package/src/entity/storage/ApiStorage.ts +16 -2
  14. package/src/entity/storage/LocalStorage.ts +1 -1
  15. package/src/entity/storage/MemoryStorage.ts +7 -2
  16. package/src/entity/storage/MockApiStorage.ts +1 -1
  17. package/src/entity/storage/SdkStorage.ts +12 -3
  18. package/src/index.ts +19 -0
  19. package/src/kernel/Kernel.auth.ts +4 -0
  20. package/src/kernel/Kernel.registries.ts +26 -7
  21. package/src/kernel/Kernel.types.ts +23 -1
  22. package/src/kernel/Kernel.vue.ts +3 -0
  23. package/src/kernel/SignalBus.ts +6 -0
  24. package/src/modules/debug/AuthCollector.ts +15 -1
  25. package/src/modules/debug/components/panels/AuthPanel.vue +1 -0
  26. package/src/plugin.ts +1 -1
  27. package/src/query/clientFilter.ts +14 -3
  28. package/src/routeState/CompositePersister.ts +144 -0
  29. package/src/routeState/CookiePersister.ts +109 -0
  30. package/src/routeState/MemoryPersister.ts +60 -0
  31. package/src/routeState/NullPersister.ts +33 -0
  32. package/src/routeState/RouteStatePersister.ts +86 -0
  33. package/src/routeState/UrlPersister.ts +207 -0
  34. package/src/routeState/WebStoragePersister.ts +149 -0
  35. package/src/routeState/defaultPersister.ts +66 -0
  36. package/src/routeState/factory.ts +86 -0
  37. package/src/routeState/index.ts +32 -0
  38. package/src/routeState/keyLocator.ts +84 -0
  39. package/src/security/RolesProvider.ts +8 -0
@@ -141,6 +141,15 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
141
141
  )
142
142
  }
143
143
 
144
+ // A `grant` that cannot judge would be silently skipped, leaving the role
145
+ // matrix to decide alone while the app believes its backend does (#2225).
146
+ if (security.grant && typeof security.grant.isGranted !== 'function') {
147
+ throw new Error(
148
+ '[qdadm] security.grant must provide isGranted(attribute, subject, user). ' +
149
+ 'Without it the application judgement would be skipped and the role matrix would decide alone.'
150
+ )
151
+ }
152
+
144
153
  let rolesProvider = security.rolesProvider
145
154
  if (!rolesProvider && (security.role_permissions || security.role_hierarchy)) {
146
155
  rolesProvider = new StaticRoleProvider({
@@ -152,6 +161,7 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
152
161
 
153
162
  this.securityChecker = createSecurityChecker({
154
163
  rolesProvider: rolesProvider || undefined,
164
+ grant: security.grant,
155
165
  getCurrentUser: () =>
156
166
  (entityAuthAdapter as EntityAuthAdapter | null)?.getCurrentUser?.() || null,
157
167
  })
@@ -165,14 +175,22 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
165
175
  adapter.setSecurityChecker(this.securityChecker)
166
176
  }
167
177
 
178
+ // One context for both installers. `permissionRegistry` lets a provider or
179
+ // a judge pre-warm in one batch (#2225) — but modules register their
180
+ // entities AFTER this point, so the keys must be read at fetch time, not
181
+ // inside install().
182
+ const ctx = {
183
+ orchestrator: this.orchestrator,
184
+ signals: this.signals,
185
+ permissionRegistry: this.permissionRegistry,
186
+ }
168
187
  if (rolesProvider?.install) {
169
- const ctx = {
170
- orchestrator: this.orchestrator,
171
- signals: this.signals,
172
- }
173
188
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
189
  rolesProvider.install(ctx as any)
175
190
  }
191
+ if (security.grant?.install) {
192
+ security.grant.install(ctx)
193
+ }
176
194
  }
177
195
 
178
196
  /**
@@ -361,8 +379,8 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
361
379
  'features', 'hashMode', 'homeRoute', 'i18n', 'layouts', 'managerRegistry',
362
380
  'managerResolver', 'managers', 'moduleDefs', 'modules', 'modulesOptions',
363
381
  'notifications', 'onAuthExpired', 'pages', 'parentParamMode', 'primevue',
364
- 'root', 'routeParamResolver', 'routePrefix', 'sectionOrder', 'security',
365
- 'sse', 'storageResolver', 'toast', 'warmup',
382
+ 'root', 'routeParamResolver', 'routePrefix', 'routeState', 'sectionOrder',
383
+ 'security', 'sse', 'storageResolver', 'toast', 'warmup',
366
384
  ])
367
385
 
368
386
  proto._validateKernelOptions = function (this: Self): void {
@@ -388,12 +406,13 @@ export function applyRegistryMethods(KernelClass: { prototype: Kernel }): void {
388
406
  this._warnUnknownKeys(
389
407
  'security.',
390
408
  security,
391
- new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider']),
409
+ new Set(['role_hierarchy', 'role_permissions', 'role_labels', 'entity_permissions', 'rolesProvider', 'grant']),
392
410
  {
393
411
  role_hierarchy: 'roles will not inherit from one another',
394
412
  role_permissions: 'no role will carry any permission',
395
413
  entity_permissions: 'per-entity permissions will not be generated',
396
414
  rolesProvider: "the current user's roles will not be resolved",
415
+ grant: "the application's judgement will not be consulted — the role matrix decides alone",
397
416
  }
398
417
  )
399
418
  }
@@ -10,8 +10,10 @@ import type { RoutesConfig } from './EventRouter'
10
10
  import type { EntityManager } from '../entity/EntityManager'
11
11
  import type { EntityAuthAdapter } from '../entity/auth/EntityAuthAdapter'
12
12
  import type { RoleProvider } from '../security/RolesProvider'
13
+ import type { GrantJudge } from '../entity/auth/SecurityChecker'
13
14
  import type { I18nOptions } from '../i18n/types'
14
15
  import type { ApiClientSource } from '../api/apiClient'
16
+ import type { RouteStatePersister } from '../routeState'
15
17
 
16
18
  /**
17
19
  * Auth adapter interface (app-level authentication)
@@ -97,7 +99,12 @@ export interface SecurityConfig {
97
99
  role_permissions?: Record<string, string[]>
98
100
  role_labels?: Record<string, string>
99
101
  entity_permissions?: boolean | string[]
100
- rolesProvider?: RoleProvider
102
+ rolesProvider?: RoleProvider /**
103
+ * Application-provided judgement (#2225), consulted before the role matrix.
104
+ * `true`/`false` is the verdict, anything else falls through; a judge that
105
+ * throws denies. See docs/security.md.
106
+ */
107
+ grant?: GrantJudge
101
108
  }
102
109
 
103
110
  /**
@@ -218,6 +225,21 @@ export interface KernelOptions {
218
225
  defaultEntityCacheTtlMs?: number
219
226
  eventRouter?: RoutesConfig
220
227
  sse?: SSEConfig
228
+ /**
229
+ * Where screens remember their route state by default (#2146): a slug the
230
+ * framework knows (`'url'`, `'local_storage'`, `'cookie'`, `'memory'`,
231
+ * `'none'`), or a persister instance.
232
+ *
233
+ * The app-wide floor. An entity overrides it for every screen showing that
234
+ * entity; a single list overrides it for itself. Unset means `'url'`, which
235
+ * is the only medium where what you are looking at is also what you can
236
+ * send someone.
237
+ *
238
+ * An unknown slug throws at list construction rather than falling back —
239
+ * a persistence choice that quietly does something else is the failure
240
+ * ADR 0011 forbids.
241
+ */
242
+ routeState?: string | RouteStatePersister
221
243
  debugBar?: DebugBarConfig
222
244
  notifications?: NotificationsConfig
223
245
  toast?: Record<string, unknown>
@@ -323,6 +323,9 @@ export function applyVueMethods(KernelClass: { prototype: Kernel }): void {
323
323
  }
324
324
 
325
325
  app.provide('qdadmHooks', this.hookRegistry)
326
+ // The app-wide route-state default (#2146). Provided even when unset, so
327
+ // a list can tell "nobody chose" from "somebody chose and it was null".
328
+ app.provide('qdadmRouteState', this.options.routeState ?? null)
326
329
  app.provide('qdadmDeferred', this.deferred)
327
330
  app.provide('qdadmLayoutComponents', this.layoutComponents)
328
331
  // Surface PrimeVue presence so host-rendered <QdadmRoot /> can
@@ -43,6 +43,12 @@ export const SIGNALS = {
43
43
  AUTH_LOGOUT: 'auth:logout',
44
44
  AUTH_EXPIRED: 'auth:expired', // Emitted on 401/403 API responses
45
45
 
46
+ // Security signals
47
+ // The application's permission answers changed — every screen re-evaluates
48
+ // (#2225). Emit it only when an answer actually differs: it remounts the app,
49
+ // which discards unsaved form state.
50
+ SECURITY_CHANGED: 'security:changed',
51
+
46
52
  // API error signals
47
53
  API_ERROR: 'api:error', // Emitted on any API error { status, message, url }
48
54
  } as const
@@ -41,7 +41,7 @@ export interface AuthEvent {
41
41
  * Auth entry types
42
42
  */
43
43
  export interface AuthEntry extends CollectorEntry {
44
- type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'adapter' | 'error'
44
+ type: 'status' | 'user' | 'impersonated' | 'token' | 'user-permissions' | 'hierarchy' | 'role-permissions' | 'permissions' | 'delegated-verdicts' | 'adapter' | 'error'
45
45
  label?: string
46
46
  message?: string
47
47
  data?: unknown
@@ -372,6 +372,20 @@ export class AuthCollector extends Collector<AuthEntry> {
372
372
  label: 'Permissions',
373
373
  data: permissions
374
374
  })
375
+
376
+ // With a delegated judge (#2225) the role matrix above is not the
377
+ // authority, and showing it alone would mislead. Show what the
378
+ // checker actually answers for every registered key — the judge's
379
+ // verdicts, with the matrix only where the judge abstains.
380
+ const judged = (this._securityChecker ?? authCtx?.security) as SecurityChecker | null | undefined
381
+ if (judged?.grant) {
382
+ entries.push({
383
+ timestamp: Date.now(),
384
+ type: 'delegated-verdicts',
385
+ label: 'Delegated Judgement (security.grant)',
386
+ data: Object.fromEntries(permissions.map((key: string) => [key, judged.isGranted(key)]))
387
+ })
388
+ }
375
389
  }
376
390
  } catch (e) {
377
391
  console.warn('[AuthCollector] Error accessing permissionRegistry:', e)
@@ -157,6 +157,7 @@ function getIcon(type: string): string {
157
157
  permissions: 'pi-list',
158
158
  hierarchy: 'pi-sitemap',
159
159
  'role-permissions': 'pi-lock',
160
+ 'delegated-verdicts': 'pi-server',
160
161
  adapter: 'pi-cog'
161
162
  }
162
163
  return icons[type] || 'pi-info-circle'
package/src/plugin.ts CHANGED
@@ -107,7 +107,7 @@ export interface QdadmPlugin {
107
107
  export function createQdadm(options: QdadmOptions): QdadmPlugin {
108
108
  return {
109
109
  install(app: App) {
110
- // Validation des contrats requis
110
+ // The contracts this plugin cannot run without.
111
111
  if (!options.entityFactory && !options.orchestrator && !options.managers) {
112
112
  throw new Error('[qdadm] entityFactory, orchestrator, or managers required')
113
113
  }
@@ -103,9 +103,20 @@ export function filterItems<T>(
103
103
  }
104
104
 
105
105
  /** Substring match on every string field (the adapters' full-text search). */
106
- export function searchItems<T>(items: T[], search?: string | null): T[] {
107
- if (!search || typeof search !== 'string' || !search.trim()) return items
108
- const query = search.toLowerCase().trim()
106
+ export function searchItems<T>(items: T[], search?: unknown): T[] {
107
+ // A NUMBER is a search term too (#2147). This used to require a string and
108
+ // return the list untouched for anything else — so searching an order id
109
+ // that had been through a numeric round trip filtered nothing, silently,
110
+ // which is the failure ADR 0011 forbids. Objects and booleans are still
111
+ // ignored: there is no sensible text for them.
112
+ const term =
113
+ typeof search === 'string'
114
+ ? search
115
+ : typeof search === 'number' || typeof search === 'bigint'
116
+ ? String(search)
117
+ : ''
118
+ if (!term.trim()) return items
119
+ const query = term.toLowerCase().trim()
109
120
  return items.filter((item) => {
110
121
  for (const value of Object.values(item as Record<string, unknown>)) {
111
122
  if (typeof value === 'string' && value.toLowerCase().includes(query)) {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * One state, several media (#2146).
3
+ *
4
+ * The seam's signature was chosen so composition would be expressible without
5
+ * changing it, and this is the case that needed it: qdadm's own default.
6
+ * Filters, search and the page belong in a link; rows-per-page does not — it
7
+ * is a comfort setting somebody chose once, and putting it in the URL would
8
+ * mean a link imposing the sender's row count on the reader.
9
+ *
10
+ * So the default routes by key: everything to the URL, `pageSize` to the
11
+ * cookie it has always lived in.
12
+ *
13
+ * ```js
14
+ * new CompositePersister({
15
+ * fallback: new UrlPersister({ router, route }),
16
+ * keys: { pageSize: { persister: new CookiePersister(), scope: 'app' } },
17
+ * })
18
+ * ```
19
+ *
20
+ * A ROUTED KEY MAY PIN ITS OWN SCOPE. `pageSize` is remembered once for the
21
+ * whole app rather than per list — that is what the year-long
22
+ * `qdadm_pageSize` cookie already did, and expressing it here makes it a
23
+ * visible decision instead of an accident of where the code happened to live.
24
+ */
25
+ import type { RouteStatePersister } from './RouteStatePersister'
26
+
27
+ export interface RoutedKey {
28
+ persister: RouteStatePersister
29
+ /**
30
+ * Remember this key under a fixed scope instead of the caller's.
31
+ *
32
+ * For state that is one setting across the whole app rather than one per
33
+ * screen. Leave it out and the key follows the scope like everything else.
34
+ */
35
+ scope?: string
36
+ }
37
+
38
+ export interface CompositePersisterOptions {
39
+ /** Where anything unrouted goes. */
40
+ fallback: RouteStatePersister
41
+ /** Keys that live somewhere else. */
42
+ keys?: Record<string, RoutedKey | RouteStatePersister>
43
+ }
44
+
45
+ export class CompositePersister implements RouteStatePersister {
46
+ readonly name: string
47
+
48
+ private readonly _fallback: RouteStatePersister
49
+ private readonly _keys: Map<string, RoutedKey>
50
+
51
+ constructor(options: CompositePersisterOptions) {
52
+ this._fallback = options.fallback
53
+ this._keys = new Map(
54
+ Object.entries(options.keys ?? {}).map(([key, routed]) => [
55
+ key,
56
+ 'persister' in routed ? routed : { persister: routed },
57
+ ])
58
+ )
59
+
60
+ const media = new Set([
61
+ this._fallback.name,
62
+ ...[...this._keys.values()].map((routed) => routed.persister.name),
63
+ ])
64
+ this.name = `composite(${[...media].join('+')})`
65
+ }
66
+
67
+ /**
68
+ * The fallback's state, with each routed key overlaid from its own medium.
69
+ *
70
+ * Order matters: a routed key wins even if the fallback happens to hold one
71
+ * under the same name — a stale `?pageSize=50` left in somebody's URL must
72
+ * not beat the cookie that owns the setting.
73
+ */
74
+ read(scope: string): Record<string, unknown> | null {
75
+ const base = this._fallback.read(scope)
76
+ const state: Record<string, unknown> = base ? { ...base } : {}
77
+ let found = base !== null
78
+
79
+ // Read each medium once, however many keys it owns.
80
+ for (const [medium, keys] of this._byMedium()) {
81
+ const stored = medium.persister.read(medium.scope ?? scope)
82
+ if (!stored) continue
83
+ for (const key of keys) {
84
+ if (stored[key] === undefined) continue
85
+ found = true
86
+ state[key] = stored[key]
87
+ }
88
+ }
89
+
90
+ return found ? state : null
91
+ }
92
+
93
+ /**
94
+ * Split the state by owner and write each medium once.
95
+ *
96
+ * A routed key ABSENT from `state` is still passed on as absent, so the
97
+ * empty-means-removed rule reaches the medium that owns it — otherwise
98
+ * clearing a filter would leave it behind in whichever store it lived in.
99
+ */
100
+ write(scope: string, state: Record<string, unknown>): void {
101
+ const forFallback: Record<string, unknown> = {}
102
+ for (const [key, value] of Object.entries(state)) {
103
+ if (this._keys.has(key)) continue
104
+ forFallback[key] = value
105
+ }
106
+ this._fallback.write(scope, forFallback)
107
+
108
+ for (const [medium, keys] of this._byMedium()) {
109
+ const slice: Record<string, unknown> = {}
110
+ for (const key of keys) slice[key] = state[key]
111
+ medium.persister.write(medium.scope ?? scope, slice)
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Clear the fallback, and any routed key that follows the caller's scope.
117
+ *
118
+ * A key PINNED to its own scope is deliberately left alone: `pageSize` is
119
+ * an app-wide setting somebody chose, and "clear this list's filters" is
120
+ * not a request to reset it. Clearing it here would make one list's clear
121
+ * button change every other list's row count.
122
+ */
123
+ clear(scope: string): void {
124
+ this._fallback.clear(scope)
125
+ for (const [medium, keys] of this._byMedium()) {
126
+ if (medium.scope !== undefined) continue
127
+ const slice: Record<string, unknown> = {}
128
+ for (const key of keys) slice[key] = null
129
+ medium.persister.write(scope, slice)
130
+ }
131
+ }
132
+
133
+ /** Group routed keys by the (persister, scope) pair that owns them. */
134
+ private _byMedium(): Map<RoutedKey, string[]> {
135
+ const grouped = new Map<string, { medium: RoutedKey; keys: string[] }>()
136
+ for (const [key, routed] of this._keys) {
137
+ const id = `${routed.persister.name} ${routed.scope ?? ''}`
138
+ const existing = grouped.get(id)
139
+ if (existing) existing.keys.push(key)
140
+ else grouped.set(id, { medium: routed, keys: [key] })
141
+ }
142
+ return new Map([...grouped.values()].map(({ medium, keys }) => [medium, keys]))
143
+ }
144
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Route state in a cookie (#2146).
3
+ *
4
+ * Here because qdadm already does this — page size lives in a year-long
5
+ * `qdadm_pageSize` cookie — and a seam that could not express what the
6
+ * framework already does would be a seam with a hole in it.
7
+ *
8
+ * **Every cookie is sent on every request to your origin.** That is the whole
9
+ * difference from web storage, and the reason this persister is shaped
10
+ * differently: ONE cookie per scope holding a JSON blob, not one per key. A
11
+ * list with eight filters would otherwise put eight cookies on every image,
12
+ * script and API call the page makes.
13
+ *
14
+ * Which also means: keep it small, and do not reach for this when
15
+ * `local_storage` would do. The case for a cookie is state the SERVER needs
16
+ * to see — a rows-per-page the backend renders against, say. If the server
17
+ * never reads it, it is paying wire cost for nothing.
18
+ */
19
+ import type { RouteStatePersister } from './RouteStatePersister'
20
+ import { underscoreScopeLocator, type RouteStateScopeLocator } from './keyLocator'
21
+
22
+ /** The document surface this needs, so a test needs no browser. */
23
+ export interface CookieJar {
24
+ cookie: string
25
+ }
26
+
27
+ export interface CookiePersisterOptions {
28
+ /** Defaults to `document`. */
29
+ jar?: CookieJar
30
+ /** How a scope becomes a cookie name. Defaults to `qdadm_offers`. */
31
+ scopeLocator?: RouteStateScopeLocator
32
+ /** Lifetime in days. Defaults to 365 — what `qdadm_pageSize` already used. */
33
+ maxAgeDays?: number
34
+ /** `SameSite` attribute. Defaults to `Lax`. */
35
+ sameSite?: 'Strict' | 'Lax' | 'None'
36
+ /** Path the cookie applies to. Defaults to `/`. */
37
+ path?: string
38
+ }
39
+
40
+ export class CookiePersister implements RouteStatePersister {
41
+ readonly name = 'cookie'
42
+
43
+ private readonly _jar: CookieJar
44
+ private readonly _scopes: RouteStateScopeLocator
45
+ private readonly _maxAgeDays: number
46
+ private readonly _sameSite: string
47
+ private readonly _path: string
48
+
49
+ constructor(options: CookiePersisterOptions = {}) {
50
+ this._jar = options.jar ?? (document as unknown as CookieJar)
51
+ this._scopes = options.scopeLocator ?? underscoreScopeLocator
52
+ this._maxAgeDays = options.maxAgeDays ?? 365
53
+ this._sameSite = options.sameSite ?? 'Lax'
54
+ this._path = options.path ?? '/'
55
+ }
56
+
57
+ read(scope: string): Record<string, unknown> | null {
58
+ const raw = this._readCookie(this._scopes.encode(scope))
59
+ if (raw === null) return null
60
+ try {
61
+ const parsed = JSON.parse(decodeURIComponent(raw))
62
+ return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null
63
+ } catch {
64
+ // Somebody else's cookie under our name, or a truncated one. Ignoring it
65
+ // is right here: unlike a single key, a corrupt blob tells us nothing
66
+ // partial worth keeping.
67
+ return null
68
+ }
69
+ }
70
+
71
+ write(scope: string, state: Record<string, unknown>): void {
72
+ // The blob is rewritten whole, so the empty-means-absent rule is applied
73
+ // by dropping keys rather than by removing entries.
74
+ const kept: Record<string, unknown> = {}
75
+ for (const [key, value] of Object.entries(state)) {
76
+ if (value === null || value === undefined || value === '') continue
77
+ kept[key] = value
78
+ }
79
+
80
+ // Nothing left to remember: take the cookie off the wire rather than
81
+ // shipping `{}` on every request for the next year.
82
+ if (!Object.keys(kept).length) {
83
+ this.clear(scope)
84
+ return
85
+ }
86
+
87
+ this._writeCookie(this._scopes.encode(scope), encodeURIComponent(JSON.stringify(kept)))
88
+ }
89
+
90
+ clear(scope: string): void {
91
+ const name = this._scopes.encode(scope)
92
+ this._jar.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=${this._path}; SameSite=${this._sameSite}`
93
+ }
94
+
95
+ private _readCookie(name: string): string | null {
96
+ for (const part of this._jar.cookie.split(';')) {
97
+ const separator = part.indexOf('=')
98
+ if (separator === -1) continue
99
+ if (part.slice(0, separator).trim() !== name) continue
100
+ return part.slice(separator + 1)
101
+ }
102
+ return null
103
+ }
104
+
105
+ private _writeCookie(name: string, value: string): void {
106
+ const expires = new Date(Date.now() + this._maxAgeDays * 864e5).toUTCString()
107
+ this._jar.cookie = `${name}=${value}; expires=${expires}; path=${this._path}; SameSite=${this._sameSite}`
108
+ }
109
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Route state in memory (#2146).
3
+ *
4
+ * Survives navigating away and back within the running app; does not survive
5
+ * a reload, and is invisible to a link. That makes it the right answer for
6
+ * state that would be noise anywhere else — a wizard's step, a panel a user
7
+ * opened once — and the right answer in tests, where a shared `localStorage`
8
+ * leaks one case into the next.
9
+ *
10
+ * THE STORE OUTLIVES THE PERSISTER ON PURPOSE. A list builds its persister on
11
+ * mount; if the state lived on the instance it would die on unmount, which is
12
+ * exactly the navigation this is supposed to survive. So the default store is
13
+ * module-level and shared. Pass your own `store` to get an isolated one —
14
+ * which is what a test usually wants.
15
+ */
16
+ import type { RouteStatePersister } from './RouteStatePersister'
17
+
18
+ export type RouteStateMemoryStore = Map<string, Record<string, unknown>>
19
+
20
+ /** The shared default. See the note above on why this is module-level. */
21
+ const defaultStore: RouteStateMemoryStore = new Map()
22
+
23
+ export interface MemoryPersisterOptions {
24
+ store?: RouteStateMemoryStore
25
+ }
26
+
27
+ export class MemoryPersister implements RouteStatePersister {
28
+ readonly name = 'memory'
29
+
30
+ private readonly _store: RouteStateMemoryStore
31
+
32
+ constructor(options: MemoryPersisterOptions = {}) {
33
+ this._store = options.store ?? defaultStore
34
+ }
35
+
36
+ read(scope: string): Record<string, unknown> | null {
37
+ const stored = this._store.get(scope)
38
+ // Copied out, so a caller mutating what it read cannot rewrite history.
39
+ return stored ? { ...stored } : null
40
+ }
41
+
42
+ write(scope: string, state: Record<string, unknown>): void {
43
+ const kept: Record<string, unknown> = {}
44
+ for (const [key, value] of Object.entries(state)) {
45
+ if (value === null || value === undefined || value === '') continue
46
+ kept[key] = value
47
+ }
48
+ if (!Object.keys(kept).length) this._store.delete(scope)
49
+ else this._store.set(scope, kept)
50
+ }
51
+
52
+ clear(scope: string): void {
53
+ this._store.delete(scope)
54
+ }
55
+ }
56
+
57
+ /** Empty the shared store — for tests that use the default. */
58
+ export function clearRouteStateMemory(): void {
59
+ defaultStore.clear()
60
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Route state remembered nowhere (#2146).
3
+ *
4
+ * The way to say "this screen forgets" and have it read as a decision. An
5
+ * embedded list inside a detail view, a picker in a dialog, a preview — none
6
+ * of them should put anything in the address bar, and none of them should
7
+ * leave anything behind.
8
+ *
9
+ * A no-op object looks like the thing
10
+ * [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) forbids, and the
11
+ * difference is the whole point: this one is NAMED. Nothing falls back to it,
12
+ * nothing resolves to it by accident, and an unknown slug throws rather than
13
+ * landing here. It does nothing because somebody wrote `routeState: 'none'`,
14
+ * and that sentence is in the app's source where the next reader will find
15
+ * it.
16
+ */
17
+ import type { RouteStatePersister } from './RouteStatePersister'
18
+
19
+ export class NullPersister implements RouteStatePersister {
20
+ readonly name = 'none'
21
+
22
+ read(): null {
23
+ return null
24
+ }
25
+
26
+ write(): void {
27
+ /* deliberately nothing — see the note above */
28
+ }
29
+
30
+ clear(): void {
31
+ /* deliberately nothing */
32
+ }
33
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Where a route's state is remembered (#2146).
3
+ *
4
+ * Not "list state": the list is one CONSUMER of routing state, not the
5
+ * subject. A detail page's active tab, a dashboard's date range and a child
6
+ * page's own state are the same problem, and each would invent its own answer
7
+ * if this seam did not exist — which is exactly how qdadm ended up with five
8
+ * unrelated persistence sites, one of them a year-long cookie.
9
+ *
10
+ * The state is opaque on purpose. Every consumer has its own shape, and a
11
+ * fixed one here would only fit whichever consumer was written first.
12
+ *
13
+ * NOT for chrome preferences. A collapsed sidebar belongs to the person, not
14
+ * to the route: sending someone a link must not fold their menu. The line is
15
+ * *what I am looking at* versus *how I like my interface*.
16
+ */
17
+ export interface RouteStatePersister {
18
+ /** Everything remembered under this scope, or null when nothing is. */
19
+ read(scope: string): Record<string, unknown> | null
20
+ /**
21
+ * Remember this state under this scope.
22
+ *
23
+ * A key whose value is `null`, `undefined` or `''` is REMOVED rather than
24
+ * stored empty — so a pristine screen leaves no trace, and a shared link
25
+ * carries only what was actually chosen.
26
+ */
27
+ write(scope: string, state: Record<string, unknown>): void
28
+ clear(scope: string): void
29
+ /** For diagnostics: which persister answered. */
30
+ readonly name: string
31
+ }
32
+
33
+ /**
34
+ * How a consumer names itself.
35
+ *
36
+ * The scope is what makes two lists on one route stop fighting over `page` —
37
+ * the collision `page-compositions.md` currently tells people to avoid by
38
+ * switching persistence off entirely.
39
+ */
40
+ export type RouteStateScope = string
41
+
42
+ /** A handler as the bootstrap declares it: a slug, or an instance. */
43
+ export type RouteStatePersisterSpec = string | RouteStatePersister
44
+
45
+ /** Anything that can build a persister from a slug. */
46
+ export type RouteStatePersisterFactory = (slug: string) => RouteStatePersister | null
47
+
48
+ /** An instance, duck-typed — the same test the storage factory applies. */
49
+ export function isRouteStatePersister(value: unknown): value is RouteStatePersister {
50
+ if (!value || typeof value !== 'object') return false
51
+ const candidate = value as RouteStatePersister
52
+ return (
53
+ typeof candidate.read === 'function' &&
54
+ typeof candidate.write === 'function' &&
55
+ typeof candidate.clear === 'function'
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Turn a spec into a persister.
61
+ *
62
+ * An unknown slug THROWS. It does not fall back to the URL: a persistence
63
+ * choice that silently does something else is the exact failure
64
+ * [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) exists to forbid, and
65
+ * the one this seam is cleaning up after.
66
+ */
67
+ export function resolveRouteStatePersister(
68
+ spec: RouteStatePersisterSpec,
69
+ factory: RouteStatePersisterFactory
70
+ ): RouteStatePersister {
71
+ if (isRouteStatePersister(spec)) return spec
72
+
73
+ if (typeof spec === 'string') {
74
+ const built = factory(spec)
75
+ if (built) return built
76
+ throw new Error(
77
+ `[qdadm] Unknown route-state persister "${spec}". ` +
78
+ `Pass a known slug, or an object with read/write/clear.`
79
+ )
80
+ }
81
+
82
+ throw new Error(
83
+ `[qdadm] Invalid route-state persister: ${typeof spec}. ` +
84
+ `Expected a slug or an object with read/write/clear.`
85
+ )
86
+ }