@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,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
+ }