@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
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Extracted from useListPage.ts for maintainability.
5
5
  */
6
+ import type { RouteStatePersister } from '../routeState'
6
7
  import type { Ref, ComputedRef } from 'vue'
7
8
  import type { Router } from 'vue-router'
8
9
  import type { FilterQuery } from '../query/FilterQuery'
@@ -76,9 +77,45 @@ export interface ColumnConfig {
76
77
  /**
77
78
  * Filter configuration
78
79
  */
79
- export interface FilterConfig {
80
- name: string
81
- type?: 'select' | 'multiselect' | 'date' | 'checkbox' | 'dropdown' | 'autocomplete'
80
+ /**
81
+ * The filter controls qdadm knows how to render.
82
+ *
83
+ * Exported as a VALUE, not only a type, so a consumer's own guard can read
84
+ * this list instead of copying it — a copy goes stale the day a type is
85
+ * added and starts rejecting correct code.
86
+ */
87
+ export const FILTER_TYPES = [
88
+ 'select',
89
+ 'multiselect',
90
+ 'date',
91
+ 'checkbox',
92
+ 'dropdown',
93
+ 'autocomplete',
94
+ ] as const
95
+
96
+ export type FilterType = (typeof FILTER_TYPES)[number]
97
+
98
+ /**
99
+ * Everything a filter can be given EXCEPT its name (#2147).
100
+ *
101
+ * Split out rather than written as `Omit<FilterConfig, 'name'>`, and the
102
+ * reason is not style. `FilterConfig` carries an index signature, so
103
+ * `keyof` includes `string`; `Exclude<string, 'name'>` is still `string`, and
104
+ * the `Omit` therefore collapsed to a bare `{ [x: string]: unknown }` —
105
+ * ERASING every declared property.
106
+ *
107
+ * `addFilter` took that type, so **no filter option was checked at all**:
108
+ * not `type`, not `optionLabel`, not `local_filter`. A consumer declared
109
+ * `type: 'text'`, TypeScript accepted it, and the filter rendered as an empty
110
+ * dropdown saying "No available options". Nothing was wrong at any single
111
+ * step, which is why it survived.
112
+ *
113
+ * Declaring the properties here and having `FilterConfig` extend it keeps the
114
+ * index signature — apps do stash their own keys — while the declared ones
115
+ * are checked again.
116
+ */
117
+ export interface FilterOptions {
118
+ type?: FilterType
82
119
  placeholder?: string
83
120
  options?: Array<{ label: string; value: unknown }>
84
121
  default?: unknown
@@ -101,6 +138,10 @@ export interface FilterConfig {
101
138
  [key: string]: unknown
102
139
  }
103
140
 
141
+ export interface FilterConfig extends FilterOptions {
142
+ name: string
143
+ }
144
+
104
145
  /**
105
146
  * Row action configuration
106
147
  */
@@ -229,8 +270,27 @@ export interface UseListPageOptions<T = unknown> {
229
270
  persistFilters?: boolean
230
271
  /** Persist the active sort per entity (session storage), restored on init (#1218). Default true. */
231
272
  persistSort?: boolean
232
- /** Sync filters to URL query params (default: true) */
273
+ /**
274
+ * WRITE filters, search and the page to the URL query (default: true).
275
+ *
276
+ * Writing only. `false` has never stopped the query string being read, so
277
+ * a deep link someone typed by hand still restores the list — say
278
+ * `routeState` if you want the state kept somewhere else entirely.
279
+ */
233
280
  syncUrlParams?: boolean
281
+ /**
282
+ * Where this list's state is remembered (#2146): a slug the framework
283
+ * knows (`'url'`, …), or your own persister. Defaults to the URL.
284
+ *
285
+ * Setting it is a deliberate choice of medium, so it answers
286
+ * `syncUrlParams` rather than obeying it: a list with `routeState` set
287
+ * reads and writes that medium whatever the URL flag says.
288
+ *
289
+ * An unknown slug throws rather than falling back to the URL — a list
290
+ * quietly persisting somewhere other than where you asked is worse than
291
+ * one that will not start (ADR 0011).
292
+ */
293
+ routeState?: string | RouteStatePersister
234
294
  /** Filter mode: 'auto' | 'manager' | 'local' */
235
295
  filterMode?: 'auto' | 'manager' | 'local'
236
296
  /** Auto-filter threshold for 'auto' mode */
@@ -270,6 +330,15 @@ export interface ListPageProps {
270
330
  lazy: boolean
271
331
  totalRecords: number
272
332
  rows: number
333
+ /**
334
+ * Row offset of the current page — what POSITIONS the paginator (#2145).
335
+ *
336
+ * In lazy mode the table renders exactly the rows it is handed and has no
337
+ * way to know which page they are; `first` is the only thing that tells it.
338
+ * Without it, a restored page 2 showed page 2's rows under a paginator
339
+ * highlighting 1.
340
+ */
341
+ first: number
273
342
  rowsPerPageOptions: number[]
274
343
  sortField: string | null
275
344
  sortOrder: number
@@ -382,7 +451,7 @@ export interface UseListPageReturn<T = unknown> {
382
451
  filterValues: Ref<Record<string, unknown>>
383
452
  filteredItems: ComputedRef<T[]>
384
453
  fromCache: Ref<boolean>
385
- addFilter: (name: string, config: Omit<FilterConfig, 'name'>) => void
454
+ addFilter: (name: string, config: FilterOptions) => void
386
455
  removeFilter: (name: string) => void
387
456
  setFilterValue: (name: string, value: unknown) => void
388
457
  updateFilters: (values: Record<string, unknown>) => void
@@ -40,6 +40,35 @@ export function persistPageSize(size: number): void {
40
40
  setCookie(COOKIE_NAME, size, COOKIE_EXPIRY_DAYS)
41
41
  }
42
42
 
43
+ /**
44
+ * Settle the rows-per-page a list opens on (#2146).
45
+ *
46
+ * The seam owns this now, so the stored value arrives from whichever medium
47
+ * the app chose. The LEGACY bare-number cookie is read only when the seam has
48
+ * nothing — an existing user's choice must survive the move, or a comfort
49
+ * setting they picked once quietly resets on upgrade.
50
+ *
51
+ * Anything outside {@link PAGE_SIZE_OPTIONS} falls back to the default: the
52
+ * value reaches us from a cookie or a query string that anyone can edit, and
53
+ * a row count the paginator cannot offer would leave its dropdown showing a
54
+ * blank.
55
+ */
56
+ export function restorePageSize(stored: unknown, defaultSize: number): number {
57
+ const fromSeam = Number(stored)
58
+ if (PAGE_SIZE_OPTIONS.includes(fromSeam)) return fromSeam
59
+ return getSavedPageSize(defaultSize)
60
+ }
61
+
62
+ /**
63
+ * Drop the pre-seam cookie once its value has been carried over.
64
+ *
65
+ * Left in place it would outlive the setting it stands for by a year, and
66
+ * shadow the seam on any machine where the new store was cleared.
67
+ */
68
+ export function retireLegacyPageSizeCookie(): void {
69
+ setCookie(COOKIE_NAME, '', -1)
70
+ }
71
+
43
72
  // ─────────────────────────────────────────────────────────────────────────────
44
73
  // Default label formatter (snake_case → Title Case)
45
74
  // ─────────────────────────────────────────────────────────────────────────────
@@ -129,6 +158,35 @@ export function clearSessionSort(key: string): void {
129
158
  }
130
159
  }
131
160
 
161
+ /**
162
+ * Settle the sort a list opens on (#2146).
163
+ *
164
+ * The seam owns this now. The medium has not changed — the default
165
+ * composition routes `sort` back to `sessionStorage`, deliberately, so that
166
+ * moving it behind an interface changes nothing anyone can observe. Only the
167
+ * KEY changed, from `qdadm_sort_offers` to `qdadm:offers:sort`.
168
+ *
169
+ * Which is why the legacy entry is still read when the seam has nothing: a
170
+ * sort somebody set five minutes ago must not vanish because the framework
171
+ * was upgraded under them.
172
+ */
173
+ export function restoreSort(
174
+ stored: { sort?: unknown; sortOrder?: unknown } | null,
175
+ legacyKey: string,
176
+ defaults: SessionSort
177
+ ): SessionSort {
178
+ const field = stored?.sort
179
+ const order = Number(stored?.sortOrder)
180
+ if (typeof field === 'string' && field && (order === 1 || order === -1)) {
181
+ return { field, order }
182
+ }
183
+
184
+ const legacy = getSessionSort(legacyKey)
185
+ if (legacy) return legacy
186
+
187
+ return defaults
188
+ }
189
+
132
190
  // ─────────────────────────────────────────────────────────────────────────────
133
191
  // Smart filter discovery
134
192
  // ─────────────────────────────────────────────────────────────────────────────
@@ -27,6 +27,7 @@
27
27
  import type { EntityBadge } from './EntityManager.types'
28
28
  import type { SignalBus } from '../kernel/SignalBus'
29
29
  import type { EntityAuthAdapter } from './auth/EntityAuthAdapter'
30
+ import type { RouteStatePersister } from '../routeState'
30
31
 
31
32
  // ─── Base ────────────────────────────────────────────────────────────────────
32
33
 
@@ -84,6 +85,16 @@ export interface EntityManagerRead<T = unknown> extends EntityManagerBase<T>, En
84
85
  getFieldConfig: (name: string) => unknown | null
85
86
  /** Config-carrying property: always present on the class, VALUE nullable */
86
87
  localFilterThreshold?: number | null
88
+ /**
89
+ * Where lists of this entity remember their state (#2146): a slug the
90
+ * framework knows, or a persister instance.
91
+ *
92
+ * A property of the ENTITY, so every screen showing it agrees — a list of
93
+ * audit rows nobody links to can say `local_storage` once here rather than
94
+ * on each of the four pages that render it. A list's own `routeState`
95
+ * still wins; the kernel default applies when neither says anything.
96
+ */
97
+ routeState?: string | RouteStatePersister
87
98
  }
88
99
 
89
100
  // ─── CRUD ────────────────────────────────────────────────────────────────────
@@ -51,6 +51,7 @@ export type {
51
51
  } from './EntityManager.types'
52
52
 
53
53
  // Import concrete types needed in method signatures
54
+ import type { RouteStatePersister } from '../routeState'
54
55
  import type {
55
56
  RoutingContext,
56
57
  PresaveContext,
@@ -88,6 +89,8 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
88
89
  protected _fields: Record<string, FieldConfig>
89
90
 
90
91
  localFilterThreshold: number | null
92
+ /** Where lists of this entity remember their state (#2146); null = inherit. */
93
+ routeState: string | RouteStatePersister | null
91
94
  /** Default null placement for local sorts (#1222); field-level nullSort wins. */
92
95
  nullSort?: NullSortMode
93
96
  protected _cacheTtlMs: number | null
@@ -163,6 +166,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
163
166
  badges = null,
164
167
  fields = {},
165
168
  localFilterThreshold = null,
169
+ routeState = null,
166
170
  nullSort,
167
171
  cacheTtlMs = null,
168
172
  asymmetric = false,
@@ -196,6 +200,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
196
200
  this._fields = fields
197
201
 
198
202
  this.localFilterThreshold = localFilterThreshold
203
+ this.routeState = routeState
199
204
  this.nullSort = nullSort
200
205
  this._cacheTtlMs = cacheTtlMs
201
206
  this._asymmetric = asymmetric
@@ -16,6 +16,7 @@ import type { HookRegistry } from '../hooks/HookRegistry'
16
16
 
17
17
  // Circular type import — safe because `import type` is erased at runtime
18
18
  import type { EntityManager } from './EntityManager'
19
+ import type { RouteStatePersister } from '../routeState'
19
20
 
20
21
  // ============ INTERNAL TYPES ============
21
22
 
@@ -235,6 +236,16 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
235
236
  badges?: (entity: T) => EntityBadge[]
236
237
  fields?: Record<string, FieldConfig>
237
238
  localFilterThreshold?: number | null
239
+ /**
240
+ * Where lists of this entity remember their state (#2146): a slug the
241
+ * framework knows, or a persister instance.
242
+ *
243
+ * A property of the ENTITY, so every screen showing it agrees — a list of
244
+ * audit rows nobody links to can say `local_storage` once here rather than
245
+ * on each of the four pages that render it. A list's own `routeState`
246
+ * still wins; the kernel default applies when neither says anything.
247
+ */
248
+ routeState?: string | RouteStatePersister
238
249
  /** Default null placement for local sorts: 'first' | 'last' | 'low' | 'high' (default 'last', #1222). Field-level nullSort wins. */
239
250
  nullSort?: NullSortMode
240
251
  /** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
@@ -15,6 +15,43 @@ export interface RoleProvider {
15
15
  readonly canPersist: boolean
16
16
  }
17
17
 
18
+ /**
19
+ * What an application's judge receives at install time (#2225).
20
+ */
21
+ export interface GrantJudgeContext {
22
+ orchestrator?: unknown
23
+ signals?: unknown
24
+ /**
25
+ * The live permission registry — a REFERENCE, not a snapshot.
26
+ *
27
+ * `install` runs while the Kernel is still being built, before modules
28
+ * register their entities, so `getKeys()` called here returns only the
29
+ * framework's own namespaces. Read the keys when you pre-warm (after login,
30
+ * before mount), not in `install`.
31
+ */
32
+ permissionRegistry?: { getKeys(): string[] }
33
+ }
34
+
35
+ /**
36
+ * An application-provided judgement of permissions (#2225).
37
+ *
38
+ * For apps whose backend decides who may do what: the judge answers from its
39
+ * own cache instead of qdadm's role matrix. It is consulted FIRST, for roles
40
+ * and permissions alike.
41
+ *
42
+ * - `true` / `false` is the verdict.
43
+ * - Anything else (`undefined`, `null`, a cache miss) falls through to the
44
+ * built-in judgement, which stays the default.
45
+ * - A judge that THROWS denies — see `SecurityChecker._judge`.
46
+ *
47
+ * `isGranted` is called synchronously on every check, so answer from memory.
48
+ * Fetch in bulk beforehand and emit `security:changed` when answers change.
49
+ */
50
+ export interface GrantJudge {
51
+ isGranted(attribute: string, subject: unknown, user: AuthUser): boolean | undefined
52
+ install?(ctx: GrantJudgeContext): void
53
+ }
54
+
18
55
  /**
19
56
  * SecurityChecker constructor options
20
57
  */
@@ -22,6 +59,8 @@ export interface SecurityCheckerOptions {
22
59
  rolesProvider?: RoleProvider
23
60
  roleHierarchy?: RoleHierarchy | RoleHierarchyConfig
24
61
  rolePermissions?: Record<string, string[]>
62
+ /** Application-provided judgement, consulted first (#2225). */
63
+ grant?: GrantJudge
25
64
  getCurrentUser: () => AuthUser | null
26
65
  }
27
66
 
@@ -32,6 +71,8 @@ export interface SecurityCheckerConfig {
32
71
  rolesProvider?: RoleProvider
33
72
  role_hierarchy?: RoleHierarchyConfig
34
73
  role_permissions?: Record<string, string[]>
74
+ /** Application-provided judgement, consulted first (#2225). */
75
+ grant?: GrantJudge
35
76
  getCurrentUser: () => AuthUser | null
36
77
  }
37
78
 
@@ -55,8 +96,12 @@ export class SecurityChecker {
55
96
  readonly getCurrentUser: () => AuthUser | null
56
97
  /** Roles already warned about (warn-once, #1388) */
57
98
  protected _warnedRoles = new Set<string>()
99
+ /** Application-provided judgement, consulted before the role matrix (#2225) */
100
+ protected _grant: GrantJudge | null
101
+ /** Attributes whose judge already threw (report-once) */
102
+ protected _judgeFailures = new Set<string>()
58
103
 
59
- constructor({ rolesProvider, roleHierarchy, rolePermissions, getCurrentUser }: SecurityCheckerOptions) {
104
+ constructor({ rolesProvider, roleHierarchy, rolePermissions, grant, getCurrentUser }: SecurityCheckerOptions) {
60
105
  if (rolesProvider) {
61
106
  this._rolesProvider = rolesProvider
62
107
  } else {
@@ -75,6 +120,12 @@ export class SecurityChecker {
75
120
  }
76
121
 
77
122
  this.getCurrentUser = getCurrentUser
123
+ this._grant = grant ?? null
124
+ }
125
+
126
+ /** The application's judge, when one is configured (#2225). */
127
+ get grant(): GrantJudge | null {
128
+ return this._grant
78
129
  }
79
130
 
80
131
  /**
@@ -108,10 +159,15 @@ export class SecurityChecker {
108
159
  *
109
160
  * This is the main contract method, similar to Symfony's isGranted().
110
161
  */
111
- isGranted(attribute: string, _subject: unknown = null): boolean {
162
+ isGranted(attribute: string, subject: unknown = null): boolean {
112
163
  const user = this.getCurrentUser()
113
164
  if (!user) return false
114
165
 
166
+ // 0. The application's own judgement comes first (#2225). A boolean is a
167
+ // verdict; anything else falls through to the built-in matrix below.
168
+ const verdict = this._judge(attribute, subject, user)
169
+ if (verdict !== undefined) return verdict
170
+
115
171
  // 1. Check if it's a role (ROLE_*)
116
172
  if (attribute.startsWith('ROLE_')) {
117
173
  return this.roleHierarchy.isGrantedRole(user.roles || [user.role || ''], attribute)
@@ -122,6 +178,34 @@ export class SecurityChecker {
122
178
  return PermissionMatcher.any(userPerms, attribute)
123
179
  }
124
180
 
181
+ /**
182
+ * Ask the application's judge, if any.
183
+ *
184
+ * A judge that THROWS denies. Falling through to the built-in matrix would
185
+ * quietly substitute a different authority for the one the app configured,
186
+ * and failing open would grant whatever the backend never got to answer —
187
+ * the worst direction a security check can fail in (the rule the route
188
+ * guard already follows, #1190). Reported once per attribute.
189
+ */
190
+ protected _judge(attribute: string, subject: unknown, user: AuthUser): boolean | undefined {
191
+ if (!this._grant) return undefined
192
+ let answer: unknown
193
+ try {
194
+ answer = this._grant.isGranted(attribute, subject, user)
195
+ } catch (error) {
196
+ if (!this._judgeFailures.has(attribute)) {
197
+ this._judgeFailures.add(attribute)
198
+ console.error(
199
+ `[qdadm] security.grant threw while judging "${attribute}" — DENIED. ` +
200
+ `A failing judge denies rather than falling back to the role matrix.`,
201
+ error
202
+ )
203
+ }
204
+ return false
205
+ }
206
+ return typeof answer === 'boolean' ? answer : undefined
207
+ }
208
+
125
209
  /**
126
210
  * Get all permissions for a user (resolved from roles + user overrides)
127
211
  */
@@ -203,6 +287,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
203
287
  if (config.rolesProvider) {
204
288
  return new SecurityChecker({
205
289
  rolesProvider: config.rolesProvider,
290
+ grant: config.grant,
206
291
  getCurrentUser: config.getCurrentUser,
207
292
  })
208
293
  }
@@ -213,6 +298,7 @@ export function createSecurityChecker(config: SecurityCheckerConfig): SecurityCh
213
298
  role_hierarchy: config.role_hierarchy || {},
214
299
  role_permissions: config.role_permissions || {},
215
300
  }),
301
+ grant: config.grant,
216
302
  getCurrentUser: config.getCurrentUser,
217
303
  })
218
304
  }
@@ -23,6 +23,8 @@ export type {
23
23
  RoleProvider,
24
24
  SecurityCheckerOptions,
25
25
  SecurityCheckerConfig,
26
+ GrantJudge,
27
+ GrantJudgeContext,
26
28
  } from './SecurityChecker'
27
29
 
28
30
  // Implementations
@@ -151,7 +151,7 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
151
151
  }
152
152
 
153
153
  async list(params: ListParams = {}, context: RoutingContext | null = null): Promise<ListResult<T>> {
154
- const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
154
+ const { page = 1, page_size = 20, sort_by, sort_order, search, filters = {} } = params
155
155
 
156
156
  // `paramMapping` covers the WHOLE outgoing query, pagination included
157
157
  // (#2113). It used to be applied to the filters only, so `status` could
@@ -160,9 +160,23 @@ export class ApiStorage<T extends EntityRecord = EntityRecord> extends IStorage<
160
160
  // storage for that alone. The names qdadm uses internally are its own
161
161
  // business; what goes on the wire is the backend's.
162
162
  //
163
+ // `search` goes out too (#2147). It is part of `ListParams` and always
164
+ // has been, but this method never read it — a user typed in the search
165
+ // box, the list rebuilt its query, and the term was dropped here without
166
+ // a word. The rows came back unfiltered and nothing anywhere said why,
167
+ // which is the silent no-op ADR 0011 exists to forbid.
168
+ //
169
+ // It travels under whatever name your backend uses, like every other key:
170
+ // `paramMapping: { search: 'q' }`.
171
+ //
172
+ // `searchFields` deliberately stays behind. It says which fields the
173
+ // FRONT should look at when filtering a cached page locally; a backend
174
+ // that searches knows its own columns, and shipping an array parameter
175
+ // nobody asked for would be a new surprise in place of the old one.
176
+ //
163
177
  // Filters keep their precedence over the pagination keys, exactly as
164
178
  // before: a filter named `page` still wins, for better or worse.
165
- const outgoing = { page, page_size, sort_by, sort_order, ...filters }
179
+ const outgoing = { page, page_size, sort_by, sort_order, search, ...filters }
166
180
 
167
181
  const response = await this.client.get<Record<string, unknown>>(this.endpoint, {
168
182
  params: this._applyParamMapping(outgoing),
@@ -67,7 +67,7 @@ export class LocalStorage<T extends EntityRecord = EntityRecord> extends IStorag
67
67
  items = filterItems(items, filters, { stringMatch: 'exact' })
68
68
 
69
69
  // Apply search (shared pipeline, #1192)
70
- items = searchItems(items, search as string | undefined)
70
+ items = searchItems(items, search)
71
71
 
72
72
  const total = items.length
73
73
 
@@ -1,7 +1,7 @@
1
1
  import { IStorage } from './IStorage'
2
2
  import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
3
3
  import { StorageError } from './errors'
4
- import { sortItems, filterItems, paginate, defaultGenerateId } from '../../query/clientFilter'
4
+ import { sortItems, filterItems, paginate, defaultGenerateId, searchItems } from '../../query/clientFilter'
5
5
 
6
6
  /**
7
7
  * MemoryStorage options
@@ -61,10 +61,15 @@ export class MemoryStorage<T extends EntityRecord = EntityRecord> extends IStora
61
61
  }
62
62
 
63
63
  async list(params: ListParams = {}): Promise<ListResult<T>> {
64
- const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
64
+ const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
65
65
 
66
66
  let items = this._getAll()
67
67
 
68
+ // Apply the search term (#2147). `searchItems` has existed since #1192
69
+ // and LocalStorage and MockApiStorage both called it; this one never did,
70
+ // so a search box over a MemoryStorage entity typed into the void.
71
+ items = searchItems(items, search)
72
+
68
73
  // Apply filters (shared pipeline, #1192 — legacy substring semantics)
69
74
  items = filterItems(items, filters, { stringMatch: 'includes' })
70
75
 
@@ -151,7 +151,7 @@ export class MockApiStorage<T extends EntityRecord = EntityRecord> extends IStor
151
151
  }
152
152
 
153
153
  // Apply search (shared pipeline, #1192)
154
- items = searchItems(items, search as string | undefined)
154
+ items = searchItems(items, search)
155
155
 
156
156
  const total = items.length
157
157
 
@@ -1,6 +1,6 @@
1
1
  import { IStorage } from './IStorage'
2
2
  import type { EntityRecord, ListParams, ListResult, StorageCapabilities } from '../../types'
3
- import { sortItems, filterItems, paginate } from '../../query/clientFilter'
3
+ import { sortItems, filterItems, paginate, searchItems } from '../../query/clientFilter'
4
4
 
5
5
  /**
6
6
  * SDK method result with data property
@@ -275,9 +275,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
275
275
  }
276
276
 
277
277
  async list(params: ListParams = {}): Promise<ListResult<T>> {
278
- const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {} } = params
278
+ const { page = 1, page_size = 20, sort_by, sort_order = 'asc', filters = {}, search } = params
279
279
 
280
- const queryParams = { query: { page, page_size, sort_by, sort_order, ...filters } }
280
+ // `search` goes to the SDK with everything else (#2147). It was dropped
281
+ // here, so a search box over an SDK-backed entity typed into the void:
282
+ // the call went out without the term and the rows came back unfiltered,
283
+ // with nothing anywhere saying why.
284
+ const queryParams = { query: { page, page_size, sort_by, sort_order, search, ...filters } }
281
285
  const data = (await this._execute('list', queryParams)) as ListResult<T>
282
286
 
283
287
  let { items, total } = data
@@ -292,8 +296,13 @@ export class SdkStorage<T extends EntityRecord = EntityRecord> extends IStorage<
292
296
 
293
297
  // Shared pipeline (#1192) — original order preserved: sort, then
294
298
  // filter (substring semantics), then paginate.
299
+ //
300
+ // The search is applied here TOO, not only sent (#2147). An SDK that
301
+ // ignores the term would otherwise hand back the whole collection and
302
+ // this branch would paginate it as if it had been searched.
295
303
  items = sortItems([...items], sort_by, sort_order)
296
304
  items = filterItems(items, filters, { stringMatch: 'includes' })
305
+ items = searchItems(items, search)
297
306
  total = items.length
298
307
  items = paginate(items, page, page_size)
299
308
  }
package/src/index.ts CHANGED
@@ -237,10 +237,13 @@ export * from './composables/useJsonSyntax'
237
237
  export {
238
238
  useListPage,
239
239
  PAGE_SIZE_OPTIONS,
240
+ FILTER_TYPES,
240
241
  type UseListPageOptions,
241
242
  type UseListPageReturn,
242
243
  type ColumnConfig,
243
244
  type FilterConfig,
245
+ type FilterOptions,
246
+ type FilterType,
244
247
  type ActionConfig as ListActionConfig,
245
248
  type ResolvedAction,
246
249
  type HeaderActionConfig,
@@ -322,6 +325,22 @@ export {
322
325
  type LayoutMeta,
323
326
  } from './composables/useLayoutResolver'
324
327
  export { useSSEBridge, type UseSSEBridgeReturn } from './composables/useSSEBridge'
328
+
329
+ // Route-state persistence (#2146) — where a route remembers what it shows.
330
+ export {
331
+ UrlPersister,
332
+ isRouteStatePersister,
333
+ resolveRouteStatePersister,
334
+ createRouteStatePersisterFactory,
335
+ type RouteStatePersister,
336
+ type RouteStateScope,
337
+ type RouteStatePersisterSpec,
338
+ type RouteStatePersisterFactory,
339
+ type UrlPersisterOptions,
340
+ dottedKeyLocator,
341
+ namespacedKeyLocator,
342
+ type RouteStateKeyLocator,
343
+ } from './routeState'
325
344
  export {
326
345
  useDeferred,
327
346
  useDeferredValue,
@@ -120,11 +120,15 @@ export function applyAuthMethods(KernelClass: { prototype: Kernel }): void {
120
120
  */
121
121
  proto._setupAuthInvalidation = function (this: Self): void {
122
122
  const debug = this.options.debug ?? false
123
+ // `security:changed` (#2225) rides the same path: nothing on screen reads
124
+ // permissions reactively (canCreate, menu filtering and the route guard
125
+ // all ask synchronously), so a remount is what makes new answers show.
123
126
  const authSignals = [
124
127
  'auth:login',
125
128
  'auth:logout',
126
129
  'auth:impersonate',
127
130
  'auth:impersonate:stop',
131
+ 'security:changed',
128
132
  ]
129
133
 
130
134
  for (const signal of authSignals) {