qdadm 1.1.0 → 1.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdadm",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Vue 3 framework for admin dashboards with PrimeVue",
5
5
  "author": "quazardous",
6
6
  "license": "MIT",
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/quazardous/qdadm/issues"
14
14
  },
15
15
  "type": "module",
16
- "main": "src/index.js",
16
+ "main": "src/index.ts",
17
17
  "scripts": {
18
18
  "test": "vitest run",
19
19
  "test:watch": "vitest",
@@ -24,19 +24,19 @@
24
24
  "build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types"
25
25
  },
26
26
  "exports": {
27
- ".": "./src/index.js",
28
- "./auth": "./src/auth/index.js",
27
+ ".": "./src/index.ts",
28
+ "./auth": "./src/auth/index.ts",
29
29
  "./security": "./src/security/index.ts",
30
- "./composables": "./src/composables/index.js",
31
- "./components": "./src/components/index.js",
32
- "./editors": "./src/editors/index.js",
33
- "./module": "./src/module/index.js",
30
+ "./composables": "./src/composables/index.ts",
31
+ "./components": "./src/components/index.ts",
32
+ "./editors": "./src/editors/index.ts",
33
+ "./module": "./src/module/index.ts",
34
34
  "./utils": "./src/utils/index.ts",
35
- "./modules/debug": "./src/modules/debug/index.js",
35
+ "./modules/debug": "./src/modules/debug/index.ts",
36
36
  "./styles": "./src/styles/index.scss",
37
37
  "./styles/variables": "./src/styles/_variables.scss",
38
- "./gen": "./src/gen/index.js",
39
- "./gen/vite-plugin": "./src/gen/vite-plugin.js"
38
+ "./gen": "./src/gen/index.ts",
39
+ "./gen/vite-plugin": "./src/gen/vite-plugin.ts"
40
40
  },
41
41
  "files": [
42
42
  "src",
@@ -225,6 +225,14 @@ interface StoredSessionData<TUser = AuthUser> {
225
225
  originalUser?: TUser
226
226
  }
227
227
 
228
+ /**
229
+ * Configuration options for LocalStorageSessionAuthAdapter
230
+ */
231
+ export interface LocalStorageAuthConfig {
232
+ /** localStorage key for session data (default: 'qdadm_auth') */
233
+ storageKey?: string
234
+ }
235
+
228
236
  /**
229
237
  * LocalStorage-based SessionAuthAdapter implementation
230
238
  *
@@ -252,15 +260,29 @@ interface StoredSessionData<TUser = AuthUser> {
252
260
  * ```
253
261
  */
254
262
  export class LocalStorageSessionAuthAdapter<TUser extends AuthUser = AuthUser> extends SessionAuthAdapter<TUser> {
263
+ /**
264
+ * Default configuration. Override to change defaults globally:
265
+ * @example
266
+ * LocalStorageSessionAuthAdapter.defaults.storageKey = 'my_app_auth'
267
+ */
268
+ static defaults: Required<LocalStorageAuthConfig> = {
269
+ storageKey: 'qdadm_auth'
270
+ }
271
+
255
272
  protected _storageKey: string
256
273
  protected _originalUser: TUser | null = null // Stores original user during impersonation
257
274
 
258
275
  /**
259
- * @param storageKey - localStorage key for session data
276
+ * @param config - Configuration options or storage key string (for backward compatibility)
260
277
  */
261
- constructor(storageKey: string = 'qdadm_auth') {
278
+ constructor(config?: LocalStorageAuthConfig | string) {
262
279
  super()
263
- this._storageKey = storageKey
280
+ // Support both string (legacy) and config object
281
+ if (typeof config === 'string') {
282
+ this._storageKey = config
283
+ } else {
284
+ this._storageKey = config?.storageKey ?? LocalStorageSessionAuthAdapter.defaults.storageKey
285
+ }
264
286
  this._restore()
265
287
  }
266
288
 
package/src/auth/index.ts CHANGED
@@ -11,4 +11,5 @@ export {
11
11
  type SessionData,
12
12
  type AuthUser,
13
13
  type ISessionAuthAdapter,
14
+ type LocalStorageAuthConfig,
14
15
  } from './SessionAuthAdapter'
@@ -569,51 +569,8 @@ const showBreadcrumb = computed<boolean>(() => {
569
569
  z-index: 10;
570
570
  }
571
571
 
572
- /* Nav items - compact, subtle hover */
573
- .nav-item {
574
- display: flex;
575
- align-items: center;
576
- gap: 0.5rem;
577
- padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
578
- padding-left: 1rem;
579
- color: var(--p-surface-300, #cbd5e1);
580
- text-decoration: none;
581
- font-size: var(--fad-font-size-sm);
582
- font-weight: var(--fad-font-weight-normal);
583
- border-radius: 0;
584
- transition: background var(--fad-transition-fast), color var(--fad-transition-fast);
585
- }
586
-
587
- .nav-item span {
588
- white-space: nowrap;
589
- }
590
-
591
- .nav-item:hover {
592
- background: var(--p-surface-700, #334155);
593
- color: var(--p-surface-0, white);
594
- }
595
-
596
- .nav-item-active {
597
- background: var(--p-surface-700, #334155);
598
- color: var(--p-primary-300, #93c5fd);
599
- border-left: 2px solid var(--p-primary-400, #60a5fa);
600
- padding-left: calc(1rem - 2px);
601
- }
602
-
603
- .nav-item-active:hover {
604
- background: var(--p-surface-600, #475569);
605
- }
606
-
607
- .nav-item i {
608
- font-size: var(--fad-nav-icon-size);
609
- width: 1.125rem;
610
- text-align: center;
611
- opacity: 0.8;
612
- }
613
-
614
- .nav-item-active i {
615
- opacity: 1;
616
- }
572
+ /* Nav items are styled in _sidebar.scss (global styles)
573
+ See .sidebar-nav .nav-item for base styles */
617
574
 
618
575
  /* -----------------------------------------------------------------------------
619
576
  Main Content
@@ -866,25 +823,8 @@ const showBreadcrumb = computed<boolean>(() => {
866
823
  opacity: 1 !important;
867
824
  }
868
825
 
869
- .sidebar--collapsed .nav-item {
870
- padding: 0.5rem 0;
871
- gap: 0;
872
- border-left: none !important;
873
- justify-content: center;
874
- }
875
-
876
- .sidebar--collapsed .nav-item-active {
877
- border-left: none !important;
878
- padding: 0.5rem 0;
879
- }
880
-
881
- .sidebar--collapsed .nav-item span {
882
- display: none;
883
- }
884
-
885
- .sidebar--collapsed .nav-item i {
886
- width: auto;
887
- }
826
+ /* Nav item collapsed styles are in _sidebar.scss (global styles)
827
+ See .sidebar--collapsed .sidebar-nav .nav-item */
888
828
 
889
829
  /* Powered-by collapsed: smaller height */
890
830
  .sidebar--collapsed #powered-by {
@@ -995,23 +935,7 @@ const showBreadcrumb = computed<boolean>(() => {
995
935
  pointer-events: auto;
996
936
  }
997
937
 
998
- .sidebar.sidebar--collapsed .nav-item {
999
- justify-content: flex-start;
1000
- padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
1001
- padding-left: 1rem;
1002
- gap: 0.5rem;
1003
- }
1004
-
1005
- .sidebar.sidebar--collapsed .nav-item span {
1006
- display: inline;
1007
- opacity: 1;
1008
- width: auto;
1009
- }
1010
-
1011
- .sidebar.sidebar--collapsed .nav-item i {
1012
- font-size: var(--fad-nav-icon-size);
1013
- width: 1.125rem;
1014
- }
938
+ /* Nav item mobile collapsed styles are in _sidebar.scss (global styles) */
1015
939
 
1016
940
  .sidebar.sidebar--collapsed #powered-by,
1017
941
  .sidebar.sidebar--collapsed #user-zone {
@@ -95,7 +95,8 @@ watch(() => route?.path, () => {
95
95
  </script>
96
96
 
97
97
  <template>
98
- <nav class="default-menu">
98
+ <!-- Use sidebar-nav class to inherit global nav-item styles from _sidebar.scss -->
99
+ <nav class="sidebar-nav">
99
100
  <div v-for="section in navSections" :key="section.title || section.label" class="nav-section">
100
101
  <div
101
102
  class="nav-section-title"
@@ -126,7 +127,10 @@ watch(() => route?.path, () => {
126
127
  </template>
127
128
 
128
129
  <style scoped>
129
- .default-menu {
130
+ /* DefaultMenu uses sidebar-nav class to inherit global nav-item styles from _sidebar.scss
131
+ Only component-specific styles remain here */
132
+
133
+ .sidebar-nav {
130
134
  flex: 1;
131
135
  overflow-y: auto;
132
136
  padding: 1rem 0;
@@ -175,30 +179,6 @@ watch(() => route?.path, () => {
175
179
  opacity: 0;
176
180
  }
177
181
 
178
- .nav-item {
179
- display: flex;
180
- align-items: center;
181
- gap: 0.75rem;
182
- padding: 0.625rem 1.5rem;
183
- font-size: 0.9375rem;
184
- color: var(--p-surface-300, #cbd5e1);
185
- text-decoration: none;
186
- transition: all 0.15s;
187
- }
188
-
189
- .nav-item:hover {
190
- background: var(--p-surface-700, #334155);
191
- color: var(--p-surface-0, white);
192
- }
193
-
194
- .nav-item-active {
195
- background: var(--p-primary-600, #2563eb);
196
- color: var(--p-surface-0, white);
197
- }
198
-
199
- .nav-item i {
200
- font-size: 1rem;
201
- width: 1.25rem;
202
- text-align: center;
203
- }
182
+ /* Nav item styles are in _sidebar.scss (global styles)
183
+ See .sidebar-nav .nav-item for base styles */
204
184
  </style>
@@ -142,6 +142,12 @@ export interface CacheInfo {
142
142
  itemCount: number
143
143
  total: number
144
144
  loadedAt: number | null
145
+ /** Effective TTL in milliseconds (0 = no TTL) */
146
+ ttlMs: number
147
+ /** Timestamp when cache expires (null if no TTL) */
148
+ expiresAt: number | null
149
+ /** Whether the cache has expired based on TTL */
150
+ expired: boolean
145
151
  }
146
152
 
147
153
  /**
@@ -157,6 +163,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
157
163
  labelField?: string | ((entity: T) => string)
158
164
  fields?: Record<string, FieldConfig>
159
165
  localFilterThreshold?: number | null
166
+ /** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
167
+ cacheTtlMs?: number | null
160
168
  readOnly?: boolean
161
169
  warmup?: boolean
162
170
  authSensitive?: boolean
@@ -205,6 +213,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
205
213
  protected _fields: Record<string, FieldConfig>
206
214
 
207
215
  localFilterThreshold: number | null
216
+ protected _cacheTtlMs: number | null
208
217
  protected _readOnly: boolean
209
218
  protected _warmup: boolean
210
219
  protected _authSensitive: boolean
@@ -265,6 +274,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
265
274
  labelField = 'name',
266
275
  fields = {},
267
276
  localFilterThreshold = null,
277
+ cacheTtlMs = null,
268
278
  readOnly = false,
269
279
  warmup = true,
270
280
  authSensitive,
@@ -290,6 +300,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
290
300
  this._fields = fields
291
301
 
292
302
  this.localFilterThreshold = localFilterThreshold
303
+ this._cacheTtlMs = cacheTtlMs
293
304
  this._readOnly = readOnly
294
305
  this._warmup = warmup
295
306
  this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
@@ -970,6 +981,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
970
981
  Object.keys(mergedParams.filters || {}).length > 0
971
982
  const canUseCache = !hasFilters || cacheSafe
972
983
 
984
+ // Check TTL expiration before using cache
985
+ if (this._isCacheExpired()) {
986
+ this.invalidateCache()
987
+ }
988
+
973
989
  // 1. Cache valid + cacheable -> use cache with local filtering
974
990
  if (this._cache.valid && canUseCache) {
975
991
  if (!_internal) this._stats.cacheHits++
@@ -1058,6 +1074,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
1058
1074
  )
1059
1075
  this._stats.get++
1060
1076
 
1077
+ // Check TTL expiration before using cache
1078
+ if (this._isCacheExpired()) {
1079
+ this.invalidateCache()
1080
+ }
1081
+
1061
1082
  // Try cache first if valid and complete
1062
1083
  if (this._cache.valid && !this.overflow) {
1063
1084
  const idStr = String(id)
@@ -1100,6 +1121,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
1100
1121
  ): Promise<T[]> {
1101
1122
  if (!ids || ids.length === 0) return []
1102
1123
 
1124
+ // Check TTL expiration before using cache
1125
+ if (this._isCacheExpired()) {
1126
+ this.invalidateCache()
1127
+ }
1128
+
1103
1129
  // Try cache first if valid and complete
1104
1130
  if (this._cache.valid && !this.overflow) {
1105
1131
  const idStrs = new Set(ids.map(String))
@@ -1334,6 +1360,57 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
1334
1360
  return this.localFilterThreshold ?? 100
1335
1361
  }
1336
1362
 
1363
+ /**
1364
+ * Get effective cache TTL in milliseconds
1365
+ * Priority: storage.cacheTtlMs > entity.cacheTtlMs > kernel.defaultEntityCacheTtlMs > -1
1366
+ *
1367
+ * Values:
1368
+ * - 0: disable caching
1369
+ * - -1: infinite (no expiration, cache until invalidation)
1370
+ * - > 0: TTL in milliseconds
1371
+ */
1372
+ get effectiveCacheTtlMs(): number {
1373
+ // 1. Storage capabilities (can be set dynamically from API headers)
1374
+ const storageCaps =
1375
+ (this.storage as unknown as { capabilities?: StorageCapabilities })?.capabilities ||
1376
+ (this.storage?.constructor as { capabilities?: StorageCapabilities })?.capabilities
1377
+ if (storageCaps?.cacheTtlMs !== undefined) {
1378
+ return storageCaps.cacheTtlMs
1379
+ }
1380
+
1381
+ // 2. Entity-level config
1382
+ if (this._cacheTtlMs !== null) {
1383
+ return this._cacheTtlMs
1384
+ }
1385
+
1386
+ // 3. Global default from kernel options
1387
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1388
+ const globalTtl = (this._orchestrator as any)?.kernelOptions?.defaultEntityCacheTtlMs
1389
+ if (globalTtl !== undefined) {
1390
+ return globalTtl
1391
+ }
1392
+
1393
+ // 4. Default: infinite (no expiration)
1394
+ return -1
1395
+ }
1396
+
1397
+ /**
1398
+ * Check if caching is disabled by TTL=0
1399
+ */
1400
+ get isCacheTtlDisabled(): boolean {
1401
+ return this.effectiveCacheTtlMs === 0
1402
+ }
1403
+
1404
+ /**
1405
+ * Check if cache has expired based on TTL
1406
+ */
1407
+ protected _isCacheExpired(): boolean {
1408
+ if (!this._cache.valid || !this._cache.loadedAt) return false
1409
+ const ttl = this.effectiveCacheTtlMs
1410
+ if (ttl <= 0) return false // 0 = disabled (handled elsewhere), -1 = infinite
1411
+ return Date.now() - this._cache.loadedAt > ttl
1412
+ }
1413
+
1337
1414
  /**
1338
1415
  * Check if storage adapter supports returning total count
1339
1416
  */
@@ -1564,6 +1641,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
1564
1641
  */
1565
1642
  get isCacheEnabled(): boolean {
1566
1643
  if (this.effectiveThreshold <= 0) return false
1644
+ if (this.isCacheTtlDisabled) return false // TTL=0 disables cache
1567
1645
  // Check capabilities (instance getter or static)
1568
1646
  const caps =
1569
1647
  (this.storage as unknown as { capabilities?: StorageCapabilities })?.capabilities ||
@@ -1870,6 +1948,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
1870
1948
  * Get cache info (for debugging)
1871
1949
  */
1872
1950
  getCacheInfo(): CacheInfo {
1951
+ const ttlMs = this.effectiveCacheTtlMs
1873
1952
  return {
1874
1953
  enabled: this.isCacheEnabled,
1875
1954
  storageSupportsTotal: this.storageSupportsTotal,
@@ -1879,6 +1958,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
1879
1958
  itemCount: this._cache.items.length,
1880
1959
  total: this._cache.total,
1881
1960
  loadedAt: this._cache.loadedAt,
1961
+ ttlMs,
1962
+ expiresAt: this._cache.loadedAt && ttlMs > 0
1963
+ ? this._cache.loadedAt + ttlMs
1964
+ : null,
1965
+ expired: this._isCacheExpired(),
1882
1966
  }
1883
1967
  }
1884
1968
 
@@ -210,6 +210,8 @@ export interface KernelOptions {
210
210
  layouts?: LayoutComponents
211
211
  security?: SecurityConfig
212
212
  warmup?: boolean
213
+ /** Default entity cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Default: -1 (infinite). */
214
+ defaultEntityCacheTtlMs?: number
213
215
  eventRouter?: RoutesConfig
214
216
  sse?: SSEConfig
215
217
  debugBar?: DebugBarConfig
@@ -951,6 +953,11 @@ export class Kernel {
951
953
  entityAuthAdapter: (this.options.entityAuthAdapter as EntityAuthAdapter) || null,
952
954
  })
953
955
 
956
+ // Pass kernel options to orchestrator (avoid circular reference for Vue reactivity)
957
+ this.orchestrator.kernelOptions = {
958
+ defaultEntityCacheTtlMs: this.options.defaultEntityCacheTtlMs,
959
+ }
960
+
954
961
  if (this.options.toast) {
955
962
  this.orchestrator.setToastConfig(this.options.toast)
956
963
  }
@@ -57,6 +57,12 @@ export interface CacheInfo {
57
57
  overflow: boolean
58
58
  loadedAt: string | null
59
59
  items: unknown[]
60
+ /** TTL in milliseconds (0=disabled, -1=infinite, >0=TTL) */
61
+ ttlMs: number
62
+ /** Timestamp when cache expires (null if no TTL or infinite) */
63
+ expiresAt: number | null
64
+ /** Whether the cache has expired based on TTL */
65
+ expired: boolean
60
66
  }
61
67
 
62
68
  /**
@@ -350,6 +356,9 @@ export class EntitiesCollector extends Collector<EntityEntry> {
350
356
  threshold?: number
351
357
  overflow?: boolean
352
358
  loadedAt?: number
359
+ ttlMs?: number
360
+ expiresAt?: number | null
361
+ expired?: boolean
353
362
  }
354
363
  getStats?: () => StatsInfo
355
364
  getRequiredFields?: () => string[]
@@ -414,7 +423,10 @@ export class EntitiesCollector extends Collector<EntityEntry> {
414
423
  threshold: cache.threshold ?? 0,
415
424
  overflow: cache.overflow ?? false,
416
425
  loadedAt: cache.loadedAt ? new Date(cache.loadedAt).toLocaleTimeString() : null,
417
- items: this._getCacheItems(extManager, 50)
426
+ items: this._getCacheItems(extManager, 50),
427
+ ttlMs: cache.ttlMs ?? -1,
428
+ expiresAt: cache.expiresAt ?? null,
429
+ expired: cache.expired ?? false
418
430
  },
419
431
 
420
432
  permissions: {
@@ -41,6 +41,12 @@ interface CacheInfo {
41
41
  total?: number
42
42
  threshold?: number
43
43
  items?: Record<string, unknown>[]
44
+ /** TTL in milliseconds (0=disabled, -1=infinite, >0=TTL) */
45
+ ttlMs?: number
46
+ /** Timestamp when cache expires (null if no TTL) */
47
+ expiresAt?: number | null
48
+ /** Whether the cache has expired based on TTL */
49
+ expired?: boolean
44
50
  }
45
51
 
46
52
  interface RelationInfo {
@@ -212,6 +218,26 @@ function getCapabilityLabel(cap: string): string {
212
218
  }
213
219
  return labels[cap] || cap
214
220
  }
221
+
222
+ function formatTtl(ttlMs: number | undefined): string {
223
+ if (ttlMs === undefined) return '?'
224
+ if (ttlMs === -1) return '∞'
225
+ if (ttlMs === 0) return 'off'
226
+ if (ttlMs < 1000) return `${ttlMs}ms`
227
+ if (ttlMs < 60000) return `${Math.round(ttlMs / 1000)}s`
228
+ if (ttlMs < 3600000) return `${Math.round(ttlMs / 60000)}min`
229
+ return `${Math.round(ttlMs / 3600000)}h`
230
+ }
231
+
232
+ function formatExpiresIn(expiresAt: number | null | undefined): string {
233
+ if (!expiresAt) return ''
234
+ const diff = expiresAt - Date.now()
235
+ if (diff <= 0) return 'expired'
236
+ if (diff < 1000) return `${diff}ms`
237
+ if (diff < 60000) return `${Math.round(diff / 1000)}s`
238
+ if (diff < 3600000) return `${Math.round(diff / 60000)}min`
239
+ return `${Math.round(diff / 3600000)}h`
240
+ }
215
241
  </script>
216
242
 
217
243
  <template>
@@ -377,18 +403,27 @@ function getCapabilityLabel(cap: string): string {
377
403
  <template v-if="entity.cache.valid">
378
404
  ({{ entity.cache.itemCount }} items, threshold {{ entity.cache.threshold }})
379
405
  </template>
406
+ <span class="entity-cache-ttl" :class="{ 'ttl-expired': entity.cache.expired }">
407
+ TTL: {{ formatTtl(entity.cache.ttlMs) }}
408
+ <template v-if="entity.cache.valid && entity.cache.expiresAt && !entity.cache.expired">
409
+ ({{ formatExpiresIn(entity.cache.expiresAt) }} left)
410
+ </template>
411
+ <template v-if="entity.cache.expired">
412
+ <i class="pi pi-exclamation-triangle" title="Cache expired" />
413
+ </template>
414
+ </span>
380
415
  </span>
381
416
  <button
382
- v-if="!entity.cache.valid"
417
+ v-if="!entity.cache.valid || entity.cache.expired"
383
418
  class="entity-load-btn"
384
419
  :disabled="isLoading(entity.name)"
385
420
  @click.stop="loadCache(entity.name)"
386
421
  >
387
422
  <i :class="['pi', isLoading(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
388
- {{ isLoading(entity.name) ? 'Loading...' : 'Load' }}
423
+ {{ isLoading(entity.name) ? 'Loading...' : (entity.cache.expired ? 'Reload' : 'Load') }}
389
424
  </button>
390
425
  <button
391
- v-else
426
+ v-if="entity.cache.valid && !entity.cache.expired"
392
427
  class="entity-invalidate-btn"
393
428
  title="Invalidate cache"
394
429
  @click.stop="invalidateCache(entity.name)"
@@ -102,9 +102,10 @@ export class Orchestrator {
102
102
  private _toastConfig: ToastConfig = {}
103
103
  private _toastHelper: ToastHelper | null = null
104
104
 
105
- // Reference to kernel (set by Kernel after construction)
106
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
107
- kernel?: any
105
+ // Kernel options (set by Kernel after construction, avoids circular reference)
106
+ kernelOptions?: {
107
+ defaultEntityCacheTtlMs?: number
108
+ }
108
109
 
109
110
  constructor(options: OrchestratorOptions = {}) {
110
111
  const {
@@ -252,3 +252,129 @@ $sidebar-box-content-duration: 0.1s;
252
252
  opacity: 0;
253
253
  transition: opacity 0s;
254
254
  }
255
+
256
+ /* =============================================================================
257
+ Nav Item - Sidebar navigation items
258
+
259
+ Same behavior as SidebarBox:
260
+ - Icon stays at fixed left position (never moves horizontally)
261
+ - On close: text disappears immediately
262
+ - On open: text appears with delayed fade after sidebar is fully open
263
+ ============================================================================= */
264
+
265
+ // Nav item icon width
266
+ $nav-item-icon-width: 1.125rem;
267
+
268
+ // Nav item content left = icon-left + icon-width + gap
269
+ // Icon is centered in collapsed sidebar: (collapsed-width - icon-width) / 2
270
+ // Content starts after icon: icon-left + icon-width + gap = (collapsed-width + icon-width) / 2 + gap
271
+ $nav-item-content-left: calc((var(--fad-sidebar-width-collapsed) + #{$nav-item-icon-width}) / 2 + 0.375rem);
272
+
273
+ .sidebar-nav .nav-item {
274
+ position: relative;
275
+ display: flex;
276
+ align-items: center;
277
+ padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
278
+ padding-left: $nav-item-content-left;
279
+ color: var(--p-surface-300, #cbd5e1);
280
+ text-decoration: none;
281
+ font-size: var(--fad-font-size-sm);
282
+ font-weight: var(--fad-font-weight-normal);
283
+ border-radius: 0;
284
+ transition: background var(--fad-transition-fast), color var(--fad-transition-fast), padding var(--fad-transition-slow);
285
+ }
286
+
287
+ // Icon: FIXED position at left, centered in collapsed sidebar width
288
+ // Position = (collapsed-width - icon-width) / 2 so icon is centered when collapsed
289
+ .sidebar-nav .nav-item i {
290
+ position: absolute;
291
+ left: calc((var(--fad-sidebar-width-collapsed) - #{$nav-item-icon-width}) / 2);
292
+ top: 50%;
293
+ transform: translateY(-50%);
294
+ font-size: var(--fad-nav-icon-size);
295
+ width: $nav-item-icon-width;
296
+ text-align: center;
297
+ opacity: 0.8;
298
+ // No horizontal transition - icon never moves
299
+ }
300
+
301
+ // Text: delayed fade-in when sidebar opens
302
+ .sidebar-nav .nav-item span {
303
+ white-space: nowrap;
304
+ opacity: 1;
305
+ transition: opacity $sidebar-box-content-duration ease $sidebar-box-content-delay;
306
+ }
307
+
308
+ .sidebar-nav .nav-item:hover {
309
+ background: var(--p-surface-700, #334155);
310
+ color: var(--p-surface-0, white);
311
+ }
312
+
313
+ .sidebar-nav .nav-item-active {
314
+ background: var(--p-surface-700, #334155);
315
+ color: var(--p-primary-300, #93c5fd);
316
+ border-left: 2px solid var(--p-primary-400, #60a5fa);
317
+ padding-left: calc(#{$nav-item-content-left} - 2px);
318
+ }
319
+
320
+ .sidebar-nav .nav-item-active:hover {
321
+ background: var(--p-surface-600, #475569);
322
+ }
323
+
324
+ .sidebar-nav .nav-item-active i {
325
+ opacity: 1;
326
+ }
327
+
328
+ /* =============================================================================
329
+ Nav Item - Collapsed state
330
+ ============================================================================= */
331
+
332
+ .sidebar--collapsed .sidebar-nav .nav-item {
333
+ padding: 0.5rem 0;
334
+ padding-left: $nav-item-content-left; // Keep same padding - icon position doesn't change
335
+ border-left: none !important;
336
+ }
337
+
338
+ .sidebar--collapsed .sidebar-nav .nav-item-active {
339
+ border-left: none !important;
340
+ padding-left: $nav-item-content-left;
341
+ }
342
+
343
+ // Text: disappears IMMEDIATELY when closing (no transition)
344
+ .sidebar--collapsed .sidebar-nav .nav-item span {
345
+ opacity: 0;
346
+ visibility: hidden;
347
+ transition: opacity 0s, visibility 0s; // Instant hide
348
+ }
349
+
350
+ // Icon: stays at EXACT SAME left position - no movement at all
351
+ // (inherits left: $sidebar-box-icon-left, no change needed)
352
+
353
+ /* =============================================================================
354
+ Nav Item - Mobile responsive
355
+ On mobile, sidebar becomes fullscreen and collapsed state is ignored
356
+ ============================================================================= */
357
+
358
+ @media (max-width: 767px) {
359
+ .sidebar.sidebar--collapsed .sidebar-nav .nav-item {
360
+ justify-content: flex-start;
361
+ padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
362
+ padding-left: $nav-item-content-left; // Keep same padding for absolute icon
363
+ }
364
+
365
+ .sidebar.sidebar--collapsed .sidebar-nav .nav-item span {
366
+ display: inline;
367
+ opacity: 1;
368
+ visibility: visible;
369
+ width: auto;
370
+ transition: opacity $sidebar-box-content-duration ease $sidebar-box-content-delay; // Restore delayed fade-in
371
+ }
372
+
373
+ .sidebar.sidebar--collapsed .sidebar-nav .nav-item i {
374
+ // Keep absolute positioning on mobile too - same centered position
375
+ position: absolute;
376
+ left: calc((var(--fad-sidebar-width-collapsed) - #{$nav-item-icon-width}) / 2);
377
+ font-size: var(--fad-nav-icon-size);
378
+ width: $nav-item-icon-width;
379
+ }
380
+ }
@@ -47,6 +47,8 @@ export interface StorageCapabilities {
47
47
  supportsCaching: boolean
48
48
  requiresAuth?: boolean
49
49
  searchFields?: string[]
50
+ /** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Can be set dynamically from API headers. */
51
+ cacheTtlMs?: number
50
52
  }
51
53
 
52
54
  // ============ FIELD TYPES ============
@@ -143,6 +145,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
143
145
  labelField?: string | ((entity: T) => string)
144
146
  fields?: Record<string, FieldConfig>
145
147
  localFilterThreshold?: number | null
148
+ /** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
149
+ cacheTtlMs?: number | null
146
150
  readOnly?: boolean
147
151
  warmup?: boolean
148
152
  authSensitive?: boolean