@quazardous/qdadm 2.19.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }