@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
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Route state in the query string — the default (#2146).
3
+ *
4
+ * The default because it is the only medium where "what I am looking at" is
5
+ * also "what I can send you". A list URL that does not carry its page is a
6
+ * link that shows the recipient something else.
7
+ *
8
+ * CLEAN AROUND THE EXISTING ROUTER: this goes through the `router` the app
9
+ * already has, with `replace`, and touches no route declaration. Paging is
10
+ * not a history step of its own — but the URL must carry the state at the
11
+ * moment the user leaves, so that coming back restores it.
12
+ *
13
+ * Keys are namespaced by scope (`offers.page=2`), which is what stops two
14
+ * lists on one route from fighting over `page` — the collision
15
+ * `page-compositions.md` currently tells people to avoid by switching
16
+ * persistence off.
17
+ */
18
+ import type { RouteStatePersister } from './RouteStatePersister'
19
+ import { dottedKeyLocator, type RouteStateKeyLocator } from './keyLocator'
20
+
21
+ /** The bits of vue-router this needs, so tests need no real router. */
22
+ /** One query value, shaped as vue-router accepts them. */
23
+ export type UrlPersisterQueryValue = string | number | null | undefined
24
+
25
+ export interface UrlPersisterRouter {
26
+ // Values are narrowed to what vue-router's own query type accepts, so the
27
+ // real router satisfies this without a cast — and so does a two-line fake
28
+ // in a test.
29
+ replace(to: { query: Record<string, UrlPersisterQueryValue> }): unknown
30
+ }
31
+ export interface UrlPersisterRoute {
32
+ query: Record<string, unknown>
33
+ }
34
+
35
+ export interface UrlPersisterOptions {
36
+ router: UrlPersisterRouter
37
+ /** Live route — read at call time, never captured. */
38
+ route: UrlPersisterRoute
39
+ /**
40
+ * How a (scope, key) pair becomes a query parameter. Defaults to
41
+ * `offers.page`; override to impose your own scheme.
42
+ */
43
+ keyLocator?: RouteStateKeyLocator
44
+ /**
45
+ * Also read UNPREFIXED keys when the scope has none of its own.
46
+ *
47
+ * Transitional, and on by default: qdadm wrote `?page=2` flat before this
48
+ * seam existed, so links people bookmarked or pasted into tickets carry
49
+ * that shape. Without this they would silently resolve to page 1 — the
50
+ * reader would see a wrong screen and no error.
51
+ *
52
+ * Writes are always prefixed, so a link refreshes itself the first time
53
+ * anyone touches the list. Set false to drop the compatibility, and expect
54
+ * to remove it here in a later version.
55
+ */
56
+ readFlatFallback?: boolean
57
+ }
58
+
59
+ export class UrlPersister implements RouteStatePersister {
60
+ readonly name = 'url'
61
+
62
+ private readonly _router: UrlPersisterRouter
63
+ private readonly _route: UrlPersisterRoute
64
+ private readonly _keys: RouteStateKeyLocator
65
+ private readonly _readFlatFallback: boolean
66
+
67
+ constructor(options: UrlPersisterOptions) {
68
+ this._router = options.router
69
+ this._route = options.route
70
+ this._keys = options.keyLocator ?? dottedKeyLocator
71
+ this._readFlatFallback = options.readFlatFallback ?? true
72
+ }
73
+
74
+ read(scope: string): Record<string, unknown> | null {
75
+ const state: Record<string, unknown> = {}
76
+ let found = false
77
+
78
+ for (const [storedKey, raw] of Object.entries(this._route.query)) {
79
+ const key = this._keys.decode(scope, storedKey)
80
+ if (key === null) continue
81
+ found = true
82
+ state[key] = decodeValue(raw)
83
+ }
84
+
85
+ if (found || !this._readFlatFallback) return found ? state : null
86
+
87
+ // Nothing under this scope: possibly a link written before the seam
88
+ // existed. Read it flat rather than showing the wrong screen without a
89
+ // word — but ONLY keys nobody has scoped. `jobs.page` belongs to the jobs
90
+ // list, and handing it to this one would be worse than ignoring it.
91
+ const flat: Record<string, unknown> = {}
92
+ for (const [storedKey, raw] of Object.entries(this._route.query)) {
93
+ if (this._keys.looksScoped(storedKey)) continue
94
+ flat[storedKey] = decodeValue(raw)
95
+ }
96
+ return Object.keys(flat).length ? flat : null
97
+ }
98
+
99
+ write(scope: string, state: Record<string, unknown>): void {
100
+ const query: Record<string, UrlPersisterQueryValue> = { ...toQuery(this._route.query) }
101
+
102
+ for (const [key, value] of Object.entries(state)) {
103
+ const param = this._keys.encode(scope, key)
104
+ // A flat key left by an older link would shadow the prefixed one on the
105
+ // next read, so writing a scope also retires its legacy twin.
106
+ if (this._readFlatFallback && param !== key) delete query[key]
107
+ // Absent, not stored empty: a pristine screen leaves a clean link.
108
+ if (value === null || value === undefined || value === '') delete query[param]
109
+ else query[param] = encodeValue(value)
110
+ }
111
+
112
+ this._router.replace({ query })
113
+ }
114
+
115
+ clear(scope: string): void {
116
+ const query: Record<string, UrlPersisterQueryValue> = { ...toQuery(this._route.query) }
117
+ let scoped = false
118
+ for (const storedKey of Object.keys(query)) {
119
+ if (this._keys.decode(scope, storedKey) === null) continue
120
+ scoped = true
121
+ delete query[storedKey]
122
+ }
123
+
124
+ // Clear what read() would have READ, or the URL starts lying: arrive on a
125
+ // pre-scope `?page=4` link, clear the filters, and the list shows page 1
126
+ // under an address still claiming page 4 — and a refresh would restore
127
+ // the state that was just cleared.
128
+ if (!scoped && this._readFlatFallback) {
129
+ for (const storedKey of Object.keys(query)) {
130
+ if (!this._keys.looksScoped(storedKey)) delete query[storedKey]
131
+ }
132
+ }
133
+
134
+ this._router.replace({ query })
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A value on its way into a URL.
140
+ *
141
+ * Primitives stay readable — `page=2`, not `page=%222%22` — because a query
142
+ * string people paste into tickets is worth more than perfect round-tripping.
143
+ * Objects and arrays are JSON, which `decodeValue` recognises by its opening
144
+ * brace.
145
+ */
146
+ function encodeValue(value: unknown): string {
147
+ if (typeof value === 'object') return JSON.stringify(value)
148
+ return String(value)
149
+ }
150
+
151
+ /**
152
+ * And on its way back out.
153
+ *
154
+ * The coercion is deliberately the one `restoreFilters` already applied to
155
+ * query params, so behaviour does not shift underneath anyone: `'true'`
156
+ * becomes a boolean, a numeric string becomes a number.
157
+ *
158
+ * It follows that a filter whose value is the STRING `'42'` comes back as the
159
+ * NUMBER 42. That was already true before this seam existed; it is written
160
+ * down here rather than left to be rediscovered.
161
+ *
162
+ * BUT ONLY WHEN NOTHING IS LOST (#2147). `'007'` is not the number 7 and
163
+ * `'9876667194123456789'` is not the float it would become — those are text
164
+ * that merely looks numeric, and coercing them hands the app a value the user
165
+ * never typed. So the coercion happens only when it round-trips exactly, and
166
+ * anything else stays the string it was. Reference numbers, invoice ids and
167
+ * phone numbers all live in that gap, and a search box is full of them.
168
+ */
169
+ function decodeValue(raw: unknown): unknown {
170
+ if (Array.isArray(raw)) return raw.map(decodeValue)
171
+ if (typeof raw !== 'string') return raw
172
+
173
+ if (raw === 'true') return true
174
+ if (raw === 'false') return false
175
+ if (raw === 'null') return null
176
+ if (raw !== '' && !Number.isNaN(Number(raw))) {
177
+ const asNumber = Number(raw)
178
+ // The round-trip test IS the rule: if the number cannot be written back
179
+ // as the exact same text, the text was never a number.
180
+ if (String(asNumber) === raw) return asNumber
181
+ return raw
182
+ }
183
+
184
+ const first = raw[0]
185
+ if (first === '{' || first === '[') {
186
+ try {
187
+ return JSON.parse(raw)
188
+ } catch {
189
+ // Not JSON after all — a filter value that merely starts with a brace.
190
+ return raw
191
+ }
192
+ }
193
+
194
+ return raw
195
+ }
196
+
197
+ /** The live route's query, narrowed to what we may hand back to the router. */
198
+ function toQuery(query: Record<string, unknown>): Record<string, UrlPersisterQueryValue> {
199
+ const out: Record<string, UrlPersisterQueryValue> = {}
200
+ for (const [key, value] of Object.entries(query)) {
201
+ if (value === null || value === undefined) continue
202
+ // An array param is one vue-router supports and we do not produce; keep
203
+ // its first value rather than dropping the key entirely.
204
+ out[key] = Array.isArray(value) ? (value[0] as UrlPersisterQueryValue) : (String(value) as string)
205
+ }
206
+ return out
207
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Route state in `localStorage` or `sessionStorage` (#2146).
3
+ *
4
+ * The medium you pick when the state is worth keeping but not worth putting
5
+ * in a link: a back-office list somebody returns to twenty times a day, whose
6
+ * URL nobody ever sends anyone.
7
+ *
8
+ * ONE ENTRY PER KEY, namespaced — `qdadm:offers:page`. Not one JSON blob per
9
+ * scope, because web storage is shared with everything else the app keeps
10
+ * there, and per-key namespacing is what lets this persister pick out its own
11
+ * entries without owning the medium. It also means devtools shows you
12
+ * readable state instead of a blob to decode by hand.
13
+ *
14
+ * VALUES ARE JSON, so they come back exactly as they went in. That differs
15
+ * from `UrlPersister`, which coerces (`'42'` reads back as the number 42)
16
+ * because a query string is meant to be read by people. A list moved from one
17
+ * to the other can therefore see a filter value change TYPE — worth knowing
18
+ * before you switch a screen that compares with `===`.
19
+ */
20
+ import type { RouteStatePersister } from './RouteStatePersister'
21
+ import { namespacedKeyLocator, type RouteStateKeyLocator } from './keyLocator'
22
+
23
+ /** The bit of the Storage API this needs, so a test needs no browser. */
24
+ export interface RouteStateStorage {
25
+ getItem(key: string): string | null
26
+ setItem(key: string, value: string): void
27
+ removeItem(key: string): void
28
+ readonly length: number
29
+ key(index: number): string | null
30
+ }
31
+
32
+ export interface WebStoragePersisterOptions {
33
+ /** `window.localStorage` or `window.sessionStorage`, or a stand-in. */
34
+ storage: RouteStateStorage
35
+ /** For diagnostics — which medium answered. */
36
+ name?: string
37
+ /** How a (scope, key) pair becomes a storage key. Defaults to `qdadm:scope:key`. */
38
+ keyLocator?: RouteStateKeyLocator
39
+ }
40
+
41
+ export class WebStoragePersister implements RouteStatePersister {
42
+ readonly name: string
43
+
44
+ private readonly _storage: RouteStateStorage
45
+ private readonly _keys: RouteStateKeyLocator
46
+ private _warned = false
47
+
48
+ constructor(options: WebStoragePersisterOptions) {
49
+ this._storage = options.storage
50
+ this._keys = options.keyLocator ?? namespacedKeyLocator
51
+ this.name = options.name ?? 'web-storage'
52
+ }
53
+
54
+ /**
55
+ * Web storage throws rather than returning null in more situations than
56
+ * people expect: Safari's private mode, a browser set to block site data,
57
+ * a quota that filled up.
58
+ *
59
+ * We warn ONCE and carry on with the screen working. That is not the silent
60
+ * no-op [ADR 0011](../../docs/adr/0011-no-silent-no-ops.md) forbids — the
61
+ * failure is announced, and it is the environment's, not a mistake in the
62
+ * app's configuration. Taking the list down because a page number could not
63
+ * be remembered would be the worse trade.
64
+ */
65
+ private _guard<T>(operation: () => T, fallback: T): T {
66
+ try {
67
+ return operation()
68
+ } catch (error) {
69
+ if (!this._warned) {
70
+ this._warned = true
71
+ console.warn(
72
+ `[qdadm] Route-state persister "${this.name}" cannot reach its storage; ` +
73
+ `this screen will not remember its state. ` +
74
+ `Usually private browsing, blocked site data, or a full quota.`,
75
+ error
76
+ )
77
+ }
78
+ return fallback
79
+ }
80
+ }
81
+
82
+ read(scope: string): Record<string, unknown> | null {
83
+ return this._guard(() => {
84
+ const state: Record<string, unknown> = {}
85
+ let found = false
86
+
87
+ for (const storedKey of this._ownKeys(scope)) {
88
+ const key = this._keys.decode(scope, storedKey)
89
+ if (key === null) continue
90
+ const raw = this._storage.getItem(storedKey)
91
+ if (raw === null) continue
92
+ found = true
93
+ state[key] = decode(raw)
94
+ }
95
+
96
+ return found ? state : null
97
+ }, null)
98
+ }
99
+
100
+ write(scope: string, state: Record<string, unknown>): void {
101
+ this._guard(() => {
102
+ for (const [key, value] of Object.entries(state)) {
103
+ const storedKey = this._keys.encode(scope, key)
104
+ // Absent, not stored empty — the same rule every persister follows,
105
+ // so a pristine screen leaves no trace whichever medium it uses.
106
+ if (value === null || value === undefined || value === '') {
107
+ this._storage.removeItem(storedKey)
108
+ } else {
109
+ this._storage.setItem(storedKey, JSON.stringify(value))
110
+ }
111
+ }
112
+ }, undefined)
113
+ }
114
+
115
+ clear(scope: string): void {
116
+ this._guard(() => {
117
+ for (const storedKey of this._ownKeys(scope)) {
118
+ if (this._keys.decode(scope, storedKey) !== null) this._storage.removeItem(storedKey)
119
+ }
120
+ }, undefined)
121
+ }
122
+
123
+ /**
124
+ * Snapshot the key names before touching anything.
125
+ *
126
+ * `Storage.key(i)` walks a live collection: removing as you iterate
127
+ * renumbers what is left and skips entries. Reading the names first is the
128
+ * difference between clearing a scope and clearing every other one of it.
129
+ */
130
+ private _ownKeys(scope: string): string[] {
131
+ const keys: string[] = []
132
+ for (let i = 0; i < this._storage.length; i++) {
133
+ const key = this._storage.key(i)
134
+ if (key !== null && this._keys.decode(scope, key) !== null) keys.push(key)
135
+ }
136
+ return keys
137
+ }
138
+ }
139
+
140
+ function decode(raw: string): unknown {
141
+ try {
142
+ return JSON.parse(raw)
143
+ } catch {
144
+ // Written by something other than us, or truncated. Hand back the string
145
+ // rather than dropping the key: a readable wrong value beats a silent
146
+ // absent one.
147
+ return raw
148
+ }
149
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What a list uses when nobody chose (#2146).
3
+ *
4
+ * Not simply `UrlPersister`, because not all list state wants the same
5
+ * medium. Filters, search and the page belong in a link — that is the whole
6
+ * argument for the URL being the default. Rows-per-page does not: it is a
7
+ * comfort setting somebody picked once, and a link carrying it would impose
8
+ * the sender's row count on the reader.
9
+ *
10
+ * So the default is a composition, and it says out loud what qdadm was
11
+ * already doing by accident:
12
+ *
13
+ * | State | Medium | Scope |
14
+ * |---|---|---|
15
+ * | filters, search, page | query string | the list |
16
+ * | `pageSize` | cookie, a year | the app |
17
+ * | `sort` | `sessionStorage` | the list |
18
+ *
19
+ * **Sort stays in the session** — decided on #2146 rather than moved to the
20
+ * URL. Moving it would make a list link carry its ordering, which is a real
21
+ * gain; it would also mean the sort no longer survives opening a clean
22
+ * `/offers`, which it has always done. Between a gain nobody asked for and a
23
+ * loss every existing user would feel, the seam is worth having without the
24
+ * behaviour change: this expresses today's arrangement rather than replacing
25
+ * it.
26
+ */
27
+ import { CompositePersister } from './CompositePersister'
28
+ import { CookiePersister, type CookieJar } from './CookiePersister'
29
+ import { WebStoragePersister, type RouteStateStorage } from './WebStoragePersister'
30
+ import { UrlPersister, type UrlPersisterRoute, type UrlPersisterRouter } from './UrlPersister'
31
+ import type { RouteStatePersister } from './RouteStatePersister'
32
+
33
+ export interface DefaultRouteStateOptions {
34
+ router: UrlPersisterRouter
35
+ route: UrlPersisterRoute
36
+ /** Stand-in for `document`, for a test or an environment without one. */
37
+ cookieJar?: CookieJar
38
+ /** Stand-in for `sessionStorage`, same reason. */
39
+ sessionStorage?: RouteStateStorage
40
+ }
41
+
42
+ /** The scope `pageSize` is remembered under: one setting for the whole app. */
43
+ export const APP_ROUTE_STATE_SCOPE = 'app'
44
+
45
+ export function createDefaultRouteStatePersister(
46
+ options: DefaultRouteStateOptions
47
+ ): RouteStatePersister {
48
+ const session = new WebStoragePersister({
49
+ storage: options.sessionStorage ?? globalThis.sessionStorage,
50
+ name: 'session_storage',
51
+ })
52
+
53
+ return new CompositePersister({
54
+ fallback: new UrlPersister(options),
55
+ keys: {
56
+ pageSize: {
57
+ persister: new CookiePersister(options.cookieJar ? { jar: options.cookieJar } : {}),
58
+ scope: APP_ROUTE_STATE_SCOPE,
59
+ },
60
+ // Per-list, unlike pageSize: an ordering belongs to the screen you were
61
+ // looking at, not to the whole app.
62
+ sort: { persister: session },
63
+ sortOrder: { persister: session },
64
+ },
65
+ })
66
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Slugs to persisters (#2146).
3
+ *
4
+ * Mirrors what `entity/storage/factory.ts` does for storages — an instance, or
5
+ * a name the framework knows — so a consumer meets one convention rather than
6
+ * two.
7
+ *
8
+ * | Slug | Medium | Reach for it when |
9
+ * |---|---|---|
10
+ * | `default` | query string + cookie | what a list uses when nobody chose |
11
+ * | `url` | query string | EVERYTHING in the link, row count included |
12
+ * | `local_storage` | `localStorage` | worth keeping, not worth linking |
13
+ * | `session_storage` | `sessionStorage` | worth keeping until the tab closes |
14
+ * | `cookie` | one cookie per scope | the SERVER needs to read it |
15
+ * | `memory` | a shared Map | survives navigation, not a reload |
16
+ * | `none` | nowhere | this screen forgets, and says so |
17
+ */
18
+ import type { RouteStatePersister, RouteStatePersisterFactory } from './RouteStatePersister'
19
+ import { UrlPersister, type UrlPersisterRoute, type UrlPersisterRouter } from './UrlPersister'
20
+ import { WebStoragePersister, type RouteStateStorage } from './WebStoragePersister'
21
+ import { CookiePersister, type CookieJar } from './CookiePersister'
22
+ import { MemoryPersister } from './MemoryPersister'
23
+ import { NullPersister } from './NullPersister'
24
+ import { createDefaultRouteStatePersister } from './defaultPersister'
25
+
26
+ export interface RouteStateFactoryContext {
27
+ router: UrlPersisterRouter
28
+ route: UrlPersisterRoute
29
+ /**
30
+ * Overrides for the browser globals the storage-backed slugs reach for.
31
+ *
32
+ * Only needed off a browser — SSR, or a test that would rather not share
33
+ * one `localStorage` between cases. Left alone, each slug uses the real
34
+ * thing, resolved AT BUILD TIME rather than captured here, so a factory
35
+ * created before the app mounts still works.
36
+ */
37
+ localStorage?: RouteStateStorage
38
+ sessionStorage?: RouteStateStorage
39
+ cookieJar?: CookieJar
40
+ }
41
+
42
+ /**
43
+ * The built-in factory.
44
+ *
45
+ * Returns null for a slug it does not know — the CALLER throws, with the name
46
+ * in the message. Returning a default here would make an unknown slug behave
47
+ * like `url` and say nothing, which is the failure ADR 0011 forbids and the
48
+ * one this seam is cleaning up after.
49
+ */
50
+ export function createRouteStatePersisterFactory(
51
+ context: RouteStateFactoryContext
52
+ ): RouteStatePersisterFactory {
53
+ return (slug: string): RouteStatePersister | null => {
54
+ switch (slug) {
55
+ // Nameable, so "the default, stated out loud" has a way to be said.
56
+ // It is NOT `url`: the default routes `pageSize` to a cookie, because a
57
+ // link should not impose the sender's row count on its reader.
58
+ case 'default':
59
+ return createDefaultRouteStatePersister({
60
+ router: context.router,
61
+ route: context.route,
62
+ cookieJar: context.cookieJar,
63
+ })
64
+ case 'url':
65
+ return new UrlPersister(context)
66
+ case 'local_storage':
67
+ return new WebStoragePersister({
68
+ storage: context.localStorage ?? globalThis.localStorage,
69
+ name: 'local_storage',
70
+ })
71
+ case 'session_storage':
72
+ return new WebStoragePersister({
73
+ storage: context.sessionStorage ?? globalThis.sessionStorage,
74
+ name: 'session_storage',
75
+ })
76
+ case 'cookie':
77
+ return new CookiePersister(context.cookieJar ? { jar: context.cookieJar } : {})
78
+ case 'memory':
79
+ return new MemoryPersister()
80
+ case 'none':
81
+ return new NullPersister()
82
+ default:
83
+ return null
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Route-state persistence (#2146).
3
+ *
4
+ * The seam that decides WHERE a route remembers what it is showing. The list
5
+ * is one consumer; a detail page's active tab and a dashboard's date range
6
+ * are the same problem.
7
+ */
8
+ export type {
9
+ RouteStatePersister,
10
+ RouteStateScope,
11
+ RouteStatePersisterSpec,
12
+ RouteStatePersisterFactory,
13
+ } from './RouteStatePersister'
14
+ export { isRouteStatePersister, resolveRouteStatePersister } from './RouteStatePersister'
15
+ export { UrlPersister } from './UrlPersister'
16
+ export type { UrlPersisterOptions, UrlPersisterRouter, UrlPersisterRoute } from './UrlPersister'
17
+ export { createRouteStatePersisterFactory } from './factory'
18
+ export { dottedKeyLocator, namespacedKeyLocator } from './keyLocator'
19
+ export type { RouteStateKeyLocator } from './keyLocator'
20
+ export { WebStoragePersister } from './WebStoragePersister'
21
+ export type { WebStoragePersisterOptions, RouteStateStorage } from './WebStoragePersister'
22
+ export { CookiePersister } from './CookiePersister'
23
+ export type { CookiePersisterOptions, CookieJar } from './CookiePersister'
24
+ export { MemoryPersister, clearRouteStateMemory } from './MemoryPersister'
25
+ export type { MemoryPersisterOptions, RouteStateMemoryStore } from './MemoryPersister'
26
+ export { NullPersister } from './NullPersister'
27
+ export { underscoreScopeLocator } from './keyLocator'
28
+ export type { RouteStateScopeLocator } from './keyLocator'
29
+ export type { RouteStateFactoryContext } from './factory'
30
+ export { CompositePersister } from './CompositePersister'
31
+ export type { CompositePersisterOptions, RoutedKey } from './CompositePersister'
32
+ export { createDefaultRouteStatePersister } from './defaultPersister'
@@ -0,0 +1,84 @@
1
+ /**
2
+ * How a (scope, key) pair becomes a stored key — and back (#2146).
3
+ *
4
+ * Hard-coding `offers.page` inside the URL persister would make the naming
5
+ * scheme a property of qdadm rather than of the app. An app that wants
6
+ * `o_page`, or a single packed parameter, or a `[]`-suffixed convention its
7
+ * backend already understands, must be able to say so — the same way it can
8
+ * already override how a storage is resolved.
9
+ *
10
+ * ONE PER PERSISTER TYPE, because the right scheme differs by medium: dots
11
+ * read well in a query string, colons are the convention in `localStorage`,
12
+ * and a cookie has its own constraints entirely.
13
+ *
14
+ * `decode` returns null for a key that is not ours. That is what lets a
15
+ * persister pick its own entries out of a medium it shares with everything
16
+ * else — the whole reason the scope exists.
17
+ */
18
+ export interface RouteStateKeyLocator {
19
+ encode(scope: string, key: string): string
20
+ /** The bare key, or null when this stored key belongs to someone else. */
21
+ decode(scope: string, storedKey: string): string | null
22
+ /**
23
+ * Whether this key carries a scope at all — ANY scope, not just one.
24
+ *
25
+ * A legacy-compatibility reader needs this: `offers.page` and `jobs.page`
26
+ * both belong to somebody, and treating them as unscoped leftovers would
27
+ * hand one list the other's state. Only a key that is scoped by nobody can
28
+ * be read as pre-scope state.
29
+ */
30
+ looksScoped(storedKey: string): boolean
31
+ readonly name: string
32
+ }
33
+
34
+ /** `offers` + `page` → `offers.page`. The default in a query string. */
35
+ export const dottedKeyLocator: RouteStateKeyLocator = {
36
+ name: 'dotted',
37
+ encode: (scope, key) => (scope ? `${scope}.${key}` : key),
38
+ decode: (scope, storedKey) => {
39
+ if (!scope) return storedKey
40
+ const prefix = `${scope}.`
41
+ return storedKey.startsWith(prefix) ? storedKey.slice(prefix.length) : null
42
+ },
43
+ looksScoped: (storedKey) => storedKey.includes('.'),
44
+ }
45
+
46
+ /** `offers` + `page` → `qdadm:offers:page`. The default in web storage. */
47
+ export const namespacedKeyLocator: RouteStateKeyLocator = {
48
+ name: 'namespaced',
49
+ encode: (scope, key) => `qdadm:${scope}:${key}`,
50
+ decode: (scope, storedKey) => {
51
+ const prefix = `qdadm:${scope}:`
52
+ return storedKey.startsWith(prefix) ? storedKey.slice(prefix.length) : null
53
+ },
54
+ looksScoped: (storedKey) => storedKey.startsWith('qdadm:'),
55
+ }
56
+
57
+ /**
58
+ * How a SCOPE becomes a stored name, for media that keep one entry per scope.
59
+ *
60
+ * The cookie jar is the case: a cookie rides on every request, so a scope
61
+ * gets one cookie holding a blob rather than one per key. There is no key
62
+ * half to encode, which is why this is its own small interface rather than
63
+ * `RouteStateKeyLocator` called with a key nobody uses.
64
+ */
65
+ export interface RouteStateScopeLocator {
66
+ encode(scope: string): string
67
+ /** The bare scope, or null when this stored name belongs to someone else. */
68
+ decode(storedName: string): string | null
69
+ readonly name: string
70
+ }
71
+
72
+ /**
73
+ * `offers` → `qdadm_offers`. The default in a cookie jar.
74
+ *
75
+ * Underscores rather than the colons web storage uses: a cookie NAME is an
76
+ * RFC 6265 token, and `:` is a separator there. `qdadm:offers` is not a
77
+ * cookie name a server is obliged to accept.
78
+ */
79
+ export const underscoreScopeLocator: RouteStateScopeLocator = {
80
+ name: 'underscore',
81
+ encode: (scope) => `qdadm_${scope}`,
82
+ decode: (storedName) =>
83
+ storedName.startsWith('qdadm_') ? storedName.slice('qdadm_'.length) : null,
84
+ }
@@ -70,6 +70,14 @@ export interface RoleProviderContext {
70
70
  orchestrator?: {
71
71
  get: (name: string) => unknown
72
72
  }
73
+ /**
74
+ * The live permission registry (#2225) — a reference, not a snapshot:
75
+ * `install` runs before modules register their entities, so read
76
+ * `getKeys()` later, when you actually fetch.
77
+ */
78
+ permissionRegistry?: {
79
+ getKeys: () => string[]
80
+ }
73
81
  [key: string]: unknown
74
82
  }
75
83