@quazardous/qdadm 2.18.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.
- package/package.json +5 -7
- package/src/components/editors/VanillaJsonEditor.vue +14 -4
- package/src/components/lists/ListPage.vue +7 -0
- package/src/composables/index.ts +3 -0
- package/src/composables/useLayoutResolver.ts +10 -0
- package/src/composables/useListPage.filters.ts +137 -67
- package/src/composables/useListPage.ts +148 -26
- package/src/composables/useListPage.types.ts +74 -5
- package/src/composables/useListPage.utils.ts +58 -0
- package/src/entity/EntityManager.interface.ts +11 -0
- package/src/entity/EntityManager.ts +5 -0
- package/src/entity/EntityManager.types.ts +11 -0
- package/src/entity/storage/ApiStorage.ts +16 -2
- package/src/entity/storage/LocalStorage.ts +1 -1
- package/src/entity/storage/MemoryStorage.ts +7 -2
- package/src/entity/storage/MockApiStorage.ts +1 -1
- package/src/entity/storage/SdkStorage.ts +12 -3
- package/src/gen/createManagers.ts +15 -1
- package/src/gen/index.ts +7 -1
- package/src/index.ts +19 -0
- package/src/kernel/Kernel.registries.ts +202 -46
- package/src/kernel/Kernel.ts +34 -9
- package/src/kernel/Kernel.types.ts +19 -0
- package/src/kernel/Kernel.vue.ts +7 -0
- package/src/kernel/KernelContext.routing.ts +18 -7
- package/src/plugin.ts +1 -1
- package/src/query/clientFilter.ts +14 -3
- package/src/routeState/CompositePersister.ts +144 -0
- package/src/routeState/CookiePersister.ts +109 -0
- package/src/routeState/MemoryPersister.ts +60 -0
- package/src/routeState/NullPersister.ts +33 -0
- package/src/routeState/RouteStatePersister.ts +86 -0
- package/src/routeState/UrlPersister.ts +207 -0
- package/src/routeState/WebStoragePersister.ts +149 -0
- package/src/routeState/defaultPersister.ts +66 -0
- package/src/routeState/factory.ts +86 -0
- package/src/routeState/index.ts +32 -0
- package/src/routeState/keyLocator.ts +84 -0
|
@@ -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
|
+
}
|